From d992b2bb411b3c1199aab865c43012d784d5a81a Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:01:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20seamless=20audio=20hot-plug=20?= =?UTF-8?q?=E2=80=94=20re-acquire=20live=20stream=20+=20toast=20new=20devi?= =?UTF-8?q?ces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/src/components/layout/AppLayout.tsx | 99 ++++++++++++++++++- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index eaefe4b2..eb995b47 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -92,14 +92,103 @@ export function AppLayout() { AudioManager.getInstance().setOutputDevice(outputDeviceId); }, [outputDeviceId]); - // Sweep stale persisted device IDs (mic/speaker/camera) on mount and whenever - // the device list changes (USB plug/unplug, permission unlock, etc.). + // Audio device hot-plug handler. + // + // Three jobs on every devicechange event: + // (1) Prune persisted IDs that no longer exist (delegated to voiceStore). + // (2) Force re-acquire the live mic stream when the user's chosen input is + // 'default' AND a stream is already live. Chromium does NOT migrate an + // existing getUserMedia track to the new OS-default — it stays bound to + // the device that was default at acquisition time. Setting the input + // again with 'default' triggers a fresh getUserMedia, which picks up + // the new OS default. The downstream syncMic effect republishes. + // (3) Force re-apply setSinkId('') for output when 'default' is selected, + // for the same reason on the output side. + // (4) Toast on a *new* audioinput appearance (debounced + dedupe by groupId). + // Removals do not toast — the user already knows they unplugged it. useEffect(() => { const prune = useVoiceStore.getState().pruneStaleDevices; - prune(); // initial sweep - const handler = () => prune(); + let lastInputGroupIds = new Set(); + const recentToastByGroup = new Map(); // groupId -> timestamp ms + const TOAST_DEBOUNCE_MS = 1000; + const TOAST_DEDUPE_WINDOW_MS = 30_000; + let pendingDebounceTimer: ReturnType | null = null; + + const seedBaseline = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + lastInputGroupIds = new Set( + devices.filter(d => d.kind === 'audioinput' && d.groupId).map(d => d.groupId) + ); + } catch { /* enumeration may be blocked pre-permission; baseline is empty */ } + }; + + const handleNewDeviceToasts = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const currentInputGroupIds = new Set( + devices.filter(d => d.kind === 'audioinput' && d.groupId).map(d => d.groupId) + ); + const now = Date.now(); + const newGroups: string[] = []; + for (const gid of currentInputGroupIds) { + if (lastInputGroupIds.has(gid)) continue; + const lastToast = recentToastByGroup.get(gid) ?? 0; + if (now - lastToast < TOAST_DEDUPE_WINDOW_MS) continue; + newGroups.push(gid); + recentToastByGroup.set(gid, now); + } + // GC dedupe map entries older than the window so it doesn't leak. + for (const [gid, ts] of recentToastByGroup) { + if (now - ts > TOAST_DEDUPE_WINDOW_MS) recentToastByGroup.delete(gid); + } + lastInputGroupIds = currentInputGroupIds; + + if (newGroups.length > 0) { + const newest = devices.find(d => + d.kind === 'audioinput' && d.groupId && newGroups.includes(d.groupId) && d.label, + ); + const label = newest?.label || 'New audio device'; + useUIStore.getState().addToast( + `${label} detected — choose it in Voice settings to switch`, + 'info', + 6000, + ); + } + } catch { /* enumeration failure is non-fatal */ } + }; + + const reacquireLiveStream = async () => { + const am = AudioManager.getInstance(); + const inputId = useVoiceStore.getState().inputDeviceId; + const outputId = useVoiceStore.getState().outputDeviceId; + // Re-acquire input only if user is on 'default' AND a stream is live. + // Anything else: persisted ID is concrete, stream stays bound correctly. + if (inputId === 'default' && am.hasActiveStream()) { + try { await am.setInputDevice('default'); } catch { /* serialized chain handles errors */ } + } + if (outputId === 'default') { + try { await am.setOutputDevice('default'); } catch { /* setSinkId may not exist on Safari */ } + } + }; + + const handler = async () => { + await prune(); + await reacquireLiveStream(); + // Debounce toast logic so a single plug event that emits multiple + // devicechange fires (Bluetooth re-pair storms) collapses to one toast. + if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); + pendingDebounceTimer = setTimeout(() => { handleNewDeviceToasts(); }, TOAST_DEBOUNCE_MS); + }; + + // Seed baseline so the first event after mount doesn't false-positive every + // existing device as "new". + seedBaseline().then(() => prune()); navigator.mediaDevices.addEventListener('devicechange', handler); - return () => navigator.mediaDevices.removeEventListener('devicechange', handler); + return () => { + if (pendingDebounceTimer) clearTimeout(pendingDebounceTimer); + navigator.mediaDevices.removeEventListener('devicechange', handler); + }; }, []); const { user, isLoading } = useAuth();