import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; // Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up const TARGET_FRAME_RATE = 60; const MIN_FRAME_RATE = 30; const TARGET_WIDTH = 3840; const TARGET_HEIGHT = 2160; const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT; const QHD_WIDTH = 2560; const QHD_HEIGHT = 1440; const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT; // Bitrates (bits per second) per resolution tier const BITRATE_4K = 45_000_000; const BITRATE_QHD = 28_000_000; const BITRATE_BASE = 18_000_000; const HIGH_FRAME_RATE_THRESHOLD = 60; const HIGH_FRAME_RATE_BOOST = 1.7; // Fallback track settings when the driver reports nothing const DEFAULT_WIDTH = 1920; const DEFAULT_HEIGHT = 1080; // Codec alignment: VP9/AV1 require dimensions divisible by 2 const CODEC_ALIGNMENT = 2; const RECORDER_TIMESLICE_MS = 1000; const BITS_PER_MEGABIT = 1_000_000; const CHROME_MEDIA_SOURCE = "desktop"; const RECORDING_FILE_PREFIX = "recording-"; const VIDEO_FILE_EXTENSION = ".webm"; const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; // Boost mic slightly when mixing with system audio so voice isn't drowned out const MIC_GAIN_BOOST = 1.4; type UseScreenRecorderReturn = { recording: boolean; toggleRecording: () => void; restartRecording: () => void; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; microphoneDeviceId: string | undefined; setMicrophoneDeviceId: (deviceId: string | undefined) => void; systemAudioEnabled: boolean; setSystemAudioEnabled: (enabled: boolean) => void; }; export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const mediaRecorder = useRef(null); const stream = useRef(null); const screenStream = useRef(null); const microphoneStream = useRef(null); const mixingContext = useRef(null); const chunks = useRef([]); const startTime = useRef(0); const discardRecording = useRef(false); const restarting = useRef(false); const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", "video/webm;codecs=h264", "video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", ]; return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm"; }; const computeBitrate = (width: number, height: number) => { const pixels = width * height; const highFrameRateBoost = TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1; if (pixels >= FOUR_K_PIXELS) { return Math.round(BITRATE_4K * highFrameRateBoost); } if (pixels >= QHD_PIXELS) { return Math.round(BITRATE_QHD * highFrameRateBoost); } return Math.round(BITRATE_BASE * highFrameRateBoost); }; const stopRecording = useRef(() => { if (mediaRecorder.current?.state === "recording") { if (stream.current) { stream.current.getTracks().forEach((track) => track.stop()); } if (screenStream.current) { screenStream.current.getTracks().forEach((track) => track.stop()); screenStream.current = null; } if (microphoneStream.current) { microphoneStream.current.getTracks().forEach((track) => track.stop()); microphoneStream.current = null; } if (mixingContext.current) { mixingContext.current.close().catch(() => { // Ignore close errors during recorder teardown. }); mixingContext.current = null; } mediaRecorder.current.stop(); setRecording(false); window.electronAPI?.setRecordingState(false); } }); useEffect(() => { let cleanup: (() => void) | undefined; if (window.electronAPI?.onStopRecordingFromTray) { cleanup = window.electronAPI.onStopRecordingFromTray(() => { stopRecording.current(); }); } return () => { if (cleanup) cleanup(); if (mediaRecorder.current?.state === "recording") { mediaRecorder.current.stop(); } if (stream.current) { stream.current.getTracks().forEach((track) => track.stop()); stream.current = null; } if (screenStream.current) { screenStream.current.getTracks().forEach((track) => track.stop()); screenStream.current = null; } if (microphoneStream.current) { microphoneStream.current.getTracks().forEach((track) => track.stop()); microphoneStream.current = null; } if (mixingContext.current) { mixingContext.current.close().catch(() => { // Ignore close errors during cleanup. }); mixingContext.current = null; } }; }, []); const startRecording = async () => { try { const selectedSource = await window.electronAPI.getSelectedSource(); if (!selectedSource) { alert("Please select a source to record"); return; } let screenMediaStream: MediaStream; const videoConstraints = { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: selectedSource.id, maxWidth: TARGET_WIDTH, maxHeight: TARGET_HEIGHT, maxFrameRate: TARGET_FRAME_RATE, minFrameRate: MIN_FRAME_RATE, }, }; if (systemAudioEnabled) { try { screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: selectedSource.id, }, }, video: videoConstraints, } as unknown as MediaStreamConstraints); } catch (audioErr) { console.warn("System audio capture failed, falling back to video-only:", audioErr); toast.error("System audio not available. Recording without system audio."); screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, } as unknown as MediaStreamConstraints); } } else { screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, } as unknown as MediaStreamConstraints); } screenStream.current = screenMediaStream; // If microphone is enabled, request mic stream if (microphoneEnabled) { try { microphoneStream.current = await navigator.mediaDevices.getUserMedia({ audio: microphoneDeviceId ? { deviceId: { exact: microphoneDeviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true, } : { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, video: false, }); } catch (audioError) { console.warn("Failed to get microphone access:", audioError); toast.error("Microphone access denied. Recording will continue without audio."); setMicrophoneEnabled(false); } } // Combine streams stream.current = new MediaStream(); const videoTrack = screenMediaStream.getVideoTracks()[0]; if (!videoTrack) { throw new Error("Video track is not available."); } stream.current.addTrack(videoTrack); const systemAudioTrack = screenMediaStream.getAudioTracks()[0]; const micAudioTrack = microphoneStream.current?.getAudioTracks()[0]; if (systemAudioTrack && micAudioTrack) { // Mix system audio + mic using Web Audio API const ctx = new AudioContext(); mixingContext.current = ctx; const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack])); const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack])); const micGain = ctx.createGain(); micGain.gain.value = MIC_GAIN_BOOST; const destination = ctx.createMediaStreamDestination(); systemSource.connect(destination); micSource.connect(micGain).connect(destination); stream.current.addTrack(destination.stream.getAudioTracks()[0]); } else if (systemAudioTrack) { stream.current.addTrack(systemAudioTrack); } else if (micAudioTrack) { stream.current.addTrack(micAudioTrack); } try { await videoTrack.applyConstraints({ frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, }); } catch (constraintError) { console.warn( "Unable to lock 4K/60fps constraints, using best available track settings.", constraintError, ); } let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, frameRate = TARGET_FRAME_RATE, } = videoTrack.getSettings(); // Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT; height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT; const videoBitsPerSecond = computeBitrate(width, height); const mimeType = selectMimeType(); console.log( `Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round( videoBitsPerSecond / BITS_PER_MEGABIT, )} Mbps`, ); const hasAudio = stream.current.getAudioTracks().length > 0; chunks.current = []; const recorder = new MediaRecorder(stream.current, { mimeType, videoBitsPerSecond, ...(hasAudio ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } : {}), }); mediaRecorder.current = recorder; recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) chunks.current.push(e.data); }; recorder.onstop = async () => { stream.current = null; if (discardRecording.current) { discardRecording.current = false; chunks.current = []; return; } if (chunks.current.length === 0) return; const duration = Date.now() - startTime.current; const recordedChunks = chunks.current; const buggyBlob = new Blob(recordedChunks, { type: mimeType }); // Clear chunks early to free memory immediately after blob creation chunks.current = []; const timestamp = Date.now(); const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`; try { const videoBlob = await fixWebmDuration(buggyBlob, duration); const arrayBuffer = await videoBlob.arrayBuffer(); const videoResult = await window.electronAPI.storeRecordedVideo( arrayBuffer, videoFileName, ); if (!videoResult.success) { console.error("Failed to store video:", videoResult.message); return; } if (videoResult.path) { await window.electronAPI.setCurrentVideoPath(videoResult.path); } await window.electronAPI.switchToEditor(); } catch (error) { console.error("Error saving recording:", error); } }; recorder.onerror = () => setRecording(false); recorder.start(RECORDER_TIMESLICE_MS); startTime.current = Date.now(); setRecording(true); window.electronAPI?.setRecordingState(true); } catch (error) { console.error("Failed to start recording:", error); const errorMsg = error instanceof Error ? error.message : "Failed to start recording"; if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) { toast.error("Recording permission denied. Please allow screen recording."); } else { toast.error(errorMsg); } setRecording(false); if (stream.current) { stream.current.getTracks().forEach((track) => track.stop()); stream.current = null; } if (screenStream.current) { screenStream.current.getTracks().forEach((track) => track.stop()); screenStream.current = null; } if (microphoneStream.current) { microphoneStream.current.getTracks().forEach((track) => track.stop()); microphoneStream.current = null; } if (mixingContext.current) { mixingContext.current.close().catch(() => { // Ignore close errors during error recovery. }); mixingContext.current = null; } } }; const toggleRecording = () => { recording ? stopRecording.current() : startRecording(); }; const restartRecording = async () => { if (restarting.current) return; const recorder = mediaRecorder.current; if (!recorder || recorder.state !== "recording") return; restarting.current = true; discardRecording.current = true; const waitForStop = new Promise((resolve) => { recorder.addEventListener("stop", () => resolve(), { once: true }); }); stopRecording.current(); await waitForStop; try { await startRecording(); } finally { restarting.current = false; } }; return { recording, toggleRecording, restartRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled, }; }