feat(web): add useAudioDevices hook for permission-aware audio enumeration

This commit is contained in:
Jannis Braun
2026-05-03 00:51:20 +02:00
parent c0e71b1ded
commit a1ebb23264
2 changed files with 300 additions and 0 deletions
@@ -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<Listener>();
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');
});
});
+171
View File
@@ -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<string, string>;
outputLabels: Map<string, string>;
/** 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<void>;
}
function buildLabels(devices: MediaDeviceInfo[], kindLabel: string): Map<string, string> {
const counts = new Map<string, number>();
for (const d of devices) {
if (d.label) counts.set(d.label, (counts.get(d.label) ?? 0) + 1);
}
const seen = new Map<string, number>();
const labels = new Map<string, string>();
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<AudioDevicesPermState>('unknown');
const [inputs, setInputs] = useState<MediaDeviceInfo[]>([]);
const [outputs, setOutputs] = useState<MediaDeviceInfo[]>([]);
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<string>();
const seenOut = new Set<string>();
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 };
}