Files
openscreen/src/hooks/useScreenRecorder.ts
T
Etienne Lescot e9650225ba feat: add cursor overlay pipeline for high-fidelity cursor recording and playback
- 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
2026-05-10 15:11:00 +02:00

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,
};
}