introduce stream compose flow
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user