import { useCallback, useEffect, useState } from 'react'; export type PermissionState = 'unknown' | 'granted' | 'denied'; interface UseMediaDevicesResult { audioInputs: MediaDeviceInfo[]; videoInputs: MediaDeviceInfo[]; permissionState: PermissionState; refresh: () => Promise; requestLabels: () => Promise; error: string | null; } /** * Enumerates input devices and stays subscribed to `devicechange`. * * Labels are only populated after the user has granted mic/camera * permission — `requestLabels` triggers a brief getUserMedia so that * subsequent enumerations return human-readable names, matching the * pattern most video-conferencing apps use. */ export function useMediaDevices(): UseMediaDevicesResult { const [devices, setDevices] = useState([]); const [permissionState, setPermissionState] = useState('unknown'); const [error, setError] = useState(null); const refresh = useCallback(() => { return navigator.mediaDevices .enumerateDevices() .then((list) => { setDevices(list); // If at least one input device has a non-empty label, permission // has been granted at some point for that device kind. const hasLabels = list.some( (d) => (d.kind === 'audioinput' || d.kind === 'videoinput') && d.label.length > 0, ); if (hasLabels) setPermissionState('granted'); }) .catch((err) => { setError(err instanceof Error ? err.message : 'Failed to list devices'); }); }, []); const requestLabels = useCallback(async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true, }); // Immediately stop — we only needed the permission grant. stream.getTracks().forEach((t) => t.stop()); setPermissionState('granted'); setError(null); await refresh(); } catch (err) { setPermissionState('denied'); setError( err instanceof Error ? err.message : 'Microphone/camera access denied', ); } }, [refresh]); useEffect(() => { refresh(); const handle = () => { refresh(); }; navigator.mediaDevices.addEventListener('devicechange', handle); return () => { navigator.mediaDevices.removeEventListener('devicechange', handle); }; }, [refresh]); return { // Before permission is granted, enumerateDevices returns placeholder // entries with an empty deviceId — filter them out so consumers never // render an empty-value , which Radix rejects. audioInputs: devices.filter( (d) => d.kind === 'audioinput' && d.deviceId !== '', ), videoInputs: devices.filter( (d) => d.kind === 'videoinput' && d.deviceId !== '', ), permissionState, refresh, requestLabels, error, }; }