diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 85d82946..adf03592 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -63,7 +63,11 @@ interface Window { message?: string; error?: string; }>; - setRecordingState: (recording: boolean, recordingId?: number) => Promise; + setRecordingState: ( + state: "recording" | "paused" | "stopped", + recordingId?: number, + elapsedMs?: number, + ) => Promise; discardCursorTelemetry: (recordingId: number) => Promise; getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; @@ -72,6 +76,7 @@ interface Window { error?: string; }>; onStopRecordingFromTray: (callback: () => void) => () => void; + onTogglePauseRecordingFromTray: (callback: () => void) => () => void; openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; saveExportedVideo: ( videoData: ArrayBuffer, diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 95ed797e..b438aa61 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -360,7 +360,7 @@ export function registerIpcHandlers( getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null, getCountdownOverlayWindow: () => BrowserWindow | null, - onRecordingStateChange?: (recording: boolean, sourceName: string) => void, + onRecordingStateChange?: (state: "recording" | "paused" | "stopped", sourceName: string) => void, switchToHud?: () => void, ) { const supportsWindowOpacity = process.platform !== "linux"; @@ -700,28 +700,33 @@ export function registerIpcHandlers( } }); - ipcMain.handle("set-recording-state", (_, recording: boolean, recordingId?: number) => { - if (recording) { - stopCursorCapture(); - // The renderer is the source of truth for the recording id (it - // uses the same id as the saved fileName). Fall back to a - // timestamp only if the renderer didn't supply one, so the - // buffer always has a stable key per session. - const id = typeof recordingId === "number" ? recordingId : Date.now(); - cursorTelemetryBuffer.startSession(id); - cursorCaptureStartTimeMs = Date.now(); - sampleCursorPoint(); - cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); - } else { - stopCursorCapture(); - cursorTelemetryBuffer.endSession(); - } + ipcMain.handle( + "set-recording-state", + (_, state: "recording" | "paused" | "stopped", recordingId?: number, elapsedMs?: number) => { + if (state === "recording") { + stopCursorCapture(); + // The renderer is the source of truth for the recording id (it + // uses the same id as the saved fileName). Fall back to a + // timestamp only if the renderer didn't supply one, so the + // buffer always has a stable key per session. + const id = typeof recordingId === "number" ? recordingId : Date.now(); + cursorTelemetryBuffer.startSession(id); + cursorCaptureStartTimeMs = Date.now() - (elapsedMs || 0); + sampleCursorPoint(); + cursorCaptureInterval = setInterval(sampleCursorPoint, CURSOR_SAMPLE_INTERVAL_MS); + } else if (state === "paused") { + stopCursorCapture(); + } else { + stopCursorCapture(); + cursorTelemetryBuffer.endSession(); + } - const source = selectedSource || { name: "Screen" }; - if (onRecordingStateChange) { - onRecordingStateChange(recording, source.name); - } - }); + const source = selectedSource || { name: "Screen" }; + if (onRecordingStateChange) { + onRecordingStateChange(state, source.name); + } + }, + ); ipcMain.handle("discard-cursor-telemetry", (_, recordingId: number) => { cursorTelemetryBuffer.discardBatch(recordingId); @@ -1128,14 +1133,4 @@ export function registerIpcHandlers( return null; } }); - - ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => { - try { - await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8"); - return { success: true }; - } catch (error) { - console.error("Failed to save shortcuts:", error); - return { success: false, error: String(error) }; - } - }); } diff --git a/electron/main.ts b/electron/main.ts index ad0a33fc..b912843a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -5,6 +5,7 @@ import { app, BrowserWindow, dialog, + globalShortcut, ipcMain, Menu, nativeImage, @@ -217,35 +218,59 @@ function getTrayIcon(filename: string, size: number) { }); } -function updateTrayMenu(recording: boolean = false) { +function updateTrayMenu(state: "recording" | "paused" | "stopped" = "stopped") { if (!tray) return; - const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen"; - const menuTemplate = recording - ? [ - { - label: mainT("common", "actions.stopRecording") || "Stop Recording", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("stop-recording-from-tray"); - } - }, + + const isRecording = state === "recording"; + const isPaused = state === "paused"; + // TODO: Create an actual paused icon later, or use default/recording for now. Let's just use default or an indicator? + // Using default tray icon for paused visually differentiates it from the red recording dot + const trayIcon = isRecording ? recordingTrayIcon : defaultTrayIcon; + + let trayToolTip = "OpenScreen"; + if (isRecording) trayToolTip = `Recording: ${selectedSourceName}`; + if (isPaused) trayToolTip = `Paused: ${selectedSourceName}`; + + let menuTemplate: Parameters[0] = []; + + if (isRecording || isPaused) { + menuTemplate = [ + { + label: isPaused + ? mainT("common", "actions.resumeRecording") || "Resume Recording" + : mainT("common", "actions.pauseRecording") || "Pause Recording", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("toggle-pause-recording-from-tray"); + } }, - ] - : [ - { - label: mainT("common", "actions.open") || "Open", - click: () => { - showMainWindow(); - }, + }, + { + label: mainT("common", "actions.stopRecording") || "Stop Recording", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("stop-recording-from-tray"); + } }, - { - label: mainT("common", "actions.quit") || "Quit", - click: () => { - app.quit(); - }, + }, + ]; + } else { + menuTemplate = [ + { + label: mainT("common", "actions.open") || "Open", + click: () => { + showMainWindow(); + }, + }, + { + label: mainT("common", "actions.quit") || "Quit", + click: () => { + app.quit(); }, - ]; + }, + ]; + } + tray.setImage(trayIcon); tray.setToolTip(trayToolTip); tray.setContextMenu(Menu.buildFromTemplate(menuTemplate)); @@ -410,6 +435,52 @@ app.whenReady().then(async () => { showMainWindow(); } + async function updateGlobalShortcuts() { + globalShortcut.unregisterAll(); + let config: any = {}; + try { + const data = await fs.readFile(path.join(app.getPath("userData"), "shortcuts.json"), "utf-8"); + config = JSON.parse(data); + } catch {} + + const binding = config.togglePauseRecording || { key: "p", ctrl: true, alt: true }; + const parts = []; + if (binding.ctrl) parts.push("CommandOrControl"); + if (binding.alt) parts.push("Alt"); + if (binding.shift) parts.push("Shift"); + const key = binding.key && binding.key.length === 1 ? binding.key.toUpperCase() : binding.key; + if (key) { + parts.push(key); + const accelerator = parts.join("+"); + try { + globalShortcut.register(accelerator, () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("toggle-pause-recording-from-tray"); + } + }); + } catch (e) { + console.error("Failed to register global shortcut:", accelerator, e); + } + } + } + + await updateGlobalShortcuts(); + + ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => { + try { + await fs.writeFile( + path.join(app.getPath("userData"), "shortcuts.json"), + JSON.stringify(shortcuts, null, 2), + "utf-8", + ); + await updateGlobalShortcuts(); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + return { success: false, error: String(error) }; + } + }); + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, @@ -417,11 +488,11 @@ app.whenReady().then(async () => { () => mainWindow, () => sourceSelectorWindow, () => countdownOverlayWindow, - (recording: boolean, sourceName: string) => { + (state: "recording" | "paused" | "stopped", sourceName: string) => { selectedSourceName = sourceName; if (!tray) createTray(); - updateTrayMenu(recording); - if (!recording) { + updateTrayMenu(state); + if (state === "stopped") { showMainWindow(); } }, diff --git a/electron/preload.ts b/electron/preload.ts index 46e16f04..72a3f44d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -51,8 +51,12 @@ contextBridge.exposeInMainWorld("electronAPI", { getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); }, - setRecordingState: (recording: boolean, recordingId?: number) => { - return ipcRenderer.invoke("set-recording-state", recording, recordingId); + setRecordingState: ( + state: "recording" | "paused" | "stopped", + recordingId?: number, + elapsedMs?: number, + ) => { + return ipcRenderer.invoke("set-recording-state", state, recordingId, elapsedMs); }, getCursorTelemetry: (videoPath?: string) => { return ipcRenderer.invoke("get-cursor-telemetry", videoPath); @@ -65,6 +69,11 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("stop-recording-from-tray", listener); return () => ipcRenderer.removeListener("stop-recording-from-tray", listener); }, + onTogglePauseRecordingFromTray: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("toggle-pause-recording-from-tray", listener); + return () => ipcRenderer.removeListener("toggle-pause-recording-from-tray", listener); + }, openExternalUrl: (url: string) => { return ipcRenderer.invoke("open-external-url", url); }, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index dc9758fb..e770ab6e 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -302,7 +302,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setElapsedSeconds(0); accumulatedDurationMs.current = 0; segmentStartedAt.current = null; - window.electronAPI?.setRecordingState(false); + window.electronAPI?.setRecordingState("stopped"); void (async () => { try { @@ -409,17 +409,25 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }); useEffect(() => { - let cleanup: (() => void) | undefined; + let stopCleanup: (() => void) | undefined; + let pauseCleanup: (() => void) | undefined; if (window.electronAPI?.onStopRecordingFromTray) { - cleanup = window.electronAPI.onStopRecordingFromTray(() => { + stopCleanup = window.electronAPI.onStopRecordingFromTray(() => { stopRecording.current(); }); } + if (window.electronAPI?.onTogglePauseRecordingFromTray) { + pauseCleanup = window.electronAPI.onTogglePauseRecordingFromTray(() => { + togglePaused(); + }); + } + return () => { const activeRunId = countdownRunId.current; - if (cleanup) cleanup(); + if (stopCleanup) stopCleanup(); + if (pauseCleanup) pauseCleanup(); countdownRunId.current += 1; void safeHideCountdownOverlay(activeRunId); allowAutoFinalize.current = false; @@ -773,7 +781,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setRecording(true); setPaused(false); setElapsedSeconds(0); - window.electronAPI?.setRecordingState(true, recordingId.current); + window.electronAPI?.setRecordingState("recording", recordingId.current, 0); const activeScreenRecorder = screenRecorder.current; const activeWebcamRecorder = webcamRecorder.current; @@ -830,6 +838,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } segmentStartedAt.current = Date.now(); setPaused(false); + window.electronAPI?.setRecordingState( + "recording", + recordingId.current, + accumulatedDurationMs.current, + ); } catch (error) { console.error("Failed to resume recording:", error); } @@ -849,6 +862,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeWebcamRecorder.pause(); } setPaused(true); + window.electronAPI?.setRecordingState( + "paused", + recordingId.current, + accumulatedDurationMs.current, + ); } catch (error) { console.error("Failed to pause recording:", error); } diff --git a/src/lib/cursorTelemetryBuffer.test.ts b/src/lib/cursorTelemetryBuffer.test.ts index 17174acc..383fb4ef 100644 --- a/src/lib/cursorTelemetryBuffer.test.ts +++ b/src/lib/cursorTelemetryBuffer.test.ts @@ -103,6 +103,25 @@ describe("createCursorTelemetryBuffer", () => { expect(batch?.samples.map((s) => s.timeMs)).toEqual([1]); }); + it("preserves active session samples when resuming with the same recordingId", () => { + const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); + + buf.startSession(1); + buf.push(sample(1)); + buf.push(sample(2)); + + // Resuming the same session (e.g. after a pause) should not clear the buffer + buf.startSession(1); + buf.push(sample(3)); + buf.endSession(); + + expect(buf.pendingCount).toBe(1); + const batch = buf.takeNextBatch(); + expect(batch?.recordingId).toBe(1); + expect(batch?.samples).toHaveLength(3); + expect(batch?.samples.map((s) => s.timeMs)).toEqual([1, 2, 3]); + }); + it("discardBatch(id) drops only the batch produced by that recording id", () => { const buf = createCursorTelemetryBuffer({ maxActiveSamples: 10 }); diff --git a/src/lib/cursorTelemetryBuffer.ts b/src/lib/cursorTelemetryBuffer.ts index 0c7e0e10..0819108f 100644 --- a/src/lib/cursorTelemetryBuffer.ts +++ b/src/lib/cursorTelemetryBuffer.ts @@ -148,6 +148,9 @@ export function createCursorTelemetryBuffer( return { startSession(recordingId) { + if (activeRecordingId === recordingId) { + return; + } active = []; activeRecordingId = recordingId; }, diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 96592a41..34d8331b 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -7,6 +7,7 @@ export const SHORTCUT_ACTIONS = [ "addKeyframe", "deleteSelected", "playPause", + "togglePauseRecording", ] as const; export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number]; @@ -113,6 +114,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addKeyframe: { key: "f" }, deleteSelected: { key: "d", ctrl: true }, playPause: { key: " " }, + togglePauseRecording: { key: "p", ctrl: true, alt: true }, // Cmd+Alt+P equivalent }; export const SHORTCUT_LABELS: Record = { @@ -124,6 +126,7 @@ export const SHORTCUT_LABELS: Record = { addKeyframe: "Add Keyframe", deleteSelected: "Delete Selected", playPause: "Play / Pause", + togglePauseRecording: "Pause/Resume Recording (Global)", }; export function matchesShortcut(