import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import { type NativeMacRecordingRequest, parseMacDisplayIdFromSourceId, parseMacWindowIdFromSourceId, } from "@/lib/nativeMacRecording"; import { type NativeWindowsRecordingRequest, parseWindowHandleFromSourceId, } from "@/lib/nativeWindowsRecording"; import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession"; import { requestCameraAccess } from "@/lib/requestCameraAccess"; 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; 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; const DEFAULT_WIDTH = 1920; const DEFAULT_HEIGHT = 1080; 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 WEBCAM_FILE_SUFFIX = "-webcam"; const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; paused: boolean; elapsedSeconds: number; toggleRecording: () => void; togglePaused: () => void; canPauseRecording: boolean; restartRecording: () => void; cancelRecording: () => void; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; microphoneDeviceId: string | undefined; setMicrophoneDeviceId: (deviceId: string | undefined) => void; microphoneDeviceName: string | undefined; setMicrophoneDeviceName: (deviceName: string | undefined) => void; webcamDeviceId: string | undefined; setWebcamDeviceId: (deviceId: string | undefined) => void; webcamDeviceName: string | undefined; setWebcamDeviceName: (deviceName: string | undefined) => void; systemAudioEnabled: boolean; setSystemAudioEnabled: (enabled: boolean) => void; webcamEnabled: boolean; setWebcamEnabled: (enabled: boolean) => Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; }; type RecorderHandle = { recorder: MediaRecorder; recordedBlobPromise: Promise; }; type NativeWindowsRecordingHandle = { recordingId: number; finalizing: boolean; paused: boolean; webcamRecorder: RecorderHandle | null; }; type NativeMacRecordingHandle = { recordingId: number; finalizing: boolean; paused: boolean; }; function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle { const recorder = new MediaRecorder(stream, options); const chunks: Blob[] = []; const mimeType = options.mimeType || "video/webm"; const recordedBlobPromise = new Promise((resolve, reject) => { recorder.ondataavailable = (event: BlobEvent) => { if (event.data && event.data.size > 0) { chunks.push(event.data); } }; recorder.onerror = () => { reject(new Error("Recording failed")); }; recorder.onstop = () => { resolve(new Blob(chunks, { type: mimeType })); }; }); recorder.start(RECORDER_TIMESLICE_MS); return { recorder, recordedBlobPromise }; } export function useScreenRecorder(): UseScreenRecorderReturn { const t = useScopedT("editor"); const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [microphoneDeviceName, setMicrophoneDeviceName] = useState(undefined); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); const [webcamDeviceName, setWebcamDeviceName] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); const screenRecorder = useRef(null); const webcamRecorder = useRef(null); const nativeWindowsRecording = useRef(null); const nativeMacRecording = useRef(null); const stream = useRef(null); const screenStream = useRef(null); const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); const recordingId = useRef(0); const accumulatedDurationMs = useRef(0); const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); const countdownRunId = useRef(0); const [countdownActive, setCountdownActive] = useState(false); const webcamReady = useRef(false); const webcamAcquireId = useRef(0); const canPauseRecording = recording && Boolean( (nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) || (nativeMacRecording.current && !nativeMacRecording.current.finalizing) || (screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"), ); const getRecordingDurationMs = useCallback(() => { const segmentDuration = segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current; return accumulatedDurationMs.current + segmentDuration; }, []); const selectMimeType = () => { // H.264 first: hardware-accelerated on all modern devices, gives sharp // real-time output. AV1/VP9 are great for distribution but too // CPU-intensive for live 60 fps capture — they produce blurry frames // when the software encoder can't keep up. const preferred = [ "video/webm;codecs=h264", "video/webm;codecs=vp8", "video/webm;codecs=vp9", "video/webm;codecs=av1", "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 teardownMedia = useCallback(() => { 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 recorder teardown. }); mixingContext.current = null; } }, []); const stopWebcamPreviewStream = useCallback(() => { if (!webcamStream.current) { return; } webcamAcquireId.current++; webcamStream.current.getTracks().forEach((track) => { track.onended = null; track.stop(); }); webcamStream.current = null; webcamReady.current = true; }, []); const setWebcamEnabled = useCallback( async (enabled: boolean) => { if (!enabled) { setWebcamEnabledState(false); return true; } const accessResult = await requestCameraAccess(); if (!accessResult.success) { toast.error(t("recording.failedCameraAccess")); return false; } if (!accessResult.granted) { toast.error(t("recording.cameraBlocked")); return false; } setWebcamEnabledState(true); return true; }, [t], ); useEffect(() => { if (!webcamEnabled) return; let cancelled = false; let acquiredStream: MediaStream | null = null; const thisAcquireId = ++webcamAcquireId.current; webcamReady.current = false; const acquire = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: webcamDeviceId ? { deviceId: { exact: webcamDeviceId }, frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, } : { frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE }, }, }); if (cancelled || thisAcquireId !== webcamAcquireId.current) { stream.getTracks().forEach((track) => { track.onended = null; track.stop(); }); return; } acquiredStream = stream; stream.getVideoTracks().forEach((track) => { track.onended = () => { webcamStream.current = null; if (!restarting.current) { setWebcamEnabledState(false); toast.error(t("recording.cameraDisconnected")); } }; }); webcamStream.current = stream; webcamReady.current = true; } catch (cameraError) { if (!cancelled) { console.warn("Failed to get webcam access:", cameraError); setWebcamEnabledState(false); const isDeviceError = cameraError instanceof DOMException && [ "NotFoundError", "DevicesNotFoundError", "OverconstrainedError", "NotReadableError", ].includes(cameraError.name); toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked")); webcamReady.current = true; } } }; void acquire(); return () => { cancelled = true; webcamReady.current = false; if (acquiredStream) { acquiredStream.getTracks().forEach((track) => { track.onended = null; track.stop(); }); webcamStream.current = null; } }; }, [webcamEnabled, webcamDeviceId, t]); const finalizeRecording = useCallback( ( activeScreenRecorder: RecorderHandle, activeWebcamRecorder: RecorderHandle | null, duration: number, activeRecordingId: number, ) => { if (finalizingRecordingId.current === activeRecordingId) { return; } finalizingRecordingId.current = activeRecordingId; if (screenRecorder.current === activeScreenRecorder) { screenRecorder.current = null; } if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { webcamRecorder.current = null; } teardownMedia(); setRecording(false); setPaused(false); setElapsedSeconds(0); accumulatedDurationMs.current = 0; segmentStartedAt.current = null; window.electronAPI?.setRecordingState(false); void (async () => { try { const screenBlob = await activeScreenRecorder.recordedBlobPromise; if (discardRecordingId.current === activeRecordingId) { window.electronAPI?.discardCursorTelemetry(activeRecordingId); return; } if (screenBlob.size === 0) { return; } const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); let fixedWebcamBlob: Blob | null = null; if (activeWebcamRecorder) { const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); if (webcamBlob && webcamBlob.size > 0) { fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); } } const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`; const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; const result = await window.electronAPI.storeRecordedSession({ screen: { videoData: await fixedScreenBlob.arrayBuffer(), fileName: screenFileName, }, webcam: fixedWebcamBlob ? { videoData: await fixedWebcamBlob.arrayBuffer(), fileName: webcamFileName, } : undefined, createdAt: activeRecordingId, cursorCaptureMode, }); if (!result.success) { console.error("Failed to store recording session:", result.message); return; } if (result.session) { await window.electronAPI.setCurrentRecordingSession(result.session); } else if (result.path) { await window.electronAPI.setCurrentVideoPath(result.path); } await window.electronAPI.switchToEditor(); } catch (error) { console.error("Error saving recording:", error); } finally { if (finalizingRecordingId.current === activeRecordingId) { finalizingRecordingId.current = null; } if (discardRecordingId.current === activeRecordingId) { discardRecordingId.current = null; } } })(); }, [cursorCaptureMode, teardownMedia], ); const finalizeNativeWindowsRecording = useCallback( async (discard = false) => { const activeNativeRecording = nativeWindowsRecording.current; if (!activeNativeRecording || activeNativeRecording.finalizing) { return false; } activeNativeRecording.finalizing = true; const activeWebcamRecorder = activeNativeRecording.webcamRecorder; const duration = Math.max(0, getRecordingDurationMs()); if ( activeWebcamRecorder?.recorder.state === "recording" || activeWebcamRecorder?.recorder.state === "paused" ) { try { activeWebcamRecorder.recorder.stop(); } catch { // Recorder may already be stopping. } } if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { webcamRecorder.current = null; } const clearNativeRecordingState = () => { nativeWindowsRecording.current = null; setRecording(false); setPaused(false); setElapsedSeconds(0); accumulatedDurationMs.current = 0; segmentStartedAt.current = null; }; try { const result = await window.electronAPI.stopNativeWindowsRecording(discard); if (discard || result.discarded) { clearNativeRecordingState(); return true; } if (!result.success) { console.error("Failed to stop native Windows recording:", result.error); toast.error(result.error ?? "Failed to stop native Windows recording"); activeNativeRecording.finalizing = false; return true; } const nativeScreenPath = result.session?.screenVideoPath ?? result.path; let storedSession = result.session; if (activeWebcamRecorder && nativeScreenPath) { const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath); if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) { const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); const nativeScreenFileName = nativeScreenPath.split(/[\\/]/).pop() ?? `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; const stored = await window.electronAPI.storeRecordedSession({ screen: { videoData: screenRead.data, fileName: nativeScreenFileName, }, webcam: { videoData: await fixedWebcamBlob.arrayBuffer(), fileName: webcamFileName, }, createdAt: activeNativeRecording.recordingId, cursorCaptureMode, }); if (stored.success && stored.session) { storedSession = stored.session; } } } clearNativeRecordingState(); if (storedSession) { await window.electronAPI.setCurrentRecordingSession(storedSession); } else if (result.path) { await window.electronAPI.setCurrentVideoPath(result.path); } await window.electronAPI.switchToEditor(); return true; } catch (error) { console.error("Error saving native Windows recording:", error); toast.error( error instanceof Error ? error.message : "Failed to save native Windows recording", ); activeNativeRecording.finalizing = false; return true; } finally { if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } } }, [cursorCaptureMode, getRecordingDurationMs], ); const finalizeNativeMacRecording = useCallback( async (discard = false) => { const activeNativeRecording = nativeMacRecording.current; if (!activeNativeRecording || activeNativeRecording.finalizing) { return false; } activeNativeRecording.finalizing = true; const duration = Math.max(0, getRecordingDurationMs()); const activeWebcamRecorder = webcamRecorder.current; if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) { webcamRecorder.current = null; } const webcamAssetPromise = (async (): Promise => { if (!activeWebcamRecorder) { return undefined; } try { if (activeWebcamRecorder.recorder.state !== "inactive") { activeWebcamRecorder.recorder.stop(); } const webcamBlob = await activeWebcamRecorder.recordedBlobPromise; if (!webcamBlob || webcamBlob.size === 0) { return undefined; } const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); return { videoData: await fixedWebcamBlob.arrayBuffer(), fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, }; } catch (error) { console.error("Failed to finalize native macOS webcam recording:", error); return undefined; } })(); const clearNativeRecordingState = () => { nativeMacRecording.current = null; setRecording(false); setPaused(false); setElapsedSeconds(0); accumulatedDurationMs.current = 0; segmentStartedAt.current = null; }; try { const result = await window.electronAPI.stopNativeMacRecording(discard); const webcamAsset = await webcamAssetPromise; if (discard || result.discarded) { clearNativeRecordingState(); return true; } if (!result.success) { console.error("Failed to stop native macOS recording:", result.error); toast.error(result.error ?? "Failed to stop native macOS recording"); activeNativeRecording.finalizing = false; return true; } if (webcamAsset && result.path) { const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({ screenVideoPath: result.path, recordingId: activeNativeRecording.recordingId, webcam: webcamAsset, cursorCaptureMode, }); if (attachResult.success) { result.session = attachResult.session; } else { console.error("Failed to attach native macOS webcam recording:", attachResult.error); toast.error(attachResult.error ?? "Failed to store webcam recording"); } } clearNativeRecordingState(); if (result.session) { await window.electronAPI.setCurrentRecordingSession(result.session); } else if (result.path) { await window.electronAPI.setCurrentVideoPath(result.path); } await window.electronAPI.switchToEditor(); return true; } catch (error) { console.error("Error saving native macOS recording:", error); toast.error( error instanceof Error ? error.message : "Failed to save native macOS recording", ); activeNativeRecording.finalizing = false; return true; } finally { if (discardRecordingId.current === activeNativeRecording.recordingId) { discardRecordingId.current = null; } } }, [cursorCaptureMode, getRecordingDurationMs], ); const stopRecording = useRef(() => { if (nativeWindowsRecording.current) { void finalizeNativeWindowsRecording(false); return; } if (nativeMacRecording.current) { void finalizeNativeMacRecording(false); return; } const activeScreenRecorder = screenRecorder.current; if (!activeScreenRecorder) { return; } const activeWebcamRecorder = webcamRecorder.current; const duration = getRecordingDurationMs(); const activeRecordingId = recordingId.current; finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, duration, activeRecordingId, ); if ( activeScreenRecorder.recorder.state === "recording" || activeScreenRecorder.recorder.state === "paused" ) { try { activeScreenRecorder.recorder.stop(); } catch { // Recorder may already be stopping. } } if (activeWebcamRecorder) { if ( activeWebcamRecorder.recorder.state === "recording" || activeWebcamRecorder.recorder.state === "paused" ) { try { activeWebcamRecorder.recorder.stop(); } catch { // Recorder may already be stopping. } } } }); const safeHideCountdownOverlay = useCallback(async (runId: number) => { try { await window.electronAPI.hideCountdownOverlay(runId); } catch (error) { console.warn("Failed to hide countdown overlay:", error); } }, []); useEffect(() => { let cleanup: (() => void) | undefined; if (window.electronAPI?.onStopRecordingFromTray) { cleanup = window.electronAPI.onStopRecordingFromTray(() => { stopRecording.current(); }); } return () => { const activeRunId = countdownRunId.current; if (cleanup) cleanup(); countdownRunId.current += 1; void safeHideCountdownOverlay(activeRunId); allowAutoFinalize.current = false; restarting.current = false; discardRecordingId.current = null; if (nativeWindowsRecording.current) { void finalizeNativeWindowsRecording(true); } if (nativeMacRecording.current) { void finalizeNativeMacRecording(true); } if ( screenRecorder.current?.recorder.state === "recording" || screenRecorder.current?.recorder.state === "paused" ) { try { screenRecorder.current.recorder.stop(); } catch { // Ignore recorder teardown errors during cleanup. } } if ( webcamRecorder.current?.recorder.state === "recording" || webcamRecorder.current?.recorder.state === "paused" ) { try { webcamRecorder.current.recorder.stop(); } catch { // Ignore recorder teardown errors during cleanup. } } screenRecorder.current = null; webcamRecorder.current = null; teardownMedia(); }; }, [ teardownMedia, safeHideCountdownOverlay, finalizeNativeWindowsRecording, finalizeNativeMacRecording, ]); const safeShowCountdownOverlay = async (value: number, runId: number) => { try { await window.electronAPI.showCountdownOverlay(value, runId); return true; } catch (error) { console.warn("Failed to show countdown overlay:", error); return false; } }; const cancelCountdown = () => { const activeRunId = countdownRunId.current; countdownRunId.current += 1; setCountdownActive(false); void safeHideCountdownOverlay(activeRunId); }; const safeSetCountdownOverlayValue = async (value: number, runId: number) => { try { await window.electronAPI.setCountdownOverlayValue(value, runId); } catch (error) { console.warn("Failed to update countdown overlay value:", error); } }; const isCountdownRunActive = (runId?: number) => runId === undefined || countdownRunId.current === runId; const waitForWebcamReady = async () => { if (webcamReady.current) { return; } await new Promise((resolve) => { const interval = setInterval(() => { if (webcamReady.current) { clearInterval(interval); resolve(); } }, 50); setTimeout(() => { clearInterval(interval); resolve(); }, 5000); }); }; const startNativeWindowsRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, countdownRunToken?: number, ) => { try { const platform = await window.electronAPI.getPlatform(); if (platform !== "win32") { return false; } const availability = await window.electronAPI.isNativeWindowsCaptureAvailable(); if (!availability.success || !availability.available) { if (availability.reason === "unsupported-os") { return false; } if (availability.reason === "missing-helper") { console.warn("Native Windows capture helper is not available; using browser capture."); return false; } throw new Error(availability.error ?? "Native Windows capture is not available."); } if (!isCountdownRunActive(countdownRunToken)) { return true; } const activeRecordingId = Date.now(); const displayId = Number(selectedSource.display_id); const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const windowHandle = parseWindowHandleFromSourceId(selectedSource.id); if (webcamEnabled) { await waitForWebcamReady(); if (!isCountdownRunActive(countdownRunToken)) { return true; } } const browserWebcamRecorder = webcamEnabled && webcamStream.current ? createRecorderHandle(webcamStream.current, { mimeType: selectMimeType(), videoBitsPerSecond: BITRATE_BASE, }) : null; if (webcamEnabled && !browserWebcamRecorder) { stopWebcamPreviewStream(); } const request: NativeWindowsRecordingRequest = { recordingId: activeRecordingId, source: { type: sourceType, sourceId: selectedSource.id, ...(Number.isFinite(displayId) ? { displayId } : {}), ...(windowHandle ? { windowHandle } : {}), }, video: { fps: TARGET_FRAME_RATE, width: TARGET_WIDTH, height: TARGET_HEIGHT, }, audio: { system: { enabled: systemAudioEnabled, }, microphone: { enabled: microphoneEnabled, deviceId: microphoneDeviceId, deviceName: microphoneDeviceName, gain: MIC_GAIN_BOOST, }, }, webcam: { enabled: webcamEnabled && !browserWebcamRecorder, deviceId: webcamDeviceId, deviceName: webcamDeviceName, width: 0, height: 0, fps: WEBCAM_TARGET_FRAME_RATE, }, cursor: { mode: cursorCaptureMode, }, }; const result = await window.electronAPI.startNativeWindowsRecording(request); if (!result.success || !result.recordingId) { if ( browserWebcamRecorder?.recorder.state === "recording" || browserWebcamRecorder?.recorder.state === "paused" ) { browserWebcamRecorder.recorder.stop(); } throw new Error(result.error ?? "Native Windows capture failed."); } recordingId.current = result.recordingId; nativeWindowsRecording.current = { recordingId: result.recordingId, finalizing: false, paused: false, webcamRecorder: browserWebcamRecorder, }; webcamRecorder.current = browserWebcamRecorder; accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; setRecording(true); setPaused(false); setElapsedSeconds(0); return true; } catch (error) { console.error("Native Windows capture failed:", error); throw error; } }; const startNativeMacRecordingIfAvailable = async ( selectedSource: ProcessedDesktopSource, countdownRunToken?: number, ) => { try { const platform = await window.electronAPI.getPlatform(); if (platform !== "darwin") { return false; } const availability = await window.electronAPI.isNativeMacCaptureAvailable(); if (!availability.success || !availability.available) { if (availability.reason === "unsupported-platform") { return false; } throw new Error( availability.reason === "missing-helper" ? "Native macOS capture helper is not available." : (availability.error ?? "Native macOS capture is not available."), ); } if (!isCountdownRunActive(countdownRunToken)) { return true; } const activeRecordingId = Date.now(); const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display"; const displayId = Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id); const windowId = parseMacWindowIdFromSourceId(selectedSource.id); let nativeWebcamRecorder: RecorderHandle | null = null; if (webcamEnabled) { if (!webcamReady.current) { await new Promise((resolve) => { const interval = setInterval(() => { if (webcamReady.current) { clearInterval(interval); resolve(); } }, 50); setTimeout(() => { clearInterval(interval); resolve(); }, 5000); }); } if (!isCountdownRunActive(countdownRunToken)) { return true; } if (webcamStream.current) { nativeWebcamRecorder = createRecorderHandle(webcamStream.current, { mimeType: selectMimeType(), videoBitsPerSecond: BITRATE_BASE, }); } else { webcamAcquireId.current++; setWebcamEnabledState(false); } } if (!isCountdownRunActive(countdownRunToken)) { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } return true; } const request: NativeMacRecordingRequest = { schemaVersion: 1, recordingId: activeRecordingId, source: { type: sourceType, sourceId: selectedSource.id, ...(displayId ? { displayId } : {}), ...(windowId ? { windowId } : {}), }, video: { fps: TARGET_FRAME_RATE, width: TARGET_WIDTH, height: TARGET_HEIGHT, bitrate: computeBitrate(TARGET_WIDTH, TARGET_HEIGHT), hideSystemCursor: cursorCaptureMode === "editable-overlay", }, audio: { system: { enabled: systemAudioEnabled, }, microphone: { enabled: microphoneEnabled, deviceId: microphoneDeviceId, deviceName: microphoneDeviceName, gain: MIC_GAIN_BOOST, }, }, webcam: { enabled: webcamEnabled, deviceId: webcamDeviceId, deviceName: webcamDeviceName, width: 0, height: 0, fps: WEBCAM_TARGET_FRAME_RATE, }, cursor: { mode: cursorCaptureMode, }, outputs: { screenPath: "", }, }; const result = await window.electronAPI.startNativeMacRecording(request); if (!result.success || !result.recordingId) { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } throw new Error(result.error ?? "Native macOS capture failed."); } if (!isCountdownRunActive(countdownRunToken)) { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } await window.electronAPI.stopNativeMacRecording(true); return true; } recordingId.current = result.recordingId; nativeMacRecording.current = { recordingId: result.recordingId, finalizing: false, paused: false, }; webcamRecorder.current = nativeWebcamRecorder; accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; setRecording(true); setPaused(false); setElapsedSeconds(0); return true; } catch (error) { console.error("Native macOS capture failed:", error); throw error; } }; const startRecordCountdown = async () => { if (countdownActive || recording) { return; } const runId = countdownRunId.current + 1; countdownRunId.current = runId; let selectedSource: ProcessedDesktopSource | null = null; try { selectedSource = await window.electronAPI.getSelectedSource(); } catch (error) { console.warn("Failed to read selected source before countdown:", error); } if (!isCountdownRunActive(runId)) { return; } if (!selectedSource) { if (countdownRunId.current === runId) { setCountdownActive(false); } alert(t("recording.selectSource")); return; } try { const platform = await window.electronAPI.getPlatform(); if (platform === "darwin" && cursorCaptureMode === "editable-overlay") { const access = await window.electronAPI.requestNativeMacCursorAccess(); if (!access.granted) { toast.info(t("recording.accessibilityAllowAndRetry")); return; } } } catch (error) { console.warn("Failed to preflight macOS cursor accessibility before countdown:", error); } if (!isCountdownRunActive(runId)) { return; } setCountdownActive(true); let overlayHiddenBeforeStart = false; try { const values = [3, 2, 1]; const overlayShown = await safeShowCountdownOverlay(values[0], runId); if (countdownRunId.current !== runId) { return; } for (const value of values) { if (countdownRunId.current !== runId) { return; } if (overlayShown && value !== values[0]) { await safeSetCountdownOverlayValue(value, runId); if (countdownRunId.current !== runId) { return; } } await new Promise((resolve) => window.setTimeout(resolve, 1000)); } if (countdownRunId.current !== runId) { return; } setCountdownActive(false); await safeHideCountdownOverlay(runId); overlayHiddenBeforeStart = true; if (countdownRunId.current !== runId) { return; } await startRecording(runId); } finally { if (!overlayHiddenBeforeStart && countdownRunId.current === runId) { setCountdownActive(false); await safeHideCountdownOverlay(runId); } } }; const startRecording = async (countdownRunToken?: number) => { try { const selectedSource = await window.electronAPI.getSelectedSource(); if (!selectedSource) { alert(t("recording.selectSource")); return; } if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; } if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) { return; } if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) { return; } let screenMediaStream: MediaStream; const platform = await window.electronAPI.getPlatform(); if (platform === "win32") { // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the // pre-selected source. Editable cursor mode excludes the system cursor so // the editor can render a replacement; system mode bakes it into the video. screenMediaStream = await navigator.mediaDevices.getDisplayMedia({ video: { cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always", width: { max: TARGET_WIDTH }, height: { max: TARGET_HEIGHT }, frameRate: { ideal: TARGET_FRAME_RATE }, } as MediaTrackConstraints, audio: systemAudioEnabled, } as DisplayMediaStreamOptions); } else { 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(t("recording.systemAudioUnavailable")); 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 (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; } 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(t("recording.microphoneDenied")); setMicrophoneEnabled(false); } } if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; } if (webcamEnabled) { if (!webcamReady.current) { await new Promise((resolve) => { const interval = setInterval(() => { if (webcamReady.current) { clearInterval(interval); resolve(); } }, 50); setTimeout(() => { clearInterval(interval); resolve(); }, 5000); }); } if (!webcamStream.current) { webcamAcquireId.current++; setWebcamEnabledState(false); } } if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; } 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) { 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, ); } if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; } let { width = DEFAULT_WIDTH, height = DEFAULT_HEIGHT, frameRate = TARGET_FRAME_RATE, } = videoTrack.getSettings(); 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; if (!isCountdownRunActive(countdownRunToken)) { teardownMedia(); return; } recordingId.current = Date.now(); const activeRecordingId = recordingId.current; screenRecorder.current = createRecorderHandle(stream.current, { mimeType, videoBitsPerSecond, ...(hasAudio ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } : {}), }); screenRecorder.current.recorder.addEventListener( "error", () => { setRecording(false); }, { once: true }, ); if (webcamStream.current) { webcamRecorder.current = createRecorderHandle(webcamStream.current, { mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE), }); } accumulatedDurationMs.current = 0; segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; setRecording(true); setPaused(false); setElapsedSeconds(0); window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current; if (activeScreenRecorder) { activeScreenRecorder.recorder.addEventListener( "stop", () => { if (!allowAutoFinalize.current) { return; } finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, Math.max(0, getRecordingDurationMs()), activeRecordingId, ); }, { once: 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(t("recording.permissionDenied")); } else { toast.error(errorMsg); } setRecording(false); setPaused(false); setElapsedSeconds(0); accumulatedDurationMs.current = 0; segmentStartedAt.current = null; screenRecorder.current = null; webcamRecorder.current = null; teardownMedia(); } }; const togglePaused = () => { const activeNativeWindowsRecording = nativeWindowsRecording.current; if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) { void (async () => { try { if (activeNativeWindowsRecording.paused) { const result = await window.electronAPI.resumeNativeWindowsRecording(); if (!result.success) { throw new Error(result.error ?? "Failed to resume native Windows recording"); } activeNativeWindowsRecording.paused = false; segmentStartedAt.current = Date.now(); setPaused(false); return; } const pausedAtMs = getRecordingDurationMs(); const result = await window.electronAPI.pauseNativeWindowsRecording(); if (!result.success) { throw new Error(result.error ?? "Failed to pause native Windows recording"); } activeNativeWindowsRecording.paused = true; accumulatedDurationMs.current = pausedAtMs; segmentStartedAt.current = null; setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); setPaused(true); } catch (error) { console.error("Failed to toggle native Windows pause state:", error); toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); } })(); return; } const activeNativeMacRecording = nativeMacRecording.current; if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) { void (async () => { const activeWebcamRecorder = webcamRecorder.current?.recorder; try { if (activeNativeMacRecording.paused) { const result = await window.electronAPI.resumeNativeMacRecording(); if (!result.success) { throw new Error(result.error ?? "Failed to resume native macOS recording"); } if (activeWebcamRecorder?.state === "paused") { activeWebcamRecorder.resume(); } activeNativeMacRecording.paused = false; segmentStartedAt.current = Date.now(); setPaused(false); return; } const pausedAtMs = getRecordingDurationMs(); const result = await window.electronAPI.pauseNativeMacRecording(); if (!result.success) { throw new Error(result.error ?? "Failed to pause native macOS recording"); } if (activeWebcamRecorder?.state === "recording") { activeWebcamRecorder.pause(); } activeNativeMacRecording.paused = true; accumulatedDurationMs.current = pausedAtMs; segmentStartedAt.current = null; setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); setPaused(true); } catch (error) { console.error("Failed to toggle native macOS pause state:", error); toast.error(error instanceof Error ? error.message : "Failed to toggle pause state"); } })(); return; } const activeScreenRecorder = screenRecorder.current?.recorder; if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") { return; } const activeWebcamRecorder = webcamRecorder.current?.recorder; if (activeScreenRecorder.state === "paused") { try { activeScreenRecorder.resume(); if (activeWebcamRecorder?.state === "paused") { activeWebcamRecorder.resume(); } segmentStartedAt.current = Date.now(); setPaused(false); } catch (error) { console.error("Failed to resume recording:", error); } return; } if (activeScreenRecorder.state !== "recording") { return; } try { accumulatedDurationMs.current = getRecordingDurationMs(); segmentStartedAt.current = null; setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000)); activeScreenRecorder.pause(); if (activeWebcamRecorder?.state === "recording") { activeWebcamRecorder.pause(); } setPaused(true); } catch (error) { console.error("Failed to pause recording:", error); } }; const toggleRecording = () => { if (recording) { stopRecording.current(); return; } if (countdownActive) { cancelCountdown(); return; } void startRecordCountdown(); }; const restartRecording = async () => { if (restarting.current) return; if (nativeWindowsRecording.current) { const activeRecordingId = recordingId.current; restarting.current = true; discardRecordingId.current = activeRecordingId; try { await finalizeNativeWindowsRecording(true); await startRecording(); } finally { restarting.current = false; } return; } if (nativeMacRecording.current) { const activeRecordingId = recordingId.current; restarting.current = true; discardRecordingId.current = activeRecordingId; try { await finalizeNativeMacRecording(true); await startRecording(); } finally { restarting.current = false; } return; } const activeScreenRecorder = screenRecorder.current; if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; restarting.current = true; discardRecordingId.current = activeRecordingId; const stopPromises = [ new Promise((resolve) => { activeScreenRecorder.recorder.addEventListener("stop", () => resolve(), { once: true }); }), ]; if ( activeWebcamRecorder?.recorder.state === "recording" || activeWebcamRecorder?.recorder.state === "paused" ) { stopPromises.push( new Promise((resolve) => { activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), { once: true, }); }), ); } stopRecording.current(); await Promise.all(stopPromises); try { await startRecording(); } finally { restarting.current = false; } }; useEffect(() => { if (!recording) { setElapsedSeconds(0); return; } setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); if (paused) { return; } const interval = window.setInterval(() => { setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000)); }, 250); return () => window.clearInterval(interval); }, [getRecordingDurationMs, paused, recording]); const cancelRecording = () => { if (nativeWindowsRecording.current) { const activeRecordingId = recordingId.current; discardRecordingId.current = activeRecordingId; allowAutoFinalize.current = false; void finalizeNativeWindowsRecording(true); return; } if (nativeMacRecording.current) { const activeRecordingId = recordingId.current; discardRecordingId.current = activeRecordingId; allowAutoFinalize.current = false; void finalizeNativeMacRecording(true); return; } const activeScreenRecorder = screenRecorder.current; if ( activeScreenRecorder?.recorder.state === "recording" || activeScreenRecorder?.recorder.state === "paused" ) { const activeRecordingId = recordingId.current; discardRecordingId.current = activeRecordingId; allowAutoFinalize.current = false; stopRecording.current(); return; } if (countdownActive) { cancelCountdown(); return; } }; return { recording, paused, elapsedSeconds, toggleRecording, togglePaused, canPauseRecording, restartRecording, cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, microphoneDeviceName, setMicrophoneDeviceName, webcamDeviceId, setWebcamDeviceId, webcamDeviceName, setWebcamDeviceName, systemAudioEnabled, setSystemAudioEnabled, webcamEnabled, setWebcamEnabled, cursorCaptureMode, setCursorCaptureMode, }; }