feat(web): add useAudioDevices hook for permission-aware audio enumeration
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user