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 { 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(null); const screenStreamRef = useRef(null); const micStreamRef = useRef(null); const chunksRef = useRef([]); const startTimeRef = useRef(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 }; }