From 751d029ac9a1a1d1aa632674b15a6ed3309da1be Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 7 Aug 2026 20:28:27 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7(frontend)=20sync=20persisted=20dev?= =?UTF-8?q?ice=20ids=20with=20the=20actual=20selected=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that the exact deviceId constraint has been dropped, the browser can pick a different device than the one persisted in localStorage (for example when the persisted device is no longer available). Sync the persisted ids in localStorage with the device id that was actually selected on the started track, so the local cache stays consistent with what the app is really using. --- CHANGELOG.md | 1 + .../src/features/rooms/components/Join.tsx | 3 ++ .../components/SyncDevicePreferences.tsx | 20 ++++++++++ .../livekit/hooks/useSyncTrackDeviceId.ts | 40 +++++++++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 2 + 5 files changed, 66 insertions(+) create mode 100644 src/frontend/src/features/rooms/livekit/components/SyncDevicePreferences.tsx create mode 100644 src/frontend/src/features/rooms/livekit/hooks/useSyncTrackDeviceId.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e7501b..8144b79b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to - ♻️(frontend) encapsulate error tracking behind a telemetry module - ♻️(frontend) encapsulate PostHog capture calls in the telemetry module +- 🔧(frontend) sync persisted device ids with the actual selected devices ### Fixed diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index 5bc75070..3d83c767 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -55,6 +55,7 @@ import { import { saveUsername, userStore } from '@/stores/user' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' +import { useSyncTrackDeviceId } from '../livekit/hooks/useSyncTrackDeviceId' import { useSnapshot } from 'valtio' import { useUser } from '@/features/auth/api/useUser' import { useConfig } from '@/api/useConfig' @@ -274,6 +275,8 @@ export const Join = ({ const videoTrack = dynamicVideoTrack || previewVideoTrack const audioTrack = dynamicAudioTrack || previewAudioTrack + useSyncTrackDeviceId(audioTrack, saveAudioInputDeviceId) + useSyncTrackDeviceId(videoTrack, saveVideoInputDeviceId) const videoEl = useRef(null) const isVideoInitiated = useRef(false) diff --git a/src/frontend/src/features/rooms/livekit/components/SyncDevicePreferences.tsx b/src/frontend/src/features/rooms/livekit/components/SyncDevicePreferences.tsx new file mode 100644 index 00000000..ba02c17d --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/SyncDevicePreferences.tsx @@ -0,0 +1,20 @@ +import { useLocalParticipant } from '@livekit/components-react' +import type { LocalTrack } from 'livekit-client' +import { useSyncTrackDeviceId } from '../hooks/useSyncTrackDeviceId' +import { + saveAudioInputDeviceId, + saveVideoInputDeviceId, +} from '@/stores/userChoices' + +export const SyncDevicePreferences = () => { + const { cameraTrack, microphoneTrack } = useLocalParticipant() + useSyncTrackDeviceId( + cameraTrack?.track as LocalTrack | undefined, + saveVideoInputDeviceId + ) + useSyncTrackDeviceId( + microphoneTrack?.track as LocalTrack | undefined, + saveAudioInputDeviceId + ) + return null +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useSyncTrackDeviceId.ts b/src/frontend/src/features/rooms/livekit/hooks/useSyncTrackDeviceId.ts new file mode 100644 index 00000000..7ce924b9 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/hooks/useSyncTrackDeviceId.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { TrackEvent, type LocalTrack } from 'livekit-client' + +/** + * Keeps the persisted preference aligned with the track's actual device. + * The track is the source of truth, not the store. + * + * Syncs on mount and TrackEvent.Restarted, covering acquisition/restart, + * setDeviceId switches, unmute re-acquisition, and browser fallback. + * + * Skip persisting the raw "default" alias: it means "follow the OS default" + * and resolving it would pin the preference to a concrete device. + */ +export const useSyncTrackDeviceId = ( + track: LocalTrack | undefined, + save: (deviceId: string) => void +) => { + useEffect(() => { + if (!track) return + let cancelled = false + const sync = () => { + track + .getDeviceId(false) + .then((deviceId) => { + if (!cancelled && deviceId) { + save(deviceId) + } + }) + .catch(() => { + // A track without settings (ended, screen share) has no id to sync. + }) + } + sync() + track.on(TrackEvent.Restarted, sync) + return () => { + cancelled = true + track.off(TrackEvent.Restarted, sync) + } + }, [track, save]) +} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index e6bfe693..1859fff3 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -26,6 +26,7 @@ import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder import { StageLayout } from '@/features/layout/components/StageLayout' import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer' import { ChatProvider } from '@/features/chat/components/ChatProvider' +import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' /** * @public @@ -65,6 +66,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { <> +