From 68a5e84f5dfc2573d6bf2b4812fe5c556f547b1b Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 10 Aug 2026 11:37:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20simplify=20previ?= =?UTF-8?q?ew=20track=20lifecycle=20and=20permission=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vendor `usePreviewTracks` from LiveKit. The only reason we kept the upstream hook was to trigger a single combined permission prompt for both microphone and camera at once, but it also tied the lifecycle of the two tracks together, which made preview handling harder than it needed to be. Simplify the track lifecycle: instantiate each preview track once, and drop the dynamic fallback that came with the shared hook. To still get a single combined prompt, trigger a dedicated `getUserMedia` call for mic + camera on entry, and release the resulting tracks as soon as the user answers the prompt. Known limitation: if the user denies both mic and camera at that first prompt, the app will prompt again per device type on later attempts, instead of asking once again for both. Acceptable trade-off for now. --- .../rooms/livekit/hooks/useJoinTracks.ts | 226 ++++++++++-------- 1 file changed, 124 insertions(+), 102 deletions(-) diff --git a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts index 7adc94f8..84f7c059 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useJoinTracks.ts @@ -1,13 +1,12 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useSnapshot } from 'valtio' -import { usePreviewTracks } from '@livekit/components-react' import { createLocalAudioTrack, createLocalVideoTrack, type LocalAudioTrack, type LocalVideoTrack, MediaDeviceFailure, - Track, + TrackEvent, } from 'livekit-client' import { BackgroundProcessorFactory } from '../components/blur' import { @@ -16,17 +15,14 @@ import { } from '@/stores/permissions' import { reportError } from '@/features/analytics/telemetry' import { - type LocalUserChoices, saveAudioInputDeviceId, + saveAudioInputEnabled, saveVideoInputDeviceId, + saveVideoInputEnabled, 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, @@ -46,49 +42,131 @@ export const onJoinPreviewError = (e: Error, kind?: PermissionKind) => { } } +// Module-level: effect dependencies, must be referentially stable. +const disableAudio = () => saveAudioInputEnabled(false) +const disableVideo = () => saveVideoInputEnabled(false) + +const stopAll = (stream: MediaStream) => + stream.getTracks().forEach((track) => track.stop()) + /** - * 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. + * Requests camera and microphone once on mount (one combined call → at + * most one browser dialog) and releases them immediately. Returns true + * once settled; track acquisition must wait for it to avoid a second + * dialog. */ -function useDynamicTrack({ +function useWarmupPermissions(): boolean { + const [done, setDone] = useState(false) + const started = useRef(false) + + useEffect(() => { + if (started.current) { + return + } + started.current = true + + const warmup = async () => { + try { + stopAll( + await navigator.mediaDevices.getUserMedia({ + audio: true, + video: true, + }) + ) + } catch (error) { + if ( + MediaDeviceFailure.getFailure(error as Error) === + MediaDeviceFailure.PermissionDenied + ) { + // Retrying after a dismissal would show a second dialog. + onJoinPreviewError(error as Error) + return + } + // Combined requests fail atomically (e.g. missing webcam fails the + // mic too) — retry per kind; permission is settled, no dialog risk. + try { + stopAll(await navigator.mediaDevices.getUserMedia({ audio: true })) + } catch (e) { + onJoinPreviewError(e as Error, 'microphone') + } + try { + stopAll(await navigator.mediaDevices.getUserMedia({ video: true })) + } catch (e) { + onJoinPreviewError(e as Error, 'camera') + } + } finally { + setDone(true) + } + } + warmup() + }, []) + + return done +} + +function useLocalTrack({ + ready, enabled, - initiallyEnabled, - previewTrack, create, permissionKind, + onFailure, }: { + ready: boolean enabled: boolean - initiallyEnabled: boolean - previewTrack: T | undefined create: () => Promise permissionKind: PermissionKind + onFailure: () => void }): T | null { const [track, setTrack] = useState(null) + // Acquire. useEffect(() => { - if (!enabled || initiallyEnabled || previewTrack || track) { + if (!ready || !enabled || 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)) + .catch((error) => { + onJoinPreviewError(error as Error, permissionKind) + onFailure() + }) return () => { cancelled = true } - }, [enabled, initiallyEnabled, previewTrack, track, create, permissionKind]) + }, [ready, enabled, track, create, permissionKind, onFailure]) - // Stop the track when it is replaced or on unmount. + // Release on toggle-off so the LED turns off. + useEffect(() => { + if (!enabled && track) { + track.stop() + setTrack(null) + } + }, [enabled, track]) + + // Track ended externally (permission revoked, device unplugged): + // disable instead of re-acquiring, so no unsolicited dialog. + useEffect(() => { + if (!track) { + return + } + const handleEnded = () => { + setTrack(null) + onFailure() + } + track.on(TrackEvent.Ended, handleEnded) + return () => { + track.off(TrackEvent.Ended, handleEnded) + } + }, [track, onFailure]) + + // Release on unmount or replacement. useEffect(() => { return () => { track?.stop() @@ -98,18 +176,6 @@ function useDynamicTrack({ 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 @@ -122,48 +188,15 @@ export function useJoinTracks(): { 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 ready = useWarmupPermissions() - const tracks = usePreviewTracks( - { - audio: initialChoices.audioEnabled && { - deviceId: initialChoices.audioDeviceId, - }, - video: initialChoices.videoEnabled && { - deviceId: initialChoices.videoDeviceId, - processor: BackgroundProcessorFactory.fromProcessorConfig( - initialChoices.processorConfig - ), - }, - }, - onJoinPreviewError - ) - - const previewVideoTrack = useMemo( + const createAudio = useCallback( () => - 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] + createLocalAudioTrack({ + deviceId: audioDeviceId, + ...VOICE_AUDIO_CONSTRAINTS, + }), + [audioDeviceId] ) const createVideo = useCallback( @@ -176,38 +209,27 @@ export function useJoinTracks(): { [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({ + const audioTrack = useLocalTrack({ + ready, enabled: audioEnabled, - initiallyEnabled: initialChoices.audioEnabled, - previewTrack: previewAudioTrack, create: createAudio, permissionKind: 'microphone', + onFailure: disableAudio, }) - // Dynamic tracks take precedence over preview tracks. - const videoTrack = dynamicVideoTrack ?? previewVideoTrack - const audioTrack = dynamicAudioTrack ?? previewAudioTrack + const videoTrack = useLocalTrack({ + ready, + enabled: videoEnabled, + create: createVideo, + permissionKind: 'camera', + onFailure: disableVideo, + }) - // Keep persisted device ids in sync with what the tracks actually use. - useSyncTrackDeviceId(audioTrack, saveAudioInputDeviceId) - useSyncTrackDeviceId(videoTrack, saveVideoInputDeviceId) + useSyncTrackDeviceId(audioTrack ?? undefined, saveAudioInputDeviceId) + useSyncTrackDeviceId(videoTrack ?? undefined, saveVideoInputDeviceId) - return { audioTrack, videoTrack } + return { + audioTrack: audioTrack ?? undefined, + videoTrack: videoTrack ?? undefined, + } }