173 lines
5.0 KiB
TypeScript
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 };
|
|
}
|