import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; import { getVideoExtensionForMimeType, isWebmMimeType, selectRecordingMimeType, selectWebcamRecordingMimeType, } from "./recordingMimeType"; const TARGET_FRAME_RATE = 60; 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 = 250; const BITS_PER_MEGABIT = 1_000_000; const MIN_FRAME_RATE = 30; const CHROME_MEDIA_SOURCE = "desktop"; const RECORDING_FILE_PREFIX = "recording-"; const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; const WEBCAM_BITRATE = 8_000_000; const WEBCAM_WIDTH = 1280; const WEBCAM_HEIGHT = 720; const WEBCAM_FRAME_RATE = 30; const WEBCAM_SUFFIX = "-webcam"; const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error"; const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error"; export type BrowserMicrophoneProfile = | "processed" | "no-agc" | "no-echo" | "no-noise-suppression" | "raw"; type BrowserCaptureCursorMode = "always" | "never"; export type BrowserCaptureCursorPolicy = { streamCursor: BrowserCaptureCursorMode; hideOsCursorBeforeRecording: boolean; hideEditorOverlayCursorByDefault: boolean; }; const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "processed"; const BROWSER_MICROPHONE_PROFILES = new Set([ "processed", "no-agc", "no-echo", "no-noise-suppression", "raw", ]); type MicrophoneTrackSettingsSnapshot = Partial< Pick< MediaTrackSettings, | "autoGainControl" | "channelCount" | "deviceId" | "echoCancellation" | "groupId" | "noiseSuppression" | "sampleRate" | "sampleSize" > > & { trackId?: string; trackLabel?: string; trackEnabled?: boolean; trackMuted?: boolean; trackReadyState?: MediaStreamTrackState; }; type MicrophoneAudioInputDeviceSnapshot = { deviceId: string; groupId?: string; label: string; }; type MicrophoneFallbackChunkEvent = { index: number; size: number; elapsedMs: number; deltaMs: number | null; recordedElapsedMs: number; recordedDeltaMs: number | null; }; type MicrophoneFallbackPauseInterval = { startElapsedMs: number; endElapsedMs?: number; durationMs?: number; }; type MicrophoneFallbackRecorderMetadata = { mimeType: string; audioBitsPerSecond: number; timesliceMs: number; }; type MicrophoneSidecarOptions = { startDelayMs?: number; browserMicrophoneProfile?: BrowserMicrophoneProfile; requestedBrowserMicrophoneProfile?: string | null; requestedConstraints?: MediaStreamConstraints; mediaTrackSettings?: MicrophoneTrackSettingsSnapshot; audioInputDevices?: MicrophoneAudioInputDeviceSnapshot[]; mediaRecorder?: MicrophoneFallbackRecorderMetadata; chunkEvents?: MicrophoneFallbackChunkEvent[]; pauseIntervals?: MicrophoneFallbackPauseInterval[]; }; const LINUX_PORTAL_SOURCE: ProcessedDesktopSource = { id: "screen:linux-portal", name: "Linux Portal", display_id: "", thumbnail: null, appIcon: null, sourceType: "screen", }; type DesktopCaptureMediaDevices = { getUserMedia: (constraints: unknown) => Promise; getDisplayMedia: (constraints: unknown) => Promise; }; type UseScreenRecorderReturn = { recording: boolean; paused: boolean; finalizing: boolean; countdownActive: boolean; toggleRecording: () => void; pauseRecording: () => void; resumeRecording: () => void; cancelRecording: () => void; preparePermissions: (options?: { startup?: boolean }) => Promise; isMacOS: boolean; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; microphoneDeviceId: string | undefined; setMicrophoneDeviceId: (deviceId: string | undefined) => void; systemAudioEnabled: boolean; setSystemAudioEnabled: (enabled: boolean) => void; webcamEnabled: boolean; setWebcamEnabled: (enabled: boolean) => void; webcamDeviceId: string | undefined; setWebcamDeviceId: (deviceId: string | undefined) => void; countdownDelay: number; setCountdownDelay: (delay: number) => void; }; function getErrorMessage(error: unknown) { if (error instanceof Error && error.message) { return error.message; } if (typeof error === "string" && error.trim().length > 0) { return error; } if (typeof error === "object" && error !== null) { try { const serialized = JSON.stringify(error); if (serialized && serialized !== "{}") { return serialized; } } catch { // Ignore stringify failures and fall through to a generic message. } if (typeof (error as { toString?: () => string }).toString === "function") { const stringified = (error as { toString: () => string }).toString(); if (stringified && stringified !== "[object Object]") { return stringified; } } } return "An unexpected error occurred"; } export function normalizeBrowserMicrophoneProfile(value?: string | null): BrowserMicrophoneProfile { const normalized = value?.trim().toLowerCase(); return normalized && BROWSER_MICROPHONE_PROFILES.has(normalized as BrowserMicrophoneProfile) ? (normalized as BrowserMicrophoneProfile) : DEFAULT_BROWSER_MICROPHONE_PROFILE; } export function resolveBrowserCaptureCursorPolicy({ nativeWindowsCaptureStartFailed = false, }: { nativeWindowsCaptureStartFailed?: boolean; } = {}): BrowserCaptureCursorPolicy { if (nativeWindowsCaptureStartFailed) { // If WGC already failed, avoid the telemetry overlay path that can lag on // constrained Windows systems; keep the browser-captured cursor instead. return { streamCursor: "always", hideOsCursorBeforeRecording: false, hideEditorOverlayCursorByDefault: true, }; } return { streamCursor: "never", hideOsCursorBeforeRecording: true, hideEditorOverlayCursorByDefault: true, }; } export function shouldUseNativeWindowsCaptureForSource( source: Pick | null | undefined, ): boolean { return source?.id?.startsWith("screen:") === true || source?.id?.startsWith("window:") === true; } export function createProcessedMicrophoneConstraints( microphoneDeviceId?: string, profile: BrowserMicrophoneProfile = DEFAULT_BROWSER_MICROPHONE_PROFILE, ): MediaStreamConstraints { const normalizedProfile = normalizeBrowserMicrophoneProfile(profile); const audio: MediaTrackConstraints = { echoCancellation: normalizedProfile !== "no-echo" && normalizedProfile !== "raw", noiseSuppression: normalizedProfile !== "no-noise-suppression" && normalizedProfile !== "raw", autoGainControl: normalizedProfile !== "no-agc" && normalizedProfile !== "raw", channelCount: { ideal: 1 }, sampleRate: { ideal: 48000 }, }; if (microphoneDeviceId) { audio.deviceId = { exact: microphoneDeviceId }; } return { audio, video: false }; } export function createBrowserRecordingOptions({ audioBitsPerSecond, mimeType, videoBitsPerSecond, }: { audioBitsPerSecond?: number; mimeType?: string; videoBitsPerSecond: number; }): MediaRecorderOptions { const options: MediaRecorderOptions = { videoBitsPerSecond, bitsPerSecond: videoBitsPerSecond + (audioBitsPerSecond ?? 0), }; if (audioBitsPerSecond !== undefined) { options.audioBitsPerSecond = audioBitsPerSecond; } if (mimeType) { options.mimeType = mimeType; } return options; } type NativeCaptureStopResult = { success: boolean; path?: string; error?: string; message?: string; }; export type DiscardNativeCaptureResult = { stopSucceeded: boolean; deleteSucceeded: boolean; path?: string; error?: unknown; }; export async function stopAndDiscardNativeCapture({ stopNativeScreenRecording, deleteRecordingFile, }: { stopNativeScreenRecording: () => Promise; deleteRecordingFile: (path: string) => Promise; }): Promise { let stoppedResult: NativeCaptureStopResult; try { stoppedResult = await stopNativeScreenRecording(); } catch (error) { return { stopSucceeded: false, deleteSucceeded: false, error }; } if (!stoppedResult.success) { return { stopSucceeded: false, deleteSucceeded: false, error: stoppedResult.error ?? stoppedResult.message, }; } if (!stoppedResult.path) { return { stopSucceeded: true, deleteSucceeded: true }; } try { await deleteRecordingFile(stoppedResult.path); return { stopSucceeded: true, deleteSucceeded: true, path: stoppedResult.path, }; } catch (error) { return { stopSucceeded: true, deleteSucceeded: false, path: stoppedResult.path, error, }; } } function createMicrophoneTrackSettingsSnapshot( stream: MediaStream, ): MicrophoneTrackSettingsSnapshot | null { const track = stream.getAudioTracks()[0]; const settings = track?.getSettings?.(); if (!track || !settings) { return null; } const snapshot: MicrophoneTrackSettingsSnapshot = { trackId: track.id, trackLabel: track.label, trackEnabled: track.enabled, trackMuted: track.muted, trackReadyState: track.readyState, }; for (const key of [ "autoGainControl", "channelCount", "deviceId", "echoCancellation", "groupId", "noiseSuppression", "sampleRate", "sampleSize", ] as const) { const value = settings[key]; if (value !== undefined) { snapshot[key] = value as never; } } return Object.keys(snapshot).length > 0 ? snapshot : null; } async function createAudioInputDeviceSnapshot(): Promise< MicrophoneAudioInputDeviceSnapshot[] | null > { if (typeof navigator.mediaDevices?.enumerateDevices !== "function") { return null; } const devices = await navigator.mediaDevices.enumerateDevices(); const audioInputs = devices .filter((device) => device.kind === "audioinput") .map((device) => ({ deviceId: device.deviceId, ...(device.groupId ? { groupId: device.groupId } : {}), label: device.label, })); return audioInputs.length > 0 ? audioInputs : null; } export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); const [finalizing, setFinalizing] = useState(false); const [countdownActive, setCountdownActive] = useState(false); const [isMacOS, setIsMacOS] = useState(false); const [microphoneEnabled, setMicrophoneEnabled] = useState(false); const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined); const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabled] = useState(false); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); const [countdownDelay, setCountdownDelayState] = useState(3); const mediaRecorder = useRef(null); const webcamRecorder = useRef(null); const stream = useRef(null); const screenStream = useRef(null); const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); const chunks = useRef([]); const webcamChunks = useRef([]); const startTime = useRef(0); const webcamStartTime = useRef(null); const webcamTimeOffsetMs = useRef(0); const recordingSessionTimestamp = useRef(null); const nativeScreenRecording = useRef(false); const nativeWindowsRecording = useRef(false); const nativeWarmStartActive = useRef(false); const pendingNativeCleanupPath = useRef(null); const recordingStartGeneration = useRef(0); const nativeStopRequestInFlight = useRef(false); const startInFlight = useRef(false); const hasPromptedForReselect = useRef(false); const hasShownNativeWindowsFallbackToast = useRef(false); const countdownDelayLoaded = useRef(false); const recordingPrefsLoaded = useRef(false); const pendingWebcamPathPromise = useRef | null>(null); const webcamStopPromise = useRef | null>(null); const webcamStopResolver = useRef<((path: string | null) => void) | null>(null); const resolvedWebcamPath = useRef(null); const accumulatedPausedDurationMs = useRef(0); const pauseStartedAtMs = useRef(null); const micFallbackRecorder = useRef(null); const micFallbackChunks = useRef([]); const micFallbackStartDelayMs = useRef(null); const micFallbackTrackSettings = useRef(null); const micFallbackRequestedConstraints = useRef(null); const micFallbackAudioInputDevices = useRef(null); const micFallbackRecorderMetadata = useRef(null); const micFallbackChunkEvents = useRef([]); const micFallbackRecorderStartedAt = useRef(null); const micFallbackPauseStartedAt = useRef(null); const micFallbackPausedDurationMs = useRef(0); const micFallbackPauseIntervals = useRef([]); const browserMicrophoneProfile = useRef( DEFAULT_BROWSER_MICROPHONE_PROFILE, ); const requestedBrowserMicrophoneProfile = useRef(null); const hideEditorOverlayCursorByDefault = useRef(false); const notifyRecordingFinalizationFailure = useCallback(async (message: string) => { setFinalizing(false); toast.error(message, { duration: 10000 }); }, []); const logNativeCaptureDiagnostics = useCallback(async (context: string) => { if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") { return; } try { const result = await window.electronAPI.getLastNativeCaptureDiagnostics(); if (result.success && result.diagnostics) { console.warn(`[NativeCaptureDiagnostics:${context}]`, result.diagnostics); } } catch (error) { console.warn("Failed to load native capture diagnostics:", error); } }, []); const buildNativeCaptureFailureMessage = useCallback( async (context: string, fallbackMessage: string) => { if (typeof window.electronAPI?.getLastNativeCaptureDiagnostics !== "function") { return fallbackMessage; } try { const result = await window.electronAPI.getLastNativeCaptureDiagnostics(); const diagnostics = result.success ? (result.diagnostics ?? null) : null; if (!diagnostics) { return fallbackMessage; } console.warn(`[NativeCaptureDiagnostics:${context}]`, diagnostics); const details: string[] = []; if (diagnostics.error) { details.push(diagnostics.error); } if (diagnostics.outputPath && (diagnostics.fileSizeBytes ?? 0) > 0) { details.push(`Saved file: ${diagnostics.outputPath}`); } return details.length > 0 ? `${fallbackMessage} ${details.join(". ")}` : fallbackMessage; } catch (error) { console.warn("Failed to load native capture diagnostics:", error); return fallbackMessage; } }, [], ); const resetRecordingClock = useCallback((startedAt: number) => { startTime.current = startedAt; accumulatedPausedDurationMs.current = 0; pauseStartedAtMs.current = null; }, []); const markRecordingPaused = useCallback((pausedAt: number) => { if (pauseStartedAtMs.current === null) { pauseStartedAtMs.current = pausedAt; } }, []); const markRecordingResumed = useCallback((resumedAt: number) => { if (pauseStartedAtMs.current === null) { return; } const pauseStart = pauseStartedAtMs.current; const pauseDurationMs = Math.max(0, resumedAt - pauseStart); accumulatedPausedDurationMs.current += pauseDurationMs; pauseStartedAtMs.current = null; }, []); const getRecordingDurationMs = useCallback((endedAt: number) => { return getEffectiveRecordingDurationMs({ startTimeMs: startTime.current, endTimeMs: endedAt, accumulatedPausedDurationMs: accumulatedPausedDurationMs.current, pauseStartedAtMs: pauseStartedAtMs.current, }); }, []); const getMicFallbackRecordedElapsedMs = useCallback((now = performance.now()) => { const startedAt = micFallbackRecorderStartedAt.current; if (startedAt === null) { return 0; } const currentPauseDurationMs = micFallbackPauseStartedAt.current === null ? 0 : Math.max(0, now - micFallbackPauseStartedAt.current); return Math.max( 0, Math.round( now - startedAt - micFallbackPausedDurationMs.current - currentPauseDurationMs, ), ); }, []); const resetMicFallbackTimingDiagnostics = useCallback(() => { micFallbackChunkEvents.current = []; micFallbackRecorderStartedAt.current = null; micFallbackPauseStartedAt.current = null; micFallbackPausedDurationMs.current = 0; micFallbackPauseIntervals.current = []; }, []); const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => { const platform = await window.electronAPI.getPlatform(); if (platform !== "darwin") { return true; } const screenPermission = await window.electronAPI.getScreenRecordingPermissionStatus(); if (!screenPermission.success || screenPermission.status !== "granted") { await window.electronAPI.openScreenRecordingPreferences(); alert( options.startup ? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly." : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", ); return false; } const accessibilityPermission = await window.electronAPI.getAccessibilityPermissionStatus(); if (!accessibilityPermission.success) { return false; } if (accessibilityPermission.trusted) { return true; } const requestedAccessibility = await window.electronAPI.requestAccessibilityPermission(); if (requestedAccessibility.success && requestedAccessibility.trusted) { return true; } await window.electronAPI.openAccessibilityPreferences(); alert( options.startup ? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly." : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.", ); return false; }, []); const selectMimeType = useCallback(() => { return selectRecordingMimeType(); }, []); const selectWebcamMimeType = useCallback(() => { return selectWebcamRecordingMimeType(); }, []); 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 cleanupCapturedMedia = 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 (webcamStream.current) { webcamStream.current.getTracks().forEach((track) => track.stop()); webcamStream.current = null; } if (mixingContext.current) { mixingContext.current.close().catch(() => undefined); mixingContext.current = null; } if (micFallbackRecorder.current) { try { if (micFallbackRecorder.current.state !== "inactive") { micFallbackRecorder.current.stop(); } micFallbackRecorder.current.stream?.getTracks().forEach((track) => track.stop()); } catch { /* ignore */ } micFallbackRecorder.current = null; micFallbackChunks.current = []; micFallbackTrackSettings.current = null; micFallbackRequestedConstraints.current = null; micFallbackAudioInputDevices.current = null; micFallbackRecorderMetadata.current = null; resetMicFallbackTimingDiagnostics(); } }, [resetMicFallbackTimingDiagnostics]); const appendMicFallbackChunk = useCallback( (event: BlobEvent) => { if (event.data.size <= 0) { return; } micFallbackChunks.current.push(event.data); const startedAt = micFallbackRecorderStartedAt.current; if (startedAt === null) { return; } const now = performance.now(); const elapsedMs = Math.max(0, Math.round(now - startedAt)); const recordedElapsedMs = getMicFallbackRecordedElapsedMs(now); const previous = micFallbackChunkEvents.current[micFallbackChunkEvents.current.length - 1]; micFallbackChunkEvents.current.push({ index: micFallbackChunkEvents.current.length, size: event.data.size, elapsedMs, deltaMs: previous ? Math.max(0, elapsedMs - previous.elapsedMs) : null, recordedElapsedMs, recordedDeltaMs: previous ? Math.max(0, recordedElapsedMs - previous.recordedElapsedMs) : null, }); }, [getMicFallbackRecordedElapsedMs], ); const resolveBrowserCaptureSource = useCallback(async (source: ProcessedDesktopSource) => { if (!source?.id?.startsWith("screen:")) { return source; } // Linux/Wayland portal sentinel: do NOT call getSources here, because // on Wayland that triggers an additional xdg-desktop-portal dialog. // The sentinel is handled later by routing through getDisplayMedia, // which lets the portal pick the source in a single dialog. if (source.id === "screen:linux-portal") { return source; } try { const liveSources = await window.electronAPI.getSources({ types: ["screen"], thumbnailSize: { width: 1, height: 1 }, fetchWindowIcons: false, }); const exactMatch = liveSources.find((candidate) => candidate.id === source.id); if (exactMatch) { return { ...source, id: exactMatch.id, name: exactMatch.name ?? source.name, display_id: exactMatch.display_id ?? source.display_id, }; } const displayMatch = liveSources.find( (candidate) => String(candidate.display_id ?? "") === String(source.display_id ?? ""), ); if (displayMatch) { return { ...source, id: displayMatch.id, name: displayMatch.name ?? source.name, display_id: displayMatch.display_id ?? source.display_id, }; } } catch (error) { console.warn("Failed to resolve browser capture source:", error); } return source; }, []); const finalizeRecordingSession = useCallback( async (videoPath: string, webcamPath: string | null) => { const start = performance.now(); console.log("[PERF:RENDERER] Finalize Session & Switch to Editor: STARTED"); const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current; try { if (webcamPath) { await window.electronAPI.setCurrentRecordingSession({ videoPath, webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: shouldHideOverlayCursor, }); } else { await window.electronAPI.setCurrentVideoPath(videoPath, { hideOverlayCursorByDefault: shouldHideOverlayCursor, }); } } catch (error) { console.error("Failed to persist recording session metadata:", error); try { await window.electronAPI.setCurrentVideoPath(videoPath, { hideOverlayCursorByDefault: shouldHideOverlayCursor, }); } catch (fallbackError) { console.error("Failed to persist fallback video path:", fallbackError); } } setFinalizing(false); await window.electronAPI.switchToEditor(); console.log( `[PERF:RENDERER] Finalize Session & Switch to Editor: COMPLETED in ${(performance.now() - start).toFixed(2)}ms`, ); }, [], ); const closeMicFallbackPauseInterval = useCallback((now = performance.now()) => { const pauseStartedAt = micFallbackPauseStartedAt.current; if (pauseStartedAt === null) { return; } const durationMs = Math.max(0, Math.round(now - pauseStartedAt)); micFallbackPausedDurationMs.current += durationMs; const startedAt = micFallbackRecorderStartedAt.current ?? now; const lastInterval = micFallbackPauseIntervals.current[micFallbackPauseIntervals.current.length - 1]; if (lastInterval && lastInterval.endElapsedMs === undefined) { lastInterval.endElapsedMs = Math.max( lastInterval.startElapsedMs, Math.round(now - startedAt), ); lastInterval.durationMs = durationMs; } micFallbackPauseStartedAt.current = null; }, []); const stopMicFallbackRecorder = useCallback((): Promise => { return new Promise((resolve) => { const recorder = micFallbackRecorder.current; if (!recorder || recorder.state === "inactive") { micFallbackRecorder.current = null; resolve(null); return; } closeMicFallbackPauseInterval(); recorder.ondataavailable = appendMicFallbackChunk; recorder.onstop = () => { const blob = micFallbackChunks.current.length > 0 ? new Blob(micFallbackChunks.current, { type: recorder.mimeType }) : null; micFallbackChunks.current = []; recorder.stream.getTracks().forEach((track) => track.stop()); micFallbackRecorder.current = null; micFallbackRecorderStartedAt.current = null; resolve(blob); }; recorder.stop(); }); }, [appendMicFallbackChunk, closeMicFallbackPauseInterval]); const pauseMicFallbackRecorder = useCallback(() => { const recorder = micFallbackRecorder.current; if (recorder?.state !== "recording") { return; } try { recorder.requestData(); } catch (error) { console.warn("Failed to flush microphone fallback chunk before pause:", error); } recorder.pause(); const now = performance.now(); const startedAt = micFallbackRecorderStartedAt.current ?? now; micFallbackPauseStartedAt.current = now; micFallbackPauseIntervals.current.push({ startElapsedMs: Math.max(0, Math.round(now - startedAt)), }); }, []); const resumeMicFallbackRecorder = useCallback(() => { const recorder = micFallbackRecorder.current; if (recorder?.state !== "paused") { return; } closeMicFallbackPauseInterval(); recorder.resume(); }, [closeMicFallbackPauseInterval]); const storeMicrophoneSidecar = useCallback( async ( micFallbackBlobPromise: Promise | null | undefined, finalPath: string, startDelayMs?: number | null, mediaTrackSettings?: MicrophoneTrackSettingsSnapshot | null, ) => { const micFallbackBlob = await micFallbackBlobPromise; if (!micFallbackBlob) { micFallbackStartDelayMs.current = null; micFallbackTrackSettings.current = null; micFallbackRequestedConstraints.current = null; micFallbackAudioInputDevices.current = null; micFallbackRecorderMetadata.current = null; resetMicFallbackTimingDiagnostics(); return; } try { const arrayBuffer = await micFallbackBlob.arrayBuffer(); const effectiveStartDelayMs = startDelayMs ?? micFallbackStartDelayMs.current; const effectiveTrackSettings = mediaTrackSettings ?? micFallbackTrackSettings.current; const sidecarOptions: MicrophoneSidecarOptions = { ...(Number.isFinite(effectiveStartDelayMs) && (effectiveStartDelayMs ?? 0) >= 0 ? { startDelayMs: effectiveStartDelayMs ?? 0 } : {}), browserMicrophoneProfile: browserMicrophoneProfile.current, ...(requestedBrowserMicrophoneProfile.current ? { requestedBrowserMicrophoneProfile: requestedBrowserMicrophoneProfile.current, } : {}), ...(micFallbackRequestedConstraints.current ? { requestedConstraints: micFallbackRequestedConstraints.current } : {}), ...(effectiveTrackSettings ? { mediaTrackSettings: effectiveTrackSettings } : {}), ...(micFallbackAudioInputDevices.current ? { audioInputDevices: micFallbackAudioInputDevices.current } : {}), ...(micFallbackRecorderMetadata.current ? { mediaRecorder: micFallbackRecorderMetadata.current } : {}), ...(micFallbackChunkEvents.current.length > 0 ? { chunkEvents: [...micFallbackChunkEvents.current] } : {}), ...(micFallbackPauseIntervals.current.length > 0 ? { pauseIntervals: micFallbackPauseIntervals.current.map( (interval) => ({ ...interval }), ), } : {}), }; const result = await window.electronAPI.storeMicrophoneSidecar( arrayBuffer, finalPath, sidecarOptions, ); if (!result.success) { const errorMessage = result.error || "Failed to save the fallback microphone audio track"; console.warn("Failed to store microphone sidecar:", errorMessage); toast.error( `${errorMessage}. Recording was saved without the fallback microphone track.`, { id: MICROPHONE_SIDECAR_ERROR_TOAST_ID, duration: 10000 }, ); } } catch (error) { console.warn("Failed to store microphone sidecar:", error); toast.error( `${getErrorMessage(error)}. Recording was saved without the fallback microphone track.`, { id: MICROPHONE_SIDECAR_ERROR_TOAST_ID, duration: 10000 }, ); } finally { micFallbackStartDelayMs.current = null; micFallbackTrackSettings.current = null; micFallbackRequestedConstraints.current = null; micFallbackAudioInputDevices.current = null; micFallbackRecorderMetadata.current = null; resetMicFallbackTimingDiagnostics(); } }, [resetMicFallbackTimingDiagnostics], ); const stopWebcamRecorder = useCallback(async () => { const recorder = webcamRecorder.current; const pending = webcamStopPromise.current; if (!recorder) { const result = pending ? await pending : resolvedWebcamPath.current; webcamStopPromise.current = null; pendingWebcamPathPromise.current = null; resolvedWebcamPath.current = result ?? null; return result ?? null; } if (recorder.state !== "inactive") { recorder.stop(); } else if (pending && webcamStopResolver.current) { webcamStopResolver.current(resolvedWebcamPath.current); webcamStopResolver.current = null; } const result = pending ? await pending : resolvedWebcamPath.current; webcamStopPromise.current = null; pendingWebcamPathPromise.current = null; resolvedWebcamPath.current = result ?? null; webcamRecorder.current = null; return result ?? null; }, []); const recoverNativeRecordingSession = useCallback( async ( micFallbackBlobPromise?: Promise | null, startDelayMs?: number | null, ) => { if (typeof window.electronAPI?.recoverNativeScreenRecording !== "function") { return null; } const result = await window.electronAPI.recoverNativeScreenRecording(); if (!result.success || !result.path) { return null; } const resolvedMicFallbackBlobPromise = micFallbackBlobPromise ?? stopMicFallbackRecorder(); const webcamPath = await stopWebcamRecorder(); await storeMicrophoneSidecar(resolvedMicFallbackBlobPromise, result.path, startDelayMs); await finalizeRecordingSession(result.path, webcamPath); if (typeof window.electronAPI?.hudOverlayClose === "function") { window.electronAPI.hudOverlayClose(); } return result.path; }, [ finalizeRecordingSession, stopMicFallbackRecorder, stopWebcamRecorder, storeMicrophoneSidecar, ], ); /** * Acquire the webcam stream and prepare the MediaRecorder, but do NOT start * recording yet. Call {@link beginWebcamCapture} after the main recording * has started so both begin at approximately the same time. */ const prepareWebcamRecorder = useCallback(async () => { if (!webcamEnabled) { resolvedWebcamPath.current = null; pendingWebcamPathPromise.current = Promise.resolve(null); webcamStartTime.current = null; webcamTimeOffsetMs.current = 0; return; } try { webcamStream.current = await navigator.mediaDevices.getUserMedia({ video: webcamDeviceId ? { deviceId: { exact: webcamDeviceId }, width: { ideal: WEBCAM_WIDTH }, height: { ideal: WEBCAM_HEIGHT }, frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, } : { width: { ideal: WEBCAM_WIDTH }, height: { ideal: WEBCAM_HEIGHT }, frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, }, audio: false, }); const mimeType = selectWebcamMimeType(); webcamChunks.current = []; resolvedWebcamPath.current = null; webcamStopPromise.current = new Promise((resolve) => { webcamStopResolver.current = resolve; }); pendingWebcamPathPromise.current = webcamStopPromise.current; const recorder = new MediaRecorder(webcamStream.current, { videoBitsPerSecond: WEBCAM_BITRATE, ...(mimeType ? { mimeType } : {}), }); webcamRecorder.current = recorder; recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) { webcamChunks.current.push(event.data); } }; recorder.onerror = () => { webcamStopResolver.current?.(null); webcamStopResolver.current = null; }; recorder.onstop = async () => { const sessionTimestamp = recordingSessionTimestamp.current ?? Date.now(); const webcamMimeType = recorder.mimeType || mimeType; const webcamFileName = `${RECORDING_FILE_PREFIX}${sessionTimestamp}${WEBCAM_SUFFIX}${getVideoExtensionForMimeType(webcamMimeType)}`; try { if (webcamChunks.current.length === 0) { webcamStopResolver.current?.(null); return; } const duration = Math.max( 0, getRecordingDurationMs(Date.now()) - webcamTimeOffsetMs.current, ); const webcamBlob = new Blob( webcamChunks.current, webcamMimeType ? { type: webcamMimeType } : undefined, ); webcamChunks.current = []; const finalBlob = isWebmMimeType(webcamMimeType) ? await fixWebmDuration(webcamBlob, duration) : webcamBlob; const arrayBuffer = await finalBlob.arrayBuffer(); const result = await window.electronAPI.storeRecordedVideo( arrayBuffer, webcamFileName, ); webcamStopResolver.current?.(result.success ? (result.path ?? null) : null); } catch (error) { console.error("Error saving webcam recording:", error); webcamStopResolver.current?.(null); } finally { webcamStopResolver.current = null; webcamRecorder.current = null; webcamStartTime.current = null; if (webcamStream.current) { webcamStream.current.getTracks().forEach((track) => track.stop()); webcamStream.current = null; } } }; } catch (error) { console.warn( "Failed to start webcam recording; continuing without webcam layer:", error, ); resolvedWebcamPath.current = null; pendingWebcamPathPromise.current = Promise.resolve(null); webcamStopPromise.current = Promise.resolve(null); webcamRecorder.current = null; webcamStartTime.current = null; webcamTimeOffsetMs.current = 0; if (webcamStream.current) { webcamStream.current.getTracks().forEach((track) => track.stop()); webcamStream.current = null; } } }, [getRecordingDurationMs, selectWebcamMimeType, webcamDeviceId, webcamEnabled]); /** Start the prepared webcam MediaRecorder. Call after main recording begins. */ const beginWebcamCapture = useCallback(() => { const recorder = webcamRecorder.current; if (recorder && recorder.state === "inactive") { webcamStartTime.current = Date.now(); recorder.start(RECORDER_TIMESLICE_MS); } }, []); const prepareRecordingStart = useCallback(async () => { const platform = await window.electronAPI.getPlatform(); hideEditorOverlayCursorByDefault.current = false; const existingSource = await window.electronAPI.getSelectedSource(); const selectedSource = existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null); if (!selectedSource) { alert("Please select a source to record"); return null; } if (!existingSource && selectedSource.id === "screen:linux-portal") { try { await window.electronAPI.selectSource(selectedSource); } catch (err) { console.warn("Failed to persist Linux portal sentinel source:", err); } } const permissionsReady = await preparePermissions(); if (!permissionsReady) { return null; } recordingSessionTimestamp.current = Date.now(); resetRecordingClock(recordingSessionTimestamp.current); await prepareWebcamRecorder(); const useNativeMacScreenCapture = platform === "darwin" && (selectedSource.id?.startsWith("screen:") || selectedSource.id?.startsWith("window:")) && typeof window.electronAPI.startNativeScreenRecording === "function"; let useNativeWindowsCapture = false; if ( platform === "win32" && shouldUseNativeWindowsCaptureForSource(selectedSource) && typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function" ) { try { const nativeWindowsResult = await window.electronAPI.isNativeWindowsCaptureAvailable(); useNativeWindowsCapture = nativeWindowsResult.available; if (!useNativeWindowsCapture && !hasShownNativeWindowsFallbackToast.current) { void logNativeCaptureDiagnostics("is-native-windows-capture-available"); hasShownNativeWindowsFallbackToast.current = true; toast.info( "Native Windows capture is unavailable. Falling back to browser capture.", ); } } catch { useNativeWindowsCapture = false; if (!hasShownNativeWindowsFallbackToast.current) { hasShownNativeWindowsFallbackToast.current = true; toast.info( "Unable to check native Windows capture. Falling back to browser capture.", ); } } } let micLabel: string | undefined; if ((useNativeMacScreenCapture || useNativeWindowsCapture) && microphoneEnabled) { try { const devices = await navigator.mediaDevices.enumerateDevices(); const mic = devices.find( (d) => d.deviceId === microphoneDeviceId && d.kind === "audioinput", ); micLabel = mic?.label || undefined; } catch { // Fall through - native process will use the default mic. } } return { platform, selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel, }; }, [ logNativeCaptureDiagnostics, microphoneDeviceId, microphoneEnabled, preparePermissions, prepareWebcamRecorder, resetRecordingClock, ]); const discardActiveNativeCapture = useCallback(async () => { const pendingPath = pendingNativeCleanupPath.current; if (pendingPath) { try { await window.electronAPI.deleteRecordingFile(pendingPath); pendingNativeCleanupPath.current = null; } catch (error) { console.warn("Failed to delete pending native capture file:", error); } } if (!nativeScreenRecording.current) { return pendingNativeCleanupPath.current === null; } if (nativeStopRequestInFlight.current) { return false; } nativeStopRequestInFlight.current = true; let result: DiscardNativeCaptureResult; try { result = await stopAndDiscardNativeCapture({ stopNativeScreenRecording: () => window.electronAPI.stopNativeScreenRecording(), deleteRecordingFile: (path) => window.electronAPI.deleteRecordingFile(path), }); } finally { nativeStopRequestInFlight.current = false; } if (result.stopSucceeded) { nativeScreenRecording.current = false; nativeWindowsRecording.current = false; nativeWarmStartActive.current = false; } if (!result.deleteSucceeded && result.path) { pendingNativeCleanupPath.current = result.path; } if (!result.stopSucceeded || !result.deleteSucceeded) { console.warn("Failed to fully discard native capture:", result.error); return false; } return true; }, []); const stopRecording = useRef(() => { recordingStartGeneration.current += 1; setPaused(false); if (nativeScreenRecording.current && nativeWarmStartActive.current) { setRecording(false); void (async () => { await discardActiveNativeCapture(); cleanupCapturedMedia(); await Promise.allSettled([ stopMicFallbackRecorder(), stopWebcamRecorder(), window.electronAPI?.setRecordingState(false), ]); })(); return; } if (nativeScreenRecording.current) { if (nativeStopRequestInFlight.current) { return; } nativeStopRequestInFlight.current = true; setRecording(false); setFinalizing(true); void (async () => { const stopStart = performance.now(); console.log("[PERF:RENDERER] Total Stop Sequence: STARTED"); const fallbackStartDelayMs = micFallbackStartDelayMs.current; const fallbackTrackSettings = micFallbackTrackSettings.current; const stoppedAtMs = Date.now(); markRecordingResumed(stoppedAtMs); const expectedDurationMs = getRecordingDurationMs(stoppedAtMs); const micFallbackBlobPromise = stopMicFallbackRecorder(); const webcamPathPromise = stopWebcamRecorder(); const isNativeWindows = nativeWindowsRecording.current; const ipcStopStart = performance.now(); console.log("[PERF:RENDERER] IPC: stopNativeScreenRecording: STARTED"); let result: NativeCaptureStopResult; try { result = await window.electronAPI.stopNativeScreenRecording(); } catch (error) { result = { success: false, error: getErrorMessage(error) }; } nativeStopRequestInFlight.current = false; console.log( `[PERF:RENDERER] IPC: stopNativeScreenRecording: COMPLETED in ${(performance.now() - ipcStopStart).toFixed(2)}ms`, ); if (result.success) { nativeScreenRecording.current = false; nativeWindowsRecording.current = false; nativeWarmStartActive.current = false; } try { await window.electronAPI?.setRecordingState(false); } catch (stateError) { console.warn("Failed to reset main-process recording state:", stateError); } if (!result.success || !result.path) { console.error( "Failed to stop native screen recording:", result.error ?? result.message, ); void logNativeCaptureDiagnostics("stop-native-screen-recording"); try { const recoveredPath = await recoverNativeRecordingSession( micFallbackBlobPromise, fallbackStartDelayMs, ); if (recoveredPath) { console.log( `[PERF:RENDERER] Total Stop Sequence (RECOVERED) in ${(performance.now() - stopStart).toFixed(2)}ms`, ); return; } } catch (recoveryError) { console.error("Failed to recover native screen recording:", recoveryError); } const failureMessage = await buildNativeCaptureFailureMessage( "stop-native-screen-recording", isMacOS ? "Failed to finish the macOS recording, so the editor was not opened." : "Failed to finish the recording, so the editor was not opened.", ); await notifyRecordingFinalizationFailure(failureMessage); return; } const finalPath = result.path; // 1. Finalize the session and switch to editor immediately (Optimistic UI) // We pass null for webcamPath initially to avoid blocking on webcam disk writes/muxing. await finalizeRecordingSession(finalPath, null); // 2. Perform background finalization (webcam, muxing, sidecars) // We don't await this to keep the UI responsive void (async () => { try { // Webcam persistence, microphone sidecars, and Windows muxing are // independent. Run them concurrently so the editor can keep playing // the main video and reveal the webcam as soon as only that file is ready. const webcamReadyPromise = webcamPathPromise.then(async (webcamPath) => { console.log( "[useScreenRecorder] Background native processing: webcamPath is", webcamPath, ); if (webcamPath) { try { await window.electronAPI.setCurrentRecordingSession({ videoPath: finalPath, webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, }); } catch (sessionError) { console.error( "Failed to publish the asynchronously finalized webcam:", sessionError, ); } } return webcamPath; }); const microphoneReadyPromise = storeMicrophoneSidecar( micFallbackBlobPromise, finalPath, fallbackStartDelayMs, fallbackTrackSettings, ); const muxReadyPromise = isNativeWindows ? window.electronAPI.muxNativeWindowsRecording(expectedDurationMs) : Promise.resolve(null); const [webcamResult, microphoneResult, muxResult] = await Promise.allSettled([ webcamReadyPromise, microphoneReadyPromise, muxReadyPromise, ]); const webcamPath = webcamResult.status === "fulfilled" ? webcamResult.value : null; for (const [taskName, result] of [ ["webcam", webcamResult], ["microphone sidecar", microphoneResult], ["Windows mux", muxResult], ] as const) { if (result.status === "rejected") { console.error( `[useScreenRecorder] ${taskName} finalization failed:`, result.reason, ); } } console.log( "[useScreenRecorder] Emitting setCurrentRecordingSession with:", { finalPath, webcamPath }, ); // Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready. // This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets. await window.electronAPI.setCurrentRecordingSession({ videoPath: finalPath, webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, }); console.log( `[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`, ); } catch (bgError) { console.error("Error in background finalization:", bgError); } finally { // After all background tasks are done (webcam, mic sidecars, muxing), // we can safely close the HUD window to release hardware and resources. if (typeof window.electronAPI?.hudOverlayClose === "function") { console.log( "[useScreenRecorder] All background tasks finished, closing HUD", ); window.electronAPI.hudOverlayClose(); } } })(); })(); return; } const recorder = mediaRecorder.current; const recorderState = recorder?.state; if (recorder && (recorderState === "recording" || recorderState === "paused")) { if (recorderState === "paused") { try { recorder.resume(); markRecordingResumed(Date.now()); } catch (error) { console.warn("Failed to resume recorder before stopping:", error); } } pendingWebcamPathPromise.current = stopWebcamRecorder(); try { recorder.requestData(); } catch (error) { console.warn("Failed to flush recorder before stopping:", error); } recorder.stop(); setRecording(false); setFinalizing(true); window.electronAPI?.setRecordingState(false); } }); useEffect(() => { void (async () => { const platform = await window.electronAPI.getPlatform(); setIsMacOS(platform === "darwin"); })(); }, []); useEffect(() => { if (typeof window.electronAPI?.getRecordingAudioLabConfig !== "function") { return; } void (async () => { const result = await window.electronAPI.getRecordingAudioLabConfig(); browserMicrophoneProfile.current = normalizeBrowserMicrophoneProfile( result.browserMicrophoneProfile, ); requestedBrowserMicrophoneProfile.current = result.requestedBrowserMicrophoneProfile ?? null; console.info("Browser microphone profile:", browserMicrophoneProfile.current); })(); }, []); useEffect(() => { if (countdownDelayLoaded.current) return; countdownDelayLoaded.current = true; void (async () => { const result = await window.electronAPI.getCountdownDelay(); if (result.success && typeof result.delay === "number") { setCountdownDelayState(result.delay); } })(); }, []); const setCountdownDelay = useCallback((delay: number) => { setCountdownDelayState(delay); void window.electronAPI.setCountdownDelay(delay); }, []); useEffect(() => { if (recordingPrefsLoaded.current) return; recordingPrefsLoaded.current = true; void (async () => { const result = await window.electronAPI.getRecordingPreferences(); if (result.success) { setMicrophoneEnabled(result.microphoneEnabled); if (result.microphoneDeviceId) { setMicrophoneDeviceId(result.microphoneDeviceId); } setSystemAudioEnabled(result.systemAudioEnabled); setWebcamEnabled(result.webcamEnabled); if (result.webcamDeviceId) { setWebcamDeviceId(result.webcamDeviceId); } } })(); }, []); const persistMicrophoneEnabled = useCallback((enabled: boolean) => { setMicrophoneEnabled(enabled); void window.electronAPI.setRecordingPreferences({ microphoneEnabled: enabled }); }, []); const persistMicrophoneDeviceId = useCallback((deviceId: string | undefined) => { setMicrophoneDeviceId(deviceId); void window.electronAPI.setRecordingPreferences({ microphoneDeviceId: deviceId }); }, []); const persistSystemAudioEnabled = useCallback((enabled: boolean) => { setSystemAudioEnabled(enabled); void window.electronAPI.setRecordingPreferences({ systemAudioEnabled: enabled }); }, []); const persistWebcamEnabled = useCallback((enabled: boolean) => { setWebcamEnabled(enabled); void window.electronAPI.setRecordingPreferences({ webcamEnabled: enabled }); }, []); const persistWebcamDeviceId = useCallback((deviceId: string | undefined) => { setWebcamDeviceId(deviceId); void window.electronAPI.setRecordingPreferences({ webcamDeviceId: deviceId }); }, []); useEffect(() => { let cleanup: (() => void) | undefined; if (window.electronAPI?.onStopRecordingFromTray) { cleanup = window.electronAPI.onStopRecordingFromTray(() => { stopRecording.current(); }); } const removeRecordingStateListener = window.electronAPI?.onRecordingStateChanged?.( (state) => { setRecording(state.recording); }, ); const removeRecordingInterruptedListener = window.electronAPI?.onRecordingInterrupted?.( (state) => { void (async () => { recordingStartGeneration.current += 1; setRecording(false); nativeScreenRecording.current = false; nativeWindowsRecording.current = false; nativeWarmStartActive.current = false; cleanupCapturedMedia(); await window.electronAPI.setRecordingState(false); if (state.reason !== "window-unavailable") { try { const recoveredPath = await recoverNativeRecordingSession(); if (recoveredPath) { return; } } catch (recoveryError) { console.error( "Failed to recover interrupted native screen recording:", recoveryError, ); } } if (state.reason === "window-unavailable" && !hasPromptedForReselect.current) { hasPromptedForReselect.current = true; alert(state.message); await window.electronAPI.openSourceSelector(); } else { console.error(state.message); toast.error(state.message); } })(); }, ); return () => { recordingStartGeneration.current += 1; cleanup?.(); removeRecordingStateListener?.(); removeRecordingInterruptedListener?.(); if (nativeScreenRecording.current) { if (nativeWarmStartActive.current) { void discardActiveNativeCapture(); } else if (!nativeStopRequestInFlight.current) { nativeStopRequestInFlight.current = true; void window.electronAPI .stopNativeScreenRecording() .then((result) => { if (result.success) { nativeScreenRecording.current = false; nativeWindowsRecording.current = false; } }) .catch((error) => { console.warn("Failed to stop native capture during cleanup:", error); }) .finally(() => { nativeStopRequestInFlight.current = false; }); } } else if (pendingNativeCleanupPath.current) { void discardActiveNativeCapture(); } const recorder = mediaRecorder.current; const recorderState = recorder?.state; if (recorder && (recorderState === "recording" || recorderState === "paused")) { recorder.stop(); } cleanupCapturedMedia(); }; }, [cleanupCapturedMedia, discardActiveNativeCapture, recoverNativeRecordingSession]); const startRecording = async () => { if (startInFlight.current) { return; } const startGeneration = recordingStartGeneration.current + 1; recordingStartGeneration.current = startGeneration; const startWasCancelled = () => recordingStartGeneration.current !== startGeneration; let hudSourceSelectionActive = false; const setHudSourceSelectionActive = (active: boolean) => { if (hudSourceSelectionActive === active) { return; } hudSourceSelectionActive = active; window.electronAPI?.hudOverlaySetSourceSelectionActive?.(active); }; hasPromptedForReselect.current = false; startInFlight.current = true; setStarting(true); try { const preparedStart = await prepareRecordingStart(); if (!preparedStart || startWasCancelled()) { cleanupCapturedMedia(); await stopWebcamRecorder(); return; } const { selectedSource, useNativeMacScreenCapture, useNativeWindowsCapture, micLabel } = preparedStart; const useNativeCapture = useNativeMacScreenCapture || useNativeWindowsCapture; const shouldWarmStartNativeCapture = useNativeCapture && countdownDelay > 0; if (countdownDelay > 0 && !shouldWarmStartNativeCapture) { setCountdownActive(true); try { const result = await window.electronAPI.startCountdown(countdownDelay); if (!result.success || result.cancelled || startWasCancelled()) { cleanupCapturedMedia(); await stopWebcamRecorder(); return; } } finally { setCountdownActive(false); } recordingSessionTimestamp.current = Date.now(); resetRecordingClock(recordingSessionTimestamp.current); } let nativeWindowsCaptureStartFailed = false; if (useNativeCapture) { const nativeResult = await window.electronAPI.startNativeScreenRecording( selectedSource, { capturesSystemAudio: systemAudioEnabled, capturesMicrophone: microphoneEnabled, microphoneDeviceId, microphoneLabel: micLabel, }, ); if (nativeResult.success && startWasCancelled()) { nativeScreenRecording.current = true; nativeWindowsRecording.current = useNativeWindowsCapture; nativeWarmStartActive.current = shouldWarmStartNativeCapture; await discardActiveNativeCapture(); cleanupCapturedMedia(); await stopWebcamRecorder(); return; } if (!nativeResult.success) { if (useNativeWindowsCapture) { nativeWindowsCaptureStartFailed = true; console.warn( "Native Windows capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message, ); void logNativeCaptureDiagnostics("start-native-screen-recording"); if (!hasShownNativeWindowsFallbackToast.current) { hasShownNativeWindowsFallbackToast.current = true; toast.warning( "Native Windows capture failed to start. Falling back to browser capture.", ); } } else if (!nativeResult.userNotified) { throw new Error( nativeResult.error ?? nativeResult.message ?? "Failed to start native screen recording", ); } else { setRecording(false); cleanupCapturedMedia(); await stopWebcamRecorder(); return; } } if (nativeResult.success) { nativeScreenRecording.current = true; nativeWindowsRecording.current = useNativeWindowsCapture; if (shouldWarmStartNativeCapture) { nativeWarmStartActive.current = true; const pauseResult = await window.electronAPI.pauseNativeScreenRecording(); if (startWasCancelled()) { return; } if (!pauseResult.success) { throw new Error( pauseResult.error ?? pauseResult.message ?? "Failed to pause native capture before countdown", ); } setCountdownActive(true); try { const countdownResult = await window.electronAPI.startCountdown(countdownDelay); if ( !countdownResult.success || countdownResult.cancelled || startWasCancelled() ) { if (!startWasCancelled()) { await discardActiveNativeCapture(); } cleanupCapturedMedia(); await stopWebcamRecorder(); return; } } finally { setCountdownActive(false); } const resumeResult = await window.electronAPI.resumeNativeScreenRecording(); if (startWasCancelled()) { return; } if (!resumeResult.success) { throw new Error( resumeResult.error ?? resumeResult.message ?? "Failed to resume native capture after countdown", ); } nativeWarmStartActive.current = false; } if (startWasCancelled()) { return; } const mainStartedAt = Date.now(); micFallbackStartDelayMs.current = null; beginWebcamCapture(); resetRecordingClock(mainStartedAt); webcamTimeOffsetMs.current = webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; // When native mic capture is unavailable or explicitly bypassed, // record mic via browser getUserMedia as a sidecar file. if (nativeResult.microphoneFallbackRequired && microphoneEnabled) { void logNativeCaptureDiagnostics("start-browser-microphone-fallback"); console.info("Using browser microphone processing for this recording."); try { const microphoneConstraints = createProcessedMicrophoneConstraints( microphoneDeviceId, browserMicrophoneProfile.current, ); micFallbackRequestedConstraints.current = microphoneConstraints; const micStream = await navigator.mediaDevices.getUserMedia(microphoneConstraints); micFallbackTrackSettings.current = createMicrophoneTrackSettingsSnapshot(micStream); micFallbackAudioInputDevices.current = await createAudioInputDeviceSnapshot().catch(() => null); console.info( "Browser microphone track settings:", micFallbackTrackSettings.current, ); console.info( "Browser microphone audio input devices:", micFallbackAudioInputDevices.current, ); micFallbackChunks.current = []; const recorder = new MediaRecorder(micStream, { mimeType: "audio/webm;codecs=opus", audioBitsPerSecond: AUDIO_BITRATE_VOICE, }); micFallbackRecorderMetadata.current = { mimeType: recorder.mimeType, audioBitsPerSecond: AUDIO_BITRATE_VOICE, timesliceMs: RECORDER_TIMESLICE_MS, }; resetMicFallbackTimingDiagnostics(); micFallbackRecorderStartedAt.current = performance.now(); recorder.ondataavailable = appendMicFallbackChunk; micFallbackStartDelayMs.current = Math.max( 0, Date.now() - mainStartedAt, ); recorder.start(RECORDER_TIMESLICE_MS); micFallbackRecorder.current = recorder; } catch (micError) { micFallbackStartDelayMs.current = null; micFallbackTrackSettings.current = null; micFallbackRequestedConstraints.current = null; micFallbackAudioInputDevices.current = null; micFallbackRecorderMetadata.current = null; resetMicFallbackTimingDiagnostics(); console.warn("Browser microphone fallback failed:", micError); const permissionDenied = micError instanceof DOMException && (micError.name === "NotAllowedError" || micError.name === "SecurityError"); toast.error( permissionDenied ? "Microphone permission denied. Recording will continue without microphone audio." : `${getErrorMessage(micError)}. Recording will continue without microphone audio.`, { id: MICROPHONE_FALLBACK_ERROR_TOAST_ID, duration: 10000 }, ); } } if (startWasCancelled()) { await stopMicFallbackRecorder(); await stopWebcamRecorder(); cleanupCapturedMedia(); return; } setRecording(true); try { await window.electronAPI?.setRecordingState(true); } catch (stateError) { console.warn( "Failed to notify main process that native recording started:", stateError, ); } return; } } if (nativeWindowsCaptureStartFailed && countdownDelay > 0) { setCountdownActive(true); try { const result = await window.electronAPI.startCountdown(countdownDelay); if (!result.success || result.cancelled) { cleanupCapturedMedia(); await stopWebcamRecorder(); return; } } finally { setCountdownActive(false); } recordingSessionTimestamp.current = Date.now(); resetRecordingClock(recordingSessionTimestamp.current); } const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({ nativeWindowsCaptureStartFailed, }); hideEditorOverlayCursorByDefault.current = browserCursorPolicy.hideEditorOverlayCursorByDefault; const wantsAudioCapture = microphoneEnabled || systemAudioEnabled; const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource); if ( browserCaptureSource?.id?.startsWith("screen:fallback:") || browserCaptureSource?.id?.startsWith("window:fallback:") ) { throw new Error( "Selected display is not available for browser capture on this system.", ); } if (browserCursorPolicy.hideOsCursorBeforeRecording) { try { const hideCursorResult = await window.electronAPI.hideOsCursor?.(); if (hideCursorResult && !hideCursorResult.success) { console.warn( "Could not hide OS cursor before recording.", hideCursorResult, ); } } catch { console.warn("Could not hide OS cursor before recording."); } } let videoTrack: MediaStreamTrack | undefined; let systemAudioIncluded = false; const mediaDevices = navigator.mediaDevices as DesktopCaptureMediaDevices; const useLinuxPortal = selectedSource.id === "screen:linux-portal"; const browserScreenVideoConstraints = { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: browserCaptureSource.id, maxWidth: TARGET_WIDTH, maxHeight: TARGET_HEIGHT, maxFrameRate: TARGET_FRAME_RATE, minFrameRate: MIN_FRAME_RATE, googCaptureCursor: browserCursorPolicy.streamCursor === "always", }, cursor: browserCursorPolicy.streamCursor, }; if (wantsAudioCapture) { let screenMediaStream: MediaStream; const acquireLinuxPortalStream = (withAudio: boolean) => mediaDevices.getDisplayMedia({ audio: withAudio, video: { displaySurface: "monitor", width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, cursor: browserCursorPolicy.streamCursor, }, selfBrowserSurface: "exclude", surfaceSwitching: "exclude", }); if (systemAudioEnabled) { try { screenMediaStream = useLinuxPortal ? await acquireLinuxPortalStream(true) : await mediaDevices.getUserMedia({ audio: { mandatory: { chromeMediaSource: CHROME_MEDIA_SOURCE, chromeMediaSourceId: browserCaptureSource.id, }, }, video: browserScreenVideoConstraints, }); } catch (audioError) { console.warn( "System audio capture failed, falling back to video-only:", audioError, ); alert( "System audio is not available for this source. Recording will continue without system audio.", ); screenMediaStream = useLinuxPortal ? await acquireLinuxPortalStream(false) : await mediaDevices.getUserMedia({ audio: false, video: browserScreenVideoConstraints, }); } } else { screenMediaStream = useLinuxPortal ? await acquireLinuxPortalStream(false) : await mediaDevices.getUserMedia({ audio: false, video: browserScreenVideoConstraints, }); } screenStream.current = screenMediaStream; stream.current = new MediaStream(); videoTrack = screenMediaStream.getVideoTracks()[0]; if (!videoTrack) { throw new Error("Video track is not available."); } stream.current.addTrack(videoTrack); if (microphoneEnabled) { try { microphoneStream.current = await navigator.mediaDevices.getUserMedia( createProcessedMicrophoneConstraints( microphoneDeviceId, browserMicrophoneProfile.current, ), ); } catch (audioError) { console.warn("Failed to get microphone access:", audioError); alert( "Microphone access was denied. Recording will continue without microphone audio.", ); setMicrophoneEnabled(false); } } const systemAudioTrack = screenMediaStream.getAudioTracks()[0]; const micAudioTrack = microphoneStream.current?.getAudioTracks()[0]; if (systemAudioTrack && micAudioTrack) { const context = new AudioContext({ sampleRate: 48000 }); mixingContext.current = context; const systemSource = context.createMediaStreamSource( new MediaStream([systemAudioTrack]), ); const micSource = context.createMediaStreamSource( new MediaStream([micAudioTrack]), ); const micGain = context.createGain(); micGain.gain.value = MIC_GAIN_BOOST; const destination = context.createMediaStreamDestination(); systemSource.connect(destination); micSource.connect(micGain).connect(destination); const mixedTrack = destination.stream.getAudioTracks()[0]; if (mixedTrack) { stream.current.addTrack(mixedTrack); systemAudioIncluded = true; } } else if (systemAudioTrack) { stream.current.addTrack(systemAudioTrack); systemAudioIncluded = true; } else if (micAudioTrack) { stream.current.addTrack(micAudioTrack); } } else { const mediaStream = useLinuxPortal ? await mediaDevices.getDisplayMedia({ audio: false, video: { displaySurface: selectedSource.id?.startsWith("window:") ? "window" : "monitor", width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH }, height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT }, frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, cursor: browserCursorPolicy.streamCursor, }, selfBrowserSurface: "exclude", surfaceSwitching: "exclude", }) : await mediaDevices.getUserMedia({ audio: false, video: browserScreenVideoConstraints, }); stream.current = mediaStream; videoTrack = mediaStream.getVideoTracks()[0]; } if (!stream.current || !videoTrack) { throw new Error("Media stream is not available."); } 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 }, } as MediaTrackConstraints); } catch (error) { console.warn( "Unable to lock 4K/60fps constraints, using best available track settings.", error, ); } 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 ?? "browser default"} / ${Math.round( videoBitsPerSecond / BITS_PER_MEGABIT, )} Mbps`, ); chunks.current = []; const hasAudio = stream.current.getAudioTracks().length > 0; const audioBitsPerSecond = hasAudio ? systemAudioIncluded ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE : undefined; const recorder = new MediaRecorder( stream.current, createBrowserRecordingOptions({ audioBitsPerSecond, mimeType, videoBitsPerSecond, }), ); mediaRecorder.current = recorder; recorder.ondataavailable = (event) => { if (event.data && event.data.size > 0) chunks.current.push(event.data); }; recorder.onstop = async () => { cleanupCapturedMedia(); if (chunks.current.length === 0) { setFinalizing(false); return; } const duration = getRecordingDurationMs(Date.now()); const recordedChunks = chunks.current; const recordingBlobType = recorder.mimeType || mimeType; const buggyBlob = new Blob( recordedChunks, recordingBlobType ? { type: recordingBlobType } : undefined, ); chunks.current = []; const timestamp = recordingSessionTimestamp.current ?? Date.now(); const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${getVideoExtensionForMimeType(recordingBlobType)}`; try { const videoBlob = isWebmMimeType(recordingBlobType) ? await fixWebmDuration(buggyBlob, duration) : buggyBlob; const arrayBuffer = await videoBlob.arrayBuffer(); const videoResult = await window.electronAPI.storeRecordedVideo( arrayBuffer, videoFileName, ); if (!videoResult.success) { console.error("Failed to store video:", videoResult.message); await notifyRecordingFinalizationFailure( videoResult.message || "Failed to store the recording.", ); return; } if (videoResult.path) { const finalVideoPath = videoResult.path; // 1. Launch editor immediately (Optimistic UI) await finalizeRecordingSession(finalVideoPath, null); // 2. Background webcam processing void (async () => { const webcamPath = pendingWebcamPathPromise.current ? await pendingWebcamPathPromise.current : resolvedWebcamPath.current; try { if (webcamPath) { await window.electronAPI.setCurrentRecordingSession({ videoPath: finalVideoPath, webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, }); } } finally { // After all background tasks are done (webcam), // we can safely close the HUD window to release hardware and resources. if (typeof window.electronAPI?.hudOverlayClose === "function") { console.log( "[useScreenRecorder:browser] All background tasks finished, closing HUD", ); window.electronAPI.hudOverlayClose(); } } })(); } else { await notifyRecordingFinalizationFailure("Failed to save the recording."); } } catch (error) { console.error("Error saving recording:", error); const message = error instanceof Error ? error.message : String(error); await notifyRecordingFinalizationFailure( `Failed to finalize the recording. ${message}`, ); } }; recorder.onerror = () => { setRecording(false); }; const mainStartedAt = Date.now(); beginWebcamCapture(); resetRecordingClock(mainStartedAt); webcamTimeOffsetMs.current = webcamStartTime.current === null ? 0 : webcamStartTime.current - mainStartedAt; recorder.start(RECORDER_TIMESLICE_MS); setRecording(true); try { await window.electronAPI?.setRecordingState(true); } catch (stateError) { console.warn("Failed to notify main process that recording started:", stateError); } } catch (error) { console.error("Failed to start recording:", error); alert( error instanceof Error ? `Failed to start recording: ${error.message}` : "Failed to start recording", ); setRecording(false); if (nativeScreenRecording.current) { await discardActiveNativeCapture(); } try { await window.electronAPI?.setRecordingState(false); } catch (stateError) { console.warn("Failed to reset main-process recording state:", stateError); } finally { cleanupCapturedMedia(); await stopWebcamRecorder(); } } finally { setHudSourceSelectionActive(false); startInFlight.current = false; setStarting(false); } }; const pauseRecording = useCallback(() => { if (!recording || paused) return; if (nativeScreenRecording.current) { void (async () => { const result = await window.electronAPI.pauseNativeScreenRecording(); if (!result.success) { console.error( "Failed to pause native screen recording:", result.error ?? result.message, ); return; } if (webcamRecorder.current?.state === "recording") { webcamRecorder.current.pause(); } pauseMicFallbackRecorder(); const boundaryMs = Date.now(); markRecordingPaused(boundaryMs); setPaused(true); try { await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to pause cursor capture:", error); } })(); return; } if (mediaRecorder.current?.state === "recording") { mediaRecorder.current.pause(); if (webcamRecorder.current?.state === "recording") { webcamRecorder.current.pause(); } void (async () => { const boundaryMs = Date.now(); markRecordingPaused(boundaryMs); setPaused(true); try { await window.electronAPI.pauseCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to pause cursor capture:", error); } })(); } }, [markRecordingPaused, pauseMicFallbackRecorder, paused, recording]); const resumeRecording = useCallback(() => { if (!recording || !paused) return; if (nativeScreenRecording.current) { void (async () => { const result = await window.electronAPI.resumeNativeScreenRecording(); if (!result.success) { console.error( "Failed to resume native screen recording:", result.error ?? result.message, ); return; } if (webcamRecorder.current?.state === "paused") { webcamRecorder.current.resume(); } resumeMicFallbackRecorder(); const boundaryMs = Date.now(); markRecordingResumed(boundaryMs); setPaused(false); try { await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to resume cursor capture:", error); } })(); return; } if (mediaRecorder.current?.state === "paused") { mediaRecorder.current.resume(); if (webcamRecorder.current?.state === "paused") { webcamRecorder.current.resume(); } void (async () => { const boundaryMs = Date.now(); markRecordingResumed(boundaryMs); setPaused(false); try { await window.electronAPI.resumeCursorCapture(boundaryMs); } catch (error) { console.warn("Failed to resume cursor capture:", error); } })(); } }, [markRecordingResumed, paused, recording, resumeMicFallbackRecorder]); const cancelRecording = useCallback(() => { recordingStartGeneration.current += 1; if (!recording) return; setPaused(false); markRecordingResumed(Date.now()); // Discard webcam recording regardless of recording mode webcamChunks.current = []; if (webcamRecorder.current && webcamRecorder.current.state !== "inactive") { webcamRecorder.current.stop(); } webcamRecorder.current = null; webcamStartTime.current = null; webcamTimeOffsetMs.current = 0; webcamStream.current?.getTracks().forEach((t) => t.stop()); webcamStream.current = null; pendingWebcamPathPromise.current = null; resolvedWebcamPath.current = null; if (nativeScreenRecording.current) { setRecording(false); window.electronAPI?.setRecordingState(false); void (async () => { await discardActiveNativeCapture(); })(); return; } if (mediaRecorder.current) { chunks.current = []; cleanupCapturedMedia(); if (mediaRecorder.current.state !== "inactive") { mediaRecorder.current.stop(); } setRecording(false); window.electronAPI?.setRecordingState(false); } }, [cleanupCapturedMedia, discardActiveNativeCapture, markRecordingResumed, recording]); const toggleRecording = async () => { if (starting || countdownActive || finalizing) { return; } if (recording) { stopRecording.current(); return; } startRecording(); }; return { recording, paused, finalizing, countdownActive, toggleRecording, pauseRecording, resumeRecording, cancelRecording, preparePermissions, isMacOS, microphoneEnabled, setMicrophoneEnabled: persistMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId: persistMicrophoneDeviceId, systemAudioEnabled, setSystemAudioEnabled: persistSystemAudioEnabled, webcamEnabled, setWebcamEnabled: persistWebcamEnabled, webcamDeviceId, setWebcamDeviceId: persistWebcamDeviceId, countdownDelay, setCountdownDelay, }; }