🚸(frontend) improve prejoin UX

- Always enable camera/mic by default (like Google Meet)
- Fix video state transitions and add visual feedback
- Simplify form using React Aria components
- Reduce shadow intensity for better visual balance
This commit is contained in:
lebaudantoine
2025-01-28 23:51:49 +01:00
committed by aleb_the_flash
parent 1b52d76168
commit 4347d87f33
6 changed files with 116 additions and 202 deletions

View File

@@ -1,25 +1,18 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
ParticipantPlaceholder,
usePersistentUserChoices, usePersistentUserChoices,
usePreviewTracks, usePreviewTracks,
type LocalUserChoices, type LocalUserChoices,
} from '@livekit/components-react' } from '@livekit/components-react'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { log } from '@livekit/components-core'
import { defaultUserChoices } from '@livekit/components-core'
import { Screen } from '@/layout/Screen' import { Screen } from '@/layout/Screen'
import { useUser } from '@/features/auth' import { useMemo, useEffect, useRef, useState } from 'react'
import React from 'react' import { LocalVideoTrack, Track } from 'livekit-client'
import {
facingModeFromLocalTrack,
LocalVideoTrack,
Track,
} from 'livekit-client'
import { H } from '@/primitives/H' import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice' import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field' import { Field } from '@/primitives/Field'
import { Button } from '@/primitives' import { Form } from '@/primitives'
import { HStack, VStack } from '@/styled-system/jsx'
const onError = (e: Error) => console.error('ERROR', e) const onError = (e: Error) => console.error('ERROR', e)
@@ -28,92 +21,47 @@ export const Join = ({
}: { }: {
onSubmit: (choices: LocalUserChoices) => void onSubmit: (choices: LocalUserChoices) => void
}) => { }) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { user } = useUser()
const defaults: Partial<LocalUserChoices> = { username: user?.full_name }
const persistUserChoices = true
const joinLabel = t('join.joinLabel')
const userLabel = t('join.usernameLabel')
const [userChoices, setUserChoices] = React.useState(defaultUserChoices)
// TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`.
const partialDefaults: Partial<LocalUserChoices> = {
...(defaults.audioDeviceId !== undefined && {
audioDeviceId: defaults.audioDeviceId,
}),
...(defaults.videoDeviceId !== undefined && {
videoDeviceId: defaults.videoDeviceId,
}),
...(defaults.audioEnabled !== undefined && {
audioEnabled: defaults.audioEnabled,
}),
...(defaults.videoEnabled !== undefined && {
videoEnabled: defaults.videoEnabled,
}),
...(defaults.username !== undefined && { username: defaults.username }),
}
const { const {
userChoices: initialUserChoices, userChoices: initialUserChoices,
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled,
saveVideoInputDeviceId, saveVideoInputDeviceId,
saveVideoInputEnabled,
saveUsername, saveUsername,
} = usePersistentUserChoices({ } = usePersistentUserChoices({})
defaults: partialDefaults,
preventSave: !persistUserChoices,
preventLoad: !persistUserChoices,
})
// Initialize device settings const [audioDeviceId, setAudioDeviceId] = useState<string>(
const [audioEnabled, setAudioEnabled] = React.useState<boolean>(
initialUserChoices.audioEnabled
)
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(
initialUserChoices.videoEnabled
)
const [audioDeviceId, setAudioDeviceId] = React.useState<string>(
initialUserChoices.audioDeviceId initialUserChoices.audioDeviceId
) )
const [videoDeviceId, setVideoDeviceId] = React.useState<string>( const [videoDeviceId, setVideoDeviceId] = useState<string>(
initialUserChoices.videoDeviceId initialUserChoices.videoDeviceId
) )
const [username, setUsername] = React.useState(initialUserChoices.username) const [username, setUsername] = useState<string>(initialUserChoices.username)
// Save user choices to persistent storage. useEffect(() => {
React.useEffect(() => {
saveAudioInputEnabled(audioEnabled)
}, [audioEnabled, saveAudioInputEnabled])
React.useEffect(() => {
saveVideoInputEnabled(videoEnabled)
}, [videoEnabled, saveVideoInputEnabled])
React.useEffect(() => {
saveAudioInputDeviceId(audioDeviceId) saveAudioInputDeviceId(audioDeviceId)
}, [audioDeviceId, saveAudioInputDeviceId]) }, [audioDeviceId, saveAudioInputDeviceId])
React.useEffect(() => {
useEffect(() => {
saveVideoInputDeviceId(videoDeviceId) saveVideoInputDeviceId(videoDeviceId)
}, [videoDeviceId, saveVideoInputDeviceId]) }, [videoDeviceId, saveVideoInputDeviceId])
React.useEffect(() => {
useEffect(() => {
saveUsername(username) saveUsername(username)
}, [username, saveUsername]) }, [username, saveUsername])
const [audioEnabled, setAudioEnabled] = useState(true)
const [videoEnabled, setVideoEnabled] = useState(true)
const tracks = usePreviewTracks( const tracks = usePreviewTracks(
{ {
audio: audioEnabled audio: { deviceId: initialUserChoices.audioDeviceId },
? { deviceId: initialUserChoices.audioDeviceId } video: { deviceId: initialUserChoices.videoDeviceId },
: false,
video: videoEnabled
? { deviceId: initialUserChoices.videoDeviceId }
: false,
}, },
onError onError
) )
const videoEl = React.useRef(null) const videoTrack = useMemo(
const videoTrack = React.useMemo(
() => () =>
tracks?.filter( tracks?.filter(
(track) => track.kind === Track.Kind.Video (track) => track.kind === Track.Kind.Video
@@ -121,7 +69,7 @@ export const Join = ({
[tracks] [tracks]
) )
const audioTrack = React.useMemo( const audioTrack = useMemo(
() => () =>
tracks?.filter( tracks?.filter(
(track) => track.kind === Track.Kind.Audio (track) => track.kind === Track.Kind.Audio
@@ -129,59 +77,37 @@ export const Join = ({
[tracks] [tracks]
) )
const facingMode = React.useMemo(() => { const videoEl = useRef(null)
if (videoTrack) {
const { facingMode } = facingModeFromLocalTrack(videoTrack)
return facingMode
} else {
return 'undefined'
}
}, [videoTrack])
React.useEffect(() => { useEffect(() => {
if (videoEl.current && videoTrack) { const videoElement = videoEl.current as HTMLVideoElement | null
const handleVideoLoaded = () => {
if (videoElement) {
videoElement.style.opacity = '1'
}
}
if (videoElement && videoTrack && videoEnabled) {
videoTrack.unmute() videoTrack.unmute()
videoTrack.attach(videoEl.current) videoTrack.attach(videoElement)
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
} }
return () => { return () => {
videoTrack?.detach() videoTrack?.detach()
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
} }
}, [videoTrack]) }, [videoTrack, videoEnabled])
const [isValid, setIsValid] = React.useState<boolean>()
const handleValidation = React.useCallback((values: LocalUserChoices) => {
return values.username !== ''
}, [])
React.useEffect(() => {
const newUserChoices = {
username,
videoEnabled,
videoDeviceId,
audioEnabled,
audioDeviceId,
}
setUserChoices(newUserChoices)
setIsValid(handleValidation(newUserChoices))
}, [
username,
videoEnabled,
handleValidation,
audioEnabled,
audioDeviceId,
videoDeviceId,
])
function handleSubmit() { function handleSubmit() {
if (handleValidation(userChoices)) { onSubmit({
if (typeof onSubmit === 'function') { audioEnabled,
onSubmit(userChoices) videoEnabled,
} audioDeviceId,
} else { videoDeviceId,
log.warn('Validation failed with: ', userChoices) username,
} })
} }
return ( return (
@@ -232,67 +158,60 @@ export const Join = ({
height: 'auto', height: 'auto',
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
'--tw-shadow': '--tw-shadow':
'0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a', '0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
'--tw-shadow-colored': '--tw-shadow-colored':
'0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)', '0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
boxShadow: boxShadow:
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)', 'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
backgroundColor: 'black',
})} })}
> >
{videoTrack && ( {videoTrack && videoEnabled ? (
// eslint-disable-next-line jsx-a11y/media-has-caption // eslint-disable-next-line jsx-a11y/media-has-caption
<video <video
ref={videoEl} ref={videoEl}
width="1280" width="1280"
height="720" height="720"
data-lk-facing-mode={facingMode}
className={css({ className={css({
display: 'block', display: 'block',
width: '100%', width: '102%',
height: '100%', height: '102%',
objectFit: 'cover', objectFit: 'cover',
transform: 'rotateY(180deg)', transform: 'rotateY(180deg)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
})} })}
/> />
)} ) : (
{(!videoTrack || !videoEnabled) && (
<div <div
id="container"
className={css({ className={css({
position: 'absolute', width: '100%',
top: 0, height: '100%',
left: 0, color: 'white',
right: 0, display: 'flex',
bottom: 0, justifyContent: 'center',
backgroundColor: '#000', alignItems: 'center',
display: 'grid',
placeItems: 'center',
})} })}
> >
<ParticipantPlaceholder <p
className={css({ className={css({
maxWidth: '100%', fontSize: '24px',
height: '70%', fontWeight: '300',
})} })}
/> >
{!videoEnabled && t('cameraDisabled')}
{videoEnabled && !videoTrack && t('cameraStarting')}
</p>
</div> </div>
)} )}
<div className="lk-button-group-container"></div>
</div> </div>
<div
className={css({ <HStack justify="center" padding={1.5}>
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '1.5rem',
gap: '1rem',
})}
>
<SelectToggleDevice <SelectToggleDevice
source={Track.Source.Microphone} source={Track.Source.Microphone}
initialState={audioEnabled} initialState={audioEnabled}
track={audioTrack} track={audioTrack}
initialDeviceId={audioDeviceId} initialDeviceId={initialUserChoices.audioDeviceId}
onChange={(enabled) => setAudioEnabled(enabled)} onChange={(enabled) => setAudioEnabled(enabled)}
onDeviceError={(error) => console.error(error)} onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) => onActiveDeviceChange={(deviceId) =>
@@ -304,19 +223,16 @@ export const Join = ({
source={Track.Source.Camera} source={Track.Source.Camera}
initialState={videoEnabled} initialState={videoEnabled}
track={videoTrack} track={videoTrack}
initialDeviceId={videoDeviceId} initialDeviceId={initialUserChoices.videoDeviceId}
onChange={(enabled) => { onChange={(enabled) => setVideoEnabled(enabled)}
setVideoEnabled(enabled)
}}
onDeviceError={(error) => console.error(error)} onDeviceError={(error) => console.error(error)}
onActiveDeviceChange={(deviceId) => onActiveDeviceChange={(deviceId) =>
setVideoDeviceId(deviceId ?? '') setVideoDeviceId(deviceId ?? '')
} }
variant="tertiary" variant="tertiary"
/> />
</div> </HStack>
</div> </div>
<div <div
className={css({ className={css({
width: '100%', width: '100%',
@@ -328,48 +244,35 @@ export const Join = ({
}, },
})} })}
> >
<form <Form
className={css({ onSubmit={handleSubmit}
display: 'flex', submitLabel={t('joinLabel')}
flexDirection: 'column', submitButtonProps={{
justifyContent: 'center', fullWidth: true,
alignItems: 'center', }}
gap: '1rem',
})}
> >
<H lvl={1} className={css({ marginBottom: 0 })}> <VStack marginBottom={1}>
{t('join.heading')} <H lvl={1} margin={false}>
</H> {t('heading')}
<Field </H>
type="text" <Field
label={userLabel} type="text"
defaultValue={username} onChange={setUsername}
onChange={(value) => setUsername(value)} label={t('usernameLabel')}
validate={(value) => { aria-label={t('usernameLabel')}
return !value ? <p>{t('join.errors.usernameEmpty')}</p> : null defaultValue={initialUserChoices?.username}
}} validate={(value) => !value && t('errors.usernameEmpty')}
className={css({ wrapperProps={{
width: '100%', noMargin: true,
})} fullWidth: true,
wrapperProps={{ }}
noMargin: true, labelProps={{
fullWidth: true, center: true,
}} }}
labelProps={{ maxLength={50}
center: true, />
}} </VStack>
maxLength={50} </Form>
/>
<Button
type="submit"
variant={'primary'}
onPress={handleSubmit}
isDisabled={!isValid}
fullWidth
>
{joinLabel}
</Button>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -27,7 +27,9 @@
"usernameLabel": "", "usernameLabel": "",
"errors": { "errors": {
"usernameEmpty": "" "usernameEmpty": ""
} },
"cameraDisabled": "",
"cameraStarting": ""
}, },
"leaveRoomPrompt": "", "leaveRoomPrompt": "",
"shareDialog": { "shareDialog": {

View File

@@ -27,7 +27,9 @@
"usernameLabel": "Your name", "usernameLabel": "Your name",
"errors": { "errors": {
"usernameEmpty": "Your name cannot be empty" "usernameEmpty": "Your name cannot be empty"
} },
"cameraDisabled": "Camera is disabled.",
"cameraStarting": "Camera is starting."
}, },
"leaveRoomPrompt": "This will make you leave the meeting.", "leaveRoomPrompt": "This will make you leave the meeting.",
"shareDialog": { "shareDialog": {

View File

@@ -27,7 +27,9 @@
"usernameLabel": "Votre nom", "usernameLabel": "Votre nom",
"errors": { "errors": {
"usernameEmpty": "Votre nom ne peut pas être vide" "usernameEmpty": "Votre nom ne peut pas être vide"
} },
"cameraDisabled": "La caméra est désactivée.",
"cameraStarting": "La caméra va démarrer."
}, },
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.", "leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
"shareDialog": { "shareDialog": {

View File

@@ -3,6 +3,7 @@ import { Form as RACForm, type FormProps } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { HStack } from '@/styled-system/jsx' import { HStack } from '@/styled-system/jsx'
import { Button, useCloseDialog } from '@/primitives' import { Button, useCloseDialog } from '@/primitives'
import { ButtonProps } from '@/primitives/Button'
/** /**
* From wrapper that exposes form data on submit and adds submit/cancel buttons * From wrapper that exposes form data on submit and adds submit/cancel buttons
@@ -13,6 +14,7 @@ import { Button, useCloseDialog } from '@/primitives'
export const Form = ({ export const Form = ({
onSubmit, onSubmit,
submitLabel, submitLabel,
submitButtonProps,
withCancelButton = true, withCancelButton = true,
onCancelButtonPress, onCancelButtonPress,
children, children,
@@ -25,6 +27,7 @@ export const Form = ({
event: FormEvent<HTMLFormElement> event: FormEvent<HTMLFormElement>
) => void ) => void
submitLabel: string submitLabel: string
submitButtonProps?: ButtonProps
withCancelButton?: boolean withCancelButton?: boolean
onCancelButtonPress?: () => void onCancelButtonPress?: () => void
}) => { }) => {
@@ -47,7 +50,7 @@ export const Form = ({
> >
{children} {children}
<HStack gap="gutter"> <HStack gap="gutter">
<Button type="submit" variant="primary"> <Button type="submit" variant="primary" {...submitButtonProps}>
{submitLabel} {submitLabel}
</Button> </Button>
{!!onCancel && ( {!!onCancel && (

View File

@@ -1,10 +1,12 @@
import { Text } from './Text' import { Text, TextProps } from './Text'
export const H = ({ export const H = ({
children, children,
lvl, lvl,
...props ...props
}: React.HTMLAttributes<HTMLHeadingElement> & { lvl: 1 | 2 | 3 }) => { }: React.HTMLAttributes<HTMLHeadingElement> & {
lvl: 1 | 2 | 3
} & TextProps) => {
const tag = `h${lvl}` as const const tag = `h${lvl}` as const
return ( return (
<Text as={tag} variant={tag} {...props}> <Text as={tag} variant={tag} {...props}>