| 'use client'; |
|
|
| import { useCallback, useEffect, useRef, useState } from 'react'; |
| import type { ClipPrompt } from '@/lib/reviews/types'; |
|
|
| export type RecordingState = |
| | 'idle' |
| | 'requesting_permission' |
| | 'ready' |
| | 'recording' |
| | 'finalizing'; |
|
|
| const recorderMimePriority = [ |
| 'video/webm;codecs=vp8,opus', |
| 'video/webm;codecs=vp9,opus', |
| 'video/webm', |
| 'video/mp4', |
| ]; |
|
|
| const audioRecorderMimePriority = [ |
| 'audio/webm;codecs=opus', |
| 'audio/webm', |
| 'audio/mp4', |
| ]; |
|
|
| const RECORDER_TIMESLICE_MS = 500; |
| const MIN_VIDEO_BLOB_BYTES = 16 * 1024; |
| const MIN_AUDIO_BLOB_BYTES = 4 * 1024; |
| const MIN_CLIP_MS = 2000; |
| const VIDEO_BITS_PER_SECOND = 1_400_000; |
| const AUDIO_BITS_PER_SECOND = 64_000; |
|
|
| function pickRecorderMime(mediaType: 'video' | 'audio') { |
| if (typeof MediaRecorder === 'undefined') return undefined; |
| const mimes = mediaType === 'audio' ? audioRecorderMimePriority : recorderMimePriority; |
| for (const mime of mimes) { |
| if (MediaRecorder.isTypeSupported(mime)) return mime; |
| } |
| return undefined; |
| } |
|
|
| function extFromMime(mime: string | undefined): 'webm' | 'mp4' | 'mov' { |
| if (!mime) return 'webm'; |
| if (mime.includes('mp4')) return 'mp4'; |
| if (mime.includes('quicktime')) return 'mov'; |
| return 'webm'; |
| } |
|
|
| function videoConstraints(camera: 'front' | 'rear', exactFacingMode: boolean): MediaTrackConstraints { |
| return { |
| facingMode: |
| camera === 'rear' |
| ? exactFacingMode |
| ? { exact: 'environment' } |
| : { ideal: 'environment' } |
| : exactFacingMode |
| ? { exact: 'user' } |
| : { ideal: 'user' }, |
| width: { ideal: 720, max: 1280 }, |
| height: { ideal: 1280, max: 1920 }, |
| frameRate: { ideal: 24, max: 30 }, |
| }; |
| } |
|
|
| async function probeRecordedMedia(blob: Blob, mediaType: 'video' | 'audio') { |
| return await new Promise<{ readable: boolean; durationSeconds: number }>((resolve) => { |
| const url = URL.createObjectURL(blob); |
| const media = document.createElement(mediaType); |
| let settled = false; |
|
|
| const finish = (readable: boolean, durationSeconds = 0) => { |
| if (settled) return; |
| settled = true; |
| URL.revokeObjectURL(url); |
| resolve({ readable, durationSeconds }); |
| }; |
|
|
| const timer = window.setTimeout(() => finish(false), 4000); |
| media.preload = 'metadata'; |
| media.muted = true; |
| media.onloadedmetadata = () => { |
| window.clearTimeout(timer); |
| const durationSeconds = |
| Number.isFinite(media.duration) && media.duration > 0 ? media.duration : 0; |
| finish(true, durationSeconds); |
| }; |
| media.onerror = () => { |
| window.clearTimeout(timer); |
| finish(false); |
| }; |
| media.src = url; |
| }); |
| } |
|
|
| export type UseGuidedRecordingOptions = { |
| prompt: ClipPrompt; |
| |
| onClipReady: (clip: { blob: Blob; durationSeconds: number; ext: 'webm' | 'mp4' | 'mov' }) => void; |
| }; |
|
|
| export function useGuidedRecording({ prompt, onClipReady }: UseGuidedRecordingOptions) { |
| const mediaType = prompt.mediaType ?? 'video'; |
| const [state, setState] = useState<RecordingState>('idle'); |
| const [elapsedMs, setElapsedMs] = useState(0); |
| const [error, setError] = useState<string | null>(null); |
|
|
| const videoRef = useRef<HTMLVideoElement | null>(null); |
| const streamRef = useRef<MediaStream | null>(null); |
| const recorderRef = useRef<MediaRecorder | null>(null); |
| const chunksRef = useRef<BlobPart[]>([]); |
| const startedAtRef = useRef<number>(0); |
| const tickRef = useRef<number | null>(null); |
| const autoStopRef = useRef<number | null>(null); |
|
|
| const stopTicking = useCallback(() => { |
| if (tickRef.current !== null) { |
| window.clearInterval(tickRef.current); |
| tickRef.current = null; |
| } |
| }, []); |
|
|
| const stopAutoStop = useCallback(() => { |
| if (autoStopRef.current !== null) { |
| window.clearTimeout(autoStopRef.current); |
| autoStopRef.current = null; |
| } |
| }, []); |
|
|
| const stopStream = useCallback(() => { |
| streamRef.current?.getTracks().forEach((t) => t.stop()); |
| streamRef.current = null; |
| }, []); |
|
|
| const stopActiveRecorder = useCallback(() => { |
| const recorder = recorderRef.current; |
| if (!recorder || recorder.state === 'inactive') return; |
|
|
| setState('finalizing'); |
| stopTicking(); |
| stopAutoStop(); |
|
|
| try { |
| recorder.requestData(); |
| } catch { |
| |
| } |
|
|
| window.setTimeout(() => { |
| try { |
| if (recorder.state !== 'inactive') recorder.stop(); |
| } catch { |
| |
| } |
| }, 100); |
| }, [stopAutoStop, stopTicking]); |
|
|
| const minRecordingMs = Math.min(MIN_CLIP_MS, prompt.maxSeconds * 1000); |
|
|
| const requestPermissionAndPreview = useCallback(async () => { |
| setError(null); |
| setState('requesting_permission'); |
| try { |
| let stream: MediaStream; |
| if (mediaType === 'audio') { |
| stream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| } else { |
| try { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| video: videoConstraints(prompt.camera, true), |
| audio: true, |
| }); |
| } catch { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| video: videoConstraints(prompt.camera, false), |
| audio: true, |
| }); |
| } |
| } |
| streamRef.current = stream; |
| if (mediaType === 'video' && videoRef.current) { |
| videoRef.current.srcObject = stream; |
| videoRef.current.muted = true; |
| await videoRef.current.play().catch(() => undefined); |
| } |
| setState('ready'); |
| } catch (err) { |
| setError(err instanceof Error ? err.message : 'Camera access was blocked.'); |
| setState('idle'); |
| } |
| }, [mediaType, prompt.camera]); |
|
|
| |
| |
| useEffect(() => { |
| void requestPermissionAndPreview(); |
| return () => { |
| stopTicking(); |
| stopAutoStop(); |
| stopActiveRecorder(); |
| stopStream(); |
| }; |
| |
| }, [prompt.camera, mediaType]); |
|
|
| const startRecording = useCallback(() => { |
| if (!streamRef.current) return; |
| const mime = pickRecorderMime(mediaType); |
| let recorder: MediaRecorder; |
| try { |
| const options: MediaRecorderOptions = {}; |
| if (mime) options.mimeType = mime; |
| if (mediaType === 'video') { |
| options.videoBitsPerSecond = VIDEO_BITS_PER_SECOND; |
| options.audioBitsPerSecond = AUDIO_BITS_PER_SECOND; |
| } else { |
| options.audioBitsPerSecond = AUDIO_BITS_PER_SECOND; |
| } |
| recorder = new MediaRecorder(streamRef.current, options); |
| } catch (err) { |
| setError(err instanceof Error ? err.message : 'Recording is not supported on this device.'); |
| return; |
| } |
|
|
| recorderRef.current = recorder; |
| chunksRef.current = []; |
| setError(null); |
|
|
| recorder.ondataavailable = (e) => { |
| if (e.data && e.data.size > 0) chunksRef.current.push(e.data); |
| }; |
|
|
| recorder.onerror = () => { |
| setError('Recording failed on this device. Please try this clip again.'); |
| setState('ready'); |
| }; |
|
|
| recorder.onstop = async () => { |
| stopTicking(); |
| stopAutoStop(); |
| const elapsedSeconds = Math.max(0, (Date.now() - startedAtRef.current) / 1000); |
| const blobMime = mime ?? 'video/webm'; |
| const chunks = chunksRef.current.filter((chunk) => { |
| if (chunk instanceof Blob) return chunk.size > 0; |
| return true; |
| }); |
| chunksRef.current = []; |
| setState('finalizing'); |
|
|
| const blob = new Blob(chunks, { type: blobMime }); |
| const minBytes = mediaType === 'audio' ? MIN_AUDIO_BLOB_BYTES : MIN_VIDEO_BLOB_BYTES; |
| const mediaProbe = |
| blob.size >= minBytes |
| ? await probeRecordedMedia(blob, mediaType) |
| : { readable: false, durationSeconds: 0 }; |
| const durationSeconds = mediaProbe.durationSeconds || elapsedSeconds; |
|
|
| if (!mediaProbe.readable) { |
| setError('That clip did not finish saving cleanly. Please record this prompt again.'); |
| setState('ready'); |
| return; |
| } |
|
|
| onClipReady({ |
| blob, |
| durationSeconds, |
| ext: extFromMime(mime), |
| }); |
| |
| window.setTimeout(() => setState('ready'), 200); |
| }; |
|
|
| startedAtRef.current = Date.now(); |
| setElapsedMs(0); |
| setState('recording'); |
| recorder.start(RECORDER_TIMESLICE_MS); |
|
|
| tickRef.current = window.setInterval(() => { |
| setElapsedMs(Date.now() - startedAtRef.current); |
| }, 100); |
|
|
| autoStopRef.current = window.setTimeout(() => { |
| stopActiveRecorder(); |
| }, prompt.maxSeconds * 1000); |
| }, [mediaType, onClipReady, prompt.maxSeconds, stopActiveRecorder, stopAutoStop, stopTicking]); |
|
|
| const stopRecording = useCallback(() => { |
| if (minRecordingMs > 0 && Date.now() - startedAtRef.current < minRecordingMs) { |
| setError(`Keep recording until ${Math.ceil(minRecordingMs / 1000)}s.`); |
| return; |
| } |
|
|
| stopActiveRecorder(); |
| }, [minRecordingMs, stopActiveRecorder]); |
|
|
| const liveProgress = Math.min(1, elapsedMs / (prompt.maxSeconds * 1000)); |
| const canStopRecording = state === 'recording' && elapsedMs >= minRecordingMs; |
|
|
| useEffect(() => { |
| if (state === 'recording' && minRecordingMs > 0 && elapsedMs >= minRecordingMs) { |
| setError((current) => (current?.startsWith('Keep recording until') ? null : current)); |
| } |
| }, [elapsedMs, minRecordingMs, state]); |
|
|
| return { |
| state, |
| elapsedMs, |
| liveProgress, |
| canStopRecording, |
| minRecordingMs, |
| error, |
| videoRef, |
| startRecording, |
| stopRecording, |
| requestPermissionAndPreview, |
| }; |
| } |
|
|