84 lines
2.6 KiB
TypeScript
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,
|
|
};
|
|
}
|