feat(web): seamless audio hot-plug — re-acquire live stream + toast new devices

This commit is contained in:
Jannis Braun
2026-05-03 01:01:03 +02:00
parent 2726cc5c5a
commit d992b2bb41
@@ -92,14 +92,103 @@ export function AppLayout() {
AudioManager.getInstance().setOutputDevice(outputDeviceId); AudioManager.getInstance().setOutputDevice(outputDeviceId);
}, [outputDeviceId]); }, [outputDeviceId]);
// Sweep stale persisted device IDs (mic/speaker/camera) on mount and whenever // Audio device hot-plug handler.
// the device list changes (USB plug/unplug, permission unlock, etc.). //
// 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(() => { useEffect(() => {
const prune = useVoiceStore.getState().pruneStaleDevices; const prune = useVoiceStore.getState().pruneStaleDevices;
prune(); // initial sweep let lastInputGroupIds = new Set<string>();
const handler = () => prune(); const recentToastByGroup = new Map<string, number>(); // groupId -> timestamp ms
const TOAST_DEBOUNCE_MS = 1000;
const TOAST_DEDUPE_WINDOW_MS = 30_000;
let pendingDebounceTimer: ReturnType<typeof setTimeout> | 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); 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(); const { user, isLoading } = useAuth();