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

View File

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

View File

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

View File

@@ -27,7 +27,9 @@
"usernameLabel": "Votre nom",
"errors": {
"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.",
"shareDialog": {

View File

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

View File

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