* chore: only set visibility for container particles * create reusable controls indicator for reply or new * compress the size of top bar * refactor: restructure state, routing, and more * introduce stream compose flow * feat: compose new stream full flow * implement stream player * fix: prevent redirect for signed object urls * fix: implement stream playback cleaner structure * refactor: layout file name * feat: show stream name in breadcrumbs * chore: tweak padding * chore: adjust position of audio bars * feat: show latest particle preview in stream list * fix: remove console log * refactor: reorder classes * fix: avoid passing in updated_at to firestore particle * refactor: extract properties for container particles to flat fields in firestore * make the stream previews look alive * feat: show audio bars during audio clip playback * feat: order streams by last child creation * feat: playback where I left off * chore: remove unused store * fix: recording mode not using shared state * chore: clean unused variable * remove unused imports * fix: improve controls indicator immersion * feat: show playback progress in bar & auto-play text * feat: auto-exit stream on playback completion * fix: jittery media playback progress * fix: navigate during state change is invalid with react router * fix: buggy exit progress when changing clips * feat: add app icon * update package.json info * feat: only show streams visible to me * feat: show seen indicator on particles * fix: prevent unnecessary effects * fix: play new particle after playback is ended * use contols indicator for exit timer
125 lines
3.6 KiB
TypeScript
125 lines
3.6 KiB
TypeScript
import { useCallback, useEffect, useRef } from "react";
|
|
import type { RecordingMode } from "@/hooks/use-recording-mode";
|
|
|
|
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;
|
|
onStreamReady: (stream: MediaStream) => void;
|
|
onStreamCleanup: () => void;
|
|
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
|
|
onError: (message: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Manages MediaRecorder lifecycle. Pure media utility — knows nothing
|
|
* about application state. The consumer provides callbacks for all outputs.
|
|
*/
|
|
export function useRecorder({
|
|
mode,
|
|
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 =
|
|
mode === "video" ? { video: true, audio: true } : { audio: true };
|
|
|
|
const mediaStream =
|
|
await navigator.mediaDevices.getUserMedia(constraints);
|
|
|
|
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, 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 };
|
|
}
|