feat: select input devices for recordings

Closes #68
This commit is contained in:
talksik
2026-04-13 10:29:10 -07:00
parent 106254ef83
commit 38bd098ca6
9 changed files with 621 additions and 11 deletions
+24
View File
@@ -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);
}
+83
View File
@@ -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,
};
}