110 lines
3.3 KiB
TypeScript
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 };
|
|
}
|