From a1ebb232648e390559d6e9fa2c28818d7766643e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 00:51:20 +0200 Subject: [PATCH] feat(web): add useAudioDevices hook for permission-aware audio enumeration --- .../web/src/hooks/useAudioDevices.test.ts | 129 +++++++++++++ packages/web/src/hooks/useAudioDevices.ts | 171 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 packages/web/src/hooks/useAudioDevices.test.ts create mode 100644 packages/web/src/hooks/useAudioDevices.ts diff --git a/packages/web/src/hooks/useAudioDevices.test.ts b/packages/web/src/hooks/useAudioDevices.test.ts new file mode 100644 index 00000000..d2ebad58 --- /dev/null +++ b/packages/web/src/hooks/useAudioDevices.test.ts @@ -0,0 +1,129 @@ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useAudioDevices } from './useAudioDevices'; + +type Listener = () => void; + +function setupMediaDevicesMock(opts: { + permissionState?: 'granted' | 'prompt' | 'denied'; + devices?: MediaDeviceInfo[]; + permissionThrows?: boolean; +} = {}) { + const listeners = new Set(); + const permState = opts.permissionState ?? 'granted'; + const devices = opts.devices ?? [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-2', kind: 'audioinput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'spk-1', kind: 'audiooutput', label: 'Built-in Speakers', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'spk-2', kind: 'audiooutput', label: 'USB Headset', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo, + ]; + + const mediaDevices = { + enumerateDevices: vi.fn().mockResolvedValue(devices), + addEventListener: (_evt: string, l: Listener) => { listeners.add(l); }, + removeEventListener: (_evt: string, l: Listener) => { listeners.delete(l); }, + getUserMedia: vi.fn().mockResolvedValue({ getTracks: () => [{ stop: vi.fn() }] }), + }; + Object.defineProperty(navigator, 'mediaDevices', { value: mediaDevices, configurable: true }); + + const permStatus = { + state: permState, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + const permissions = { + query: opts.permissionThrows + ? vi.fn().mockRejectedValue(new Error('not supported')) + : vi.fn().mockResolvedValue(permStatus), + }; + Object.defineProperty(navigator, 'permissions', { value: permissions, configurable: true }); + + return { listeners, mediaDevices, permissions, permStatus, fireDeviceChange: () => listeners.forEach(l => l()) }; +} + +describe('useAudioDevices', () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + it('starts in unknown state, transitions to granted, enumerates inputs and outputs', async () => { + const m = setupMediaDevicesMock({ permissionState: 'granted' }); + const { result } = renderHook(() => useAudioDevices()); + + expect(result.current.permState).toBe('unknown'); + await waitFor(() => expect(result.current.permState).toBe('granted')); + await waitFor(() => expect(result.current.inputs.length).toBe(2)); + expect(result.current.outputs.length).toBe(2); + expect(result.current.inputs[0].deviceId).toBe('mic-1'); + }); + + it('returns prompt state when permission is prompt', async () => { + setupMediaDevicesMock({ permissionState: 'prompt' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('prompt')); + expect(result.current.inputs).toEqual([]); + expect(result.current.outputs).toEqual([]); + }); + + it('returns denied state when permission is denied', async () => { + setupMediaDevicesMock({ permissionState: 'denied' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('denied')); + }); + + it('falls back to prompt when permissions.query throws', async () => { + setupMediaDevicesMock({ permissionThrows: true }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('prompt')); + }); + + it('refreshes lists on devicechange', async () => { + const m = setupMediaDevicesMock({ permissionState: 'granted' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.inputs.length).toBe(2)); + + m.mediaDevices.enumerateDevices.mockResolvedValueOnce([ + { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + ]); + act(() => { m.fireDeviceChange(); }); + await waitFor(() => expect(result.current.inputs.length).toBe(1)); + }); + + it('deduplicates devices by deviceId', async () => { + setupMediaDevicesMock({ + permissionState: 'granted', + devices: [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-1', kind: 'audioinput', label: 'A', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'spk-1', kind: 'audiooutput', label: 'B', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + ], + }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('granted')); + expect(result.current.inputs.length).toBe(1); + expect(result.current.outputs.length).toBe(1); + }); + + it('requestPermission fires getUserMedia({audio:true}) and stops the stream', async () => { + const m = setupMediaDevicesMock({ permissionState: 'prompt' }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.permState).toBe('prompt')); + + await act(async () => { await result.current.requestPermission(); }); + expect(m.mediaDevices.getUserMedia).toHaveBeenCalledWith({ audio: true }); + }); + + it('builds display labels with disambiguation suffix for duplicate names', async () => { + setupMediaDevicesMock({ + permissionState: 'granted', + devices: [ + { deviceId: 'mic-1', kind: 'audioinput', label: 'USB Audio', groupId: 'g1', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-2', kind: 'audioinput', label: 'USB Audio', groupId: 'g2', toJSON: () => ({}) } as MediaDeviceInfo, + { deviceId: 'mic-3', kind: 'audioinput', label: '', groupId: 'g3', toJSON: () => ({}) } as MediaDeviceInfo, + ], + }); + const { result } = renderHook(() => useAudioDevices()); + await waitFor(() => expect(result.current.inputs.length).toBe(3)); + expect(result.current.inputLabels.get('mic-1')).toBe('USB Audio (1)'); + expect(result.current.inputLabels.get('mic-2')).toBe('USB Audio (2)'); + expect(result.current.inputLabels.get('mic-3')).toBe('Microphone 3'); + }); +}); diff --git a/packages/web/src/hooks/useAudioDevices.ts b/packages/web/src/hooks/useAudioDevices.ts new file mode 100644 index 00000000..b30210f7 --- /dev/null +++ b/packages/web/src/hooks/useAudioDevices.ts @@ -0,0 +1,171 @@ +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; + +/** + * Permission state machine for audio device enumeration. + * + * - `unknown`: initial mount, before `permissions.query` resolves. + * - `granted`: permission granted; both input + output lists populated. + * - `prompt`: permission not yet decided; lists empty until requestPermission(). + * - `denied`: permission denied; lists empty. + * + * `permissions.query({ name: 'microphone' })` is the ONLY mount-time API call. + * It is passive — does NOT light the mic indicator on any platform. We never + * auto-fire `getUserMedia` to "unlock labels"; that requires an explicit user + * gesture via `requestPermission()`. + * + * Note on output devices: there is no separate "speaker" permission. Browsers + * gate output-device labels behind the same microphone permission. So a single + * permission state covers both lists. + */ +export type AudioDevicesPermState = 'unknown' | 'granted' | 'prompt' | 'denied'; + +export interface UseAudioDevicesResult { + permState: AudioDevicesPermState; + inputs: MediaDeviceInfo[]; + outputs: MediaDeviceInfo[]; + inputLabels: Map; + outputLabels: Map; + /** Re-enumerate immediately. Safe to call any time after permission is granted. */ + refresh: () => void; + /** + * Explicit user gesture: fires `getUserMedia({audio:true})` to grant permission + * and unlock device labels. Stops the stream immediately. Only call from a + * click/keydown handler — calling this from an effect would defeat the privacy + * model and flash the mic indicator. + */ + requestPermission: () => Promise; +} + +function buildLabels(devices: MediaDeviceInfo[], kindLabel: string): Map { + const counts = new Map(); + for (const d of devices) { + if (d.label) counts.set(d.label, (counts.get(d.label) ?? 0) + 1); + } + const seen = new Map(); + const labels = new Map(); + devices.forEach((d, i) => { + if (!d.label) { + labels.set(d.deviceId, `${kindLabel} ${i + 1}`); + return; + } + const total = counts.get(d.label) ?? 1; + if (total <= 1) { + labels.set(d.deviceId, d.label); + return; + } + const used = (seen.get(d.label) ?? 0) + 1; + seen.set(d.label, used); + labels.set(d.deviceId, `${d.label} (${used})`); + }); + return labels; +} + +export function useAudioDevices(): UseAudioDevicesResult { + const [permState, setPermState] = useState('unknown'); + const [inputs, setInputs] = useState([]); + const [outputs, setOutputs] = useState([]); + const mountedRef = useRef(true); + const enumerateGenRef = useRef(0); + + const enumerate = useCallback(async () => { + const gen = ++enumerateGenRef.current; + try { + const all = await navigator.mediaDevices.enumerateDevices(); + if (gen !== enumerateGenRef.current || !mountedRef.current) return; + const seenIn = new Set(); + const seenOut = new Set(); + const ins: MediaDeviceInfo[] = []; + const outs: MediaDeviceInfo[] = []; + for (const d of all) { + if (d.kind === 'audioinput' && !seenIn.has(d.deviceId)) { + seenIn.add(d.deviceId); + ins.push(d); + } else if (d.kind === 'audiooutput' && !seenOut.has(d.deviceId)) { + seenOut.add(d.deviceId); + outs.push(d); + } + } + setInputs(ins); + setOutputs(outs); + } catch { + if (gen === enumerateGenRef.current && mountedRef.current) { + setInputs([]); + setOutputs([]); + } + } + }, []); + + // Mount-time permission probe. Mirrors VideoSection.tsx:158-210. + useEffect(() => { + mountedRef.current = true; + let cancelled = false; + let status: PermissionStatus | null = null; + let onChange: (() => void) | null = null; + + const apply = (state: PermissionState) => { + if (cancelled || !mountedRef.current) return; + if (state === 'granted') setPermState('granted'); + else if (state === 'prompt') setPermState('prompt'); + else setPermState('denied'); + }; + + const run = async () => { + if (!navigator.permissions || typeof navigator.permissions.query !== 'function') { + if (!cancelled && mountedRef.current) setPermState('prompt'); + return; + } + try { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const s = await navigator.permissions.query({ name: 'microphone' as PermissionName }); + status = s; + apply(s.state); + onChange = () => apply(s.state); + s.addEventListener('change', onChange); + } catch { + if (!cancelled && mountedRef.current) setPermState('prompt'); + } + }; + + run(); + + return () => { + cancelled = true; + mountedRef.current = false; + if (status && onChange) { + try { status.removeEventListener('change', onChange); } catch { /* best-effort */ } + } + }; + }, []); + + // Enumerate when permission grants; refresh on devicechange. + useEffect(() => { + if (permState !== 'granted') return; + enumerate(); + const onChange = () => { enumerate(); }; + navigator.mediaDevices.addEventListener('devicechange', onChange); + return () => { + navigator.mediaDevices.removeEventListener('devicechange', onChange); + }; + }, [permState, enumerate]); + + const inputLabels = useMemo(() => buildLabels(inputs, 'Microphone'), [inputs]); + const outputLabels = useMemo(() => buildLabels(outputs, 'Speakers'), [outputs]); + + const requestPermission = useCallback(async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach(t => t.stop()); + if (mountedRef.current) { + setPermState('granted'); + await enumerate(); + } + } catch (err: unknown) { + if (err instanceof Error && err.name === 'NotAllowedError' && mountedRef.current) { + setPermState('denied'); + } + throw err; + } + }, [enumerate]); + + return { permState, inputs, outputs, inputLabels, outputLabels, refresh: enumerate, requestPermission }; +}