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; onStreamReady: (stream: MediaStream) => void; onStreamCleanup: () => void; onFinish: (blob: Blob, durationMs: number, mimeType: string) => void; onError: (message: string) => void; } /** * Manages MediaRecorder lifecycle. Pure media utility — knows nothing * about application state. The consumer provides callbacks for all outputs. */ export function useRecorder({ mode, 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 = mode === "video" ? { video: true, audio: true } : { audio: true }; const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); 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, 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 }; }