From b7f55ac35df2e2404c83a0f0f3500a4f70557472 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Wed, 20 Aug 2025 15:30:46 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20fix=20camera/mic=20acq?= =?UTF-8?q?uisition=20when=20disabled=20in=20user=20preferences?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace default usePreviewTrack behavior that acquired media streams even when users disabled audio/video in preferences, causing OS recording indicators to show despite explicit user rejection. Implement custom logic to only initiate preview tracks when actually needed by user. Code is naive and could be optimized, but fixes the misleading OS-level recording feedback that created user distrust. --- .../src/features/rooms/components/Join.tsx | 113 ++++++++++++++++-- .../rooms/components/join/ToggleDevice.tsx | 8 +- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 406dac21..04544af2 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -3,7 +3,13 @@ import { usePreviewTracks } from '@livekit/components-react' import { css } from '@/styled-system/css' import { Screen } from '@/layout/Screen' import { useEffect, useMemo, useRef, useState } from 'react' -import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client' +import { + createLocalVideoTrack, + createLocalAudioTrack, + LocalAudioTrack, + LocalVideoTrack, + Track, +} from 'livekit-client' import { H } from '@/primitives/H' import { Field } from '@/primitives/Field' import { Button, Dialog, Text, Form } from '@/primitives' @@ -133,19 +139,28 @@ export const Join = ({ const tracks = usePreviewTracks( { - audio: !!initialUserChoices.current && { - deviceId: initialUserChoices.current.audioDeviceId, - }, - video: !!initialUserChoices.current && { - deviceId: initialUserChoices.current.videoDeviceId, - processor: - BackgroundProcessorFactory.deserializeProcessor(processorSerialized), - }, + audio: !!initialUserChoices.current && + initialUserChoices.current?.audioEnabled && { + deviceId: initialUserChoices.current.audioDeviceId, + }, + video: !!initialUserChoices.current && + initialUserChoices.current?.videoEnabled && { + deviceId: initialUserChoices.current.videoDeviceId, + processor: + BackgroundProcessorFactory.deserializeProcessor( + processorSerialized + ), + }, }, onError ) - const videoTrack = useMemo( + const [dynamicVideoTrack, setDynamicVideoTrack] = + useState(null) + const [dynamicAudioTrack, setDynamicAudioTrack] = + useState(null) + + const previewVideoTrack = useMemo( () => tracks?.filter( (track) => track.kind === Track.Kind.Video @@ -153,7 +168,7 @@ export const Join = ({ [tracks] ) - const audioTrack = useMemo( + const previewAudioTrack = useMemo( () => tracks?.filter( (track) => track.kind === Track.Kind.Audio @@ -161,6 +176,80 @@ export const Join = ({ [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: { exact: videoDeviceId }, + processor: + BackgroundProcessorFactory.deserializeProcessor( + processorSerialized + ), + }) + setDynamicVideoTrack(track) + } catch (error) { + onError(error as Error) + } + } + + if ( + videoEnabled && + !initialUserChoices.current?.videoEnabled && + !previewVideoTrack && + !dynamicVideoTrack + ) { + createVideoTrack() + } + }, [ + videoEnabled, + videoDeviceId, + processorSerialized, + previewVideoTrack, + dynamicVideoTrack, + ]) + + useEffect(() => { + const createAudioTrack = async () => { + try { + const track = await createLocalAudioTrack({ + deviceId: { exact: audioDeviceId }, + }) + setDynamicAudioTrack(track) + } catch (error) { + onError(error as Error) + } + } + if ( + audioEnabled && + !initialUserChoices.current?.audioEnabled && + !dynamicAudioTrack && + !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 + // LiveKit by default populates device choices with "default" value. // Instead, use the current device id used by the preview track as a default useResolveInitiallyDefaultDeviceId( @@ -188,7 +277,7 @@ export const Join = ({ } if (videoElement && videoTrack && videoEnabled) { - videoTrack.unmute() + // videoTrack.unmute() videoTrack.attach(videoElement) videoElement.addEventListener('loadedmetadata', handleVideoLoaded) } diff --git a/src/frontend/src/features/rooms/components/join/ToggleDevice.tsx b/src/frontend/src/features/rooms/components/join/ToggleDevice.tsx index 4a973632..76172aec 100644 --- a/src/frontend/src/features/rooms/components/join/ToggleDevice.tsx +++ b/src/frontend/src/features/rooms/components/join/ToggleDevice.tsx @@ -43,19 +43,15 @@ export const ToggleDevice = ({ }, [config, permissions]) const toggle = useCallback(async () => { - if (!track) { - console.error('Track is undefined.') - return - } try { if (isTrackEnabled) { setIsTrackEnabled(false) onChange?.(false, true) - await track.mute() + await track?.mute() } else { setIsTrackEnabled(true) onChange?.(true, true) - await track.unmute() + await track?.unmute() } } catch (error) { console.error('Failed to toggle track:', error)