174 lines
5.2 KiB
TypeScript
174 lines
5.2 KiB
TypeScript
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<MediaStream> {
|
|
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<MediaRecorder | null>(null);
|
|
const streamRef = useRef<MediaStream | null>(null);
|
|
const chunksRef = useRef<Blob[]>([]);
|
|
const startTimeRef = useRef<number>(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 };
|
|
}
|