@@ -0,0 +1,24 @@
|
||||
import type { SavedDevice } from "@/stores/media-devices-store";
|
||||
|
||||
/**
|
||||
* Resolves a saved device preference against the currently available
|
||||
* devices. Returns the saved `deviceId` only if it still appears in the
|
||||
* list — otherwise `undefined` so getUserMedia falls back to the
|
||||
* system default. This keeps "unplugged device" handling in one place.
|
||||
*/
|
||||
export function resolveEffectiveDeviceId(
|
||||
saved: SavedDevice | null,
|
||||
available: MediaDeviceInfo[],
|
||||
): string | undefined {
|
||||
if (!saved) return undefined;
|
||||
const match = available.find((d) => d.deviceId === saved.deviceId);
|
||||
return match ? match.deviceId : undefined;
|
||||
}
|
||||
|
||||
export function isSavedDeviceAvailable(
|
||||
saved: SavedDevice | null,
|
||||
available: MediaDeviceInfo[],
|
||||
): boolean {
|
||||
if (!saved) return true;
|
||||
return available.some((d) => d.deviceId === saved.deviceId);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user