Files
llink/js/desktop/src/features/compose/use-recorder.ts
T
Arjun PatelandGitHub 849d41fa06 feat: use sexier audio waveform (#265)
* use sexier audio waveform

* format
2026-06-11 10:07:25 -07:00

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 };
}