e9650225ba
- Implement native bridge for Windows cursor capture via PowerShell/C# - Add cursor-free capture using getDisplayMedia with setDisplayMediaRequestHandler - Update video player and exporters to support native cursor telemetry - Enable system audio capture on Windows via WASAPI loopback - Add interpolation for smoother cursor movement in playback and export - Improve cursor scaling and visibility handling in editor and playback
945 lines
26 KiB
TypeScript
945 lines
26 KiB
TypeScript
import { fixWebmDuration } from "@fix-webm-duration/fix";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { nativeBridgeClient } from "@/native";
|
|
|
|
const TARGET_FRAME_RATE = 60;
|
|
const MIN_FRAME_RATE = 30;
|
|
const TARGET_WIDTH = 3840;
|
|
const TARGET_HEIGHT = 2160;
|
|
const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
|
|
const QHD_WIDTH = 2560;
|
|
const QHD_HEIGHT = 1440;
|
|
const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
|
|
|
|
const BITRATE_4K = 45_000_000;
|
|
const BITRATE_QHD = 28_000_000;
|
|
const BITRATE_BASE = 18_000_000;
|
|
const HIGH_FRAME_RATE_THRESHOLD = 60;
|
|
const HIGH_FRAME_RATE_BOOST = 1.7;
|
|
|
|
const DEFAULT_WIDTH = 1920;
|
|
const DEFAULT_HEIGHT = 1080;
|
|
|
|
const CODEC_ALIGNMENT = 2;
|
|
|
|
const RECORDER_TIMESLICE_MS = 1000;
|
|
const BITS_PER_MEGABIT = 1_000_000;
|
|
const 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_WIDTH = 1280;
|
|
const WEBCAM_TARGET_HEIGHT = 720;
|
|
const WEBCAM_TARGET_FRAME_RATE = 30;
|
|
|
|
type UseScreenRecorderReturn = {
|
|
recording: boolean;
|
|
paused: boolean;
|
|
elapsedSeconds: number;
|
|
toggleRecording: () => void;
|
|
togglePaused: () => void;
|
|
restartRecording: () => void;
|
|
cancelRecording: () => void;
|
|
microphoneEnabled: boolean;
|
|
setMicrophoneEnabled: (enabled: boolean) => void;
|
|
microphoneDeviceId: string | undefined;
|
|
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
|
|
webcamDeviceId: string | undefined;
|
|
setWebcamDeviceId: (deviceId: string | undefined) => void;
|
|
systemAudioEnabled: boolean;
|
|
setSystemAudioEnabled: (enabled: boolean) => void;
|
|
webcamEnabled: boolean;
|
|
setWebcamEnabled: (enabled: boolean) => Promise<boolean>;
|
|
};
|
|
|
|
type RecorderHandle = {
|
|
recorder: MediaRecorder;
|
|
recordedBlobPromise: Promise<Blob>;
|
|
};
|
|
|
|
function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle {
|
|
const recorder = new MediaRecorder(stream, options);
|
|
const chunks: Blob[] = [];
|
|
const mimeType = options.mimeType || "video/webm";
|
|
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
|
|
recorder.ondataavailable = (event: BlobEvent) => {
|
|
if (event.data && event.data.size > 0) {
|
|
chunks.push(event.data);
|
|
}
|
|
};
|
|
recorder.onerror = () => {
|
|
reject(new Error("Recording failed"));
|
|
};
|
|
recorder.onstop = () => {
|
|
resolve(new Blob(chunks, { type: mimeType }));
|
|
};
|
|
});
|
|
|
|
recorder.start(RECORDER_TIMESLICE_MS);
|
|
return { recorder, recordedBlobPromise };
|
|
}
|
|
|
|
export function useScreenRecorder(): UseScreenRecorderReturn {
|
|
const t = useScopedT("editor");
|
|
const [recording, setRecording] = useState(false);
|
|
const [paused, setPaused] = useState(false);
|
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
|
|
const [microphoneDeviceId, setMicrophoneDeviceId] = useState<string | undefined>(undefined);
|
|
const [webcamDeviceId, setWebcamDeviceId] = useState<string | undefined>(undefined);
|
|
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
|
|
const [webcamEnabled, setWebcamEnabledState] = useState(false);
|
|
const screenRecorder = useRef<RecorderHandle | null>(null);
|
|
const webcamRecorder = useRef<RecorderHandle | 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 [countdownActive, setCountdownActive] = useState(false);
|
|
const webcamReady = useRef(false);
|
|
const webcamAcquireId = useRef(0);
|
|
|
|
const getRecordingDurationMs = useCallback(() => {
|
|
const segmentDuration =
|
|
segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current;
|
|
return accumulatedDurationMs.current + segmentDuration;
|
|
}, []);
|
|
|
|
const selectMimeType = () => {
|
|
// H.264 first: hardware-accelerated on all modern devices, gives sharp
|
|
// real-time output. AV1/VP9 are great for distribution but too
|
|
// CPU-intensive for live 60 fps capture — they produce blurry frames
|
|
// when the software encoder can't keep up.
|
|
const preferred = [
|
|
"video/webm;codecs=h264",
|
|
"video/webm;codecs=vp8",
|
|
"video/webm;codecs=vp9",
|
|
"video/webm;codecs=av1",
|
|
"video/webm",
|
|
];
|
|
|
|
return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
|
|
};
|
|
|
|
const computeBitrate = (width: number, height: number) => {
|
|
const pixels = width * height;
|
|
const highFrameRateBoost =
|
|
TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
|
|
|
|
if (pixels >= FOUR_K_PIXELS) {
|
|
return Math.round(BITRATE_4K * highFrameRateBoost);
|
|
}
|
|
|
|
if (pixels >= QHD_PIXELS) {
|
|
return Math.round(BITRATE_QHD * highFrameRateBoost);
|
|
}
|
|
|
|
return Math.round(BITRATE_BASE * highFrameRateBoost);
|
|
};
|
|
|
|
const teardownMedia = useCallback(() => {
|
|
if (stream.current) {
|
|
stream.current.getTracks().forEach((track) => track.stop());
|
|
stream.current = null;
|
|
}
|
|
if (screenStream.current) {
|
|
screenStream.current.getTracks().forEach((track) => track.stop());
|
|
screenStream.current = null;
|
|
}
|
|
if (microphoneStream.current) {
|
|
microphoneStream.current.getTracks().forEach((track) => track.stop());
|
|
microphoneStream.current = null;
|
|
}
|
|
if (mixingContext.current) {
|
|
mixingContext.current.close().catch(() => {
|
|
// Ignore close errors during recorder teardown.
|
|
});
|
|
mixingContext.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const 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 },
|
|
width: { ideal: WEBCAM_TARGET_WIDTH },
|
|
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
|
}
|
|
: {
|
|
width: { ideal: WEBCAM_TARGET_WIDTH },
|
|
height: { ideal: WEBCAM_TARGET_HEIGHT },
|
|
frameRate: { ideal: WEBCAM_TARGET_FRAME_RATE, max: WEBCAM_TARGET_FRAME_RATE },
|
|
},
|
|
});
|
|
|
|
if (cancelled || thisAcquireId !== webcamAcquireId.current) {
|
|
stream.getTracks().forEach((track) => {
|
|
track.onended = null;
|
|
track.stop();
|
|
});
|
|
return;
|
|
}
|
|
|
|
acquiredStream = stream;
|
|
stream.getVideoTracks().forEach((track) => {
|
|
track.onended = () => {
|
|
webcamStream.current = null;
|
|
if (!restarting.current) {
|
|
setWebcamEnabledState(false);
|
|
toast.error(t("recording.cameraDisconnected"));
|
|
}
|
|
};
|
|
});
|
|
webcamStream.current = stream;
|
|
webcamReady.current = true;
|
|
} catch (cameraError) {
|
|
if (!cancelled) {
|
|
console.warn("Failed to get webcam access:", cameraError);
|
|
setWebcamEnabledState(false);
|
|
const isDeviceError =
|
|
cameraError instanceof DOMException &&
|
|
[
|
|
"NotFoundError",
|
|
"DevicesNotFoundError",
|
|
"OverconstrainedError",
|
|
"NotReadableError",
|
|
].includes(cameraError.name);
|
|
toast.error(t(isDeviceError ? "recording.cameraNotFound" : "recording.cameraBlocked"));
|
|
webcamReady.current = true;
|
|
}
|
|
}
|
|
};
|
|
|
|
void acquire();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
webcamReady.current = false;
|
|
if (acquiredStream) {
|
|
acquiredStream.getTracks().forEach((track) => {
|
|
track.onended = null;
|
|
track.stop();
|
|
});
|
|
webcamStream.current = null;
|
|
}
|
|
};
|
|
}, [webcamEnabled, webcamDeviceId, t]);
|
|
|
|
const finalizeRecording = useCallback(
|
|
(
|
|
activeScreenRecorder: RecorderHandle,
|
|
activeWebcamRecorder: RecorderHandle | null,
|
|
duration: number,
|
|
activeRecordingId: number,
|
|
) => {
|
|
if (finalizingRecordingId.current === activeRecordingId) {
|
|
return;
|
|
}
|
|
finalizingRecordingId.current = activeRecordingId;
|
|
|
|
if (screenRecorder.current === activeScreenRecorder) {
|
|
screenRecorder.current = null;
|
|
}
|
|
if (activeWebcamRecorder && webcamRecorder.current === activeWebcamRecorder) {
|
|
webcamRecorder.current = null;
|
|
}
|
|
|
|
teardownMedia();
|
|
setRecording(false);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = null;
|
|
window.electronAPI?.setRecordingState(false);
|
|
|
|
void (async () => {
|
|
try {
|
|
const screenBlob = await activeScreenRecorder.recordedBlobPromise;
|
|
if (discardRecordingId.current === activeRecordingId) {
|
|
window.electronAPI?.discardCursorTelemetry(activeRecordingId);
|
|
return;
|
|
}
|
|
if (screenBlob.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
|
|
let fixedWebcamBlob: Blob | null = null;
|
|
if (activeWebcamRecorder) {
|
|
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
|
|
if (webcamBlob && webcamBlob.size > 0) {
|
|
fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
|
|
}
|
|
}
|
|
|
|
const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
|
|
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
|
|
const result = await window.electronAPI.storeRecordedSession({
|
|
screen: {
|
|
videoData: await fixedScreenBlob.arrayBuffer(),
|
|
fileName: screenFileName,
|
|
},
|
|
webcam: fixedWebcamBlob
|
|
? {
|
|
videoData: await fixedWebcamBlob.arrayBuffer(),
|
|
fileName: webcamFileName,
|
|
}
|
|
: undefined,
|
|
createdAt: activeRecordingId,
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error("Failed to store recording session:", result.message);
|
|
return;
|
|
}
|
|
|
|
if (result.session) {
|
|
await window.electronAPI.setCurrentRecordingSession(result.session);
|
|
} else if (result.path) {
|
|
await window.electronAPI.setCurrentVideoPath(result.path);
|
|
}
|
|
|
|
await window.electronAPI.switchToEditor();
|
|
} catch (error) {
|
|
console.error("Error saving recording:", error);
|
|
} finally {
|
|
if (finalizingRecordingId.current === activeRecordingId) {
|
|
finalizingRecordingId.current = null;
|
|
}
|
|
if (discardRecordingId.current === activeRecordingId) {
|
|
discardRecordingId.current = null;
|
|
}
|
|
}
|
|
})();
|
|
},
|
|
[teardownMedia],
|
|
);
|
|
|
|
const stopRecording = useRef(() => {
|
|
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 (
|
|
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]);
|
|
|
|
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 startRecordCountdown = async () => {
|
|
if (countdownActive || recording) {
|
|
return;
|
|
}
|
|
|
|
const runId = countdownRunId.current + 1;
|
|
countdownRunId.current = runId;
|
|
setCountdownActive(true);
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
let screenMediaStream: MediaStream;
|
|
|
|
// getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the
|
|
// pre-selected source and honors cursor:"never" to exclude the system cursor
|
|
// from every captured frame. System audio is provided via WASAPI loopback
|
|
// on Windows when the user has enabled it.
|
|
screenMediaStream = await navigator.mediaDevices.getDisplayMedia({
|
|
video: {
|
|
cursor: "never",
|
|
width: { max: TARGET_WIDTH },
|
|
height: { max: TARGET_HEIGHT },
|
|
frameRate: { ideal: TARGET_FRAME_RATE, min: MIN_FRAME_RATE },
|
|
} as MediaTrackConstraints,
|
|
audio: systemAudioEnabled,
|
|
} as DisplayMediaStreamOptions);
|
|
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;
|
|
}
|
|
|
|
screenRecorder.current = createRecorderHandle(stream.current, {
|
|
mimeType,
|
|
videoBitsPerSecond,
|
|
...(hasAudio
|
|
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
|
|
: {}),
|
|
});
|
|
screenRecorder.current.recorder.addEventListener(
|
|
"error",
|
|
() => {
|
|
setRecording(false);
|
|
},
|
|
{ once: true },
|
|
);
|
|
|
|
if (webcamStream.current) {
|
|
webcamRecorder.current = createRecorderHandle(webcamStream.current, {
|
|
mimeType,
|
|
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE),
|
|
});
|
|
}
|
|
|
|
recordingId.current = Date.now();
|
|
accumulatedDurationMs.current = 0;
|
|
segmentStartedAt.current = Date.now();
|
|
allowAutoFinalize.current = true;
|
|
setRecording(true);
|
|
setPaused(false);
|
|
setElapsedSeconds(0);
|
|
window.electronAPI?.setRecordingState(true, recordingId.current);
|
|
|
|
const activeScreenRecorder = screenRecorder.current;
|
|
const activeWebcamRecorder = webcamRecorder.current;
|
|
const activeRecordingId = recordingId.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 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;
|
|
|
|
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 = () => {
|
|
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,
|
|
restartRecording,
|
|
cancelRecording,
|
|
microphoneEnabled,
|
|
setMicrophoneEnabled,
|
|
microphoneDeviceId,
|
|
setMicrophoneDeviceId,
|
|
webcamDeviceId,
|
|
setWebcamDeviceId,
|
|
systemAudioEnabled,
|
|
setSystemAudioEnabled,
|
|
webcamEnabled,
|
|
setWebcamEnabled,
|
|
};
|
|
}
|