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(async () => { try { const list = await navigator.mediaDevices.enumerateDevices(); 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 { audioInputs: devices.filter((d) => d.kind === "audioinput"), videoInputs: devices.filter((d) => d.kind === "videoinput"), permissionState, refresh, requestLabels, error, }; }