import { useCallback, useEffect, useRef, useState } from "react"; import { isSafariBrowser } from "@/lib/browser"; type RecorderState = { stream: MediaStream | null; recording: boolean; paused: boolean; error: string | null; mimeType: string | null; audioOnly: boolean; }; type Options = { videoDeviceId?: string; audioDeviceId?: string; preferAudioOnly?: boolean; onChunk: (chunk: Blob) => void; }; export const useMediaRecorder = ({ videoDeviceId, audioDeviceId, preferAudioOnly = false, onChunk }: Options) => { const recorderRef = useRef(null); const streamRef = useRef(null); const startStreamPromiseRef = useRef | null>(null); const chunkBufferRef = useRef([]); const stopResolveRef = useRef<((chunks: Blob[]) => void) | null>(null); const stopTimeoutRef = useRef | null>(null); const stopFinalizeTimeoutRef = useRef | null>(null); const [state, setState] = useState({ stream: null, recording: false, paused: false, error: null, mimeType: null, audioOnly: preferAudioOnly, }); const resetStream = useCallback(() => { streamRef.current?.getTracks().forEach((track) => track.stop()); streamRef.current = null; startStreamPromiseRef.current = null; setState((prev) => ({ ...prev, stream: null })); }, []); const startStream = useCallback(async () => { if (streamRef.current) { return streamRef.current; } if (startStreamPromiseRef.current) { return startStreamPromiseRef.current; } startStreamPromiseRef.current = (async () => { try { if (!navigator.mediaDevices?.getUserMedia) { throw new Error("Media devices API not available in this browser context."); } if (preferAudioOnly) { const audioStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true, }); streamRef.current = audioStream; setState((prev) => ({ ...prev, stream: audioStream, error: null, audioOnly: true })); return audioStream; } const constraints = { video: videoDeviceId ? { deviceId: { exact: videoDeviceId } } : { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } }, audio: audioDeviceId ? { deviceId: { exact: audioDeviceId } } : true, }; try { let stream = await navigator.mediaDevices.getUserMedia(constraints); if (!stream.getVideoTracks().length && constraints.video) { stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); } streamRef.current = stream; setState((prev) => ({ ...prev, stream, error: null, audioOnly: false })); return stream; } catch (error) { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); streamRef.current = stream; setState((prev) => ({ ...prev, stream, error: "Fell back to default camera & microphone.", audioOnly: false, })); return stream; } catch (fallbackError) { try { const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); streamRef.current = stream; setState((prev) => ({ ...prev, stream, error: "Microphone blocked. Previewing camera video only.", audioOnly: false, })); return stream; } catch (_videoOnlyError) { try { const audioOnlyStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: true }); streamRef.current = audioOnlyStream; setState((prev) => ({ ...prev, stream: audioOnlyStream, error: "Camera unavailable — switched automatically to Audio Presenter Mode.", audioOnly: true, })); return audioOnlyStream; } catch (_audioError) { const baseError = error instanceof Error ? error : fallbackError; const message = baseError instanceof Error ? `${baseError.name}: ${baseError.message}` : "Camera or microphone permission denied"; setState((prev) => ({ ...prev, error: message })); throw baseError; } } } } } catch (error) { const message = error instanceof Error ? `${error.name}: ${error.message}` : "Camera or microphone permission denied"; setState((prev) => ({ ...prev, error: message })); throw error; } finally { startStreamPromiseRef.current = null; } })(); return startStreamPromiseRef.current; }, [audioDeviceId, preferAudioOnly, videoDeviceId]); const startRecording = useCallback(async () => { if (typeof MediaRecorder === "undefined") { const message = "MediaRecorder not supported in this browser."; setState((prev) => ({ ...prev, error: message })); throw new Error(message); } const isSafari = isSafariBrowser(); if (streamRef.current && !streamRef.current.active) { resetStream(); } const stream = streamRef.current ?? (await startStream()); const hasVideo = stream.getVideoTracks().length > 0; const preferredTypes = !hasVideo ? isSafari ? ["audio/mp4", "audio/webm;codecs=opus", "audio/webm"] : ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"] : isSafari ? ["video/mp4;codecs=avc1,mp4a.40.2", "video/mp4;codecs=h264,aac", "video/mp4"] : [ "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm", "video/mp4;codecs=h264,aac", "video/mp4", ]; const supportedType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)); const recorder = supportedType ? new MediaRecorder(stream, { mimeType: supportedType }) : new MediaRecorder(stream); const shouldUseMp4 = Boolean(isSafari || supportedType?.includes("mp4")); const fallbackMimeType = !hasVideo ? shouldUseMp4 ? "audio/mp4" : "audio/webm" : shouldUseMp4 ? "video/mp4" : "video/webm"; const initialMimeType = recorder.mimeType || supportedType || fallbackMimeType; chunkBufferRef.current = []; recorderRef.current = recorder; recorder.ondataavailable = (event) => { if (event.data.size > 0) { chunkBufferRef.current.push(event.data); onChunk(event.data); } if (event.data.type) { setState((prev) => (prev.mimeType === event.data.type ? prev : { ...prev, mimeType: event.data.type })); } if (recorder.state === "inactive" && stopResolveRef.current) { if (stopFinalizeTimeoutRef.current) { clearTimeout(stopFinalizeTimeoutRef.current); } stopFinalizeTimeoutRef.current = setTimeout(() => { stopFinalizeTimeoutRef.current = null; stopResolveRef.current?.([...chunkBufferRef.current]); stopResolveRef.current = null; }, 400); } }; recorder.onpause = () => { setState((prev) => ({ ...prev, paused: true })); }; recorder.onresume = () => { setState((prev) => ({ ...prev, paused: false })); }; recorder.onerror = (event) => { const nextError = event.error?.message || "Recording failed before any media chunk was produced."; setState((prev) => ({ ...prev, error: nextError, recording: false, paused: false })); }; recorder.onstop = () => { if (stopTimeoutRef.current) { clearTimeout(stopTimeoutRef.current); stopTimeoutRef.current = null; } if (stopResolveRef.current) { if (stopFinalizeTimeoutRef.current) { clearTimeout(stopFinalizeTimeoutRef.current); } stopFinalizeTimeoutRef.current = setTimeout(() => { stopFinalizeTimeoutRef.current = null; stopResolveRef.current?.([...chunkBufferRef.current]); stopResolveRef.current = null; }, 400); } setState((prev) => ({ ...prev, recording: false, paused: false })); }; try { recorder.start(shouldUseMp4 ? 1000 : 2000); } catch (_error) { recorder.start(); } setState((prev) => ({ ...prev, recording: true, paused: false, audioOnly: !hasVideo, mimeType: initialMimeType || prev.mimeType, })); }, [onChunk, resetStream, startStream]); const pauseRecording = useCallback(() => { const recorder = recorderRef.current; if (!recorder) return false; if (typeof recorder.pause !== "function") { setState((prev) => ({ ...prev, error: "Pause not supported in this browser." })); return false; } if (recorder.state !== "recording") { return false; } try { recorder.pause(); return true; } catch (error) { const message = error instanceof Error ? `${error.name}: ${error.message}` : "Pause failed."; setState((prev) => ({ ...prev, error: message })); return false; } }, []); const resumeRecording = useCallback(() => { const recorder = recorderRef.current; if (!recorder) return false; if (typeof recorder.resume !== "function") { setState((prev) => ({ ...prev, error: "Resume not supported in this browser." })); return false; } if (recorder.state !== "paused") { return false; } try { recorder.resume(); return true; } catch (error) { const message = error instanceof Error ? `${error.name}: ${error.message}` : "Resume failed."; setState((prev) => ({ ...prev, error: message })); return false; } }, []); const stopRecording = useCallback(() => { const recorder = recorderRef.current; if (!recorder || recorder.state === "inactive") { return Promise.resolve([...chunkBufferRef.current]); } if (stopTimeoutRef.current) { clearTimeout(stopTimeoutRef.current); stopTimeoutRef.current = null; } if (stopFinalizeTimeoutRef.current) { clearTimeout(stopFinalizeTimeoutRef.current); stopFinalizeTimeoutRef.current = null; } return new Promise((resolve) => { stopResolveRef.current = resolve; stopTimeoutRef.current = setTimeout(() => { stopTimeoutRef.current = null; if (stopFinalizeTimeoutRef.current) { clearTimeout(stopFinalizeTimeoutRef.current); stopFinalizeTimeoutRef.current = null; } stopResolveRef.current?.([...chunkBufferRef.current]); stopResolveRef.current = null; }, isSafariBrowser() ? 5000 : 2500); try { if (!isSafariBrowser() && typeof recorder.requestData === "function" && recorder.state === "recording") { recorder.requestData(); } recorder.stop(); } catch (error) { if (stopTimeoutRef.current) { clearTimeout(stopTimeoutRef.current); stopTimeoutRef.current = null; } stopResolveRef.current?.([...chunkBufferRef.current]); stopResolveRef.current = null; throw error; } setState((prev) => ({ ...prev, recording: false, paused: false })); }); }, []); const stopTracks = useCallback(() => { if (stopTimeoutRef.current) { clearTimeout(stopTimeoutRef.current); stopTimeoutRef.current = null; } if (stopFinalizeTimeoutRef.current) { clearTimeout(stopFinalizeTimeoutRef.current); stopFinalizeTimeoutRef.current = null; } stopResolveRef.current = null; recorderRef.current = null; resetStream(); }, [resetStream]); const stopStream = useCallback(() => { stopTracks(); setState((prev) => ({ ...prev, stream: null })); }, [stopTracks]); useEffect(() => () => stopTracks(), [stopTracks]); return { ...state, startStream, startRecording, pauseRecording, resumeRecording, stopRecording, stopStream, }; };