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
+55 -8
View File
@@ -19,18 +19,69 @@ function getMediaMime(mode: "video" | "audio"): string {
interface UseRecorderOptions {
mode: RecordingMode;
micDeviceId?: string;
cameraDeviceId?: string;
onStreamReady: (stream: MediaStream) => void;
onStreamCleanup: () => void;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
function buildConstraints(
mode: RecordingMode,
micDeviceId: string | undefined,
cameraDeviceId: string | undefined,
): MediaStreamConstraints {
const audio: MediaTrackConstraints | boolean = micDeviceId
? { deviceId: { exact: micDeviceId } }
: true;
if (mode === "audio") return { audio };
const video: MediaTrackConstraints = cameraDeviceId
? { deviceId: { exact: cameraDeviceId }, aspectRatio: { ideal: 4 / 3 } }
: { aspectRatio: { ideal: 4 / 3 } };
return { audio, video };
}
async function getStreamWithFallback(
constraints: MediaStreamConstraints,
hasDeviceId: boolean,
): Promise<MediaStream> {
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
// When a saved device has been unplugged, `{ exact }` throws
// OverconstrainedError. Fall back to the system default so users
// aren't blocked from recording.
if (
hasDeviceId &&
err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
) {
const relaxed: MediaStreamConstraints = {
audio: typeof constraints.audio === "object" ? true : constraints.audio,
...(constraints.video !== undefined && {
video:
typeof constraints.video === "object"
? { aspectRatio: { ideal: 4 / 3 } }
: constraints.video,
}),
};
return navigator.mediaDevices.getUserMedia(relaxed);
}
throw err;
}
}
/**
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
* about application state. The consumer provides callbacks for all outputs.
*/
export function useRecorder({
mode,
micDeviceId,
cameraDeviceId,
onStreamReady,
onStreamCleanup,
onFinish,
@@ -61,13 +112,9 @@ export function useRecorder({
const startRecording = useCallback(async () => {
try {
const constraints =
mode === "video"
? { video: { aspectRatio: { ideal: 4 / 3 } }, audio: true }
: { audio: true };
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
const constraints = buildConstraints(mode, micDeviceId, cameraDeviceId);
const hasDeviceId = Boolean(micDeviceId || cameraDeviceId);
const mediaStream = await getStreamWithFallback(constraints, hasDeviceId);
streamRef.current = mediaStream;
onStreamReady(mediaStream);
@@ -99,7 +146,7 @@ export function useRecorder({
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [mode, onStreamReady, stopTracks]);
}, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]);
const stopRecording = useCallback(() => {
if (recorderRef.current?.state === "recording") {