mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
2451 lines
77 KiB
TypeScript
2451 lines
77 KiB
TypeScript
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<BrowserMicrophoneProfile>([
|
|
"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<MediaStream>;
|
|
getDisplayMedia: (constraints: unknown) => Promise<MediaStream>;
|
|
};
|
|
|
|
type UseScreenRecorderReturn = {
|
|
recording: boolean;
|
|
paused: boolean;
|
|
finalizing: boolean;
|
|
countdownActive: boolean;
|
|
toggleRecording: () => void;
|
|
pauseRecording: () => void;
|
|
resumeRecording: () => void;
|
|
cancelRecording: () => void;
|
|
preparePermissions: (options?: { startup?: boolean }) => Promise<boolean>;
|
|
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<ProcessedDesktopSource, "id"> | 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<NativeCaptureStopResult>;
|
|
deleteRecordingFile: (path: string) => Promise<unknown>;
|
|
}): Promise<DiscardNativeCaptureResult> {
|
|
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<string | undefined>(undefined);
|
|
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
|
const [webcamEnabled, setWebcamEnabled] = useState(false);
|
|
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
|
const [countdownDelay, setCountdownDelayState] = useState(3);
|
|
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
|
const webcamRecorder = useRef<MediaRecorder | null>(null);
|
|
const stream = useRef<MediaStream | null>(null);
|
|
const screenStream = useRef<MediaStream | null>(null);
|
|
const microphoneStream = useRef<MediaStream | null>(null);
|
|
const webcamStream = useRef<MediaStream | null>(null);
|
|
const mixingContext = useRef<AudioContext | null>(null);
|
|
const chunks = useRef<Blob[]>([]);
|
|
const webcamChunks = useRef<Blob[]>([]);
|
|
const startTime = useRef<number>(0);
|
|
const webcamStartTime = useRef<number | null>(null);
|
|
const webcamTimeOffsetMs = useRef(0);
|
|
const recordingSessionTimestamp = useRef<number | null>(null);
|
|
const nativeScreenRecording = useRef(false);
|
|
const nativeWindowsRecording = useRef(false);
|
|
const nativeWarmStartActive = useRef(false);
|
|
const pendingNativeCleanupPath = useRef<string | null>(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<Promise<string | null> | null>(null);
|
|
const webcamStopPromise = useRef<Promise<string | null> | null>(null);
|
|
const webcamStopResolver = useRef<((path: string | null) => void) | null>(null);
|
|
const resolvedWebcamPath = useRef<string | null>(null);
|
|
const accumulatedPausedDurationMs = useRef(0);
|
|
const pauseStartedAtMs = useRef<number | null>(null);
|
|
const micFallbackRecorder = useRef<MediaRecorder | null>(null);
|
|
const micFallbackChunks = useRef<Blob[]>([]);
|
|
const micFallbackStartDelayMs = useRef<number | null>(null);
|
|
const micFallbackTrackSettings = useRef<MicrophoneTrackSettingsSnapshot | null>(null);
|
|
const micFallbackRequestedConstraints = useRef<MediaStreamConstraints | null>(null);
|
|
const micFallbackAudioInputDevices = useRef<MicrophoneAudioInputDeviceSnapshot[] | null>(null);
|
|
const micFallbackRecorderMetadata = useRef<MicrophoneFallbackRecorderMetadata | null>(null);
|
|
const micFallbackChunkEvents = useRef<MicrophoneFallbackChunkEvent[]>([]);
|
|
const micFallbackRecorderStartedAt = useRef<number | null>(null);
|
|
const micFallbackPauseStartedAt = useRef<number | null>(null);
|
|
const micFallbackPausedDurationMs = useRef(0);
|
|
const micFallbackPauseIntervals = useRef<MicrophoneFallbackPauseInterval[]>([]);
|
|
const browserMicrophoneProfile = useRef<BrowserMicrophoneProfile>(
|
|
DEFAULT_BROWSER_MICROPHONE_PROFILE,
|
|
);
|
|
const requestedBrowserMicrophoneProfile = useRef<string | null>(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<Blob | null> => {
|
|
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<Blob | null> | 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<Blob | null> | 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,
|
|
};
|
|
}
|