- 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
This commit is contained in:
Gurpreet Kait
2026-03-18 11:59:58 +05:30
parent 9679e20e48
commit ebe333f029
9 changed files with 379 additions and 48 deletions
+3
View File
@@ -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,
+19
View File
@@ -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;
});
+3
View File
@@ -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);
},
+18 -18
View File
@@ -489,7 +489,7 @@ export function LaunchWindow() {
<>
{screenSources.length > 0 && (
<>
<div className={styles.ddLabel}>Screens</div>
<div className={styles.ddLabel}>{t("recording.screens")}</div>
{screenSources.map((source) => (
<DropdownItem
key={source.id}
@@ -505,7 +505,7 @@ export function LaunchWindow() {
{windowSources.length > 0 && (
<>
<div className={styles.ddLabel} style={screenSources.length > 0 ? { marginTop: 4 } : undefined}>
Windows
{t("recording.windows")}
</div>
{windowSources.map((source) => (
<DropdownItem
@@ -523,7 +523,7 @@ export function LaunchWindow() {
)}
{screenSources.length === 0 && windowSources.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
No sources found
{t("recording.noSourcesFound")}
</div>
)}
</>
@@ -533,18 +533,18 @@ export function LaunchWindow() {
{activeDropdown === "mic" && (
<>
<div className={styles.ddLabel}>Microphone</div>
<div className={styles.ddLabel}>{t("recording.microphone")}</div>
{microphoneEnabled && (
<DropdownItem
icon={<MicOff size={16} />}
onClick={() => { setMicrophoneEnabled(false); setActiveDropdown("none"); }}
>
Turn Off Microphone
{t("recording.turnOffMicrophone")}
</DropdownItem>
)}
{!microphoneEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
Select a microphone to enable
{t("recording.selectMicToEnable")}
</div>
)}
{devices.map((device) => (
@@ -561,7 +561,7 @@ export function LaunchWindow() {
))}
{devices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
No microphones found
{t("recording.noMicrophonesFound")}
</div>
)}
</>
@@ -569,18 +569,18 @@ export function LaunchWindow() {
{activeDropdown === "webcam" && (
<>
<div className={styles.ddLabel}>Webcam</div>
<div className={styles.ddLabel}>{t("recording.webcam")}</div>
{webcamEnabled && (
<DropdownItem
icon={<VideoOff size={16} />}
onClick={() => { setWebcamEnabled(false); setActiveDropdown("none"); }}
>
Turn Off Webcam
{t("recording.turnOffWebcam")}
</DropdownItem>
)}
{!webcamEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
Select a webcam to enable
{t("recording.selectWebcamToEnable")}
</div>
)}
{showWebcamControls && (
@@ -612,7 +612,7 @@ export function LaunchWindow() {
))}
{videoDevices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
No webcams found
{t("recording.noWebcamsFound")}
</div>
)}
</>
@@ -637,7 +637,7 @@ export function LaunchWindow() {
{activeDropdown === "more" && (
<>
<DropdownItem icon={<FolderOpen size={16} />} onClick={chooseRecordingsDirectory}>
Recordings Folder
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem icon={<VideoIcon size={16} />} onClick={openVideoFile}>
{t("recording.openVideoFile")}
@@ -645,7 +645,7 @@ export function LaunchWindow() {
<DropdownItem icon={<FolderOpen size={16} />} onClick={openProjectFile}>
{t("recording.openProject")}
</DropdownItem>
<div className={styles.ddLabel} style={{ marginTop: 4 }}>Language</div>
<div className={styles.ddLabel} style={{ marginTop: 4 }}>{t("recording.language")}</div>
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
@@ -674,7 +674,7 @@ export function LaunchWindow() {
<div className="flex items-center gap-[5px]">
<div className={`w-[7px] h-[7px] rounded-full ${paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}`}`} />
<span className={`text-[10px] font-bold tracking-[0.06em] ${paused ? "text-[#fbbf24]" : "text-[#f43f5e]"}`}>
{paused ? "PAUSED" : "REC"}
{paused ? t("recording.paused") : t("recording.rec")}
</span>
</div>
@@ -690,15 +690,15 @@ export function LaunchWindow() {
<Separator />
<IconButton onClick={paused ? resumeRecording : pauseRecording} title={paused ? "Resume" : "Pause"} className={paused ? styles.ibGreen : ""}>
<IconButton onClick={paused ? resumeRecording : pauseRecording} title={paused ? t("recording.resume") : t("recording.pause")} className={paused ? styles.ibGreen : ""}>
{paused ? <Play size={18} fill="currentColor" strokeWidth={0} /> : <Pause size={18} />}
</IconButton>
<IconButton onClick={toggleRecording} title="Stop" className={styles.ibRed}>
<IconButton onClick={toggleRecording} title={t("recording.stop")} className={styles.ibRed}>
<Square size={16} fill="currentColor" strokeWidth={0} />
</IconButton>
<IconButton onClick={cancelRecording} title="Cancel">
<IconButton onClick={cancelRecording} title={t("recording.cancel")}>
<X size={18} />
</IconButton>
</>
@@ -772,7 +772,7 @@ export function LaunchWindow() {
<Separator />
<IconButton onClick={() => toggleDropdown("more")} title="More">
<IconButton onClick={() => toggleDropdown("more")} title={t("recording.more")}>
<MoreVertical size={18} />
</IconButton>
+232 -24
View File
@@ -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<typeof createMockMediaRecorder>,
isNativeRecording: boolean,
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | 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<typeof createMockMediaRecorder> | 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<typeof createMockMediaRecorder>,
recording: boolean,
paused: boolean,
isNativeRecording: boolean,
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | 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<typeof createMockMediaRecorder>,
isNativeRecording: boolean,
chunks: { current: Blob[] },
webcamRecorder?: ReturnType<typeof createMockMediaRecorder> | 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<typeof createMockMediaRecorder>;
@@ -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");
});
});
});
+41 -3
View File
@@ -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;
}
+21 -1
View File
@@ -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...",
+21 -1
View File
@@ -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...",
+21 -1
View File
@@ -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": "正在加载源...",