import { useCallback, useEffect, useRef } from 'react'; import type { RecordingMode } from '@/stores/media-settings-store'; 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 }; }