diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts
index 07dc0cf8..c51a5fe0 100644
--- a/electron/electron-env.d.ts
+++ b/electron/electron-env.d.ts
@@ -121,6 +121,9 @@ interface Window {
}>;
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
+ deleteRecordingFile: (
+ filePath: string,
+ ) => Promise<{ success: boolean; error?: string }>;
saveProjectFile: (
projectData: unknown,
suggestedName?: string,
diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts
index 42605553..0a12b9de 100644
--- a/electron/ipc/handlers.ts
+++ b/electron/ipc/handlers.ts
@@ -3139,6 +3139,25 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh}
return { success: true };
});
+ ipcMain.handle('delete-recording-file', async (_, filePath: string) => {
+ try {
+ if (!filePath || !isAutoRecordingPath(filePath)) {
+ return { success: false, error: 'Only auto-generated recordings can be deleted' };
+ }
+ await fs.unlink(filePath);
+ // Also delete the cursor telemetry sidecar if it exists
+ const telemetryPath = getTelemetryPathForVideo(filePath);
+ await fs.unlink(telemetryPath).catch(() => {});
+ if (currentVideoPath === filePath) {
+ currentVideoPath = null;
+ currentRecordingSession = null;
+ }
+ return { success: true };
+ } catch (error) {
+ return { success: false, error: String(error) };
+ }
+ });
+
ipcMain.handle('get-platform', () => {
return process.platform;
});
diff --git a/electron/preload.ts b/electron/preload.ts
index 5b84d451..55fd26b1 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -150,6 +150,9 @@ contextBridge.exposeInMainWorld("electronAPI", {
clearCurrentVideoPath: () => {
return ipcRenderer.invoke("clear-current-video-path");
},
+ deleteRecordingFile: (filePath: string) => {
+ return ipcRenderer.invoke("delete-recording-file", filePath);
+ },
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
},
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index 93e77257..69863fd5 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -489,7 +489,7 @@ export function LaunchWindow() {
<>
{screenSources.length > 0 && (
<>
-
Screens
+ {t("recording.screens")}
{screenSources.map((source) => (
0 && (
<>
0 ? { marginTop: 4 } : undefined}>
- Windows
+ {t("recording.windows")}
{windowSources.map((source) => (
- No sources found
+ {t("recording.noSourcesFound")}
)}
>
@@ -533,18 +533,18 @@ export function LaunchWindow() {
{activeDropdown === "mic" && (
<>
- Microphone
+ {t("recording.microphone")}
{microphoneEnabled && (
}
onClick={() => { setMicrophoneEnabled(false); setActiveDropdown("none"); }}
>
- Turn Off Microphone
+ {t("recording.turnOffMicrophone")}
)}
{!microphoneEnabled && (
- Select a microphone to enable
+ {t("recording.selectMicToEnable")}
)}
{devices.map((device) => (
@@ -561,7 +561,7 @@ export function LaunchWindow() {
))}
{devices.length === 0 && (
- No microphones found
+ {t("recording.noMicrophonesFound")}
)}
>
@@ -569,18 +569,18 @@ export function LaunchWindow() {
{activeDropdown === "webcam" && (
<>
- Webcam
+ {t("recording.webcam")}
{webcamEnabled && (
}
onClick={() => { setWebcamEnabled(false); setActiveDropdown("none"); }}
>
- Turn Off Webcam
+ {t("recording.turnOffWebcam")}
)}
{!webcamEnabled && (
- Select a webcam to enable
+ {t("recording.selectWebcamToEnable")}
)}
{showWebcamControls && (
@@ -612,7 +612,7 @@ export function LaunchWindow() {
))}
{videoDevices.length === 0 && (
- No webcams found
+ {t("recording.noWebcamsFound")}
)}
>
@@ -637,7 +637,7 @@ export function LaunchWindow() {
{activeDropdown === "more" && (
<>
} onClick={chooseRecordingsDirectory}>
- Recordings Folder
+ {t("recording.recordingsFolder")}
} onClick={openVideoFile}>
{t("recording.openVideoFile")}
@@ -645,7 +645,7 @@ export function LaunchWindow() {
} onClick={openProjectFile}>
{t("recording.openProject")}
- Language
+ {t("recording.language")}
{SUPPORTED_LOCALES.map((code) => (
- {paused ? "PAUSED" : "REC"}
+ {paused ? t("recording.paused") : t("recording.rec")}
@@ -690,15 +690,15 @@ export function LaunchWindow() {
-
+
{paused ? : }
-
+
-
+
>
@@ -772,7 +772,7 @@ export function LaunchWindow() {
- toggleDropdown("more")} title="More">
+ toggleDropdown("more")} title={t("recording.more")}>
diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts
index 2a5610fc..e8c2b6a3 100644
--- a/src/hooks/useScreenRecorder.test.ts
+++ b/src/hooks/useScreenRecorder.test.ts
@@ -1,15 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-/**
- * Tests for the MediaRecorder pause/stop state machine logic
- * extracted from useScreenRecorder.
- *
- * These verify that:
- * - Stop works from both "recording" and "paused" states
- * - Resume is called before stop when stopping from paused state
- * - Pause is a no-op when already paused or not recording
- * - Resume is a no-op when not paused
- */
+type RecordingState = "inactive" | "recording" | "paused";
function createMockMediaRecorder(initialState: RecordingState = "inactive") {
let _state: RecordingState = initialState;
@@ -32,15 +23,15 @@ function createMockMediaRecorder(initialState: RecordingState = "inactive") {
};
}
-/**
- * Extracted state machine logic matching useScreenRecorder's stopRecording,
- * pauseRecording, and resumeRecording implementations.
- */
function stopRecording(
recorder: ReturnType,
isNativeRecording: boolean,
+ webcamRecorder?: ReturnType | null,
) {
if (isNativeRecording) {
+ if (webcamRecorder && webcamRecorder.state !== "inactive") {
+ webcamRecorder.stop();
+ }
return { stopped: true, wasNative: true };
}
@@ -49,6 +40,9 @@ function stopRecording(
if (recorderState === "paused") {
recorder.resume();
}
+ if (webcamRecorder && webcamRecorder.state !== "inactive") {
+ webcamRecorder.stop();
+ }
recorder.stop();
return { stopped: true, wasNative: false };
}
@@ -60,11 +54,20 @@ function pauseRecording(
recording: boolean,
paused: boolean,
isNativeRecording: boolean,
+ webcamRecorder?: ReturnType | null,
): boolean {
if (!recording || paused) return false;
- if (isNativeRecording) return false;
+ if (isNativeRecording) {
+ if (webcamRecorder?.state === "recording") {
+ webcamRecorder.pause();
+ }
+ return true;
+ }
if (recorder.state === "recording") {
recorder.pause();
+ if (webcamRecorder?.state === "recording") {
+ webcamRecorder.pause();
+ }
return true;
}
return false;
@@ -74,15 +77,49 @@ function resumeRecording(
recorder: ReturnType,
recording: boolean,
paused: boolean,
+ isNativeRecording: boolean,
+ webcamRecorder?: ReturnType | null,
): boolean {
if (!recording || !paused) return false;
+ if (isNativeRecording) {
+ if (webcamRecorder?.state === "paused") {
+ webcamRecorder.resume();
+ }
+ return true;
+ }
if (recorder.state === "paused") {
recorder.resume();
+ if (webcamRecorder?.state === "paused") {
+ webcamRecorder.resume();
+ }
return true;
}
return false;
}
+function cancelRecording(
+ recorder: ReturnType,
+ isNativeRecording: boolean,
+ chunks: { current: Blob[] },
+ webcamRecorder?: ReturnType | null,
+ webcamChunks?: { current: Blob[] },
+) {
+ if (webcamChunks) webcamChunks.current = [];
+ if (webcamRecorder && webcamRecorder.state !== "inactive") {
+ webcamRecorder.stop();
+ }
+
+ if (isNativeRecording) {
+ return { cancelled: true, wasNative: true };
+ }
+
+ chunks.current = [];
+ if (recorder.state !== "inactive") {
+ recorder.stop();
+ }
+ return { cancelled: true, wasNative: false };
+}
+
describe("useScreenRecorder state machine", () => {
let recorder: ReturnType;
@@ -143,6 +180,24 @@ describe("useScreenRecorder state machine", () => {
expect(result.wasNative).toBe(true);
expect(recorder.stop).not.toHaveBeenCalled();
});
+
+ it("stops webcam when stopping browser recording", () => {
+ const webcam = createMockMediaRecorder("recording");
+
+ stopRecording(recorder, false, webcam);
+
+ expect(webcam.stop).toHaveBeenCalled();
+ expect(webcam.state).toBe("inactive");
+ });
+
+ it("stops webcam when stopping native recording", () => {
+ const webcam = createMockMediaRecorder("recording");
+
+ stopRecording(recorder, true, webcam);
+
+ expect(webcam.stop).toHaveBeenCalled();
+ expect(webcam.state).toBe("inactive");
+ });
});
describe("pauseRecording", () => {
@@ -171,11 +226,36 @@ describe("useScreenRecorder state machine", () => {
expect(recorder.pause).not.toHaveBeenCalled();
});
- it("does nothing for native recordings", () => {
+ it("allows pause for native recordings", () => {
const result = pauseRecording(recorder, true, false, true);
- expect(result).toBe(false);
- expect(recorder.pause).not.toHaveBeenCalled();
+ expect(result).toBe(true);
+ });
+
+ it("pauses webcam alongside browser recording", () => {
+ const webcam = createMockMediaRecorder("recording");
+
+ pauseRecording(recorder, true, false, false, webcam);
+
+ expect(recorder.state).toBe("paused");
+ expect(webcam.state).toBe("paused");
+ });
+
+ it("pauses webcam during native recording pause", () => {
+ const webcam = createMockMediaRecorder("recording");
+
+ const result = pauseRecording(recorder, true, false, true, webcam);
+
+ expect(result).toBe(true);
+ expect(webcam.state).toBe("paused");
+ });
+
+ it("skips webcam pause when webcam is not recording", () => {
+ const webcam = createMockMediaRecorder("inactive");
+
+ pauseRecording(recorder, true, false, false, webcam);
+
+ expect(webcam.pause).not.toHaveBeenCalled();
});
});
@@ -183,7 +263,7 @@ describe("useScreenRecorder state machine", () => {
it("resumes a paused recording", () => {
recorder.pause();
- const result = resumeRecording(recorder, true, true);
+ const result = resumeRecording(recorder, true, true, false);
expect(result).toBe(true);
expect(recorder.resume).toHaveBeenCalled();
@@ -191,21 +271,108 @@ describe("useScreenRecorder state machine", () => {
});
it("does nothing when not paused", () => {
- const result = resumeRecording(recorder, true, false);
+ const result = resumeRecording(recorder, true, false, false);
expect(result).toBe(false);
expect(recorder.resume).not.toHaveBeenCalled();
});
it("does nothing when not recording", () => {
- const result = resumeRecording(recorder, false, true);
+ const result = resumeRecording(recorder, false, true, false);
expect(result).toBe(false);
});
+
+ it("resumes webcam alongside browser recording", () => {
+ const webcam = createMockMediaRecorder("recording");
+ recorder.pause();
+ webcam.pause();
+
+ resumeRecording(recorder, true, true, false, webcam);
+
+ expect(recorder.state).toBe("recording");
+ expect(webcam.state).toBe("recording");
+ });
+
+ it("resumes webcam during native recording resume", () => {
+ const webcam = createMockMediaRecorder("recording");
+ webcam.pause();
+
+ const result = resumeRecording(recorder, true, true, true, webcam);
+
+ expect(result).toBe(true);
+ expect(webcam.state).toBe("recording");
+ });
+
+ it("skips webcam resume when webcam is not paused", () => {
+ recorder.pause();
+ const webcam = createMockMediaRecorder("inactive");
+
+ resumeRecording(recorder, true, true, false, webcam);
+
+ expect(webcam.resume).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("cancelRecording", () => {
+ it("clears chunks and stops browser recording", () => {
+ const chunks = { current: [new Blob(["data"])] };
+
+ const result = cancelRecording(recorder, false, chunks);
+
+ expect(result.cancelled).toBe(true);
+ expect(result.wasNative).toBe(false);
+ expect(chunks.current).toEqual([]);
+ expect(recorder.stop).toHaveBeenCalled();
+ expect(recorder.state).toBe("inactive");
+ });
+
+ it("clears webcam chunks and stops webcam on cancel", () => {
+ const chunks = { current: [new Blob(["data"])] };
+ const webcamChunks = { current: [new Blob(["cam"])] };
+ const webcam = createMockMediaRecorder("recording");
+
+ cancelRecording(recorder, false, chunks, webcam, webcamChunks);
+
+ expect(webcamChunks.current).toEqual([]);
+ expect(webcam.stop).toHaveBeenCalled();
+ expect(webcam.state).toBe("inactive");
+ });
+
+ it("stops webcam when cancelling native recording", () => {
+ const chunks = { current: [] as Blob[] };
+ const webcam = createMockMediaRecorder("recording");
+
+ const result = cancelRecording(recorder, true, chunks, webcam);
+
+ expect(result.wasNative).toBe(true);
+ expect(webcam.stop).toHaveBeenCalled();
+ expect(recorder.stop).not.toHaveBeenCalled();
+ });
+
+ it("handles cancel when recorder is already inactive", () => {
+ const inactiveRecorder = createMockMediaRecorder("inactive");
+ const chunks = { current: [new Blob(["data"])] };
+
+ const result = cancelRecording(inactiveRecorder, false, chunks);
+
+ expect(result.cancelled).toBe(true);
+ expect(chunks.current).toEqual([]);
+ expect(inactiveRecorder.stop).not.toHaveBeenCalled();
+ });
+
+ it("handles cancel when webcam is already inactive", () => {
+ const chunks = { current: [] as Blob[] };
+ const webcam = createMockMediaRecorder("inactive");
+
+ cancelRecording(recorder, false, chunks, webcam);
+
+ expect(webcam.stop).not.toHaveBeenCalled();
+ });
});
describe("pause → stop → editor flow", () => {
- it("full lifecycle: record → pause → stop completes cleanly", () => {
+ it("record → pause → stop completes cleanly", () => {
expect(recorder.state).toBe("recording");
pauseRecording(recorder, true, false, false);
@@ -216,18 +383,59 @@ describe("useScreenRecorder state machine", () => {
expect(recorder.state).toBe("inactive");
});
- it("full lifecycle: record → pause → resume → stop completes cleanly", () => {
+ it("record → pause → resume → stop completes cleanly", () => {
expect(recorder.state).toBe("recording");
pauseRecording(recorder, true, false, false);
expect(recorder.state).toBe("paused");
- resumeRecording(recorder, true, true);
+ resumeRecording(recorder, true, true, false);
expect(recorder.state).toBe("recording");
const result = stopRecording(recorder, false);
expect(result.stopped).toBe(true);
expect(recorder.state).toBe("inactive");
});
+
+ it("webcam stays in sync through full pause/resume/stop cycle", () => {
+ const webcam = createMockMediaRecorder("recording");
+
+ pauseRecording(recorder, true, false, false, webcam);
+ expect(recorder.state).toBe("paused");
+ expect(webcam.state).toBe("paused");
+
+ resumeRecording(recorder, true, true, false, webcam);
+ expect(recorder.state).toBe("recording");
+ expect(webcam.state).toBe("recording");
+
+ stopRecording(recorder, false, webcam);
+ expect(recorder.state).toBe("inactive");
+ expect(webcam.state).toBe("inactive");
+ });
+
+ it("native recording: webcam pauses/resumes while screen keeps capturing", () => {
+ const webcam = createMockMediaRecorder("recording");
+
+ pauseRecording(recorder, true, false, true, webcam);
+ expect(webcam.state).toBe("paused");
+ expect(recorder.pause).not.toHaveBeenCalled();
+
+ resumeRecording(recorder, true, true, true, webcam);
+ expect(webcam.state).toBe("recording");
+ expect(recorder.resume).not.toHaveBeenCalled();
+ });
+
+ it("cancel discards both screen and webcam recordings", () => {
+ const webcam = createMockMediaRecorder("recording");
+ const chunks = { current: [new Blob(["screen"])] };
+ const webcamChunks = { current: [new Blob(["cam"])] };
+
+ cancelRecording(recorder, false, chunks, webcam, webcamChunks);
+
+ expect(chunks.current).toEqual([]);
+ expect(webcamChunks.current).toEqual([]);
+ expect(recorder.state).toBe("inactive");
+ expect(webcam.state).toBe("inactive");
+ });
});
});
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 2804d0d9..10905d11 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -750,18 +750,37 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const pauseRecording = useCallback(() => {
if (!recording || paused) return;
- // Native recordings (macOS SCK / Windows WGC) don't support pause yet
- if (nativeScreenRecording.current) return;
+ if (nativeScreenRecording.current) {
+ // Native captures cannot truly pause, but we pause the timer/UI and webcam
+ if (webcamRecorder.current?.state === "recording") {
+ webcamRecorder.current.pause();
+ }
+ setPaused(true);
+ return;
+ }
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.pause();
+ if (webcamRecorder.current?.state === "recording") {
+ webcamRecorder.current.pause();
+ }
setPaused(true);
}
}, [recording, paused]);
const resumeRecording = useCallback(() => {
if (!recording || !paused) return;
+ if (nativeScreenRecording.current) {
+ if (webcamRecorder.current?.state === "paused") {
+ webcamRecorder.current.resume();
+ }
+ setPaused(false);
+ return;
+ }
if (mediaRecorder.current?.state === "paused") {
mediaRecorder.current.resume();
+ if (webcamRecorder.current?.state === "paused") {
+ webcamRecorder.current.resume();
+ }
setPaused(false);
}
}, [recording, paused]);
@@ -770,12 +789,31 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (!recording) return;
setPaused(false);
+ // Discard webcam recording regardless of recording mode
+ webcamChunks.current = [];
+ if (webcamRecorder.current && webcamRecorder.current.state !== "inactive") {
+ webcamRecorder.current.stop();
+ }
+ webcamRecorder.current = null;
+ webcamStream.current?.getTracks().forEach((t) => t.stop());
+ webcamStream.current = null;
+ pendingWebcamPathPromise.current = null;
+
if (nativeScreenRecording.current) {
nativeScreenRecording.current = false;
wgcRecording.current = false;
setRecording(false);
window.electronAPI?.setRecordingState(false);
- void window.electronAPI.stopNativeScreenRecording();
+ void (async () => {
+ try {
+ const result = await window.electronAPI.stopNativeScreenRecording();
+ if (result?.path) {
+ await window.electronAPI.deleteRecordingFile(result.path);
+ }
+ } catch {
+ // Best-effort cleanup
+ }
+ })();
return;
}
diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json
index 9d3a9faa..1e7ecedc 100644
--- a/src/i18n/locales/en/launch.json
+++ b/src/i18n/locales/en/launch.json
@@ -17,7 +17,27 @@
"hideHudFromVideo": "Hide HUD from recording",
"showHudInVideo": "Show HUD in recording",
"hideHud": "Hide HUD",
- "closeApp": "Close App"
+ "closeApp": "Close App",
+ "screens": "Screens",
+ "windows": "Windows",
+ "noSourcesFound": "No sources found",
+ "microphone": "Microphone",
+ "turnOffMicrophone": "Turn Off Microphone",
+ "selectMicToEnable": "Select a microphone to enable",
+ "noMicrophonesFound": "No microphones found",
+ "webcam": "Webcam",
+ "turnOffWebcam": "Turn Off Webcam",
+ "selectWebcamToEnable": "Select a webcam to enable",
+ "noWebcamsFound": "No webcams found",
+ "recordingsFolder": "Recordings Folder",
+ "language": "Language",
+ "paused": "PAUSED",
+ "rec": "REC",
+ "resume": "Resume",
+ "pause": "Pause",
+ "stop": "Stop",
+ "cancel": "Cancel",
+ "more": "More"
},
"sourceSelector": {
"loadingSources": "Loading sources...",
diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json
index d8a12baa..e4d50f3d 100644
--- a/src/i18n/locales/es/launch.json
+++ b/src/i18n/locales/es/launch.json
@@ -17,7 +17,27 @@
"hideHudFromVideo": "Ocultar HUD en la grabación",
"showHudInVideo": "Mostrar HUD en la grabación",
"hideHud": "Ocultar HUD",
- "closeApp": "Cerrar aplicación"
+ "closeApp": "Cerrar aplicación",
+ "screens": "Pantallas",
+ "windows": "Ventanas",
+ "noSourcesFound": "No se encontraron fuentes",
+ "microphone": "Micrófono",
+ "turnOffMicrophone": "Desactivar micrófono",
+ "selectMicToEnable": "Selecciona un micrófono para activar",
+ "noMicrophonesFound": "No se encontraron micrófonos",
+ "webcam": "Cámara",
+ "turnOffWebcam": "Desactivar cámara",
+ "selectWebcamToEnable": "Selecciona una cámara para activar",
+ "noWebcamsFound": "No se encontraron cámaras",
+ "recordingsFolder": "Carpeta de grabaciones",
+ "language": "Idioma",
+ "paused": "PAUSADO",
+ "rec": "REC",
+ "resume": "Reanudar",
+ "pause": "Pausa",
+ "stop": "Detener",
+ "cancel": "Cancelar",
+ "more": "Más"
},
"sourceSelector": {
"loadingSources": "Cargando fuentes...",
diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json
index 6c3d91d3..72c9573d 100644
--- a/src/i18n/locales/zh-CN/launch.json
+++ b/src/i18n/locales/zh-CN/launch.json
@@ -17,7 +17,27 @@
"hideHudFromVideo": "在录制中隐藏 HUD",
"showHudInVideo": "在录制中显示 HUD",
"hideHud": "隐藏 HUD",
- "closeApp": "关闭应用"
+ "closeApp": "关闭应用",
+ "screens": "屏幕",
+ "windows": "窗口",
+ "noSourcesFound": "未找到源",
+ "microphone": "麦克风",
+ "turnOffMicrophone": "关闭麦克风",
+ "selectMicToEnable": "选择一个麦克风以启用",
+ "noMicrophonesFound": "未找到麦克风",
+ "webcam": "摄像头",
+ "turnOffWebcam": "关闭摄像头",
+ "selectWebcamToEnable": "选择一个摄像头以启用",
+ "noWebcamsFound": "未找到摄像头",
+ "recordingsFolder": "录制文件夹",
+ "language": "语言",
+ "paused": "已暂停",
+ "rec": "录制中",
+ "resume": "恢复",
+ "pause": "暂停",
+ "stop": "停止",
+ "cancel": "取消",
+ "more": "更多"
},
"sourceSelector": {
"loadingSources": "正在加载源...",