From ebe333f02918d8b7e162f387475b06dea0db3c5f Mon Sep 17 00:00:00 2001 From: Gurpreet Kait Date: Wed, 18 Mar 2026 11:59:58 +0530 Subject: [PATCH] - tests driven - language support - webcame pause/resume sync - native recording pause - cancel (clears chunks and stops both recoders and webcame discarded on native cancel - full lifecycle with webcame - pause - resume - stop keeps webcame in sync throughout --- electron/electron-env.d.ts | 3 + electron/ipc/handlers.ts | 19 ++ electron/preload.ts | 3 + src/components/launch/LaunchWindow.tsx | 36 ++-- src/hooks/useScreenRecorder.test.ts | 256 ++++++++++++++++++++++--- src/hooks/useScreenRecorder.ts | 44 ++++- src/i18n/locales/en/launch.json | 22 ++- src/i18n/locales/es/launch.json | 22 ++- src/i18n/locales/zh-CN/launch.json | 22 ++- 9 files changed, 379 insertions(+), 48 deletions(-) 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": "正在加载源...",