🐛(frontend) fix camera/mic acquisition when disabled in user preferences

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.
This commit is contained in:
lebaudantoine
2025-08-20 15:30:46 +02:00
committed by aleb_the_flash
parent 329a729bdc
commit b7f55ac35d
2 changed files with 103 additions and 18 deletions

View File

@@ -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<LocalVideoTrack | null>(null)
const [dynamicAudioTrack, setDynamicAudioTrack] =
useState<LocalAudioTrack | null>(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)
}

View File

@@ -43,19 +43,15 @@ export const ToggleDevice = <T extends ToggleSource>({
}, [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)