Files
llink/js/desktop/src/features/compose/use-screen-recorder.ts
T

173 lines
5.0 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus";
const VIDEO_FALLBACK_MIME = "video/webm";
function getScreenMime(): string {
return MediaRecorder.isTypeSupported(VIDEO_PREFERRED_MIME)
? VIDEO_PREFERRED_MIME
: VIDEO_FALLBACK_MIME;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
interface UseScreenRecorderOptions {
micDeviceId?: string;
onFinish: (blob: Blob, durationMs: number, mimeType: string) => void;
onError: (message: string) => void;
}
async function getMicStream(
micDeviceId: string | undefined,
): Promise<MediaStream> {
const constraints: MediaStreamConstraints = {
audio: micDeviceId ? { deviceId: { exact: micDeviceId } } : true,
};
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
if (
micDeviceId &&
err instanceof Error &&
(err.name === "OverconstrainedError" || err.name === "NotFoundError")
) {
return navigator.mediaDevices.getUserMedia({ audio: true });
}
throw err;
}
}
/**
* Manages screen recording via Electron's desktopCapturer.
* Captures screen video + mic audio.
*/
export function useScreenRecorder({
micDeviceId,
onFinish,
onError,
}: UseScreenRecorderOptions) {
const recorderRef = useRef<MediaRecorder | null>(null);
const screenStreamRef = useRef<MediaStream | null>(null);
const micStreamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const cleanupIpcRef = useRef<(() => void) | null>(null);
const onFinishRef = useRef(onFinish);
const onErrorRef = useRef(onError);
useEffect(() => {
onFinishRef.current = onFinish;
onErrorRef.current = onError;
});
const stopAllTracks = useCallback(() => {
screenStreamRef.current?.getTracks().forEach((t) => t.stop());
micStreamRef.current?.getTracks().forEach((t) => t.stop());
screenStreamRef.current = null;
micStreamRef.current = null;
recorderRef.current = null;
chunksRef.current = [];
cleanupIpcRef.current?.();
cleanupIpcRef.current = null;
}, []);
const startRecording = useCallback(
async (sourceId: string) => {
try {
// 1. Screen video
const screenStream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: sourceId,
},
} as MediaTrackConstraints,
});
screenStreamRef.current = screenStream;
// 2. Mic audio
const micStream = await getMicStream(micDeviceId);
micStreamRef.current = micStream;
// 3. Combine screen video + mic audio
const combined = new MediaStream([
...screenStream.getVideoTracks(),
...micStream.getAudioTracks(),
]);
chunksRef.current = [];
startTimeRef.current = Date.now();
const mime = getScreenMime();
const recorder = new MediaRecorder(combined, { 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 });
stopAllTracks();
window.electronScreen.stopRecordingWindow();
if (blob.size > 0) {
onFinishRef.current(blob, durationMs, mime);
}
};
recorder.start(1000);
// 4. Show floating control window
window.electronScreen.startRecordingWindow();
// 5. Listen for stop from floating window
cleanupIpcRef.current = window.electronScreen.onStopRequested(() => {
if (recorderRef.current?.state === "recording") {
recorderRef.current.stop();
}
});
} catch (err) {
stopAllTracks();
window.electronScreen.stopRecordingWindow();
onErrorRef.current(
err instanceof Error ? err.message : "Failed to start screen recording",
);
}
},
[micDeviceId, stopAllTracks],
);
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();
}
}
stopAllTracks();
window.electronScreen.stopRecordingWindow();
}, [stopAllTracks]);
// Cleanup on unmount
useEffect(() => {
return () => {
stopAllTracks();
window.electronScreen.stopRecordingWindow();
};
}, [stopAllTracks]);
return { startRecording, stopRecording, cancelRecording };
}