Files
llink/js/src/features/compose/use-recorder.ts
T
2026-03-18 14:52:22 -07:00

110 lines
3.3 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import { useComposeStore } from "@/stores/compose-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;
}
/**
* Manages MediaRecorder lifecycle and writes results to compose-store.
*
* Does NOT handle uploads or particle creation — that responsibility
* belongs to the configure step after the user finalizes stream metadata.
*/
export function useRecorder() {
const recorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const recordingMode = useComposeStore((s) => s.recordingMode);
const setMediaStream = useComposeStore((s) => s.setMediaStream);
const setError = useComposeStore((s) => s.setError);
const finishRecording = useComposeStore((s) => s.finishRecording);
const stopTracks = useCallback(() => {
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
setMediaStream(null);
}, [setMediaStream]);
const startRecording = useCallback(async () => {
try {
const constraints =
recordingMode === "video"
? { video: true, audio: true }
: { audio: true };
const mediaStream =
await navigator.mediaDevices.getUserMedia(constraints);
streamRef.current = mediaStream;
setMediaStream(mediaStream);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getMediaMime(recordingMode);
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) {
finishRecording(blob, durationMs);
}
};
recorder.start();
} catch (err) {
stopTracks();
setError(
err instanceof Error ? err.message : "Failed to start recording",
);
}
}, [recordingMode, setMediaStream, setError, finishRecording, 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 };
}