From b84ee74ee26159c0c4f27b51d8b31987dbcb70ca Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sun, 9 Aug 2026 23:50:10 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20reorganize=20the?= =?UTF-8?q?=20Join=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the code inside the Join component to factorize related pieces and group them more consistently. This does not change behavior; it just makes the component easier to read and maintain. --- .../src/features/rooms/components/Join.tsx | 946 +++++++----------- .../rooms/livekit/hooks/useJoinTracks.ts | 213 ++++ 2 files changed, 600 insertions(+), 559 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 18a1610b..6877a4aa 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -1,38 +1,25 @@ +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { usePreviewTracks } from '@livekit/components-react' +import { useSnapshot } from 'valtio' import { css } from '@/styled-system/css' import { Screen } from '@/layout/Screen' -import { useEffect, useMemo, useRef, useState } from 'react' -import { - createLocalAudioTrack, - createLocalVideoTrack, - type LocalAudioTrack, - type LocalVideoTrack, - MediaDeviceFailure, - Track, -} from 'livekit-client' +import { type LocalAudioTrack, type LocalVideoTrack } from 'livekit-client' import { Button, Dialog, Text } from '@/primitives' import { Heading } from 'react-aria-components' import { RiImageCircleAiFill } from '@remixicon/react' +import { isMobileBrowser } from '@livekit/components-core' import { EffectsConfiguration, EffectsConfigurationProps, } from '../livekit/components/effects/EffectsConfiguration' import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice' -import { Lobby } from './Lobby' import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice' import { BackgroundProcessorFactory } from '../livekit/components/blur' -import { isMobileBrowser } from '@livekit/components-core' -import { - notePermissionDeniedFromGum, - openPermissionsDialog, - PermissionKind, -} from '@/stores/permissions' +import { Lobby } from './Lobby' +import { openPermissionsDialog } from '@/stores/permissions' import { isSafari } from '@/utils/livekit' import { reportError } from '@/features/analytics/telemetry' - import { - type LocalUserChoices, saveAudioInputDeviceId, saveAudioInputEnabled, saveAudioOutputDeviceId, @@ -40,18 +27,246 @@ import { saveVideoInputEnabled, userChoicesStore, } from '@/stores/userChoices' - import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' -import { useSyncTrackDeviceId } from '../livekit/hooks/useSyncTrackDeviceId' -import { useSnapshot } from 'valtio' +import { useJoinTracks } from '../livekit/hooks/useJoinTracks' -const onError = (e: Error, kind?: PermissionKind) => { - reportError('join_preview_failure', e, { path: 'join_preview' }) - if ( - MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied - ) { - notePermissionDeniedFromGum(kind) +const styles = { + page: css({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: '100%', + flexDirection: 'column', + flexGrow: 1, + gap: { base: '1rem', sm: '2rem', lg: '2rem' }, + lg: { flexDirection: 'row' }, + }), + previewColumn: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + minWidth: 0, + maxWidth: '764px', + lg: { height: '540px', flexGrow: 1 }, + }), + previewStack: css({ + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + minWidth: 0, + width: '100%', + }), + previewFrame: css({ + borderRadius: '1rem', + minWidth: { base: 0, sm: '320px' }, + maxWidth: '100%', + margin: { base: '0.5rem', sm: '1rem', lg: '1rem 0.5rem 1rem 1rem' }, + overflow: 'hidden', + position: 'relative', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }), + gradientTop: css({ + position: 'absolute', + top: 0, + height: '5rem', + width: '100%', + backgroundImage: + 'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)', + zIndex: 1, + }), + gradientBottom: css({ + position: 'absolute', + bottom: 0, + height: '5rem', + width: '100%', + backgroundImage: + 'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)', + zIndex: 1, + }), + previewAspect: css({ + position: 'relative', + width: '100%', + aspectRatio: '16 / 9', + }), + previewSurface: css({ + backgroundColor: 'black', + position: 'absolute', + boxSizing: 'border-box', + top: 0, + width: '100%', + height: '100%', + overflow: 'hidden', + }), + videoStatus: css({ + position: 'absolute', + top: 0, + width: '100%', + }), + videoCrop: css({ + width: '100%', + height: 'auto', + aspectRatio: '16 / 9', + overflow: 'hidden', + position: 'absolute', + top: '-2px', + left: '-2px', + pointerEvents: 'none', + transform: 'scale(1.02)', + }), + video: css({ + position: 'absolute', + transform: 'rotateY(180deg)', + opacity: 0, + height: '100%', + transition: 'opacity 0.3s ease-in-out', + objectFit: 'cover', + }), + hintOverlay: css({ + display: 'flex', + flexDirection: 'column', + height: '100%', + width: '100%', + justifyContent: 'center', + textAlign: 'center', + alignItems: 'center', + padding: '0.24rem', + boxSizing: 'border-box', + gap: '1rem', + }), + hintText: css({ + fontWeight: '400', + fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' }, + textWrap: 'balance', + color: 'white', + }), + togglesOverlay: css({ + position: 'absolute', + bottom: '1rem', + zIndex: '1', + display: 'flex', + gap: '1rem', + justifyContent: 'center', + left: '50%', + transform: 'translateX(-50%)', + }), + effectsOverlay: css({ + position: 'absolute', + right: '1rem', + bottom: '1rem', + zIndex: '1', + }), + selectorsRow: css({ + display: 'flex', + justifyContent: 'center', + gap: '2%', + width: '80%', + marginX: 'auto', + }), + selectorItem: css({ + width: '30%', + }), + lobbyColumn: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + flex: '0 0 360px', + position: 'relative', + margin: '1rem 1rem 1rem 0.5rem', + }), + effectsTitle: css({ + textStyle: 'h1', + marginBottom: '0.25rem', + }), + effectsSubTitle: css({ + marginBottom: '1.5rem', + }), +} + +type PreviewTrack = LocalAudioTrack | LocalVideoTrack + +const toggleTrack = + (enabled: boolean, save: (v: boolean) => void, track?: PreviewTrack) => + async () => { + save(!enabled) + try { + await (enabled ? track?.mute() : track?.unmute()) + } catch (err) { + save(enabled) + reportError('join_preview_failure', err as Error, { + path: 'join_preview', + }) + } } + +const switchTrackDevice = + (save: (id: string) => void, track?: PreviewTrack) => async (id: string) => { + try { + await track?.setDeviceId({ exact: id }) + save(id) + } catch (err) { + reportError('join_preview_failure', err as Error, { + path: 'join_preview', + }) + } + } + +function getPreviewMessages({ + cameraDenied, + micDenied, + videoEnabled, + videoStarted, +}: { + cameraDenied: boolean + micDenied: boolean + videoEnabled: boolean + videoStarted: boolean +}): { hint: string | null; permissionsButtonLabel: string | null } { + if (cameraDenied) { + const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted' + return { hint: key, permissionsButtonLabel: key } + } + if (!videoEnabled) { + return { hint: 'cameraDisabled', permissionsButtonLabel: null } + } + if (!videoStarted) { + return { hint: 'cameraStarting', permissionsButtonLabel: null } + } + return { hint: null, permissionsButtonLabel: null } +} + +function useAttachedVideo( + videoTrack: LocalVideoTrack | undefined, + videoEnabled: boolean +) { + const videoEl = useRef(null) + const [videoStarted, setVideoStarted] = useState(false) + + useEffect(() => { + const element = videoEl.current + if (!element || !videoTrack || !videoEnabled) { + return + } + + const handleLoaded = () => { + setVideoStarted(true) + element.style.opacity = '1' + } + + videoTrack.attach(element) + element.addEventListener('loadedmetadata', handleLoaded) + + return () => { + videoTrack.detach(element) + element.removeEventListener('loadedmetadata', handleLoaded) + element.style.opacity = '0' + setVideoStarted(false) + } + }, [videoTrack, videoEnabled]) + + return { videoEl, videoStarted } } const Effects = ({ @@ -59,10 +274,9 @@ const Effects = ({ }: Pick) => { const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' }) const [isDialogOpen, setIsDialogOpen] = useState(false) - const openDialog = () => setIsDialogOpen(true) if (!BackgroundProcessorFactory.isSupported() || isMobileBrowser()) { - return + return null } return ( @@ -74,29 +288,17 @@ const Effects = ({ type="flex" size="large" > - + {t('title')} - + {t('subTitle')} + )} + + +
+ + +
+
+ +
+ + + ) +} + +const DeviceSelectors = ({ + videoTrack, + audioTrack, +}: { + videoTrack: LocalVideoTrack | undefined + audioTrack: LocalAudioTrack | undefined +}) => { + const { audioDeviceId, audioOutputDeviceId, videoDeviceId } = + useSnapshot(userChoicesStore) + + return ( +
+
+ +
+ {!isSafari() && ( +
+ +
+ )} +
+ +
+
+ ) +} + export const Join = ({ enterRoom, roomId, @@ -113,525 +448,18 @@ export const Join = ({ enterRoom: () => void roomId: string }) => { - const { t } = useTranslation('rooms', { keyPrefix: 'join' }) - - const { - audioEnabled, - videoEnabled, - audioDeviceId, - audioOutputDeviceId, - videoDeviceId, - processorConfig, - } = useSnapshot(userChoicesStore) - - const initialUserChoices = useRef(null) - - if (initialUserChoices.current === null) { - initialUserChoices.current = { - audioEnabled, - videoEnabled, - audioDeviceId, - audioOutputDeviceId, - videoDeviceId, - processorConfig, - } - } - - const tracks = usePreviewTracks( - { - audio: !!initialUserChoices.current && - initialUserChoices.current?.audioEnabled && { - deviceId: initialUserChoices.current.audioDeviceId, - }, - video: !!initialUserChoices.current && - initialUserChoices.current?.videoEnabled && { - deviceId: initialUserChoices.current.videoDeviceId, - processor: BackgroundProcessorFactory.fromProcessorConfig( - initialUserChoices.current.processorConfig - ), - }, - }, - onError - ) - - const [dynamicVideoTrack, setDynamicVideoTrack] = - useState(null) - const [dynamicAudioTrack, setDynamicAudioTrack] = - useState(null) - - const previewVideoTrack = useMemo( - () => - tracks?.filter( - (track) => track.kind === Track.Kind.Video - )[0] as LocalVideoTrack, - [tracks] - ) - - const previewAudioTrack = useMemo( - () => - tracks?.filter( - (track) => track.kind === Track.Kind.Audio - )[0] as LocalAudioTrack, - [tracks] - ) - - /* - * Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video - * but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track - * on-demand. We avoid creating tracks when the user explicitly requested them to be disabled. - */ - useEffect(() => { - const createVideoTrack = async () => { - try { - const track = await createLocalVideoTrack({ - deviceId: videoDeviceId, - processor: - BackgroundProcessorFactory.fromProcessorConfig(processorConfig), - }) - setDynamicVideoTrack(track) - } catch (error) { - onError(error as Error, 'camera') - } - } - - if ( - videoEnabled && - !initialUserChoices.current?.videoEnabled && - !previewVideoTrack && - !dynamicVideoTrack - ) { - createVideoTrack() - } - }, [ - videoEnabled, - videoDeviceId, - processorConfig, - previewVideoTrack, - dynamicVideoTrack, - ]) - - useEffect(() => { - const createAudioTrack = async () => { - try { - const track = await createLocalAudioTrack({ - deviceId: audioDeviceId, - noiseSuppression: true, - echoCancellation: true, - autoGainControl: true, - voiceIsolation: false, - // Audio quality optimized for voice - sampleRate: 48000, // High quality sample rate - channelCount: 1, // Mono for voice calls (saves bandwidth) - sampleSize: 16, // 16-bit audio - }) - setDynamicAudioTrack(track) - } catch (error) { - onError(error as Error, 'microphone') - } - } - if ( - audioEnabled && - !initialUserChoices.current?.audioEnabled && - !previewAudioTrack && - !dynamicAudioTrack - ) { - createAudioTrack() - } - }, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack]) - - // Cleanup dynamic tracks - useEffect(() => { - return () => { - dynamicVideoTrack?.stop() - } - }, [dynamicVideoTrack]) - useEffect(() => { - return () => { - dynamicAudioTrack?.stop() - } - }, [dynamicAudioTrack]) - - // Final tracks (dynamic takes precedence over preview) - const videoTrack = dynamicVideoTrack || previewVideoTrack - const audioTrack = dynamicAudioTrack || previewAudioTrack - - useSyncTrackDeviceId(audioTrack, saveAudioInputDeviceId) - useSyncTrackDeviceId(videoTrack, saveVideoInputDeviceId) - - const videoEl = useRef(null) - const isVideoInitiated = useRef(false) - - useEffect(() => { - const videoElement = videoEl.current as HTMLVideoElement | null - - const handleVideoLoaded = () => { - if (videoElement) { - isVideoInitiated.current = true - videoElement.style.opacity = '1' - } - } - - if (videoElement && videoTrack && videoEnabled) { - videoTrack.attach(videoElement) - videoElement.addEventListener('loadedmetadata', handleVideoLoaded) - } - - return () => { - videoTrack?.detach() - if (videoElement) { - videoElement.removeEventListener('loadedmetadata', handleVideoLoaded) - videoElement.style.opacity = '0' - } - isVideoInitiated.current = false - } - }, [videoTrack, videoEnabled]) - - const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput') - const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput') - - const hintMessage = useMemo(() => { - if (isCameraDeniedOrPrompted) { - return isMicrophoneDeniedOrPrompted - ? 'cameraAndMicNotGranted' - : 'cameraNotGranted' - } - if (!videoEnabled) { - return 'cameraDisabled' - } - if (!isVideoInitiated.current) { - return 'cameraStarting' - } - if (videoTrack && videoEnabled) { - return '' - } - }, [ - videoTrack, - videoEnabled, - isCameraDeniedOrPrompted, - isMicrophoneDeniedOrPrompted, - ]) - - const permissionsButtonLabel = useMemo(() => { - if (!isMicrophoneDeniedOrPrompted && !isCameraDeniedOrPrompted) { - return null - } - if (isCameraDeniedOrPrompted && isMicrophoneDeniedOrPrompted) { - return 'cameraAndMicNotGranted' - } - if (isCameraDeniedOrPrompted && !isMicrophoneDeniedOrPrompted) { - return 'cameraNotGranted' - } - return null - }, [isMicrophoneDeniedOrPrompted, isCameraDeniedOrPrompted]) + const { audioTrack, videoTrack } = useJoinTracks() return ( -
-
-
-
-
-
-
-
-
-
- {/* eslint-disable jsx-a11y/media-has-caption */} -
-
-
-

- {hintMessage && t(hintMessage)} -

- {isCameraDeniedOrPrompted && ( - - )} -
-
-
- { - saveAudioInputEnabled(!audioEnabled) - if (audioEnabled) { - await audioTrack?.mute() - } else { - await audioTrack?.unmute() - } - }} - /> - { - saveVideoInputEnabled(!videoEnabled) - if (videoEnabled) { - await videoTrack?.mute() - } else { - await videoTrack?.unmute() - } - }} - /> -
-
- -
-
-
-
-
- { - try { - saveAudioInputDeviceId(id) - if (audioTrack) { - await audioTrack.setDeviceId({ exact: id }) - } - } catch (err) { - console.error('Failed to switch microphone device', err) - } - }} - /> -
- {!isSafari() && ( -
- -
- )} -
- { - try { - saveVideoInputDeviceId(id) - if (videoTrack) { - await videoTrack.setDeviceId({ exact: id }) - } - } catch (err) { - console.error('Failed to switch camera device', err) - } - }} - /> -
-
+
+
+
+ +
-
+
diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts new file mode 100644 index 00000000..7adc94f8 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useSnapshot } from 'valtio' +import { usePreviewTracks } from '@livekit/components-react' +import { + createLocalAudioTrack, + createLocalVideoTrack, + type LocalAudioTrack, + type LocalVideoTrack, + MediaDeviceFailure, + Track, +} from 'livekit-client' +import { BackgroundProcessorFactory } from '../components/blur' +import { + notePermissionDeniedFromGum, + type PermissionKind, +} from '@/stores/permissions' +import { reportError } from '@/features/analytics/telemetry' +import { + type LocalUserChoices, + saveAudioInputDeviceId, + saveVideoInputDeviceId, + userChoicesStore, +} from '@/stores/userChoices' +import { useSyncTrackDeviceId } from './useSyncTrackDeviceId' + +/** + * Audio capture constraints tuned for voice calls: + * 48 kHz / 16-bit is plenty for speech, mono halves the bandwidth. + */ +const VOICE_AUDIO_CONSTRAINTS = { + noiseSuppression: true, + echoCancellation: true, + autoGainControl: true, + voiceIsolation: false, + sampleRate: 48000, + channelCount: 1, + sampleSize: 16, +} as const + +export const onJoinPreviewError = (e: Error, kind?: PermissionKind) => { + reportError('join_preview_failure', e, { path: 'join_preview' }) + if ( + MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied + ) { + notePermissionDeniedFromGum(kind) + } +} + +/** + * Just-in-time track acquisition: creates a local track only when the user + * enables a device that was disabled on mount (so no preview track exists + * for it). Handles the async race on unmount/deps-change and stops the + * track when it is replaced or the owner unmounts. + */ +function useDynamicTrack({ + enabled, + initiallyEnabled, + previewTrack, + create, + permissionKind, +}: { + enabled: boolean + initiallyEnabled: boolean + previewTrack: T | undefined + create: () => Promise + permissionKind: PermissionKind +}): T | null { + const [track, setTrack] = useState(null) + + useEffect(() => { + if (!enabled || initiallyEnabled || previewTrack || track) { + return + } + let cancelled = false + create() + .then((newTrack) => { + if (cancelled) { + // Resolved after unmount or after deps changed: release the device + // instead of leaking an orphaned track. + newTrack.stop() + return + } + setTrack(newTrack) + }) + .catch((error) => onJoinPreviewError(error as Error, permissionKind)) + return () => { + cancelled = true + } + }, [enabled, initiallyEnabled, previewTrack, track, create, permissionKind]) + + // Stop the track when it is replaced or on unmount. + useEffect(() => { + return () => { + track?.stop() + } + }, [track]) + + return track +} + +/** + * Owns every track concern of the Join screen: + * + * - requests preview tracks for the devices enabled when the screen mounted + * - lazily acquires a track when the user enables a device afterwards + * (dynamic tracks take precedence over preview tracks) + * - reports acquisition failures and permission denials + * - keeps the persisted device ids in sync with the active tracks + * + * Returns the tracks to render/toggle. Either can be undefined while + * acquisition is pending or when the device is disabled. + */ +export function useJoinTracks(): { + audioTrack: LocalAudioTrack | undefined + videoTrack: LocalVideoTrack | undefined +} { + const { + audioEnabled, + videoEnabled, + audioDeviceId, + videoDeviceId, + processorConfig, + } = useSnapshot(userChoicesStore) + + // Snapshot of the user's choices at mount time. Preview tracks are only + // requested for devices enabled at that point; anything enabled later + // goes through the dynamic path. useState's lazy initializer captures + // this exactly once, with no ref-mutation-during-render. + const [initialChoices] = useState(() => ({ + audioEnabled: userChoicesStore.audioEnabled, + videoEnabled: userChoicesStore.videoEnabled, + audioDeviceId: userChoicesStore.audioDeviceId, + audioOutputDeviceId: userChoicesStore.audioOutputDeviceId, + videoDeviceId: userChoicesStore.videoDeviceId, + processorConfig: userChoicesStore.processorConfig, + })) + + const tracks = usePreviewTracks( + { + audio: initialChoices.audioEnabled && { + deviceId: initialChoices.audioDeviceId, + }, + video: initialChoices.videoEnabled && { + deviceId: initialChoices.videoDeviceId, + processor: BackgroundProcessorFactory.fromProcessorConfig( + initialChoices.processorConfig + ), + }, + }, + onJoinPreviewError + ) + + const previewVideoTrack = useMemo( + () => + tracks?.find( + (track): track is LocalVideoTrack => track.kind === Track.Kind.Video + ), + [tracks] + ) + + const previewAudioTrack = useMemo( + () => + tracks?.find( + (track): track is LocalAudioTrack => track.kind === Track.Kind.Audio + ), + [tracks] + ) + + const createVideo = useCallback( + () => + createLocalVideoTrack({ + deviceId: videoDeviceId, + processor: + BackgroundProcessorFactory.fromProcessorConfig(processorConfig), + }), + [videoDeviceId, processorConfig] + ) + + const createAudio = useCallback( + () => + createLocalAudioTrack({ + deviceId: audioDeviceId, + ...VOICE_AUDIO_CONSTRAINTS, + }), + [audioDeviceId] + ) + + const dynamicVideoTrack = useDynamicTrack({ + enabled: videoEnabled, + initiallyEnabled: initialChoices.videoEnabled, + previewTrack: previewVideoTrack, + create: createVideo, + permissionKind: 'camera', + }) + + const dynamicAudioTrack = useDynamicTrack({ + enabled: audioEnabled, + initiallyEnabled: initialChoices.audioEnabled, + previewTrack: previewAudioTrack, + create: createAudio, + permissionKind: 'microphone', + }) + + // Dynamic tracks take precedence over preview tracks. + const videoTrack = dynamicVideoTrack ?? previewVideoTrack + const audioTrack = dynamicAudioTrack ?? previewAudioTrack + + // Keep persisted device ids in sync with what the tracks actually use. + useSyncTrackDeviceId(audioTrack, saveAudioInputDeviceId) + useSyncTrackDeviceId(videoTrack, saveVideoInputDeviceId) + + return { audioTrack, videoTrack } +}