1797 lines
53 KiB
TypeScript
1797 lines
53 KiB
TypeScript
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { useScopedT } from "@/contexts/I18nContext";
|
|
import {
|
|
type NativeMacRecordingRequest,
|
|
parseMacDisplayIdFromSourceId,
|
|
parseMacWindowIdFromSourceId,
|
|
} from "@/lib/nativeMacRecording";
|
|
import {
|
|
type NativeWindowsRecordingRequest,
|
|
parseWindowHandleFromSourceId,
|
|
} from "@/lib/nativeWindowsRecording";
|
|
import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession";
|
|
import { requestCameraAccess } from "@/lib/requestCameraAccess";
|
|
import { createRecorderHandle, type RecorderHandle } from "./recorderHandle";
|
|
|
|
const TARGET_FRAME_RATE = 60;
|
|
const MIN_FRAME_RATE = 30;
|
|
const TARGET_WIDTH = 3840;
|
|
const TARGET_HEIGHT = 2160;
|
|
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
|
const QHD_WIDTH = 2560;
|
|
const QHD_HEIGHT = 1440;
|
|
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
|
|
|
const BITRATE_4K = 45_000_000;
|
|
const BITRATE_QHD = 28_000_000;
|
|
const BITRATE_BASE = 18_000_000;
|
|
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
|
const HIGH_FRAME_RATE_BOOST = 1.7;
|
|
|
|
const DEFAULT_WIDTH = 1920;
|
|
const DEFAULT_HEIGHT = 1080;
|
|
|
|
const CODEC_ALIGNMENT = 2;
|
|
|
|
const BITS_PER_MEGABIT = 1_000_000;
|
|
const CHROME_MEDIA_SOURCE = "desktop";
|
|
const RECORDING_FILE_PREFIX = "recording-";
|
|
const VIDEO_FILE_EXTENSION = ".webm";
|
|
const WEBCAM_FILE_SUFFIX = "-webcam";
|
|
|
|
const AUDIO_BITRATE_VOICE = 128_000;
|
|
const AUDIO_BITRATE_SYSTEM = 192_000;
|
|
|
|
const MIC_GAIN_BOOST = 1.4;
|
|
const WEBCAM_TARGET_FRAME_RATE = 30;
|
|
|
|
type UseScreenRecorderReturn = {
|
|
recording: boolean;
|
|
paused: boolean;
|
|
elapsedSeconds: number;
|
|
toggleRecording: () => void;
|
|
togglePaused: () => void;
|
|
canPauseRecording: boolean;
|
|
guideModeEnabled: boolean;
|
|
setGuideModeEnabled: (enabled: boolean) => void;
|
|
addGuideMarker: () => void;
|
|
restartRecording: () => void;
|
|
cancelRecording: () => void;
|
|
microphoneEnabled: boolean;
|
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
|
microphoneDeviceId: string | undefined;
|
|
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
|
microphoneDeviceName: string | undefined;
|
|
setMicrophoneDeviceName: (deviceName: string | undefined) => void;
|
|
webcamDeviceId: string | undefined;
|
|
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
|
webcamDeviceName: string | undefined;
|
|
setWebcamDeviceName: (deviceName: string | undefined) => void;
|
|
systemAudioEnabled: boolean;
|
|
setSystemAudioEnabled: (enabled: boolean) => void;
|
|
webcamEnabled: boolean;
|
|
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
|
|
cursorCaptureMode: CursorCaptureMode;
|
|
setCursorCaptureMode: (mode: CursorCaptureMode) => void;
|
|
};
|
|
|
|
type NativeWindowsRecordingHandle = {
|
|
recordingId: number;
|
|
finalizing: boolean;
|
|
paused: boolean;
|
|
webcamRecorder: RecorderHandle | null;
|
|
};
|
|
|
|
type NativeMacRecordingHandle = {
|
|
recordingId: number;
|
|
finalizing: boolean;
|
|
paused: boolean;
|
|
};
|
|
|
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
|
const t = useScopedT("editor");
|
|
const [recording, setRecording] = useState(false);
|
|
const [paused, setPaused] = useState(false);
|
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
|
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
|
const [microphoneDeviceName, setMicrophoneDeviceName] = useState<string | undefined>(undefined);
|
|
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
|
const [webcamDeviceName, setWebcamDeviceName] = useState<string | undefined>(undefined);
|
|
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
|
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
|
const [cursorCaptureMode, setCursorCaptureMode] = useState<CursorCaptureMode>("editable-overlay");
|
|
const [guideModeEnabled, setGuideModeEnabledState] = useState(false);
|
|
const screenRecorder = useRef<RecorderHandle | null>(null);
|
|
const webcamRecorder = useRef<RecorderHandle | null>(null);
|
|
const nativeWindowsRecording = useRef<NativeWindowsRecordingHandle | null>(null);
|
|
const nativeMacRecording = useRef<NativeMacRecordingHandle | 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 recordingId = useRef<number>(0);
|
|
const accumulatedDurationMs = useRef(0);
|
|
const segmentStartedAt = useRef<number | null>(null);
|
|
const finalizingRecordingId = useRef<number | null>(null);
|
|
const allowAutoFinalize = useRef(false);
|
|
const discardRecordingId = useRef<number | null>(null);
|
|
const restarting = useRef(false);
|
|
const countdownRunId = useRef(0);
|
|
const activeGuideRecordingId = useRef<number | null>(null);
|
|
const guideModeEnabledRef = useRef(false);
|
|
const [countdownActive, setCountdownActive] = useState(false);
|
|
const webcamReady = useRef(false);
|
|
const webcamAcquireId = useRef(0);
|
|
const canPauseRecording =
|
|
recording &&
|
|
Boolean(
|
|
(nativeWindowsRecording.current && !nativeWindowsRecording.current.finalizing) ||
|
|
(nativeMacRecording.current && !nativeMacRecording.current.finalizing) ||
|
|
(screenRecorder.current && screenRecorder.current.recorder.state !== "inactive"),
|
|
);
|
|
|
|
const getRecordingDurationMs = useCallback(() => {
|
|
const segmentDuration =
|
|
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
|
|
return accumulatedDurationMs.current + segmentDuration;
|
|
}, []);
|
|
|
|
const setGuideModeEnabled = useCallback(
|
|
(enabled: boolean) => {
|
|
if (recording) {
|
|
return;
|
|
}
|
|
guideModeEnabledRef.current = enabled;
|
|
setGuideModeEnabledState(enabled);
|
|
},
|
|
[recording],
|
|
);
|
|
|
|
const startGuideSession = useCallback(async (activeRecordingId: number) => {
|
|
if (!guideModeEnabledRef.current || !window.electronAPI?.guide) {
|
|
return;
|
|
}
|
|
|
|
const result = await window.electronAPI.guide.startSession(activeRecordingId);
|
|
if (!result.success) {
|
|
console.warn("Failed to start guide session:", result.error);
|
|
return;
|
|
}
|
|
|
|
activeGuideRecordingId.current = activeRecordingId;
|
|
}, []);
|
|
|
|
const finalizeGuideSession = useCallback(
|
|
async (activeRecordingId: number, videoPath?: string | null) => {
|
|
if (
|
|
activeGuideRecordingId.current !== activeRecordingId ||
|
|
!videoPath ||
|
|
!window.electronAPI?.guide
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const result = await window.electronAPI.guide.finalizeEvents({
|
|
recordingId: activeRecordingId,
|
|
videoPath,
|
|
});
|
|
if (!result.success) {
|
|
console.warn("Failed to finalize guide session:", result.error);
|
|
}
|
|
activeGuideRecordingId.current = null;
|
|
},
|
|
[],
|
|
);
|
|
|
|
const discardGuideSession = useCallback(async (activeRecordingId: number) => {
|
|
if (!window.electronAPI?.guide) {
|
|
return;
|
|
}
|
|
|
|
const result = await window.electronAPI.guide.discardSession({
|
|
recordingId: activeRecordingId,
|
|
});
|
|
if (!result.success) {
|
|
console.warn("Failed to discard guide session:", result.error);
|
|
}
|
|
if (activeGuideRecordingId.current === activeRecordingId) {
|
|
activeGuideRecordingId.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const addGuideMarker = useCallback(() => {
|
|
const activeRecordingId = activeGuideRecordingId.current;
|
|
if (!recording || activeRecordingId === null || !window.electronAPI?.guide) {
|
|
return;
|
|
}
|
|
|
|
void window.electronAPI.guide
|
|
.addMarker({
|
|
recordingId: activeRecordingId,
|
|
kind: "manual",
|
|
timeMs: getRecordingDurationMs(),
|
|
label: "Manual marker",
|
|
})
|
|
.then((result) => {
|
|
if (!result.success) {
|
|
console.warn("Failed to add guide marker:", result.error);
|
|
}
|
|
});
|
|
}, [getRecordingDurationMs, recording]);
|
|
|
|
const selectMimeType = () => {
|
|
// H.264 first: hardware-accelerated on all modern devices, gives sharp
|
|
// real-time output. AV1/VP9 are great for distribution but too
|
|
// CPU-intensive for live 60 fps capture — they produce blurry frames
|
|
// when the software encoder can't keep up.
|
|
const preferred = [
|
|
"video/webm;codecs=h264",
|
|
"video/webm;codecs=vp8",
|
|
"video/webm;codecs=vp9",
|
|
"video/webm;codecs=av1",
|
|
"video/webm",
|
|
];
|
|
|
|
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
|
|
};
|
|
|
|
const computeBitrate = (width: number, height: number) => {
|
|
const pixels = width * height;
|
|
const highFrameRateBoost =
|
|
TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
|
|
|
|
if (pixels >= FOUR_K_PIXELS) {
|
|
return Math.round(BITRATE_4K * highFrameRateBoost);
|
|
}
|
|
|
|
if (pixels >= QHD_PIXELS) {
|
|
return Math.round(BITRATE_QHD * highFrameRateBoost);
|
|
}
|
|
|
|
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
|
};
|
|
|
|
const teardownMedia = useCallback(() => {
|
|
if (stream.current) {
|
|
stream.current.getTracks().forEach((track) => track.stop());
|
|
stream.current = null;
|
|
}
|
|
if (screenStream.current) {
|
|
screenStream.current.getTracks().forEach((track) => track.stop());
|
|
screenStream.current = null;
|
|
}
|
|
if (microphoneStream.current) {
|
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
|
microphoneStream.current = null;
|
|
}
|
|
if (mixingContext.current) {
|
|
mixingContext.current.close().catch(() => {
|
|
// Ignore close errors during recorder teardown.
|
|
});
|
|
mixingContext.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const stopWebcamPreviewStream = useCallback(() => {
|
|
if (!webcamStream.current) {
|
|
return;
|
|
}
|
|
|
|
webcamAcquireId.current++;
|
|
webcamStream.current.getTracks().forEach((track) => {
|
|
track.onended = null;
|
|
track.stop();
|
|
});
|
|
webcamStream.current = null;
|
|
webcamReady.current = true;
|
|
}, []);
|
|
|
|
const setWebcamEnabled = useCallback(
|
|
async (enabled: boolean) => {
|
|
if (!enabled) {
|
|
setWebcamEnabledState(false);
|
|
return true;
|
|
}
|
|
|
|
const accessResult = await requestCameraAccess();
|
|
if (!accessResult.success) {
|
|
toast.error(t("recording.failedCameraAccess"));
|
|
return false;
|
|
}
|
|
|
|
if (!accessResult.granted) {
|
|
toast.error(t("recording.cameraBlocked"));
|
|
return false;
|
|
}
|
|
|
|
setWebcamEnabledState(true);
|
|
return true;
|
|
},
|
|
[t],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!webcamEnabled) return;
|
|
|
|
let cancelled = false;
|
|
let acquiredStream: MediaStream | null = null;
|
|
const thisAcquireId = ++webcamAcquireId.current;
|
|
webcamReady.current = false;
|
|
|
|
const acquire = async () => {
|
|
try {
|
|
const stream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: webcamDeviceId
|
|
? {
|
|
deviceId: { exact: webcamDeviceId },
|
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
|
}
|
|
: {
|
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
|
},
|
|
});
|
|
|
|
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
|
|
stream.getTracks().forEach((track) => {
|
|
track.onended = null;
|
|
track.stop();
|
|
});
|
|
return;
|
|
}
|
|
|
|
acquiredStream = stream;
|
|
stream.getVideoTracks().forEach((track) => {
|
|
track.onended = () => {
|
|
webcamStream.current = null;
|
|
if (!restarting.current) {
|
|
setWebcamEnabledState(false);
|
|
toast.error(t("recording.cameraDisconnected"));
|
|
}
|
|
};
|
|
});
|
|
webcamStream.current = stream;
|
|
webcamReady.current = true;
|
|
} catch (cameraError) {
|
|
if (!cancelled) {
|
|
console.warn("Failed to get webcam access:", cameraError);
|
|
setWebcamEnabledState(false);
|
|
const isDeviceError =
|
|
cameraError instanceof DOMException &&
|
|
[
|
|
"NotFoundError",
|
|
"DevicesNotFoundError",
|
|
"OverconstrainedError",
|
|
"NotReadableError",
|
|
].includes(cameraError.name);
|
|
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
|
|
webcamReady.current = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
void acquire();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
webcamReady.current = false;
|
|
if (acquiredStream) {
|
|
acquiredStream.getTracks().forEach((track) => {
|
|
track.onended = null;
|
|
track.stop();
|
|
});
|
|
webcamStream.current = null;
|
|
}
|
|
};
|
|
}, [webcamEnabled, webcamDeviceId, t]);
|
|
|
|
const finalizeRecording = useCallback(
|
|
(
|
|
activeScreenRecorder: RecorderHandle,
|
|
activeWebcamRecorder: RecorderHandle | null,
|
|
duration: number,
|
|
activeRecordingId: number,
|
|
) => {
|
|
if (finalizingRecordingId.current === activeRecordingId) {
|
|
return;
|
|
}
|
|
finalizingRecordingId.current = activeRecordingId;
|
|
|
|
if (screenRecorder.current === activeScreenRecorder) {
|
|
screenRecorder.current = null;
|
|
}
|
|
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
|
webcamRecorder.current = null;
|
|
}
|
|
|
|
teardownMedia();
|
|
setRecording(false);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = null;
|
|
window.electronAPI?.setRecordingState(false);
|
|
|
|
void (async () => {
|
|
// Each disk stream must end up either saved or explicitly discarded.
|
|
// store-recorded-session finalizes the streams included in a successful
|
|
// save; the finally block discards everything else.
|
|
let storeSucceeded = false;
|
|
let webcamIncludedInSave = false;
|
|
try {
|
|
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
|
if (discardRecordingId.current === activeRecordingId) {
|
|
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
|
await discardGuideSession(activeRecordingId);
|
|
return;
|
|
}
|
|
// When streaming succeeded the blob is empty — the data is already on disk.
|
|
if (!activeScreenRecorder.isStreaming() && screenBlob.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
|
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
|
|
|
// Only fix duration / convert to ArrayBuffer for in-memory data;
|
|
// streamed recordings are patched on disk by the main process.
|
|
let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
|
|
if (!activeScreenRecorder.isStreaming() && screenBlob.size > 0) {
|
|
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
|
screenVideoData = await fixedScreenBlob.arrayBuffer();
|
|
}
|
|
|
|
let webcamVideoData: ArrayBuffer | undefined;
|
|
if (activeWebcamRecorder) {
|
|
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
|
if (!activeWebcamRecorder.isStreaming() && webcamBlob && webcamBlob.size > 0) {
|
|
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
|
webcamVideoData = await fixedWebcamBlob.arrayBuffer();
|
|
} else if (activeWebcamRecorder.isStreaming()) {
|
|
webcamVideoData = new ArrayBuffer(0);
|
|
}
|
|
}
|
|
webcamIncludedInSave = webcamVideoData !== undefined;
|
|
|
|
const result = await window.electronAPI.storeRecordedSession({
|
|
screen: {
|
|
videoData: screenVideoData,
|
|
fileName: screenFileName,
|
|
},
|
|
webcam:
|
|
webcamVideoData !== undefined
|
|
? { videoData: webcamVideoData, fileName: webcamFileName }
|
|
: undefined,
|
|
createdAt: activeRecordingId,
|
|
cursorCaptureMode,
|
|
durationMs: duration,
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error("Failed to store recording session:", result.message);
|
|
return;
|
|
}
|
|
// store-recorded-session has flushed and closed the saved streams.
|
|
storeSucceeded = true;
|
|
|
|
if (result.session) {
|
|
await window.electronAPI.setCurrentRecordingSession(result.session);
|
|
} else if (result.path) {
|
|
await window.electronAPI.setCurrentVideoPath(result.path);
|
|
}
|
|
await finalizeGuideSession(
|
|
activeRecordingId,
|
|
result.session?.screenVideoPath ?? result.path,
|
|
);
|
|
|
|
await window.electronAPI.switchToEditor();
|
|
} catch (error) {
|
|
console.error("Error saving recording:", error);
|
|
} finally {
|
|
// Discard any recorder whose data was not part of a successful save
|
|
// — a discarded run, a failed save, or a webcam whose disk write
|
|
// failed (so it was omitted while the screen still saved) — so no
|
|
// stream or partial file is left open or orphaned.
|
|
if (!storeSucceeded) {
|
|
await activeScreenRecorder.discard().catch(() => undefined);
|
|
}
|
|
if (activeWebcamRecorder && !(storeSucceeded && webcamIncludedInSave)) {
|
|
await activeWebcamRecorder.discard().catch(() => undefined);
|
|
}
|
|
if (finalizingRecordingId.current === activeRecordingId) {
|
|
finalizingRecordingId.current = null;
|
|
}
|
|
if (discardRecordingId.current === activeRecordingId) {
|
|
discardRecordingId.current = null;
|
|
}
|
|
}
|
|
})();
|
|
},
|
|
[cursorCaptureMode, discardGuideSession, finalizeGuideSession, teardownMedia],
|
|
);
|
|
|
|
const finalizeNativeWindowsRecording = useCallback(
|
|
async (discard = false) => {
|
|
const activeNativeRecording = nativeWindowsRecording.current;
|
|
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
|
return false;
|
|
}
|
|
|
|
activeNativeRecording.finalizing = true;
|
|
const activeWebcamRecorder = activeNativeRecording.webcamRecorder;
|
|
const duration = Math.max(0, getRecordingDurationMs());
|
|
if (
|
|
activeWebcamRecorder?.recorder.state === "recording" ||
|
|
activeWebcamRecorder?.recorder.state === "paused"
|
|
) {
|
|
try {
|
|
activeWebcamRecorder.recorder.stop();
|
|
} catch {
|
|
// Recorder may already be stopping.
|
|
}
|
|
}
|
|
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
|
webcamRecorder.current = null;
|
|
}
|
|
|
|
const clearNativeRecordingState = () => {
|
|
nativeWindowsRecording.current = null;
|
|
setRecording(false);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = null;
|
|
};
|
|
|
|
try {
|
|
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
|
|
if (discard || result.discarded) {
|
|
await discardGuideSession(activeNativeRecording.recordingId);
|
|
clearNativeRecordingState();
|
|
return true;
|
|
}
|
|
if (!result.success) {
|
|
console.error("Failed to stop native Windows recording:", result.error);
|
|
toast.error(result.error ?? "Failed to stop native Windows recording");
|
|
activeNativeRecording.finalizing = false;
|
|
return true;
|
|
}
|
|
|
|
const nativeScreenPath = result.session?.screenVideoPath ?? result.path;
|
|
let storedSession = result.session;
|
|
if (activeWebcamRecorder && nativeScreenPath) {
|
|
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
|
const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath);
|
|
if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) {
|
|
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
|
const nativeScreenFileName =
|
|
nativeScreenPath.split(/[\\/]/).pop() ??
|
|
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`;
|
|
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
|
const stored = await window.electronAPI.storeRecordedSession({
|
|
screen: {
|
|
videoData: screenRead.data,
|
|
fileName: nativeScreenFileName,
|
|
},
|
|
webcam: {
|
|
videoData: await fixedWebcamBlob.arrayBuffer(),
|
|
fileName: webcamFileName,
|
|
},
|
|
createdAt: activeNativeRecording.recordingId,
|
|
cursorCaptureMode,
|
|
});
|
|
if (stored.success && stored.session) {
|
|
storedSession = stored.session;
|
|
}
|
|
}
|
|
}
|
|
|
|
clearNativeRecordingState();
|
|
if (storedSession) {
|
|
await window.electronAPI.setCurrentRecordingSession(storedSession);
|
|
} else if (result.path) {
|
|
await window.electronAPI.setCurrentVideoPath(result.path);
|
|
}
|
|
await finalizeGuideSession(
|
|
activeNativeRecording.recordingId,
|
|
storedSession?.screenVideoPath ?? result.path,
|
|
);
|
|
|
|
await window.electronAPI.switchToEditor();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error saving native Windows recording:", error);
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to save native Windows recording",
|
|
);
|
|
activeNativeRecording.finalizing = false;
|
|
return true;
|
|
} finally {
|
|
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
|
discardRecordingId.current = null;
|
|
}
|
|
}
|
|
},
|
|
[cursorCaptureMode, discardGuideSession, finalizeGuideSession, getRecordingDurationMs],
|
|
);
|
|
|
|
const finalizeNativeMacRecording = useCallback(
|
|
async (discard = false) => {
|
|
const activeNativeRecording = nativeMacRecording.current;
|
|
if (!activeNativeRecording || activeNativeRecording.finalizing) {
|
|
return false;
|
|
}
|
|
|
|
activeNativeRecording.finalizing = true;
|
|
const duration = Math.max(0, getRecordingDurationMs());
|
|
const activeWebcamRecorder = webcamRecorder.current;
|
|
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
|
webcamRecorder.current = null;
|
|
}
|
|
const webcamAssetPromise = (async (): Promise<RecordedVideoAssetInput | undefined> => {
|
|
if (!activeWebcamRecorder) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
if (activeWebcamRecorder.recorder.state !== "inactive") {
|
|
activeWebcamRecorder.recorder.stop();
|
|
}
|
|
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise;
|
|
if (!webcamBlob || webcamBlob.size === 0) {
|
|
return undefined;
|
|
}
|
|
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
|
return {
|
|
videoData: await fixedWebcamBlob.arrayBuffer(),
|
|
fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to finalize native macOS webcam recording:", error);
|
|
return undefined;
|
|
}
|
|
})();
|
|
|
|
const clearNativeRecordingState = () => {
|
|
nativeMacRecording.current = null;
|
|
setRecording(false);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = null;
|
|
};
|
|
|
|
try {
|
|
const result = await window.electronAPI.stopNativeMacRecording(discard);
|
|
const webcamAsset = await webcamAssetPromise;
|
|
if (discard || result.discarded) {
|
|
await discardGuideSession(activeNativeRecording.recordingId);
|
|
clearNativeRecordingState();
|
|
return true;
|
|
}
|
|
if (!result.success) {
|
|
console.error("Failed to stop native macOS recording:", result.error);
|
|
toast.error(result.error ?? "Failed to stop native macOS recording");
|
|
activeNativeRecording.finalizing = false;
|
|
return true;
|
|
}
|
|
|
|
if (webcamAsset && result.path) {
|
|
const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({
|
|
screenVideoPath: result.path,
|
|
recordingId: activeNativeRecording.recordingId,
|
|
webcam: webcamAsset,
|
|
cursorCaptureMode,
|
|
});
|
|
if (attachResult.success) {
|
|
result.session = attachResult.session;
|
|
} else {
|
|
console.error("Failed to attach native macOS webcam recording:", attachResult.error);
|
|
toast.error(attachResult.error ?? "Failed to store webcam recording");
|
|
}
|
|
}
|
|
|
|
clearNativeRecordingState();
|
|
if (result.session) {
|
|
await window.electronAPI.setCurrentRecordingSession(result.session);
|
|
} else if (result.path) {
|
|
await window.electronAPI.setCurrentVideoPath(result.path);
|
|
}
|
|
await finalizeGuideSession(
|
|
activeNativeRecording.recordingId,
|
|
result.session?.screenVideoPath ?? result.path,
|
|
);
|
|
|
|
await window.electronAPI.switchToEditor();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Error saving native macOS recording:", error);
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to save native macOS recording",
|
|
);
|
|
activeNativeRecording.finalizing = false;
|
|
return true;
|
|
} finally {
|
|
if (discardRecordingId.current === activeNativeRecording.recordingId) {
|
|
discardRecordingId.current = null;
|
|
}
|
|
}
|
|
},
|
|
[cursorCaptureMode, discardGuideSession, finalizeGuideSession, getRecordingDurationMs],
|
|
);
|
|
|
|
const stopRecording = useRef(() => {
|
|
if (nativeWindowsRecording.current) {
|
|
void finalizeNativeWindowsRecording(false);
|
|
return;
|
|
}
|
|
if (nativeMacRecording.current) {
|
|
void finalizeNativeMacRecording(false);
|
|
return;
|
|
}
|
|
|
|
const activeScreenRecorder = screenRecorder.current;
|
|
if (!activeScreenRecorder) {
|
|
return;
|
|
}
|
|
|
|
const activeWebcamRecorder = webcamRecorder.current;
|
|
const duration = getRecordingDurationMs();
|
|
const activeRecordingId = recordingId.current;
|
|
|
|
finalizeRecording(
|
|
activeScreenRecorder,
|
|
activeWebcamRecorder ?? null,
|
|
duration,
|
|
activeRecordingId,
|
|
);
|
|
|
|
if (
|
|
activeScreenRecorder.recorder.state === "recording" ||
|
|
activeScreenRecorder.recorder.state === "paused"
|
|
) {
|
|
try {
|
|
activeScreenRecorder.recorder.stop();
|
|
} catch {
|
|
// Recorder may already be stopping.
|
|
}
|
|
}
|
|
if (activeWebcamRecorder) {
|
|
if (
|
|
activeWebcamRecorder.recorder.state === "recording" ||
|
|
activeWebcamRecorder.recorder.state === "paused"
|
|
) {
|
|
try {
|
|
activeWebcamRecorder.recorder.stop();
|
|
} catch {
|
|
// Recorder may already be stopping.
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const safeHideCountdownOverlay = useCallback(async (runId: number) => {
|
|
try {
|
|
await window.electronAPI.hideCountdownOverlay(runId);
|
|
} catch (error) {
|
|
console.warn("Failed to hide countdown overlay:", error);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cleanup: (() => void) | undefined;
|
|
|
|
if (window.electronAPI?.onStopRecordingFromTray) {
|
|
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
|
|
stopRecording.current();
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
const activeRunId = countdownRunId.current;
|
|
if (cleanup) cleanup();
|
|
countdownRunId.current += 1;
|
|
void safeHideCountdownOverlay(activeRunId);
|
|
allowAutoFinalize.current = false;
|
|
restarting.current = false;
|
|
discardRecordingId.current = null;
|
|
if (nativeWindowsRecording.current) {
|
|
void finalizeNativeWindowsRecording(true);
|
|
}
|
|
if (nativeMacRecording.current) {
|
|
void finalizeNativeMacRecording(true);
|
|
}
|
|
|
|
if (
|
|
screenRecorder.current?.recorder.state === "recording" ||
|
|
screenRecorder.current?.recorder.state === "paused"
|
|
) {
|
|
try {
|
|
screenRecorder.current.recorder.stop();
|
|
} catch {
|
|
// Ignore recorder teardown errors during cleanup.
|
|
}
|
|
}
|
|
if (
|
|
webcamRecorder.current?.recorder.state === "recording" ||
|
|
webcamRecorder.current?.recorder.state === "paused"
|
|
) {
|
|
try {
|
|
webcamRecorder.current.recorder.stop();
|
|
} catch {
|
|
// Ignore recorder teardown errors during cleanup.
|
|
}
|
|
}
|
|
screenRecorder.current = null;
|
|
webcamRecorder.current = null;
|
|
teardownMedia();
|
|
};
|
|
}, [
|
|
teardownMedia,
|
|
safeHideCountdownOverlay,
|
|
finalizeNativeWindowsRecording,
|
|
finalizeNativeMacRecording,
|
|
]);
|
|
|
|
const safeShowCountdownOverlay = async (value: number, runId: number) => {
|
|
try {
|
|
await window.electronAPI.showCountdownOverlay(value, runId);
|
|
return true;
|
|
} catch (error) {
|
|
console.warn("Failed to show countdown overlay:", error);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const cancelCountdown = () => {
|
|
const activeRunId = countdownRunId.current;
|
|
countdownRunId.current += 1;
|
|
setCountdownActive(false);
|
|
void safeHideCountdownOverlay(activeRunId);
|
|
};
|
|
|
|
const safeSetCountdownOverlayValue = async (value: number, runId: number) => {
|
|
try {
|
|
await window.electronAPI.setCountdownOverlayValue(value, runId);
|
|
} catch (error) {
|
|
console.warn("Failed to update countdown overlay value:", error);
|
|
}
|
|
};
|
|
|
|
const isCountdownRunActive = (runId?: number) =>
|
|
runId === undefined || countdownRunId.current === runId;
|
|
|
|
const waitForWebcamReady = async () => {
|
|
if (webcamReady.current) {
|
|
return;
|
|
}
|
|
|
|
await new Promise<void>((resolve) => {
|
|
const interval = setInterval(() => {
|
|
if (webcamReady.current) {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}
|
|
}, 50);
|
|
setTimeout(() => {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
};
|
|
|
|
const startNativeWindowsRecordingIfAvailable = async (
|
|
selectedSource: ProcessedDesktopSource,
|
|
countdownRunToken?: number,
|
|
) => {
|
|
try {
|
|
const platform = await window.electronAPI.getPlatform();
|
|
if (platform !== "win32") {
|
|
return false;
|
|
}
|
|
|
|
const availability = await window.electronAPI.isNativeWindowsCaptureAvailable();
|
|
if (!availability.success || !availability.available) {
|
|
if (availability.reason === "unsupported-os") {
|
|
return false;
|
|
}
|
|
if (availability.reason === "missing-helper") {
|
|
console.warn("Native Windows capture helper is not available; using browser capture.");
|
|
return false;
|
|
}
|
|
|
|
throw new Error(availability.error ?? "Native Windows capture is not available.");
|
|
}
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
return true;
|
|
}
|
|
|
|
const activeRecordingId = Date.now();
|
|
const displayId = Number(selectedSource.display_id);
|
|
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
|
const windowHandle = parseWindowHandleFromSourceId(selectedSource.id);
|
|
if (webcamEnabled) {
|
|
await waitForWebcamReady();
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
return true;
|
|
}
|
|
}
|
|
const browserWebcamRecorder =
|
|
webcamEnabled && webcamStream.current
|
|
? createRecorderHandle(webcamStream.current, {
|
|
mimeType: selectMimeType(),
|
|
videoBitsPerSecond: BITRATE_BASE,
|
|
})
|
|
: null;
|
|
if (webcamEnabled && !browserWebcamRecorder) {
|
|
stopWebcamPreviewStream();
|
|
}
|
|
const request: NativeWindowsRecordingRequest = {
|
|
recordingId: activeRecordingId,
|
|
source: {
|
|
type: sourceType,
|
|
sourceId: selectedSource.id,
|
|
...(Number.isFinite(displayId) ? { displayId } : {}),
|
|
...(windowHandle ? { windowHandle } : {}),
|
|
},
|
|
video: {
|
|
fps: TARGET_FRAME_RATE,
|
|
width: TARGET_WIDTH,
|
|
height: TARGET_HEIGHT,
|
|
},
|
|
audio: {
|
|
system: {
|
|
enabled: systemAudioEnabled,
|
|
},
|
|
microphone: {
|
|
enabled: microphoneEnabled,
|
|
deviceId: microphoneDeviceId,
|
|
deviceName: microphoneDeviceName,
|
|
gain: MIC_GAIN_BOOST,
|
|
},
|
|
},
|
|
webcam: {
|
|
enabled: webcamEnabled && !browserWebcamRecorder,
|
|
deviceId: webcamDeviceId,
|
|
deviceName: webcamDeviceName,
|
|
width: 0,
|
|
height: 0,
|
|
fps: WEBCAM_TARGET_FRAME_RATE,
|
|
},
|
|
cursor: {
|
|
mode: cursorCaptureMode,
|
|
},
|
|
};
|
|
const result = await window.electronAPI.startNativeWindowsRecording(request);
|
|
if (!result.success || !result.recordingId) {
|
|
if (
|
|
browserWebcamRecorder?.recorder.state === "recording" ||
|
|
browserWebcamRecorder?.recorder.state === "paused"
|
|
) {
|
|
browserWebcamRecorder.recorder.stop();
|
|
}
|
|
throw new Error(result.error ?? "Native Windows capture failed.");
|
|
}
|
|
|
|
recordingId.current = result.recordingId;
|
|
nativeWindowsRecording.current = {
|
|
recordingId: result.recordingId,
|
|
finalizing: false,
|
|
paused: false,
|
|
webcamRecorder: browserWebcamRecorder,
|
|
};
|
|
webcamRecorder.current = browserWebcamRecorder;
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = Date.now();
|
|
allowAutoFinalize.current = true;
|
|
await startGuideSession(result.recordingId);
|
|
setRecording(true);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Native Windows capture failed:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const startNativeMacRecordingIfAvailable = async (
|
|
selectedSource: ProcessedDesktopSource,
|
|
countdownRunToken?: number,
|
|
) => {
|
|
try {
|
|
const platform = await window.electronAPI.getPlatform();
|
|
if (platform !== "darwin") {
|
|
return false;
|
|
}
|
|
|
|
const availability = await window.electronAPI.isNativeMacCaptureAvailable();
|
|
if (!availability.success || !availability.available) {
|
|
if (availability.reason === "unsupported-platform") {
|
|
return false;
|
|
}
|
|
|
|
throw new Error(
|
|
availability.reason === "missing-helper"
|
|
? "Native macOS capture helper is not available."
|
|
: (availability.error ?? "Native macOS capture is not available."),
|
|
);
|
|
}
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
return true;
|
|
}
|
|
|
|
const activeRecordingId = Date.now();
|
|
const sourceType = selectedSource.id.startsWith("window:") ? "window" : "display";
|
|
const displayId =
|
|
Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id);
|
|
const windowId = parseMacWindowIdFromSourceId(selectedSource.id);
|
|
let nativeWebcamRecorder: RecorderHandle | null = null;
|
|
if (webcamEnabled) {
|
|
if (!webcamReady.current) {
|
|
await new Promise<void>((resolve) => {
|
|
const interval = setInterval(() => {
|
|
if (webcamReady.current) {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}
|
|
}, 50);
|
|
setTimeout(() => {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
}
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
return true;
|
|
}
|
|
if (webcamStream.current) {
|
|
nativeWebcamRecorder = createRecorderHandle(webcamStream.current, {
|
|
mimeType: selectMimeType(),
|
|
videoBitsPerSecond: BITRATE_BASE,
|
|
});
|
|
} else {
|
|
webcamAcquireId.current++;
|
|
setWebcamEnabledState(false);
|
|
}
|
|
}
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
|
|
nativeWebcamRecorder.recorder.stop();
|
|
}
|
|
return true;
|
|
}
|
|
const request: NativeMacRecordingRequest = {
|
|
schemaVersion: 1,
|
|
recordingId: activeRecordingId,
|
|
source: {
|
|
type: sourceType,
|
|
sourceId: selectedSource.id,
|
|
...(displayId ? { displayId } : {}),
|
|
...(windowId ? { windowId } : {}),
|
|
},
|
|
video: {
|
|
fps: TARGET_FRAME_RATE,
|
|
width: TARGET_WIDTH,
|
|
height: TARGET_HEIGHT,
|
|
bitrate: computeBitrate(TARGET_WIDTH, TARGET_HEIGHT),
|
|
hideSystemCursor: cursorCaptureMode === "editable-overlay",
|
|
},
|
|
audio: {
|
|
system: {
|
|
enabled: systemAudioEnabled,
|
|
},
|
|
microphone: {
|
|
enabled: microphoneEnabled,
|
|
deviceId: microphoneDeviceId,
|
|
deviceName: microphoneDeviceName,
|
|
gain: MIC_GAIN_BOOST,
|
|
},
|
|
},
|
|
webcam: {
|
|
enabled: webcamEnabled,
|
|
deviceId: webcamDeviceId,
|
|
deviceName: webcamDeviceName,
|
|
width: 0,
|
|
height: 0,
|
|
fps: WEBCAM_TARGET_FRAME_RATE,
|
|
},
|
|
cursor: {
|
|
mode: cursorCaptureMode,
|
|
},
|
|
outputs: {
|
|
screenPath: "",
|
|
},
|
|
};
|
|
const result = await window.electronAPI.startNativeMacRecording(request);
|
|
if (!result.success || !result.recordingId) {
|
|
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
|
|
nativeWebcamRecorder.recorder.stop();
|
|
}
|
|
throw new Error(result.error ?? "Native macOS capture failed.");
|
|
}
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
|
|
nativeWebcamRecorder.recorder.stop();
|
|
}
|
|
await window.electronAPI.stopNativeMacRecording(true);
|
|
return true;
|
|
}
|
|
|
|
recordingId.current = result.recordingId;
|
|
nativeMacRecording.current = {
|
|
recordingId: result.recordingId,
|
|
finalizing: false,
|
|
paused: false,
|
|
};
|
|
webcamRecorder.current = nativeWebcamRecorder;
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = Date.now();
|
|
allowAutoFinalize.current = true;
|
|
await startGuideSession(result.recordingId);
|
|
setRecording(true);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Native macOS capture failed:", error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const startRecordCountdown = async () => {
|
|
if (countdownActive || recording) {
|
|
return;
|
|
}
|
|
|
|
const runId = countdownRunId.current + 1;
|
|
countdownRunId.current = runId;
|
|
|
|
let selectedSource: ProcessedDesktopSource | null = null;
|
|
try {
|
|
selectedSource = await window.electronAPI.getSelectedSource();
|
|
} catch (error) {
|
|
console.warn("Failed to read selected source before countdown:", error);
|
|
}
|
|
|
|
if (!isCountdownRunActive(runId)) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedSource) {
|
|
if (countdownRunId.current === runId) {
|
|
setCountdownActive(false);
|
|
}
|
|
alert(t("recording.selectSource"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const platform = await window.electronAPI.getPlatform();
|
|
if (platform === "darwin" && cursorCaptureMode === "editable-overlay") {
|
|
const access = await window.electronAPI.requestNativeMacCursorAccess();
|
|
if (!access.granted) {
|
|
toast.info(t("recording.accessibilityAllowAndRetry"));
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn("Failed to preflight macOS cursor accessibility before countdown:", error);
|
|
}
|
|
|
|
if (!isCountdownRunActive(runId)) {
|
|
return;
|
|
}
|
|
|
|
setCountdownActive(true);
|
|
|
|
let overlayHiddenBeforeStart = false;
|
|
try {
|
|
const values = [3, 2, 1];
|
|
const overlayShown = await safeShowCountdownOverlay(values[0], runId);
|
|
|
|
if (countdownRunId.current !== runId) {
|
|
return;
|
|
}
|
|
|
|
for (const value of values) {
|
|
if (countdownRunId.current !== runId) {
|
|
return;
|
|
}
|
|
|
|
if (overlayShown && value !== values[0]) {
|
|
await safeSetCountdownOverlayValue(value, runId);
|
|
|
|
if (countdownRunId.current !== runId) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await new Promise((resolve) => window.setTimeout(resolve, 1000));
|
|
}
|
|
|
|
if (countdownRunId.current !== runId) {
|
|
return;
|
|
}
|
|
|
|
setCountdownActive(false);
|
|
await safeHideCountdownOverlay(runId);
|
|
overlayHiddenBeforeStart = true;
|
|
|
|
if (countdownRunId.current !== runId) {
|
|
return;
|
|
}
|
|
|
|
await startRecording(runId);
|
|
} finally {
|
|
if (!overlayHiddenBeforeStart && countdownRunId.current === runId) {
|
|
setCountdownActive(false);
|
|
await safeHideCountdownOverlay(runId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const startRecording = async (countdownRunToken?: number) => {
|
|
try {
|
|
const selectedSource = await window.electronAPI.getSelectedSource();
|
|
if (!selectedSource) {
|
|
alert(t("recording.selectSource"));
|
|
return;
|
|
}
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
teardownMedia();
|
|
return;
|
|
}
|
|
|
|
if (await startNativeWindowsRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
|
return;
|
|
}
|
|
if (await startNativeMacRecordingIfAvailable(selectedSource, countdownRunToken)) {
|
|
return;
|
|
}
|
|
|
|
let screenMediaStream: MediaStream;
|
|
const platform = await window.electronAPI.getPlatform();
|
|
|
|
if (platform === "win32") {
|
|
// getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the
|
|
// pre-selected source. Editable cursor mode excludes the system cursor so
|
|
// the editor can render a replacement; system mode bakes it into the video.
|
|
screenMediaStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always",
|
|
width: { max: TARGET_WIDTH },
|
|
height: { max: TARGET_HEIGHT },
|
|
frameRate: { ideal: TARGET_FRAME_RATE },
|
|
} as MediaTrackConstraints,
|
|
audio: systemAudioEnabled,
|
|
} as DisplayMediaStreamOptions);
|
|
} else {
|
|
const videoConstraints = {
|
|
mandatory: {
|
|
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
|
chromeMediaSourceId: selectedSource.id,
|
|
maxWidth: TARGET_WIDTH,
|
|
maxHeight: TARGET_HEIGHT,
|
|
maxFrameRate: TARGET_FRAME_RATE,
|
|
minFrameRate: MIN_FRAME_RATE,
|
|
},
|
|
};
|
|
|
|
if (systemAudioEnabled) {
|
|
try {
|
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: {
|
|
mandatory: {
|
|
chromeMediaSource: CHROME_MEDIA_SOURCE,
|
|
chromeMediaSourceId: selectedSource.id,
|
|
},
|
|
},
|
|
video: videoConstraints,
|
|
} as unknown as MediaStreamConstraints);
|
|
} catch (audioErr) {
|
|
console.warn("System audio capture failed, falling back to video-only:", audioErr);
|
|
toast.error(t("recording.systemAudioUnavailable"));
|
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: videoConstraints,
|
|
} as unknown as MediaStreamConstraints);
|
|
}
|
|
} else {
|
|
screenMediaStream = await navigator.mediaDevices.getUserMedia({
|
|
audio: false,
|
|
video: videoConstraints,
|
|
} as unknown as MediaStreamConstraints);
|
|
}
|
|
}
|
|
screenStream.current = screenMediaStream;
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
teardownMedia();
|
|
return;
|
|
}
|
|
|
|
if (microphoneEnabled) {
|
|
try {
|
|
microphoneStream.current = await navigator.mediaDevices.getUserMedia({
|
|
audio: microphoneDeviceId
|
|
? {
|
|
deviceId: { exact: microphoneDeviceId },
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
}
|
|
: {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true,
|
|
},
|
|
video: false,
|
|
});
|
|
} catch (audioError) {
|
|
console.warn("Failed to get microphone access:", audioError);
|
|
toast.error(t("recording.microphoneDenied"));
|
|
setMicrophoneEnabled(false);
|
|
}
|
|
}
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
teardownMedia();
|
|
return;
|
|
}
|
|
|
|
if (webcamEnabled) {
|
|
if (!webcamReady.current) {
|
|
await new Promise<void>((resolve) => {
|
|
const interval = setInterval(() => {
|
|
if (webcamReady.current) {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}
|
|
}, 50);
|
|
setTimeout(() => {
|
|
clearInterval(interval);
|
|
resolve();
|
|
}, 5000);
|
|
});
|
|
}
|
|
if (!webcamStream.current) {
|
|
webcamAcquireId.current++;
|
|
setWebcamEnabledState(false);
|
|
}
|
|
}
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
teardownMedia();
|
|
return;
|
|
}
|
|
|
|
stream.current = new MediaStream();
|
|
const videoTrack = screenMediaStream.getVideoTracks()[0];
|
|
if (!videoTrack) {
|
|
throw new Error("Video track is not available.");
|
|
}
|
|
stream.current.addTrack(videoTrack);
|
|
|
|
const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
|
|
const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
|
|
|
|
if (systemAudioTrack && micAudioTrack) {
|
|
const ctx = new AudioContext();
|
|
mixingContext.current = ctx;
|
|
const systemSource = ctx.createMediaStreamSource(new MediaStream([systemAudioTrack]));
|
|
const micSource = ctx.createMediaStreamSource(new MediaStream([micAudioTrack]));
|
|
const micGain = ctx.createGain();
|
|
micGain.gain.value = MIC_GAIN_BOOST;
|
|
const destination = ctx.createMediaStreamDestination();
|
|
systemSource.connect(destination);
|
|
micSource.connect(micGain).connect(destination);
|
|
stream.current.addTrack(destination.stream.getAudioTracks()[0]);
|
|
} else if (systemAudioTrack) {
|
|
stream.current.addTrack(systemAudioTrack);
|
|
} else if (micAudioTrack) {
|
|
stream.current.addTrack(micAudioTrack);
|
|
}
|
|
|
|
try {
|
|
await videoTrack.applyConstraints({
|
|
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
|
|
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
|
|
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
|
|
});
|
|
} catch (constraintError) {
|
|
console.warn(
|
|
"Unable to lock 4K/60fps constraints, using best available track settings.",
|
|
constraintError,
|
|
);
|
|
}
|
|
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
teardownMedia();
|
|
return;
|
|
}
|
|
|
|
let {
|
|
width = DEFAULT_WIDTH,
|
|
height = DEFAULT_HEIGHT,
|
|
frameRate = TARGET_FRAME_RATE,
|
|
} = videoTrack.getSettings();
|
|
|
|
width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
|
height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
|
|
|
|
const videoBitsPerSecond = computeBitrate(width, height);
|
|
const mimeType = selectMimeType();
|
|
|
|
console.log(
|
|
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
|
|
videoBitsPerSecond / BITS_PER_MEGABIT,
|
|
)} Mbps`,
|
|
);
|
|
|
|
const hasAudio = stream.current.getAudioTracks().length > 0;
|
|
if (!isCountdownRunActive(countdownRunToken)) {
|
|
teardownMedia();
|
|
return;
|
|
}
|
|
|
|
recordingId.current = Date.now();
|
|
const activeRecordingId = recordingId.current;
|
|
screenRecorder.current = createRecorderHandle(
|
|
stream.current,
|
|
{
|
|
mimeType,
|
|
videoBitsPerSecond,
|
|
...(hasAudio
|
|
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
|
: {}),
|
|
},
|
|
`${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`,
|
|
);
|
|
screenRecorder.current.recorder.addEventListener(
|
|
"error",
|
|
() => {
|
|
setRecording(false);
|
|
},
|
|
{ once: true },
|
|
);
|
|
|
|
if (webcamStream.current) {
|
|
webcamRecorder.current = createRecorderHandle(
|
|
webcamStream.current,
|
|
{ mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) },
|
|
`${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
|
|
);
|
|
}
|
|
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = Date.now();
|
|
allowAutoFinalize.current = true;
|
|
await startGuideSession(activeRecordingId);
|
|
setRecording(true);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
window.electronAPI?.setRecordingState(true, recordingId.current, cursorCaptureMode);
|
|
|
|
const activeScreenRecorder = screenRecorder.current;
|
|
const activeWebcamRecorder = webcamRecorder.current;
|
|
if (activeScreenRecorder) {
|
|
activeScreenRecorder.recorder.addEventListener(
|
|
"stop",
|
|
() => {
|
|
if (!allowAutoFinalize.current) {
|
|
return;
|
|
}
|
|
finalizeRecording(
|
|
activeScreenRecorder,
|
|
activeWebcamRecorder ?? null,
|
|
Math.max(0, getRecordingDurationMs()),
|
|
activeRecordingId,
|
|
);
|
|
},
|
|
{ once: true },
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to start recording:", error);
|
|
const errorMsg = error instanceof Error ? error.message : "Failed to start recording";
|
|
if (errorMsg.includes("Permission denied") || errorMsg.includes("NotAllowedError")) {
|
|
toast.error(t("recording.permissionDenied"));
|
|
} else {
|
|
toast.error(errorMsg);
|
|
}
|
|
setRecording(false);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = null;
|
|
screenRecorder.current = null;
|
|
webcamRecorder.current = null;
|
|
teardownMedia();
|
|
}
|
|
};
|
|
|
|
const togglePaused = () => {
|
|
const activeNativeWindowsRecording = nativeWindowsRecording.current;
|
|
if (activeNativeWindowsRecording && !activeNativeWindowsRecording.finalizing) {
|
|
void (async () => {
|
|
try {
|
|
if (activeNativeWindowsRecording.paused) {
|
|
const result = await window.electronAPI.resumeNativeWindowsRecording();
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to resume native Windows recording");
|
|
}
|
|
activeNativeWindowsRecording.paused = false;
|
|
segmentStartedAt.current = Date.now();
|
|
setPaused(false);
|
|
return;
|
|
}
|
|
|
|
const pausedAtMs = getRecordingDurationMs();
|
|
const result = await window.electronAPI.pauseNativeWindowsRecording();
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to pause native Windows recording");
|
|
}
|
|
activeNativeWindowsRecording.paused = true;
|
|
accumulatedDurationMs.current = pausedAtMs;
|
|
segmentStartedAt.current = null;
|
|
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
|
setPaused(true);
|
|
} catch (error) {
|
|
console.error("Failed to toggle native Windows pause state:", error);
|
|
toast.error(error instanceof Error ? error.message : "Failed to toggle pause state");
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
const activeNativeMacRecording = nativeMacRecording.current;
|
|
if (activeNativeMacRecording && !activeNativeMacRecording.finalizing) {
|
|
void (async () => {
|
|
const activeWebcamRecorder = webcamRecorder.current?.recorder;
|
|
try {
|
|
if (activeNativeMacRecording.paused) {
|
|
const result = await window.electronAPI.resumeNativeMacRecording();
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to resume native macOS recording");
|
|
}
|
|
if (activeWebcamRecorder?.state === "paused") {
|
|
activeWebcamRecorder.resume();
|
|
}
|
|
activeNativeMacRecording.paused = false;
|
|
segmentStartedAt.current = Date.now();
|
|
setPaused(false);
|
|
return;
|
|
}
|
|
|
|
const pausedAtMs = getRecordingDurationMs();
|
|
const result = await window.electronAPI.pauseNativeMacRecording();
|
|
if (!result.success) {
|
|
throw new Error(result.error ?? "Failed to pause native macOS recording");
|
|
}
|
|
if (activeWebcamRecorder?.state === "recording") {
|
|
activeWebcamRecorder.pause();
|
|
}
|
|
activeNativeMacRecording.paused = true;
|
|
accumulatedDurationMs.current = pausedAtMs;
|
|
segmentStartedAt.current = null;
|
|
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
|
setPaused(true);
|
|
} catch (error) {
|
|
console.error("Failed to toggle native macOS pause state:", error);
|
|
toast.error(error instanceof Error ? error.message : "Failed to toggle pause state");
|
|
}
|
|
})();
|
|
return;
|
|
}
|
|
|
|
const activeScreenRecorder = screenRecorder.current?.recorder;
|
|
if (!activeScreenRecorder || activeScreenRecorder.state === "inactive") {
|
|
return;
|
|
}
|
|
|
|
const activeWebcamRecorder = webcamRecorder.current?.recorder;
|
|
|
|
if (activeScreenRecorder.state === "paused") {
|
|
try {
|
|
activeScreenRecorder.resume();
|
|
if (activeWebcamRecorder?.state === "paused") {
|
|
activeWebcamRecorder.resume();
|
|
}
|
|
segmentStartedAt.current = Date.now();
|
|
setPaused(false);
|
|
} catch (error) {
|
|
console.error("Failed to resume recording:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (activeScreenRecorder.state !== "recording") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
accumulatedDurationMs.current = getRecordingDurationMs();
|
|
segmentStartedAt.current = null;
|
|
setElapsedSeconds(Math.floor(accumulatedDurationMs.current / 1000));
|
|
activeScreenRecorder.pause();
|
|
if (activeWebcamRecorder?.state === "recording") {
|
|
activeWebcamRecorder.pause();
|
|
}
|
|
setPaused(true);
|
|
} catch (error) {
|
|
console.error("Failed to pause recording:", error);
|
|
}
|
|
};
|
|
|
|
const toggleRecording = () => {
|
|
if (recording) {
|
|
stopRecording.current();
|
|
return;
|
|
}
|
|
|
|
if (countdownActive) {
|
|
cancelCountdown();
|
|
return;
|
|
}
|
|
|
|
void startRecordCountdown();
|
|
};
|
|
|
|
const restartRecording = async () => {
|
|
if (restarting.current) return;
|
|
|
|
if (nativeWindowsRecording.current) {
|
|
const activeRecordingId = recordingId.current;
|
|
restarting.current = true;
|
|
discardRecordingId.current = activeRecordingId;
|
|
try {
|
|
await finalizeNativeWindowsRecording(true);
|
|
await startRecording();
|
|
} finally {
|
|
restarting.current = false;
|
|
}
|
|
return;
|
|
}
|
|
if (nativeMacRecording.current) {
|
|
const activeRecordingId = recordingId.current;
|
|
restarting.current = true;
|
|
discardRecordingId.current = activeRecordingId;
|
|
try {
|
|
await finalizeNativeMacRecording(true);
|
|
await startRecording();
|
|
} finally {
|
|
restarting.current = false;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const activeScreenRecorder = screenRecorder.current;
|
|
if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return;
|
|
|
|
const activeWebcamRecorder = webcamRecorder.current;
|
|
const activeRecordingId = recordingId.current;
|
|
|
|
restarting.current = true;
|
|
discardRecordingId.current = activeRecordingId;
|
|
|
|
const stopPromises = [
|
|
new Promise<void>((resolve) => {
|
|
activeScreenRecorder.recorder.addEventListener("stop", () => resolve(), { once: true });
|
|
}),
|
|
];
|
|
|
|
if (
|
|
activeWebcamRecorder?.recorder.state === "recording" ||
|
|
activeWebcamRecorder?.recorder.state === "paused"
|
|
) {
|
|
stopPromises.push(
|
|
new Promise<void>((resolve) => {
|
|
activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), {
|
|
once: true,
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
stopRecording.current();
|
|
await Promise.all(stopPromises);
|
|
|
|
try {
|
|
await startRecording();
|
|
} finally {
|
|
restarting.current = false;
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!recording) {
|
|
setElapsedSeconds(0);
|
|
return;
|
|
}
|
|
|
|
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
|
|
if (paused) {
|
|
return;
|
|
}
|
|
|
|
const interval = window.setInterval(() => {
|
|
setElapsedSeconds(Math.floor(getRecordingDurationMs() / 1000));
|
|
}, 250);
|
|
|
|
return () => window.clearInterval(interval);
|
|
}, [getRecordingDurationMs, paused, recording]);
|
|
|
|
const cancelRecording = () => {
|
|
if (nativeWindowsRecording.current) {
|
|
const activeRecordingId = recordingId.current;
|
|
discardRecordingId.current = activeRecordingId;
|
|
allowAutoFinalize.current = false;
|
|
void finalizeNativeWindowsRecording(true);
|
|
return;
|
|
}
|
|
if (nativeMacRecording.current) {
|
|
const activeRecordingId = recordingId.current;
|
|
discardRecordingId.current = activeRecordingId;
|
|
allowAutoFinalize.current = false;
|
|
void finalizeNativeMacRecording(true);
|
|
return;
|
|
}
|
|
|
|
const activeScreenRecorder = screenRecorder.current;
|
|
if (
|
|
activeScreenRecorder?.recorder.state === "recording" ||
|
|
activeScreenRecorder?.recorder.state === "paused"
|
|
) {
|
|
const activeRecordingId = recordingId.current;
|
|
discardRecordingId.current = activeRecordingId;
|
|
allowAutoFinalize.current = false;
|
|
|
|
stopRecording.current();
|
|
return;
|
|
}
|
|
|
|
if (countdownActive) {
|
|
cancelCountdown();
|
|
return;
|
|
}
|
|
};
|
|
|
|
return {
|
|
recording,
|
|
paused,
|
|
elapsedSeconds,
|
|
toggleRecording,
|
|
togglePaused,
|
|
canPauseRecording,
|
|
guideModeEnabled,
|
|
setGuideModeEnabled,
|
|
addGuideMarker,
|
|
restartRecording,
|
|
cancelRecording,
|
|
microphoneEnabled,
|
|
setMicrophoneEnabled,
|
|
microphoneDeviceId,
|
|
setMicrophoneDeviceId,
|
|
microphoneDeviceName,
|
|
setMicrophoneDeviceName,
|
|
webcamDeviceId,
|
|
setWebcamDeviceId,
|
|
webcamDeviceName,
|
|
setWebcamDeviceName,
|
|
systemAudioEnabled,
|
|
setSystemAudioEnabled,
|
|
webcamEnabled,
|
|
setWebcamEnabled,
|
|
cursorCaptureMode,
|
|
setCursorCaptureMode,
|
|
};
|
|
}
|