Files
llink/js/src/hooks/use-media-devices.ts
T
2026-04-13 10:29:10 -07:00

84 lines
2.6 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
export type PermissionState = "unknown" | "granted" | "denied";
interface UseMediaDevicesResult {
audioInputs: MediaDeviceInfo[];
videoInputs: MediaDeviceInfo[];
permissionState: PermissionState;
refresh: () => Promise<void>;
requestLabels: () => Promise<void>;
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<MediaDeviceInfo[]>([]);
const [permissionState, setPermissionState] =
useState<PermissionState>("unknown");
const [error, setError] = useState<string | null>(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,
};
}