import { useCallback, useEffect, useRef } from "react"; import type { RecordingMode } from "@/hooks/use-recording-mode"; const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus"; const VIDEO_FALLBACK_MIME = "video/webm"; const AUDIO_PREFERRED_MIME = "audio/webm;codecs=opus"; const AUDIO_FALLBACK_MIME = "audio/webm"; function getMediaMime(mode: "video" | "audio"): string { if (mode === "audio") { return MediaRecorder.isTypeSupported(AUDIO_PREFERRED_MIME) ? AUDIO_PREFERRED_MIME : AUDIO_FALLBACK_MIME; } return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME) ? VIDEO_PREFERRED_MIME : VIDEO_FALLBACK_MIME; } 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 { 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, onError, }: UseRecorderOptions) { const recorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const startTimeRef = useRef(0); // Refs to avoid stale closures in MediaRecorder event handlers const onStreamCleanupRef = useRef(onStreamCleanup); const onFinishRef = useRef(onFinish); const onErrorRef = useRef(onError); useEffect(() => { onStreamCleanupRef.current = onStreamCleanup; onFinishRef.current = onFinish; onErrorRef.current = onError; }); const stopTracks = useCallback(() => { streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; recorderRef.current = null; chunksRef.current = []; onStreamCleanupRef.current(); }, []); const startRecording = useCallback(async () => { try { const constraints = buildConstraints(mode, micDeviceId, cameraDeviceId); const hasDeviceId = Boolean(micDeviceId || cameraDeviceId); const mediaStream = await getStreamWithFallback(constraints, hasDeviceId); streamRef.current = mediaStream; onStreamReady(mediaStream); chunksRef.current = []; startTimeRef.current = Date.now(); const mime = getMediaMime(mode); const recorder = new MediaRecorder(mediaStream, { mimeType: mime }); recorderRef.current = recorder; recorder.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; recorder.onstop = () => { const durationMs = Date.now() - startTimeRef.current; const blob = new Blob(chunksRef.current, { type: mime }); stopTracks(); if (blob.size > 0) { onFinishRef.current(blob, durationMs, mime); } }; recorder.start(); } catch (err) { stopTracks(); onErrorRef.current( err instanceof Error ? err.message : "Failed to start recording", ); } }, [mode, micDeviceId, cameraDeviceId, onStreamReady, stopTracks]); const stopRecording = useCallback(() => { if (recorderRef.current?.state === "recording") { recorderRef.current.stop(); } }, []); const cancelRecording = useCallback(() => { if (recorderRef.current) { recorderRef.current.ondataavailable = null; recorderRef.current.onstop = null; if (recorderRef.current.state === "recording") { recorderRef.current.stop(); } } stopTracks(); }, [stopTracks]); useEffect(() => { return () => stopTracks(); }, [stopTracks]); return { startRecording, stopRecording, cancelRecording }; }