From 2b471783c084800acd67c37e8a74439c1db864a1 Mon Sep 17 00:00:00 2001 From: Adam <69064669+abres33@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:00:36 -0500 Subject: [PATCH 01/34] feat: add Cancel Recording button to HUD --- src/components/launch/LaunchWindow.tsx | 15 +++++++++++++++ src/hooks/useScreenRecorder.ts | 13 +++++++++++++ src/i18n/locales/en/launch.json | 1 + src/i18n/locales/es/launch.json | 1 + src/i18n/locales/zh-CN/launch.json | 1 + 5 files changed, 31 insertions(+) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f1b66b8..d1185e8 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,6 +5,7 @@ import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { + MdCancel, MdMic, MdMicOff, MdMonitor, @@ -43,6 +44,7 @@ const ICON_CONFIG = { webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, + cancel: { icon: MdCancel, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -79,6 +81,7 @@ export function LaunchWindow() { recording, toggleRecording, restartRecording, + cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -477,6 +480,18 @@ export function LaunchWindow() { )} + {/* Cancel recording */} + {recording && ( + + + + )} + {/* Open video file */} + {recording && ( + + + + )} + {/* Restart recording */} {recording && ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 0c418c1..8e92962 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -41,7 +41,10 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; + paused: boolean; + elapsedSeconds: number; toggleRecording: () => void; + togglePaused: () => void; restartRecording: () => void; microphoneEnabled: boolean; setMicrophoneEnabled: (enabled: boolean) => void; @@ -85,6 +88,8 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions 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(undefined); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); @@ -97,13 +102,22 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); - const startTime = useRef(0); const recordingId = useRef(0); + const accumulatedDurationMs = useRef(0); + const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); + const getRecordingDurationMs = useCallback(() => { + const segmentDuration = + screenRecorder.current?.recorder.state === "recording" && segmentStartedAt.current + ? Date.now() - segmentStartedAt.current + : 0; + return accumulatedDurationMs.current + segmentDuration; + }, []); + const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", @@ -202,6 +216,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); setRecording(false); + setPaused(false); + setElapsedSeconds(0); + accumulatedDurationMs.current = 0; + segmentStartedAt.current = null; window.electronAPI?.setRecordingState(false); void (async () => { @@ -273,7 +291,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const activeWebcamRecorder = webcamRecorder.current; - const duration = Date.now() - startTime.current; + const duration = getRecordingDurationMs(); const activeRecordingId = recordingId.current; finalizeRecording( @@ -283,7 +301,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeRecordingId, ); - if (activeScreenRecorder.recorder.state === "recording") { + if ( + activeScreenRecorder.recorder.state === "recording" || + activeScreenRecorder.recorder.state === "paused" + ) { try { activeScreenRecorder.recorder.stop(); } catch { @@ -291,7 +312,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } if (activeWebcamRecorder) { - if (activeWebcamRecorder.recorder.state === "recording") { + if ( + activeWebcamRecorder.recorder.state === "recording" || + activeWebcamRecorder.recorder.state === "paused" + ) { try { activeWebcamRecorder.recorder.stop(); } catch { @@ -316,14 +340,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn { restarting.current = false; discardRecordingId.current = null; - if (screenRecorder.current?.recorder.state === "recording") { + 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") { + if ( + webcamRecorder.current?.recorder.state === "recording" || + webcamRecorder.current?.recorder.state === "paused" + ) { try { webcamRecorder.current.recorder.stop(); } catch { @@ -518,9 +548,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } recordingId.current = Date.now(); - startTime.current = recordingId.current; + accumulatedDurationMs.current = 0; + segmentStartedAt.current = Date.now(); allowAutoFinalize.current = true; setRecording(true); + setPaused(false); + setElapsedSeconds(0); window.electronAPI?.setRecordingState(true); const activeScreenRecorder = screenRecorder.current; @@ -536,7 +569,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, - Math.max(0, Date.now() - startTime.current), + Math.max(0, getRecordingDurationMs()), activeRecordingId, ); }, @@ -552,12 +585,56 @@ export function useScreenRecorder(): UseScreenRecorderReturn { 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 = () => { recording ? stopRecording.current() : startRecording(); }; @@ -566,7 +643,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (restarting.current) return; const activeScreenRecorder = screenRecorder.current; - if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return; + if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; @@ -581,7 +658,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }), ]; - if (activeWebcamRecorder?.recorder.state === "recording") { + if ( + activeWebcamRecorder?.recorder.state === "recording" || + activeWebcamRecorder?.recorder.state === "paused" + ) { stopPromises.push( new Promise((resolve) => { activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), { @@ -601,9 +681,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + 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]); + return { recording, + paused, + elapsedSeconds, toggleRecording, + togglePaused, restartRecording, microphoneEnabled, setMicrophoneEnabled, From 3bfcd8576b43dcb5395d109feb135724cb6ca478 Mon Sep 17 00:00:00 2001 From: theaiagent Date: Fri, 3 Apr 2026 22:44:25 +0300 Subject: [PATCH 10/34] fix: read live video.currentTime for rapid frame steps and add JSDoc - Read currentTime directly from the video element instead of the React ref so rapid arrow key presses each advance by exactly one frame - Add JSDoc docstrings to frameStep.ts exports --- src/components/video-editor/VideoEditor.tsx | 10 +++++++--- src/lib/frameStep.ts | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 3efd9ce..e83f5b0 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -949,13 +949,17 @@ export default function VideoEditor() { return; } e.preventDefault(); + const video = videoPlaybackRef.current?.video; + if (!video) { + return; + } const direction = e.key === "ArrowLeft" ? "backward" : "forward"; const newTime = computeFrameStepTime( - currentTimeRef.current, - durationRef.current, + video.currentTime, + Number.isFinite(video.duration) ? video.duration : durationRef.current, direction, ); - handleSeek(newTime); + video.currentTime = newTime; return; } diff --git a/src/lib/frameStep.ts b/src/lib/frameStep.ts index 7eaaf6b..dc42d78 100644 --- a/src/lib/frameStep.ts +++ b/src/lib/frameStep.ts @@ -1,5 +1,10 @@ +/** Duration of a single frame in seconds at 60 FPS (~16.67ms). */ export const FRAME_DURATION_SEC = 1 / 60; +/** + * Compute the new playhead time after stepping one frame forward or backward. + * The result is clamped to the range [0, duration]. + */ export function computeFrameStepTime( currentTime: number, duration: number, From 97c9a73578ce547356593f03e258ce1595415549 Mon Sep 17 00:00:00 2001 From: theaiagent Date: Fri, 3 Apr 2026 23:02:12 +0300 Subject: [PATCH 11/34] fix: skip frame-step on ARIA widgets that own arrow keys Expand the arrow key guard to also skip elements with role="separator" (PanelResizeHandle), role="slider", and role="spinbutton" so keyboard panel resizing is not intercepted. --- src/components/video-editor/VideoEditor.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e83f5b0..e3a30cf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -944,7 +944,9 @@ export default function VideoEditor() { target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || - (target instanceof HTMLElement && target.isContentEditable) + (target instanceof HTMLElement && + (target.isContentEditable || + target.closest('[role="separator"], [role="slider"], [role="spinbutton"]'))) ) { return; } From 478fe316dcd8114a665179cc958ead9d0fff9530 Mon Sep 17 00:00:00 2001 From: cocoon Date: Sat, 4 Apr 2026 13:23:51 +0000 Subject: [PATCH 12/34] fix(editor): track unsaved changes for new projects --- src/components/video-editor/VideoEditor.tsx | 41 +++++++++++-------- .../video-editor/projectPersistence.test.ts | 39 ++++++++++++++++++ .../video-editor/projectPersistence.ts | 18 ++++++++ 3 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4e5e978..50657c7 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -31,6 +31,8 @@ import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { createProjectData, + createProjectSnapshot, + hasProjectUnsavedChanges, deriveNextId, fromFileUrl, normalizeProjectEditor, @@ -234,13 +236,11 @@ export default function VideoEditor() { ) + 1; setLastSavedSnapshot( - JSON.stringify( - createProjectData( - webcamSourcePath - ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } - : { screenVideoPath: sourcePath }, - normalizedEditor, - ), + createProjectSnapshot( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + normalizedEditor, ), ); return true; @@ -252,8 +252,7 @@ export default function VideoEditor() { if (!currentProjectMedia) { return null; } - return JSON.stringify( - createProjectData(currentProjectMedia, { + return createProjectSnapshot(currentProjectMedia, { wallpaper, shadowIntensity, showBlur, @@ -274,8 +273,7 @@ export default function VideoEditor() { gifFrameRate, gifLoop, gifSizePreset, - }), - ); + }); }, [ currentProjectMedia, wallpaper, @@ -300,11 +298,9 @@ export default function VideoEditor() { gifSizePreset, ]); - const hasUnsavedChanges = Boolean( - currentProjectPath && - currentProjectSnapshot && - lastSavedSnapshot && - currentProjectSnapshot !== lastSavedSnapshot, + const hasUnsavedChanges = hasProjectUnsavedChanges( + currentProjectSnapshot, + lastSavedSnapshot, ); useEffect(() => { @@ -333,7 +329,14 @@ export default function VideoEditor() { setWebcamVideoSourcePath(webcamSourcePath); setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null); setCurrentProjectPath(null); - setLastSavedSnapshot(null); + setLastSavedSnapshot( + createProjectSnapshot( + webcamSourcePath + ? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath } + : { screenVideoPath: sourcePath }, + INITIAL_EDITOR_STATE, + ), + ); return; } @@ -345,7 +348,9 @@ export default function VideoEditor() { setWebcamVideoSourcePath(null); setWebcamVideoPath(null); setCurrentProjectPath(null); - setLastSavedSnapshot(null); + setLastSavedSnapshot( + createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE), + ); } else { setError("No video to load. Please record or select a video."); } diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 3243aca..82e084f 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { createProjectData, + createProjectSnapshot, + hasProjectUnsavedChanges, normalizeProjectEditor, PROJECT_VERSION, resolveProjectMedia, @@ -65,3 +67,40 @@ describe("projectPersistence media compatibility", () => { ).toBe("rectangle"); }); }); + + +it("creates stable snapshots for identical project state", () => { + const media = { + screenVideoPath: "/tmp/screen.webm", + webcamVideoPath: "/tmp/webcam.webm", + }; + const editor = normalizeProjectEditor({ + wallpaper: "/wallpapers/wallpaper1.jpg", + shadowIntensity: 0, + showBlur: false, + motionBlurAmount: 0, + borderRadius: 0, + padding: 50, + cropRegion: { x: 0, y: 0, width: 1, height: 1 }, + zoomRegions: [], + trimRegions: [], + speedRegions: [], + annotationRegions: [], + aspectRatio: "16:9", + webcamLayoutPreset: "picture-in-picture", + webcamMaskShape: "circle", + exportQuality: "good", + exportFormat: "mp4", + gifFrameRate: 15, + gifLoop: true, + gifSizePreset: "medium", + }); + + expect(createProjectSnapshot(media, editor)).toBe(createProjectSnapshot(media, editor)); +}); + +it("detects unsaved changes from differing snapshots", () => { + expect(hasProjectUnsavedChanges(null, null)).toBe(false); + expect(hasProjectUnsavedChanges("same", "same")).toBe(false); + expect(hasProjectUnsavedChanges("current", "baseline")).toBe(true); +}); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index d7111b1..246de62 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -405,3 +405,21 @@ export function createProjectData( editor, }; } + +export function createProjectSnapshot( + media: ProjectMedia, + editor: ProjectEditorState, +): string { + return JSON.stringify(createProjectData(media, editor)); +} + +export function hasProjectUnsavedChanges( + currentSnapshot: string | null, + baselineSnapshot: string | null, +): boolean { + return Boolean( + currentSnapshot !== null && + baselineSnapshot !== null && + currentSnapshot !== baselineSnapshot, + ); +} From d5f59a7b8e1fa49590a26babd04518a39f406ce0 Mon Sep 17 00:00:00 2001 From: JasonOA888 Date: Sat, 4 Apr 2026 23:16:39 +0800 Subject: [PATCH 13/34] fix: persist user settings across sessions Add userPreferences module to save/load padding, aspect ratio, export format and quality to localStorage. Applied on mount in VideoEditor. Closes #306 --- src/components/video-editor/VideoEditor.tsx | 1 + src/lib/userPreferences.ts | 69 +++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/lib/userPreferences.ts diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4e5e978..e2e34f1 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -21,6 +21,7 @@ import { VideoExporter, } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; +import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { matchesShortcut } from "@/lib/shortcuts"; import { getAspectRatioValue, diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts new file mode 100644 index 0000000..ae9d14f --- /dev/null +++ b/src/lib/userPreferences.ts @@ -0,0 +1,69 @@ +import type { ExportFormat, ExportQuality } from "@/lib/exporter"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; + +const PREFS_KEY = "openscreen_user_preferences"; + +export interface UserPreferences { + /** Default padding % */ + padding: number; + /** Default aspect ratio */ + aspectRatio: AspectRatio; + /** Default export quality */ + exportQuality: ExportQuality; + /** Default export format */ + exportFormat: ExportFormat; +} + +const DEFAULT_PREFS: UserPreferences = { + padding: 50, + aspectRatio: "16:9", + exportQuality: "good", + exportFormat: "mp4", +}; + +function safeJsonParse(text: string | null): Record | null { + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * Load persisted user preferences from localStorage. + * Returns defaults for any missing or invalid fields. + */ +export function loadUserPreferences(): UserPreferences { + const raw = safeJsonParse(localStorage.getItem(PREFS_KEY)); + if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS }; + + return { + padding: + typeof raw.padding === "number" && Number.isFinite(raw.padding) && raw.padding >= 0 && raw.padding <= 100 + ? raw.padding + : DEFAULT_PREFS.padding, + aspectRatio: + typeof raw.aspectRatio === "string" ? (raw.aspectRatio as AspectRatio) : DEFAULT_PREFS.aspectRatio, + exportQuality: + raw.exportQuality === "medium" || raw.exportQuality === "source" + ? (raw.exportQuality as ExportQuality) + : DEFAULT_PREFS.exportQuality, + exportFormat: + raw.exportFormat === "gif" ? (raw.exportFormat as ExportFormat) : DEFAULT_PREFS.exportFormat, + }; +} + +/** + * Persist user preferences to localStorage. + * Only the explicitly provided fields are updated. + */ +export function saveUserPreferences(partial: Partial): void { + const current = loadUserPreferences(); + const merged = { ...current, ...partial }; + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(merged)); + } catch { + // localStorage may be unavailable (e.g. private browsing quota exceeded) + } +} From 7d746196d2c26e4e6f742177fa3e6861940b50b3 Mon Sep 17 00:00:00 2001 From: JasonOA888 Date: Sat, 4 Apr 2026 23:27:56 +0800 Subject: [PATCH 14/34] fix: persist user settings across sessions (closes #306) Load saved preferences (padding, aspect ratio, export quality, export format) on mount and auto-save whenever these settings change. Uses the existing userPreferences.ts utility with a ref guard to prevent overwriting saved prefs with defaults before the initial load completes. --- src/components/video-editor/VideoEditor.tsx | 26 ++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e2e34f1..6d7c5c5 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -21,8 +21,8 @@ import { VideoExporter, } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; -import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { matchesShortcut } from "@/lib/shortcuts"; +import { loadUserPreferences, saveUserPreferences } from "@/lib/userPreferences"; import { getAspectRatioValue, getNativeAspectRatioValue, @@ -360,6 +360,30 @@ export default function VideoEditor() { loadInitialData(); }, [applyLoadedProject]); + // Track whether user preferences have been loaded to avoid + // overwriting saved prefs with defaults on the first render + const prefsLoadedRef = useRef(false); + + // Load persisted user preferences on mount + useEffect(() => { + const prefs = loadUserPreferences(); + updateState({ + padding: prefs.padding, + aspectRatio: prefs.aspectRatio, + }); + setExportQuality(prefs.exportQuality); + setExportFormat(prefs.exportFormat); + prefsLoadedRef.current = true; + // We intentionally only want this to run once on mount + // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect + }, []); + + // Auto-save user preferences when settings change + useEffect(() => { + if (!prefsLoadedRef.current) return; + saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat }); + }, [padding, aspectRatio, exportQuality, exportFormat]); + const saveProject = useCallback( async (forceSaveAs: boolean) => { if (!videoPath) { From 4f48ecd4bc796a43fc16c34f0f7acad400345ac2 Mon Sep 17 00:00:00 2001 From: JasonOA888 Date: Sat, 4 Apr 2026 23:58:25 +0800 Subject: [PATCH 15/34] fix: address code review feedback for settings persistence - Replace useRef with useState for prefsHydrated to prevent race condition - Wrap localStorage.getItem in try/catch in loadUserPreferences - Validate aspectRatio against known valid values - Include 'good' in exportQuality validation, 'mp4' in exportFormat validation --- package-lock.json | 4 +-- src/components/video-editor/VideoEditor.tsx | 8 +++--- src/lib/userPreferences.ts | 28 ++++++++++++++++++--- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70e3395..fdbd6b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openscreen", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.2.0", + "version": "1.3.0", "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 6d7c5c5..4168ef8 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -362,7 +362,7 @@ export default function VideoEditor() { // Track whether user preferences have been loaded to avoid // overwriting saved prefs with defaults on the first render - const prefsLoadedRef = useRef(false); + const [prefsHydrated, setPrefsHydrated] = useState(false); // Load persisted user preferences on mount useEffect(() => { @@ -373,16 +373,16 @@ export default function VideoEditor() { }); setExportQuality(prefs.exportQuality); setExportFormat(prefs.exportFormat); - prefsLoadedRef.current = true; + setPrefsHydrated(true); // We intentionally only want this to run once on mount // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect }, []); // Auto-save user preferences when settings change useEffect(() => { - if (!prefsLoadedRef.current) return; + if (!prefsHydrated) return; saveUserPreferences({ padding, aspectRatio, exportQuality, exportFormat }); - }, [padding, aspectRatio, exportQuality, exportFormat]); + }, [prefsHydrated, padding, aspectRatio, exportQuality, exportFormat]); const saveProject = useCallback( async (forceSaveAs: boolean) => { diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index ae9d14f..5839799 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -3,6 +3,17 @@ import type { AspectRatio } from "@/utils/aspectRatioUtils"; const PREFS_KEY = "openscreen_user_preferences"; +const VALID_ASPECT_RATIOS: readonly string[] = [ + "16:9", + "9:16", + "1:1", + "4:3", + "4:5", + "16:10", + "10:16", + "native", +]; + export interface UserPreferences { /** Default padding % */ padding: number; @@ -35,7 +46,12 @@ function safeJsonParse(text: string | null): Record | null { * Returns defaults for any missing or invalid fields. */ export function loadUserPreferences(): UserPreferences { - const raw = safeJsonParse(localStorage.getItem(PREFS_KEY)); + let raw: Record | null = null; + try { + raw = safeJsonParse(localStorage.getItem(PREFS_KEY)); + } catch { + return { ...DEFAULT_PREFS }; + } if (!raw || typeof raw !== "object") return { ...DEFAULT_PREFS }; return { @@ -44,13 +60,17 @@ export function loadUserPreferences(): UserPreferences { ? raw.padding : DEFAULT_PREFS.padding, aspectRatio: - typeof raw.aspectRatio === "string" ? (raw.aspectRatio as AspectRatio) : DEFAULT_PREFS.aspectRatio, + typeof raw.aspectRatio === "string" && VALID_ASPECT_RATIOS.includes(raw.aspectRatio) + ? (raw.aspectRatio as AspectRatio) + : DEFAULT_PREFS.aspectRatio, exportQuality: - raw.exportQuality === "medium" || raw.exportQuality === "source" + raw.exportQuality === "medium" || raw.exportQuality === "good" || raw.exportQuality === "source" ? (raw.exportQuality as ExportQuality) : DEFAULT_PREFS.exportQuality, exportFormat: - raw.exportFormat === "gif" ? (raw.exportFormat as ExportFormat) : DEFAULT_PREFS.exportFormat, + raw.exportFormat === "gif" || raw.exportFormat === "mp4" + ? (raw.exportFormat as ExportFormat) + : DEFAULT_PREFS.exportFormat, }; } From 7072c05edd45503bd050bcb54802aa47b3455f62 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 5 Apr 2026 09:39:28 -0700 Subject: [PATCH 16/34] fix: duration bug in auto-finalize path and add i18n for pause tooltip --- src/components/launch/LaunchWindow.tsx | 2 +- src/hooks/useScreenRecorder.ts | 4 +--- src/i18n/locales/en/launch.json | 2 ++ src/i18n/locales/es/launch.json | 2 ++ src/i18n/locales/zh-CN/launch.json | 2 ++ 5 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index c189b80..d6435cf 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -456,7 +456,7 @@ export function LaunchWindow() { {recording && ( - + - {recording && ( - - - - )} - {/* Restart recording */} {recording && ( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index a676d66..25adf6d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -41,10 +41,7 @@ const WEBCAM_TARGET_FRAME_RATE = 30; type UseScreenRecorderReturn = { recording: boolean; - paused: boolean; - elapsedSeconds: number; toggleRecording: () => void; - togglePaused: () => void; restartRecording: () => void; cancelRecording: () => void; microphoneEnabled: boolean; @@ -89,8 +86,6 @@ function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions 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(undefined); const [webcamDeviceId, setWebcamDeviceId] = useState(undefined); @@ -103,20 +98,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const microphoneStream = useRef(null); const webcamStream = useRef(null); const mixingContext = useRef(null); + const startTime = useRef(0); const recordingId = useRef(0); - const accumulatedDurationMs = useRef(0); - const segmentStartedAt = useRef(null); const finalizingRecordingId = useRef(null); const allowAutoFinalize = useRef(false); const discardRecordingId = useRef(null); const restarting = useRef(false); - const getRecordingDurationMs = useCallback(() => { - const segmentDuration = - segmentStartedAt.current === null ? 0 : Date.now() - segmentStartedAt.current; - return accumulatedDurationMs.current + segmentDuration; - }, []); - const selectMimeType = () => { const preferred = [ "video/webm;codecs=av1", @@ -215,10 +203,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { teardownMedia(); setRecording(false); - setPaused(false); - setElapsedSeconds(0); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = null; window.electronAPI?.setRecordingState(false); void (async () => { @@ -290,7 +274,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const activeWebcamRecorder = webcamRecorder.current; - const duration = getRecordingDurationMs(); + const duration = Date.now() - startTime.current; const activeRecordingId = recordingId.current; finalizeRecording( @@ -300,10 +284,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { activeRecordingId, ); - if ( - activeScreenRecorder.recorder.state === "recording" || - activeScreenRecorder.recorder.state === "paused" - ) { + if (activeScreenRecorder.recorder.state === "recording") { try { activeScreenRecorder.recorder.stop(); } catch { @@ -311,10 +292,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } } if (activeWebcamRecorder) { - if ( - activeWebcamRecorder.recorder.state === "recording" || - activeWebcamRecorder.recorder.state === "paused" - ) { + if (activeWebcamRecorder.recorder.state === "recording") { try { activeWebcamRecorder.recorder.stop(); } catch { @@ -339,20 +317,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { restarting.current = false; discardRecordingId.current = null; - if ( - screenRecorder.current?.recorder.state === "recording" || - screenRecorder.current?.recorder.state === "paused" - ) { + if (screenRecorder.current?.recorder.state === "recording") { try { screenRecorder.current.recorder.stop(); } catch { // Ignore recorder teardown errors during cleanup. } } - if ( - webcamRecorder.current?.recorder.state === "recording" || - webcamRecorder.current?.recorder.state === "paused" - ) { + if (webcamRecorder.current?.recorder.state === "recording") { try { webcamRecorder.current.recorder.stop(); } catch { @@ -547,12 +519,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } recordingId.current = Date.now(); - accumulatedDurationMs.current = 0; - segmentStartedAt.current = Date.now(); + startTime.current = recordingId.current; allowAutoFinalize.current = true; setRecording(true); - setPaused(false); - setElapsedSeconds(0); window.electronAPI?.setRecordingState(true); const activeScreenRecorder = screenRecorder.current; @@ -568,7 +537,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizeRecording( activeScreenRecorder, activeWebcamRecorder ?? null, - Math.max(0, getRecordingDurationMs()), + Math.max(0, Date.now() - startTime.current), activeRecordingId, ); }, @@ -584,56 +553,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { 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 = () => { recording ? stopRecording.current() : startRecording(); }; @@ -642,7 +567,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (restarting.current) return; const activeScreenRecorder = screenRecorder.current; - if (!activeScreenRecorder || activeScreenRecorder.recorder.state === "inactive") return; + if (!activeScreenRecorder || activeScreenRecorder.recorder.state !== "recording") return; const activeWebcamRecorder = webcamRecorder.current; const activeRecordingId = recordingId.current; @@ -657,10 +582,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }), ]; - if ( - activeWebcamRecorder?.recorder.state === "recording" || - activeWebcamRecorder?.recorder.state === "paused" - ) { + if (activeWebcamRecorder?.recorder.state === "recording") { stopPromises.push( new Promise((resolve) => { activeWebcamRecorder.recorder.addEventListener("stop", () => resolve(), { @@ -691,30 +613,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { stopRecording.current(); }; - 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]); - return { recording, - paused, - elapsedSeconds, toggleRecording, - togglePaused, restartRecording, cancelRecording, microphoneEnabled, diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index cf111c4..c1229cc 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -4,8 +4,6 @@ "closeApp": "Close App", "restartRecording": "Restart recording", "cancelRecording": "Cancel recording", - "pauseRecording": "Pause recording", - "resumeRecording": "Resume recording", "openVideoFile": "Open video file", "openProject": "Open project" }, diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index f47bc81..f5be07c 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -4,8 +4,6 @@ "closeApp": "Cerrar aplicación", "restartRecording": "Reiniciar grabación", "cancelRecording": "Cancelar grabación", - "pauseRecording": "Pausar grabación", - "resumeRecording": "Reanudar grabación", "openVideoFile": "Abrir archivo de video", "openProject": "Abrir proyecto" }, diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6b63df1..0c2b319 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -4,8 +4,6 @@ "closeApp": "关闭应用", "restartRecording": "重新开始录制", "cancelRecording": "取消录制", - "pauseRecording": "暂停录制", - "resumeRecording": "继续录制", "openVideoFile": "打开视频文件", "openProject": "打开项目" }, From c868469be57ef8abb961b15af55629d55fa4174c Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sun, 5 Apr 2026 10:17:35 -0700 Subject: [PATCH 18/34] fix: auto-finalize duration bug, restore cancelRecording, and add i18n for pause tooltips --- src/components/launch/LaunchWindow.tsx | 2 +- src/hooks/useScreenRecorder.ts | 15 ++++++++++++--- src/i18n/locales/en/launch.json | 2 ++ src/i18n/locales/es/launch.json | 2 ++ src/i18n/locales/zh-CN/launch.json | 2 ++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 83306ee..249dd77 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -459,7 +459,7 @@ export function LaunchWindow() { {recording && ( - +