From 382de787be257211cb3107d0dd03133c71564767 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 10:25:03 +0200 Subject: [PATCH 01/43] feat: add Open Studio button, empty-state dropzone, and broad video import - Add Clapperboard icon button to HUD toolbar that calls switchToEditor(), opening the editor without a recording (Open Studio) - Add EditorEmptyState component shown when no video is loaded, featuring: - Drag-and-drop zone for .openscreen project files - Import Video File button (MP4, MOV, WebM, MKV, AVI, M4V, WMV, FLV, TS) - Load Project button - Add File menu items: New Project (Ctrl+N) and Import Video File (Ctrl+I) - Add loadProjectFileFromPath to the full native-bridge chain so drag-dropped .openscreen files can be loaded without a file-picker dialog - Expand ALLOWED_IMPORT_VIDEO_EXTENSIONS and file picker filters for all common video formats from any screen recorder - Add i18n keys: tooltips.openStudio, unsavedChanges.newProject/importVideo --- electron/electron-env.d.ts | 2 + electron/ipc/handlers.ts | 47 ++++++- electron/ipc/nativeBridge.ts | 7 + electron/main.ts | 19 ++- .../native-bridge/services/projectService.ts | 7 + electron/preload.ts | 10 ++ src/components/launch/LaunchWindow.tsx | 13 +- .../video-editor/EditorEmptyState.tsx | 129 ++++++++++++++++++ src/components/video-editor/VideoEditor.tsx | 48 ++++++- src/i18n/locales/en/dialogs.json | 2 + src/i18n/locales/en/launch.json | 3 +- src/native/client.ts | 6 + src/native/contracts.ts | 6 + 13 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 src/components/video-editor/EditorEmptyState.tsx diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e92ce19fc..96456608f 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -166,6 +166,8 @@ interface Window { canceled?: boolean; error?: string; }>; + onMenuNewProject: (callback: () => void) => () => void; + onMenuImportVideo: (callback: () => void) => () => void; onMenuLoadProject: (callback: () => void) => () => void; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9797c95c0..66a7412b7 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -42,7 +42,17 @@ const PROJECT_FILE_EXTENSION = "openscreen"; const SHORTCUTS_FILE = path.join(app.getPath("userData"), "shortcuts.json"); const RECORDING_FILE_PREFIX = "recording-"; const RECORDING_SESSION_SUFFIX = ".session.json"; -const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([".webm", ".mp4", ".mov", ".avi", ".mkv"]); +const ALLOWED_IMPORT_VIDEO_EXTENSIONS = new Set([ + ".webm", + ".mp4", + ".mov", + ".avi", + ".mkv", + ".m4v", + ".wmv", + ".flv", + ".ts", +]); /** * Paths explicitly approved by the user via file picker dialogs or project loads. @@ -1533,7 +1543,7 @@ export function registerIpcHandlers( filters: [ { name: mainT("dialogs", "fileDialogs.videoFiles"), - extensions: ["webm", "mp4", "mov", "avi", "mkv"], + extensions: ["webm", "mp4", "mov", "avi", "mkv", "m4v", "wmv", "flv", "ts"], }, { name: mainT("dialogs", "fileDialogs.allFiles"), extensions: ["*"] }, ], @@ -1748,6 +1758,38 @@ export function registerIpcHandlers( } } + ipcMain.handle("load-project-file-from-path", async (_event, filePath: string) => { + return loadProjectFileFromPath(filePath); + }); + + async function loadProjectFileFromPath(filePath: string): Promise { + try { + if (!filePath || typeof filePath !== "string") { + return { success: false, message: "Invalid file path" }; + } + // Validate extension and readability + if (!filePath.endsWith(`.${PROJECT_FILE_EXTENSION}`)) { + return { success: false, message: "Not an Openscreen project file" }; + } + const stats = await fs.stat(filePath).catch(() => null); + if (!stats?.isFile()) { + return { success: false, message: "File not found" }; + } + const content = await fs.readFile(filePath, "utf-8"); + const project = JSON.parse(content); + currentProjectPath = filePath; + setCurrentRecordingSessionState(await getApprovedProjectSession(project, filePath)); + return { success: true, path: filePath, project }; + } catch (error) { + console.error("Failed to load project file from path:", error); + return { + success: false, + message: "Failed to load project file", + error: String(error), + }; + } + } + ipcMain.handle("load-current-project-file", async () => { return loadCurrentProjectFile(); }); @@ -1904,6 +1946,7 @@ export function registerIpcHandlers( saveProjectFile, loadProjectFile, loadCurrentProjectFile, + loadProjectFileFromPath, setCurrentVideoPath, getCurrentVideoPathResult, clearCurrentVideoPath, diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 7f7b24b51..425f93e1a 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -27,6 +27,7 @@ export interface NativeBridgeContext { ) => Promise; loadProjectFile: () => Promise; loadCurrentProjectFile: () => Promise; + loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; getCurrentVideoPathResult: () => ProjectPathResult; clearCurrentVideoPath: () => ProjectPathResult; @@ -100,6 +101,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { saveProjectFile: context.saveProjectFile, loadProjectFile: context.loadProjectFile, loadCurrentProjectFile: context.loadCurrentProjectFile, + loadProjectFileFromPath: context.loadProjectFileFromPath, setCurrentVideoPath: context.setCurrentVideoPath, getCurrentVideoPathResult: context.getCurrentVideoPathResult, clearCurrentVideoPath: context.clearCurrentVideoPath, @@ -168,6 +170,11 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await projectService.loadCurrentProjectFile(), ); + case "loadProjectFileFromPath": + return createSuccessResponse( + requestId, + await projectService.loadProjectFileFromPath(request.payload.path), + ); case "setCurrentVideoPath": return createSuccessResponse( requestId, diff --git a/electron/main.ts b/electron/main.ts index 24d06ac12..75df7e17d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -109,7 +109,12 @@ function isEditorWindow(window: BrowserWindow) { } function sendEditorMenuAction( - channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as", + channel: + | "menu-load-project" + | "menu-save-project" + | "menu-save-project-as" + | "menu-new-project" + | "menu-import-video", ) { let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -168,6 +173,18 @@ function setupApplicationMenu() { { label: mainT("common", "actions.file") || "File", submenu: [ + { + label: mainT("dialogs", "unsavedChanges.newProject") || "New Project", + accelerator: "CmdOrCtrl+N", + click: () => sendEditorMenuAction("menu-new-project"), + }, + { type: "separator" as const }, + { + label: mainT("dialogs", "unsavedChanges.importVideo") || "Import Video File…", + accelerator: "CmdOrCtrl+I", + click: () => sendEditorMenuAction("menu-import-video"), + }, + { type: "separator" as const }, { label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…", accelerator: "CmdOrCtrl+O", diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 965b4fb70..9e96aa22d 100644 --- a/electron/native-bridge/services/projectService.ts +++ b/electron/native-bridge/services/projectService.ts @@ -16,6 +16,7 @@ interface ProjectServiceOptions { ) => Promise; loadProjectFile: () => Promise; loadCurrentProjectFile: () => Promise; + loadProjectFileFromPath: (path: string) => Promise; setCurrentVideoPath: (path: string) => ProjectPathResult | Promise; getCurrentVideoPathResult: () => ProjectPathResult; clearCurrentVideoPath: () => ProjectPathResult; @@ -60,6 +61,12 @@ export class ProjectService { return result; } + async loadProjectFileFromPath(path: string) { + const result = await this.options.loadProjectFileFromPath(path); + this.getCurrentContext(); + return result; + } + async setCurrentVideoPath(path: string) { const result = await this.options.setCurrentVideoPath(path); this.getCurrentContext(); diff --git a/electron/preload.ts b/electron/preload.ts index 8302b959f..ac4a1a38e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -124,6 +124,16 @@ contextBridge.exposeInMainWorld("electronAPI", { loadCurrentProjectFile: () => { return ipcRenderer.invoke("load-current-project-file"); }, + onMenuNewProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-new-project", listener); + return () => ipcRenderer.removeListener("menu-new-project", listener); + }, + onMenuImportVideo: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-import-video", listener); + return () => ipcRenderer.removeListener("menu-import-video", listener); + }, onMenuLoadProject: (callback: () => void) => { const listener = () => callback(); ipcRenderer.on("menu-load-project", listener); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 3b2bebfe2..007c0a459 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronDown, Languages } from "lucide-react"; +import { Check, ChevronDown, Clapperboard, Languages } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; @@ -710,6 +710,17 @@ export function LaunchWindow() { {getIcon("folder", "text-white/60")} + + {/* Open studio (empty) */} + + + )} diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx new file mode 100644 index 000000000..f99d199b5 --- /dev/null +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -0,0 +1,129 @@ +import { Film, FolderOpen, Upload } from "lucide-react"; +import { useCallback, useState } from "react"; +import { nativeBridgeClient } from "@/native"; + +interface EditorEmptyStateProps { + onVideoImported: (videoPath: string) => void; + onProjectLoaded: () => void; +} + +export function EditorEmptyState({ onVideoImported, onProjectLoaded }: EditorEmptyStateProps) { + const [isDraggingOver, setIsDraggingOver] = useState(false); + + const handleImportVideo = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled || !result.success || !result.path) return; + + const setResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path); + if (!setResult.success) return; + + onVideoImported(result.path); + }, [onVideoImported]); + + const handleLoadProject = useCallback(async () => { + const result = await nativeBridgeClient.project.loadProjectFile(); + if (result.canceled || !result.success) return; + onProjectLoaded(); + }, [onProjectLoaded]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + const files = Array.from(e.dataTransfer.items); + const hasProject = files.some( + (item) => + item.kind === "file" && + (item.getAsFile()?.name.endsWith(".openscreen") || + item.type === "application/octet-stream"), + ); + if (hasProject || files.length > 0) { + setIsDraggingOver(true); + } + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setIsDraggingOver(false); + } + }, []); + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + setIsDraggingOver(false); + + const files = Array.from(e.dataTransfer.files); + const projectFile = files.find((f) => f.name.endsWith(".openscreen")); + if (!projectFile) return; + + // Electron exposes the real filesystem path on the File object + const filePath = (projectFile as File & { path: string }).path; + if (!filePath) return; + + const result = await nativeBridgeClient.project.loadProjectFileFromPath(filePath); + if (result.canceled || !result.success) return; + onProjectLoaded(); + }, + [onProjectLoaded], + ); + + return ( +
+ {/* Drop overlay */} + {isDraggingOver && ( +
+ +

Drop project file to open

+
+ )} + +
+ {/* Logo */} + + +
+

No project open

+

+ Import a video to start editing, or load an existing Openscreen project. +
+ You can also drag and drop a .openscreen file + here. +

+
+ + {/* Actions */} +
+ + +
+ +

+ Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV +

+
+
+ ); +} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 464370df5..31e47f6e3 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -46,6 +46,7 @@ import { getNativeAspectRatioValue, isPortraitAspectRatio, } from "@/utils/aspectRatioUtils"; +import { EditorEmptyState } from "./EditorEmptyState"; import { ExportDialog } from "./ExportDialog"; import PlaybackControls from "./PlaybackControls"; import { @@ -693,6 +694,26 @@ export default function VideoEditor() { } }, []); + const handleNewProject = useCallback(async () => { + await nativeBridgeClient.project.clearCurrentVideoPath(); + setVideoPath(null); + setVideoSourcePath(null); + }, []); + + const handleImportVideo = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled || !result.success || !result.path) return; + + const setResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path); + if (!setResult.success) { + toast.error(t("errors.noVideoLoaded")); + return; + } + + setVideoPath(toFileUrl(result.path)); + setVideoSourcePath(result.path); + }, [t]); + const handleLoadProject = useCallback(async () => { const result = await nativeBridgeClient.project.loadProjectFile(); @@ -715,16 +736,26 @@ export default function VideoEditor() { }, [applyLoadedProject, t]); useEffect(() => { + const removeNewProjectListener = window.electronAPI.onMenuNewProject(handleNewProject); + const removeImportVideoListener = window.electronAPI.onMenuImportVideo(handleImportVideo); const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject); const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject); const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); return () => { + removeNewProjectListener?.(); + removeImportVideoListener?.(); removeLoadListener?.(); removeSaveListener?.(); removeSaveAsListener?.(); }; - }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]); + }, [ + handleNewProject, + handleImportVideo, + handleLoadProject, + handleSaveProject, + handleSaveProjectAs, + ]); useEffect(() => { let canceled = false; @@ -2079,7 +2110,20 @@ export default function VideoEditor() { -
+ {/* Empty state — shown when no video is loaded */} + {!videoPath && ( +
+ { + setVideoPath(toFileUrl(path)); + setVideoSourcePath(path); + }} + onProjectLoaded={handleLoadProject} + /> +
+ )} + +
{/* Top section: preview and contextual settings */} diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index a84b5fda8..5bf442795 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -52,6 +52,8 @@ "detail": "Do you want to save your project before closing?", "saveAndClose": "Save & Close", "discardAndClose": "Discard & Close", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Load Project…", "saveProject": "Save Project…", "saveProjectAs": "Save Project As…" diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 133a96127..044204071 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -7,7 +7,8 @@ "pauseRecording": "Pause recording", "resumeRecording": "Resume recording", "openVideoFile": "Open video file", - "openProject": "Open project" + "openProject": "Open project", + "openStudio": "Open Studio" }, "audio": { "enableSystemAudio": "Enable system audio", diff --git a/src/native/client.ts b/src/native/client.ts index 3f53ce483..9ff60d357 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -94,6 +94,12 @@ export const nativeBridgeClient = { domain: "project", action: "loadCurrentProjectFile", }), + loadProjectFileFromPath: (path: string) => + requireNativeBridgeData({ + domain: "project", + action: "loadProjectFileFromPath", + payload: { path }, + }), setCurrentVideoPath: (path: string) => requireNativeBridgeData({ domain: "project", diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 6836095ac..77afa6f48 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -174,6 +174,12 @@ export type NativeBridgeRequest = payload?: EmptyPayload; requestId?: string; } + | { + domain: "project"; + action: "loadProjectFileFromPath"; + payload: { path: string }; + requestId?: string; + } | { domain: "project"; action: "setCurrentVideoPath"; From 437f12142f91b1e51dff79369ac9b75603c54aef Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 10:31:49 +0200 Subject: [PATCH 02/43] fix: show empty state instead of error when editor opens with no video When switchToEditor() is called from the HUD (Open Studio), there is no current video/session/project. Previously this set an error state causing a white/broken screen. Now we simply leave videoPath as null, which lets the EditorEmptyState component render correctly. --- src/components/video-editor/VideoEditor.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 31e47f6e3..0aa41b4ee 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -510,9 +510,8 @@ export default function VideoEditor() { setLastSavedSnapshot( createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE), ); - } else { - setError("No video to load. Please record or select a video."); } + // No video/project/session — leave videoPath null so the empty state renders } catch (err) { setError("Error loading video: " + String(err)); } finally { From 8c2d41c7f70773876ea66ed817d098ed42efe9a7 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 10:33:58 +0200 Subject: [PATCH 03/43] fix: conditionally render editor workspace to prevent video load error Using the CSS 'hidden' class kept VideoPlayback mounted with an empty src, which fired an error event and triggered setError(). Switch to proper conditional rendering so VideoPlayback only mounts when a video is actually loaded. --- src/components/video-editor/VideoEditor.tsx | 581 ++++++++++---------- 1 file changed, 293 insertions(+), 288 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0aa41b4ee..ce5c0858e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2122,308 +2122,313 @@ export default function VideoEditor() {
)} -
- - {/* Top section: preview and contextual settings */} - -
-
-
- {/* Video preview */} -
-
- updateState({ webcamPosition: pos })} - onWebcamPositionDragEnd={commitState} - onDurationChange={setDuration} - onTimeUpdate={setCurrentTime} - currentTime={currentTime} - onPlayStateChange={setIsPlaying} - onError={setError} - wallpaper={wallpaper} - zoomRegions={zoomRegions} - selectedZoomId={selectedZoomId} - onSelectZoom={handleSelectZoom} - onZoomFocusChange={handleZoomFocusChange} - onZoomFocusDragEnd={commitState} - isPlaying={isPlaying} - showShadow={shadowIntensity > 0} - shadowIntensity={shadowIntensity} - showBlur={showBlur} - motionBlurAmount={motionBlurAmount} - borderRadius={borderRadius} - padding={padding} - cropRegion={cropRegion} - cursorRecordingData={cursorRecordingData} - trimRegions={trimRegions} - speedRegions={speedRegions} - annotationRegions={annotationOnlyRegions} - selectedAnnotationId={selectedAnnotationId} - onSelectAnnotation={handleSelectAnnotation} - onAnnotationPositionChange={handleAnnotationPositionChange} - onAnnotationSizeChange={handleAnnotationSizeChange} - blurRegions={blurRegions} - selectedBlurId={selectedBlurId} - onSelectBlur={handleSelectBlur} - onBlurPositionChange={handleAnnotationPositionChange} - onBlurSizeChange={handleAnnotationSizeChange} - onBlurDataChange={handleBlurDataPreviewChange} - onBlurDataCommit={commitState} - cursorTelemetry={cursorTelemetry} - cursorClickTimestamps={cursorClickTimestamps} - showCursor={effectiveShowCursor} - cursorSize={cursorSize} - cursorSmoothing={cursorSmoothing} - cursorMotionBlur={cursorMotionBlur} - cursorClickBounce={cursorClickBounce} - /> + {videoPath && ( +
+ + {/* Top section: preview and contextual settings */} + +
+
+
+ {/* Video preview */} +
+
+ updateState({ webcamPosition: pos })} + onWebcamPositionDragEnd={commitState} + onDurationChange={setDuration} + onTimeUpdate={setCurrentTime} + currentTime={currentTime} + onPlayStateChange={setIsPlaying} + onError={setError} + wallpaper={wallpaper} + zoomRegions={zoomRegions} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + onZoomFocusChange={handleZoomFocusChange} + onZoomFocusDragEnd={commitState} + isPlaying={isPlaying} + showShadow={shadowIntensity > 0} + shadowIntensity={shadowIntensity} + showBlur={showBlur} + motionBlurAmount={motionBlurAmount} + borderRadius={borderRadius} + padding={padding} + cropRegion={cropRegion} + cursorRecordingData={cursorRecordingData} + trimRegions={trimRegions} + speedRegions={speedRegions} + annotationRegions={annotationOnlyRegions} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} + onAnnotationPositionChange={handleAnnotationPositionChange} + onAnnotationSizeChange={handleAnnotationSizeChange} + blurRegions={blurRegions} + selectedBlurId={selectedBlurId} + onSelectBlur={handleSelectBlur} + onBlurPositionChange={handleAnnotationPositionChange} + onBlurSizeChange={handleAnnotationSizeChange} + onBlurDataChange={handleBlurDataPreviewChange} + onBlurDataCommit={commitState} + cursorTelemetry={cursorTelemetry} + cursorClickTimestamps={cursorClickTimestamps} + showCursor={effectiveShowCursor} + cursorSize={cursorSize} + cursorSmoothing={cursorSmoothing} + cursorMotionBlur={cursorMotionBlur} + cursorClickBounce={cursorClickBounce} + /> +
-
- {/* Playback controls */} -
-
- + {/* Playback controls */} +
+
+ +
-
-
- pushState({ wallpaper: w })} - selectedZoomDepth={ - selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null - } - onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} - selectedZoomCustomScale={ - selectedZoomId - ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null) - : null - } - onZoomCustomScaleChange={handleZoomCustomScaleChange} - onZoomCustomScaleCommit={handleZoomCustomScaleCommit} - selectedZoomFocusMode={ - selectedZoomId - ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") - : null - } - onZoomFocusModeChange={(mode) => - selectedZoomId && handleZoomFocusModeChange(mode) - } - selectedZoomFocus={ - selectedZoomId - ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) - : null - } - onZoomFocusCoordinateChange={(focus) => - selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) - } - onZoomFocusCoordinateCommit={commitState} - hasCursorTelemetry={cursorTelemetry.length > 0} - selectedZoomId={selectedZoomId} +
+ pushState({ wallpaper: w })} + selectedZoomDepth={ + selectedZoomId + ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth + : null + } + onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomCustomScale={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.customScale ?? null) + : null + } + onZoomCustomScaleChange={handleZoomCustomScaleChange} + onZoomCustomScaleCommit={handleZoomCustomScaleCommit} + selectedZoomFocusMode={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") + : null + } + onZoomFocusModeChange={(mode) => + selectedZoomId && handleZoomFocusModeChange(mode) + } + selectedZoomFocus={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focus ?? null) + : null + } + onZoomFocusCoordinateChange={(focus) => + selectedZoomId && handleZoomFocusChange(selectedZoomId, focus) + } + onZoomFocusCoordinateCommit={commitState} + hasCursorTelemetry={cursorTelemetry.length > 0} + selectedZoomId={selectedZoomId} + onZoomDelete={handleZoomDelete} + selectedZoomRotationPreset={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.rotationPreset ?? null) + : null + } + onZoomRotationPresetChange={handleZoomRotationPresetChange} + selectedTrimId={selectedTrimId} + onTrimDelete={handleTrimDelete} + shadowIntensity={shadowIntensity} + onShadowChange={(v) => updateState({ shadowIntensity: v })} + onShadowCommit={commitState} + showBlur={showBlur} + onBlurChange={(v) => pushState({ showBlur: v })} + motionBlurAmount={motionBlurAmount} + onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} + onMotionBlurCommit={commitState} + borderRadius={borderRadius} + onBorderRadiusChange={(v) => updateState({ borderRadius: v })} + onBorderRadiusCommit={commitState} + padding={padding} + onPaddingChange={(v) => updateState({ padding: v })} + onPaddingCommit={commitState} + cropRegion={cropRegion} + onCropChange={(r) => pushState({ cropRegion: r })} + aspectRatio={aspectRatio} + hasWebcam={Boolean(webcamVideoPath)} + webcamLayoutPreset={webcamLayoutPreset} + onWebcamLayoutPresetChange={(preset) => + pushState({ + webcamLayoutPreset: preset, + webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, + }) + } + webcamMaskShape={webcamMaskShape} + onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} + webcamSizePreset={webcamSizePreset} + onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} + onWebcamSizePresetCommit={commitState} + videoElement={videoPlaybackRef.current?.video || null} + exportQuality={exportQuality} + onExportQualityChange={setExportQuality} + exportFormat={exportFormat} + onExportFormatChange={setExportFormat} + gifFrameRate={gifFrameRate} + onGifFrameRateChange={setGifFrameRate} + gifLoop={gifLoop} + onGifLoopChange={setGifLoop} + gifSizePreset={gifSizePreset} + onGifSizePresetChange={setGifSizePreset} + gifOutputDimensions={calculateOutputDimensions( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + gifSizePreset, + GIF_SIZE_PRESETS, + aspectRatio === "native" + ? getNativeAspectRatioValue( + videoPlaybackRef.current?.video?.videoWidth || 1920, + videoPlaybackRef.current?.video?.videoHeight || 1080, + cropRegion, + ) + : getAspectRatioValue(aspectRatio), + )} + onExport={handleOpenExportDialog} + selectedAnnotationId={selectedAnnotationId} + annotationRegions={annotationOnlyRegions} + onAnnotationContentChange={handleAnnotationContentChange} + onAnnotationTypeChange={handleAnnotationTypeChange} + onAnnotationStyleChange={handleAnnotationStyleChange} + onAnnotationFigureDataChange={handleAnnotationFigureDataChange} + onAnnotationDuplicate={handleAnnotationDuplicate} + onAnnotationDelete={handleAnnotationDelete} + selectedBlurId={selectedBlurId} + blurRegions={blurRegions} + onBlurDataChange={handleBlurDataPanelChange} + onBlurDataCommit={commitState} + onBlurDelete={handleAnnotationDelete} + selectedSpeedId={selectedSpeedId} + selectedSpeedValue={ + selectedSpeedId + ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) + : null + } + onSpeedChange={handleSpeedChange} + onSpeedDelete={handleSpeedDelete} + unsavedExport={unsavedExport} + onSaveUnsavedExport={handleSaveUnsavedExport} + onSaveDiagnostic={handleSaveDiagnostic} + showCursor={showCursor} + onShowCursorChange={setShowCursor} + cursorSize={cursorSize} + onCursorSizeChange={setCursorSize} + cursorSmoothing={cursorSmoothing} + onCursorSmoothingChange={setCursorSmoothing} + cursorMotionBlur={cursorMotionBlur} + onCursorMotionBlurChange={setCursorMotionBlur} + cursorClickBounce={cursorClickBounce} + onCursorClickBounceChange={setCursorClickBounce} + hasCursorData={ + cursorTelemetry.length > 0 || + hasNativeCursorRecordingData(cursorRecordingData) + } + showCursorSettings={showCursorSettings} + /> +
+
+ + + +
+
+ + {/* Full-width timeline */} + +
+ z.id === selectedZoomId)?.rotationPreset ?? null) - : null - } - onZoomRotationPresetChange={handleZoomRotationPresetChange} - selectedTrimId={selectedTrimId} + selectedZoomId={selectedZoomId} + onSelectZoom={handleSelectZoom} + trimRegions={trimRegions} + onTrimAdded={handleTrimAdded} + onTrimSpanChange={handleTrimSpanChange} onTrimDelete={handleTrimDelete} - shadowIntensity={shadowIntensity} - onShadowChange={(v) => updateState({ shadowIntensity: v })} - onShadowCommit={commitState} - showBlur={showBlur} - onBlurChange={(v) => pushState({ showBlur: v })} - motionBlurAmount={motionBlurAmount} - onMotionBlurChange={(v) => updateState({ motionBlurAmount: v })} - onMotionBlurCommit={commitState} - borderRadius={borderRadius} - onBorderRadiusChange={(v) => updateState({ borderRadius: v })} - onBorderRadiusCommit={commitState} - padding={padding} - onPaddingChange={(v) => updateState({ padding: v })} - onPaddingCommit={commitState} - cropRegion={cropRegion} - onCropChange={(r) => pushState({ cropRegion: r })} - aspectRatio={aspectRatio} - hasWebcam={Boolean(webcamVideoPath)} - webcamLayoutPreset={webcamLayoutPreset} - onWebcamLayoutPresetChange={(preset) => - pushState({ - webcamLayoutPreset: preset, - webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, - }) - } - webcamMaskShape={webcamMaskShape} - onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} - webcamSizePreset={webcamSizePreset} - onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })} - onWebcamSizePresetCommit={commitState} - videoElement={videoPlaybackRef.current?.video || null} - exportQuality={exportQuality} - onExportQualityChange={setExportQuality} - exportFormat={exportFormat} - onExportFormatChange={setExportFormat} - gifFrameRate={gifFrameRate} - onGifFrameRateChange={setGifFrameRate} - gifLoop={gifLoop} - onGifLoopChange={setGifLoop} - gifSizePreset={gifSizePreset} - onGifSizePresetChange={setGifSizePreset} - gifOutputDimensions={calculateOutputDimensions( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - gifSizePreset, - GIF_SIZE_PRESETS, - aspectRatio === "native" - ? getNativeAspectRatioValue( - videoPlaybackRef.current?.video?.videoWidth || 1920, - videoPlaybackRef.current?.video?.videoHeight || 1080, - cropRegion, - ) - : getAspectRatioValue(aspectRatio), - )} - onExport={handleOpenExportDialog} - selectedAnnotationId={selectedAnnotationId} + selectedTrimId={selectedTrimId} + onSelectTrim={handleSelectTrim} + speedRegions={speedRegions} + onSpeedAdded={handleSpeedAdded} + onSpeedSpanChange={handleSpeedSpanChange} + onSpeedDelete={handleSpeedDelete} + selectedSpeedId={selectedSpeedId} + onSelectSpeed={handleSelectSpeed} annotationRegions={annotationOnlyRegions} - onAnnotationContentChange={handleAnnotationContentChange} - onAnnotationTypeChange={handleAnnotationTypeChange} - onAnnotationStyleChange={handleAnnotationStyleChange} - onAnnotationFigureDataChange={handleAnnotationFigureDataChange} - onAnnotationDuplicate={handleAnnotationDuplicate} + onAnnotationAdded={handleAnnotationAdded} + onAnnotationSpanChange={handleAnnotationSpanChange} onAnnotationDelete={handleAnnotationDelete} - selectedBlurId={selectedBlurId} + selectedAnnotationId={selectedAnnotationId} + onSelectAnnotation={handleSelectAnnotation} blurRegions={blurRegions} - onBlurDataChange={handleBlurDataPanelChange} - onBlurDataCommit={commitState} + onBlurAdded={handleBlurAdded} + onBlurSpanChange={handleAnnotationSpanChange} onBlurDelete={handleAnnotationDelete} - selectedSpeedId={selectedSpeedId} - selectedSpeedValue={ - selectedSpeedId - ? (speedRegions.find((r) => r.id === selectedSpeedId)?.speed ?? null) - : null - } - onSpeedChange={handleSpeedChange} - onSpeedDelete={handleSpeedDelete} - unsavedExport={unsavedExport} - onSaveUnsavedExport={handleSaveUnsavedExport} - onSaveDiagnostic={handleSaveDiagnostic} - showCursor={showCursor} - onShowCursorChange={setShowCursor} - cursorSize={cursorSize} - onCursorSizeChange={setCursorSize} - cursorSmoothing={cursorSmoothing} - onCursorSmoothingChange={setCursorSmoothing} - cursorMotionBlur={cursorMotionBlur} - onCursorMotionBlurChange={setCursorMotionBlur} - cursorClickBounce={cursorClickBounce} - onCursorClickBounceChange={setCursorClickBounce} - hasCursorData={ - cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData) + selectedBlurId={selectedBlurId} + onSelectBlur={handleSelectBlur} + aspectRatio={aspectRatio} + onAspectRatioChange={(ar) => + pushState({ + aspectRatio: ar, + webcamLayoutPreset: + (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || + (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") + ? "picture-in-picture" + : webcamLayoutPreset, + }) } - showCursorSettings={showCursorSettings} />
-
-
- - -
-
- - {/* Full-width timeline */} - -
- - pushState({ - aspectRatio: ar, - webcamLayoutPreset: - (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || - (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") - ? "picture-in-picture" - : webcamLayoutPreset, - }) - } - /> -
-
-
-
+ + +
+ )} Date: Mon, 11 May 2026 10:37:31 +0200 Subject: [PATCH 04/43] fix: register start-new-recording IPC handler and skip dialog with no video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing ipcMain.handle('start-new-recording') that calls switchToHud, fixing the broken Return to Recorder confirm button - Skip the Return to Recorder confirmation dialog when no video is loaded (Open Studio with nothing imported) — just switch back immediately --- electron/ipc/handlers.ts | 7 +++++++ src/components/video-editor/VideoEditor.tsx | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 66a7412b7..6d0e5b773 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1002,6 +1002,13 @@ export function registerIpcHandlers( createEditorWindow(); }); + ipcMain.handle("start-new-recording", () => { + if (_switchToHud) { + _switchToHud(); + } + return { success: true }; + }); + ipcMain.handle("countdown-overlay-show", async (_, value: number, runId: number) => { const overlayWindow = getCountdownOverlayWindow?.() ?? createCountdownOverlayWindow(); if (overlayWindow.isDestroyed()) { diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ce5c0858e..3908a0d9d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2084,7 +2084,14 @@ export default function VideoEditor() {
- - {/* Open project */} - - - {/* Open studio (empty editor) */} - - - - + + + )} {/* Right sidebar controls */} From 499c9efd0c99b76f385cf8562a0173c97d13b04a Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:17:26 +0200 Subject: [PATCH 13/43] fix: prevent white flash on first editor window open Use show:false + ready-to-show on the editor window so it stays hidden until the first paint. Also set backgroundColor to #09090b so even if the window becomes visible before React mounts, it shows the correct dark background rather than white. --- electron/windows.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/electron/windows.ts b/electron/windows.ts index 978293f60..a3ccb8c6f 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -132,8 +132,8 @@ export function createEditorWindow(): BrowserWindow { alwaysOnTop: false, skipTaskbar: false, title: "OpenScreen", - backgroundColor: "#000000", - show: !HEADLESS, + backgroundColor: "#09090b", + show: false, // shown via ready-to-show to avoid white flash on first load webPreferences: { preload: path.join(__dirname, "preload.mjs"), additionalArguments: [ASSET_BASE_URL_ARG], @@ -147,6 +147,11 @@ export function createEditorWindow(): BrowserWindow { // Maximize the window by default win.maximize(); + // Show only once content is painted — prevents white flash on cold Vite start + win.once("ready-to-show", () => { + if (!HEADLESS) win.show(); + }); + win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); From 22cebecf184d5f5791288afd56a4fac8aa1fd492 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:23:07 +0200 Subject: [PATCH 14/43] fix: remove Import Video File from File menu; kill white flash with insertCSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove File > Import Video File (Ctrl+I) — importing lives in the editor empty state only, avoiding confusion with 'add on top of existing video' semantics - Inject 'background: #09090b' on dom-ready so html/body/#root are dark before React mounts, eliminating the white sub-titlebar flash even on a cold first Vite load --- electron/main.ts | 13 +------------ electron/windows.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 75df7e17d..e9018a156 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -109,12 +109,7 @@ function isEditorWindow(window: BrowserWindow) { } function sendEditorMenuAction( - channel: - | "menu-load-project" - | "menu-save-project" - | "menu-save-project-as" - | "menu-new-project" - | "menu-import-video", + channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as" | "menu-new-project", ) { let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -179,12 +174,6 @@ function setupApplicationMenu() { click: () => sendEditorMenuAction("menu-new-project"), }, { type: "separator" as const }, - { - label: mainT("dialogs", "unsavedChanges.importVideo") || "Import Video File…", - accelerator: "CmdOrCtrl+I", - click: () => sendEditorMenuAction("menu-import-video"), - }, - { type: "separator" as const }, { label: mainT("dialogs", "unsavedChanges.loadProject") || "Load Project…", accelerator: "CmdOrCtrl+O", diff --git a/electron/windows.ts b/electron/windows.ts index a3ccb8c6f..0f09a708d 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -152,6 +152,14 @@ export function createEditorWindow(): BrowserWindow { if (!HEADLESS) win.show(); }); + // Inject dark background before any React paint so the sub-titlebar area + // never flashes white even on the very first cold Vite load + win.webContents.on("dom-ready", () => { + win.webContents + .insertCSS("html, body, #root { background: #09090b !important; }") + .catch(() => {}); + }); + win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); From 1b71362ed4a2555cb70e83e311c9c20c210a40aa Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:31:10 +0200 Subject: [PATCH 15/43] fix: eliminate white flash on editor open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flash was caused by two compounding issues: 1. The Tailwind/shadcn CSS uses `.dark` class to switch from light (--background: white) to dark variables. Since no code applied `.dark` to before React mounted, every `bg-background` usage resolved to white. 2. The lazy-loaded VideoEditor Suspense fallback (`bg-background`) was the visible culprit — it rendered white for the ~50-100ms while VideoEditor was being imported. Fix: add `class="dark"` directly to the element in index.html. The attribute is parsed before any CSS or JS runs, so dark CSS variables are in effect from frame 0 — no flash. HUD/source-selector windows are unaffected because they override `background: transparent` via inline styles (which beat stylesheet rules). Also harden the Suspense fallback to use an explicit `bg-[#09090b]` instead of `bg-background` as a belt-and-suspenders guard. --- index.html | 2 +- src/App.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index a7ee0237c..510c15ddd 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/App.tsx b/src/App.tsx index 6f737b9b0..dca7c30fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -64,7 +64,7 @@ export default function App() { case "editor": return ( - }> + }> From 3a9c3a3e15ca3f2d1a7983e8fa09db043d44fff8 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:43:40 +0200 Subject: [PATCH 16/43] feat: show "Loading editor..." vs "Loading video..." contextually MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `loadingEditor` translation key to all 11 locale editor.json files - Suspense fallback in App.tsx now shows "Loading editor..." since at that point we don't yet know whether there is a video to load - VideoEditor loading spinner starts as "Loading editor..." and flips to "Loading video..." only once loadInitialData discovers a recording session or video path to load — no video means it stays as "Loading editor..." throughout --- src/App.tsx | 32 ++++++++++++++++++++- src/components/video-editor/VideoEditor.tsx | 7 ++++- src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/fr/editor.json | 3 +- src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-TW/editor.json | 1 + 13 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dca7c30fe..6c36aa8c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { LaunchWindow } from "./components/launch/LaunchWindow"; import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; +import { useScopedT } from "./contexts/I18nContext"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; @@ -18,6 +19,7 @@ export default function App() { const [windowType, setWindowType] = useState( () => new URLSearchParams(window.location.search).get("windowType") || "", ); + const tEditor = useScopedT("editor"); useEffect(() => { const type = new URLSearchParams(window.location.search).get("windowType") || ""; @@ -64,7 +66,35 @@ export default function App() { case "editor": return ( - }> + + + + + + {tEditor("loadingEditor")} +
+ } + > diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e34671057..65316dcda 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -183,6 +183,9 @@ export default function VideoEditor() { const [webcamVideoSourcePath, setWebcamVideoSourcePath] = useState(null); const [currentProjectPath, setCurrentProjectPath] = useState(null); const [loading, setLoading] = useState(true); + const [loadingMessage, setLoadingMessage] = useState<"loadingEditor" | "loadingVideo">( + "loadingEditor", + ); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); @@ -476,6 +479,7 @@ export default function VideoEditor() { const currentSessionResult = await window.electronAPI.getCurrentRecordingSession(); if (currentSessionResult.success && currentSessionResult.session) { const session = currentSessionResult.session; + setLoadingMessage("loadingVideo"); const sourcePath = fromFileUrl(session.screenVideoPath); const webcamSourcePath = session.webcamVideoPath ? fromFileUrl(session.webcamVideoPath) @@ -503,6 +507,7 @@ export default function VideoEditor() { const result = await nativeBridgeClient.project.getCurrentVideoPath(); if (result.success && result.path) { + setLoadingMessage("loadingVideo"); setVideoSourcePath(result.path); setVideoPath(toFileUrl(result.path)); setRecordingCursorCaptureMode(null); @@ -2030,7 +2035,7 @@ export default function VideoEditor() { d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> - {t("loadingVideo")} + {t(loadingMessage)}
); } diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a246f011a..996d3bca1 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -6,6 +6,7 @@ "confirm": "تأكيد" }, "loadingVideo": "جاري تحميل الفيديو...", + "loadingEditor": "جارٍ تحميل المحرر...", "errors": { "noVideoLoaded": "لم يتم تحميل أي فيديو", "videoNotReady": "الفيديو غير جاهز", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 13e2e1397..f0538a530 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -6,6 +6,7 @@ "confirm": "Confirm" }, "loadingVideo": "Loading video...", + "loadingEditor": "Loading editor...", "errors": { "noVideoLoaded": "No video loaded", "videoNotReady": "Video not ready", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 8f6ad13e7..2cd939962 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -36,6 +36,7 @@ "permissionDenied": "Permiso de grabación denegado. Por favor permite la grabación de pantalla." }, "loadingVideo": "Cargando video...", + "loadingEditor": "Cargando editor...", "newRecording": { "title": "Volver a la grabadora", "description": "Tu sesión actual ha sido guardada.", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 6380c6b3d..4391b3528 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -41,5 +41,6 @@ "cameraNotFound": "Caméra introuvable.", "permissionDenied": "Permission d'enregistrement refusée. Veuillez autoriser l'enregistrement d'écran." }, - "loadingVideo": "Chargement de la vidéo..." + "loadingVideo": "Chargement de la vidéo...", + "loadingEditor": "Chargement de l'éditeur..." } diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 051335f30..6bd4377cf 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -6,6 +6,7 @@ "confirm": "確認" }, "loadingVideo": "動画を読み込み中...", + "loadingEditor": "エディターを読み込み中...", "errors": { "noVideoLoaded": "動画が読み込まれていません", "videoNotReady": "動画の準備ができていません", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ce1624476..ea6172a64 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -6,6 +6,7 @@ "confirm": "확인" }, "loadingVideo": "비디오 로드 중...", + "loadingEditor": "편집기 로드 중...", "errors": { "noVideoLoaded": "불러온 비디오가 없습니다", "videoNotReady": "비디오가 준비되지 않았습니다", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index c5616d2f6..c97ebdd37 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -6,6 +6,7 @@ "confirm": "Подтвердить" }, "loadingVideo": "Загрузка видео...", + "loadingEditor": "Загрузка редактора...", "errors": { "noVideoLoaded": "Видео не загружено", "videoNotReady": "Видео не готово", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 0aece8aec..8b000833f 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -36,6 +36,7 @@ "cameraNotFound": "Kamera bulunamadı." }, "loadingVideo": "Video yükleniyor...", + "loadingEditor": "Editör yükleniyor...", "newRecording": { "title": "Kaydediciye Dön", "description": "Mevcut oturumunuz kaydedildi.", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index a45bf3e26..cfed91a7b 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -6,6 +6,7 @@ "confirm": "Xác nhận" }, "loadingVideo": "Đang tải video...", + "loadingEditor": "Đang tải trình chỉnh sửa...", "errors": { "noVideoLoaded": "Chưa tải video nào", "videoNotReady": "Video chưa sẵn sàng", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index f6c02d4c3..e5c58c9c3 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -6,6 +6,7 @@ "confirm": "确认" }, "loadingVideo": "正在加载视频...", + "loadingEditor": "正在加载编辑器...", "errors": { "noVideoLoaded": "未加载视频", "videoNotReady": "视频未就绪", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 21f3ba6f9..07a487954 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -6,6 +6,7 @@ "confirm": "確認" }, "loadingVideo": "正在載入影片...", + "loadingEditor": "正在載入編輯器...", "errors": { "noVideoLoaded": "未載入影片", "videoNotReady": "影片未就緒", From 8a81e83b253568c10deda45a2774fdf9c07f7b36 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:48:14 +0200 Subject: [PATCH 17/43] fix: prompt unsaved changes dialog for imported/recorded videos with no project file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasProjectUnsavedChanges only fires when the current snapshot differs from the baseline — but for a fresh import or recording, the baseline IS the current state (nothing edited yet), so the app considered it "saved" even though no .openscreen file exists on disk. Extend the hasUnsavedChanges check: if a video is loaded but currentProjectPath is null (no file saved yet), treat it as unsaved regardless of the snapshot diff. After a successful Save, currentProjectPath is set and this clause becomes false, handing control back to the normal snapshot comparison. --- src/components/video-editor/VideoEditor.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 65316dcda..cfad2b241 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -460,7 +460,11 @@ export default function VideoEditor() { gifSizePreset, ]); - const hasUnsavedChanges = hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot); + // A video is loaded but no project file exists on disk yet (fresh recording + // or imported video) — treat as unsaved even before any edits are made. + const hasUnsavedChanges = + (videoPath !== null && currentProjectPath === null) || + hasProjectUnsavedChanges(currentProjectSnapshot, lastSavedSnapshot); useEffect(() => { async function loadInitialData() { From 8241e75ff164dc19543e31e1f1d46cff202396f7 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:55:25 +0200 Subject: [PATCH 18/43] feat: show contextual unsaved-changes dialog for File > New Project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When File > New Project is triggered with an unsaved video/project loaded, show the UnsavedChangesDialog with a "newProject" variant instead of immediately clearing state. The variant swaps the copy to: - "Do you want to save your project before creating a new one?" - "Save & New Project" / "Discard & New Project" / Cancel Implementation: - UnsavedChangesDialog accepts an optional variant prop ("close"|"newProject") that switches detail text and button labels via translation keys - showCloseConfirmDialog boolean replaced by confirmDialogVariant state ("close"|"newProject"|null) — one dialog handles both cases - handleNewProject checks hasUnsavedChanges before clearing state; new doNewProject helper resets videoPath, currentProjectPath and lastSavedSnapshot cleanly - Three new translation keys added to all 11 locale dialogs.json files: detailNewProject, saveAndNewProject, discardAndNewProject --- .../video-editor/UnsavedChangesDialog.tsx | 19 ++++-- src/components/video-editor/VideoEditor.tsx | 58 +++++++++++++++---- src/i18n/locales/ar/dialogs.json | 3 + src/i18n/locales/en/dialogs.json | 3 + src/i18n/locales/es/dialogs.json | 3 + src/i18n/locales/fr/dialogs.json | 3 + src/i18n/locales/ja-JP/dialogs.json | 3 + src/i18n/locales/ko-KR/dialogs.json | 3 + src/i18n/locales/ru/dialogs.json | 3 + src/i18n/locales/tr/dialogs.json | 3 + src/i18n/locales/vi/dialogs.json | 3 + src/i18n/locales/zh-CN/dialogs.json | 3 + src/i18n/locales/zh-TW/dialogs.json | 3 + 13 files changed, 95 insertions(+), 15 deletions(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index 902b1427b..e213a2c4b 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -10,6 +10,7 @@ import { useScopedT } from "@/contexts/I18nContext"; interface UnsavedChangesDialogProps { isOpen: boolean; + variant?: "close" | "newProject"; onSaveAndClose: () => void; onDiscardAndClose: () => void; onCancel: () => void; @@ -17,6 +18,7 @@ interface UnsavedChangesDialogProps { export function UnsavedChangesDialog({ isOpen, + variant = "close", onSaveAndClose, onDiscardAndClose, onCancel, @@ -24,6 +26,15 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); + const isNewProject = variant === "newProject"; + const detail = isNewProject ? td("unsavedChanges.detailNewProject") : td("unsavedChanges.detail"); + const saveLabel = isNewProject + ? td("unsavedChanges.saveAndNewProject") + : td("unsavedChanges.saveAndClose"); + const discardLabel = isNewProject + ? td("unsavedChanges.discardAndNewProject") + : td("unsavedChanges.discardAndClose"); + return ( !open && onCancel()}> @@ -42,9 +53,7 @@ export function UnsavedChangesDialog({

{td("unsavedChanges.message")}

- - {td("unsavedChanges.detail")} - + {detail}
); diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json index 2263f600b..4dd3124a3 100644 --- a/src/i18n/locales/ar/dialogs.json +++ b/src/i18n/locales/ar/dialogs.json @@ -51,6 +51,9 @@ "detail": "هل تريد حفظ مشروعك قبل الإغلاق؟", "saveAndClose": "حفظ وإغلاق", "discardAndClose": "تجاهل وإغلاق", + "detailNewProject": "هل تريد حفظ مشروعك قبل إنشاء مشروع جديد؟", + "saveAndNewProject": "حفظ وإنشاء مشروع جديد", + "discardAndNewProject": "تجاهل وإنشاء مشروع جديد", "loadProject": "تحميل مشروع...", "saveProject": "حفظ المشروع...", "saveProjectAs": "حفظ المشروع باسم..." diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index 5bf442795..ab42d45b6 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -52,6 +52,9 @@ "detail": "Do you want to save your project before closing?", "saveAndClose": "Save & Close", "discardAndClose": "Discard & Close", + "detailNewProject": "Do you want to save your project before creating a new one?", + "saveAndNewProject": "Save & New Project", + "discardAndNewProject": "Discard & New Project", "newProject": "New Project", "importVideo": "Import Video File…", "loadProject": "Load Project…", diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index f8a5e63ff..0fe4285cb 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -51,6 +51,9 @@ "detail": "¿Deseas guardar tu proyecto antes de cerrar?", "saveAndClose": "Guardar y cerrar", "discardAndClose": "Descartar y cerrar", + "detailNewProject": "¿Deseas guardar tu proyecto antes de crear uno nuevo?", + "saveAndNewProject": "Guardar y nuevo proyecto", + "discardAndNewProject": "Descartar y nuevo proyecto", "loadProject": "Cargar proyecto…", "saveProject": "Guardar proyecto…", "saveProjectAs": "Guardar proyecto como…" diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index dbaae385f..0d7b39f6f 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -51,6 +51,9 @@ "detail": "Voulez-vous enregistrer votre projet avant de fermer ?", "saveAndClose": "Enregistrer et fermer", "discardAndClose": "Ignorer et fermer", + "detailNewProject": "Voulez-vous enregistrer votre projet avant d'en créer un nouveau ?", + "saveAndNewProject": "Enregistrer et nouveau projet", + "discardAndNewProject": "Ignorer et nouveau projet", "loadProject": "Charger un projet…", "saveProject": "Enregistrer le projet…", "saveProjectAs": "Enregistrer le projet sous…" diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index a59cde7ce..031f4c67d 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -52,6 +52,9 @@ "detail": "閉じる前にプロジェクトを保存しますか?", "saveAndClose": "保存して閉じる", "discardAndClose": "破棄して閉じる", + "detailNewProject": "新しいプロジェクトを作成する前に保存しますか?", + "saveAndNewProject": "保存して新規プロジェクト", + "discardAndNewProject": "破棄して新規プロジェクト", "loadProject": "プロジェクトを読み込む…", "saveProject": "プロジェクトを保存…", "saveProjectAs": "プロジェクトを名前を付けて保存…" diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json index 3093cdfd2..b414fb788 100644 --- a/src/i18n/locales/ko-KR/dialogs.json +++ b/src/i18n/locales/ko-KR/dialogs.json @@ -51,6 +51,9 @@ "detail": "닫기 전에 프로젝트를 저장하시겠습니까?", "saveAndClose": "저장 후 닫기", "discardAndClose": "저장하지 않고 닫기", + "detailNewProject": "새 프로젝트를 만들기 전에 저장하시겠습니까?", + "saveAndNewProject": "저장 후 새 프로젝트", + "discardAndNewProject": "저장하지 않고 새 프로젝트", "loadProject": "프로젝트 불러오기...", "saveProject": "프로젝트 저장...", "saveProjectAs": "다른 이름으로 프로젝트 저장..." diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index 40b4113ab..df8610bab 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -51,6 +51,9 @@ "detail": "Хотите сохранить проект перед закрытием?", "saveAndClose": "Сохранить и закрыть", "discardAndClose": "Отменить и закрыть", + "detailNewProject": "Хотите сохранить проект перед созданием нового?", + "saveAndNewProject": "Сохранить и новый проект", + "discardAndNewProject": "Отменить и новый проект", "loadProject": "Загрузить проект…", "saveProject": "Сохранить проект…", "saveProjectAs": "Сохранить проект как…" diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 9fab50dfb..0d36ea6cd 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -51,6 +51,9 @@ "detail": "Kapatmadan önce projenizi kaydetmek ister misiniz?", "saveAndClose": "Kaydet ve Kapat", "discardAndClose": "Kaydetmeden Kapat", + "detailNewProject": "Yeni proje oluşturmadan önce kaydetmek ister misiniz?", + "saveAndNewProject": "Kaydet ve Yeni Proje", + "discardAndNewProject": "Kaydetmeden Yeni Proje", "loadProject": "Proje Yükle…", "saveProject": "Proje Kaydet…", "saveProjectAs": "Farklı Kaydet…" diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json index c94dbaa14..61a892948 100644 --- a/src/i18n/locales/vi/dialogs.json +++ b/src/i18n/locales/vi/dialogs.json @@ -51,6 +51,9 @@ "detail": "Bạn có muốn lưu dự án của mình trước khi đóng không?", "saveAndClose": "Lưu & Đóng", "discardAndClose": "Bỏ qua & Đóng", + "detailNewProject": "Bạn có muốn lưu dự án trước khi tạo dự án mới không?", + "saveAndNewProject": "Lưu & Dự án mới", + "discardAndNewProject": "Bỏ qua & Dự án mới", "loadProject": "Tải dự án…", "saveProject": "Lưu dự án…", "saveProjectAs": "Lưu dự án thành…" diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 0385b36f3..2b7d58e2e 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -51,6 +51,9 @@ "detail": "是否在关闭前保存项目?", "saveAndClose": "保存并关闭", "discardAndClose": "放弃并关闭", + "detailNewProject": "是否在创建新项目前保存当前项目?", + "saveAndNewProject": "保存并新建项目", + "discardAndNewProject": "放弃并新建项目", "loadProject": "加载项目…", "saveProject": "保存项目…", "saveProjectAs": "项目另存为…" diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index b582aba8d..4f8484c7e 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -51,6 +51,9 @@ "detail": "是否在關閉前儲存專案?", "saveAndClose": "儲存並關閉", "discardAndClose": "捨棄並關閉", + "detailNewProject": "是否在建立新專案前儲存目前的專案?", + "saveAndNewProject": "儲存並建立新專案", + "discardAndNewProject": "捨棄並建立新專案", "loadProject": "載入專案…", "saveProject": "儲存專案…", "saveProjectAs": "專案另存新檔…" From ab085131889ddbe3178a657847e26206d4720324 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 11:57:08 +0200 Subject: [PATCH 19/43] fix: eliminate black rectangle flash before countdown overlay appears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The countdown-overlay-show handler was calling showInactive() before waiting for the page to load — Chromium showed a black rectangle while still painting the first frame. Fix: wait for the ready-to-show event (fires after first paint) before calling showInactive(), so the window only becomes visible once its content is fully rendered. The redundant did-finish-load wait is replaced by ready-to-show which is the correct Electron signal for this purpose. --- electron/ipc/handlers.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 993b2b292..b922e7745 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1015,16 +1015,19 @@ export function registerIpcHandlers( return; } - if (!overlayWindow.isVisible()) { - overlayWindow.showInactive(); - } - + // Wait for the first frame to be painted before showing the window. + // Showing before ready-to-show produces a black rectangle flash because + // Chromium hasn't rendered any pixels yet. if (overlayWindow.webContents.isLoading()) { await new Promise((resolve) => { - overlayWindow.webContents.once("did-finish-load", () => resolve()); + overlayWindow.once("ready-to-show", resolve); }); } + if (!overlayWindow.isVisible()) { + overlayWindow.showInactive(); + } + overlayWindow.webContents.send("countdown-overlay-value", value, runId); }); From cf64581c7825a38960dd7e6389d048672c8e2ac6 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 12:01:47 +0200 Subject: [PATCH 20/43] fix: fall back to web MediaRecorder when native Windows helper is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When isNativeWindowsCaptureAvailable returns reason="missing-helper" (helper binary not installed — always the case in dev mode), the code was throwing instead of returning false, which killed the recording entirely instead of falling through to the standard web MediaRecorder path. Treat missing-helper the same as unsupported-os: silently return false and let the web recorder take over. --- src/hooks/useScreenRecorder.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 45aa7b3b5..9b26668cf 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -596,15 +596,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const availability = await window.electronAPI.isNativeWindowsCaptureAvailable(); if (!availability.success || !availability.available) { - if (availability.reason === "unsupported-os") { + // Fall back to the web MediaRecorder path when the native helper + // binary isn't installed (dev mode) or the OS isn't supported. + if (availability.reason === "unsupported-os" || availability.reason === "missing-helper") { return false; } - throw new Error( - availability.reason === "missing-helper" - ? "Native Windows capture helper is not available." - : (availability.error ?? "Native Windows capture is not available."), - ); + throw new Error(availability.error ?? "Native Windows capture is not available."); } if (!isCountdownRunActive(countdownRunToken)) { From 7dd54a1edf84cd8400d60bcb7d2ff4fac51e3f0f Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 12:05:58 +0200 Subject: [PATCH 21/43] fix: use getUserMedia on all platforms instead of broken getDisplayMedia on Windows The Windows code path used navigator.mediaDevices.getDisplayMedia() which requires setDisplayMediaRequestHandler to be registered in the main process. That handler was never implemented, causing a "Not supported" error and preventing recording from starting on Windows when falling back from the native helper. Replace the platform-branched capture logic with a single getUserMedia + chromeMediaSource: "desktop" path that works on both macOS and Windows. Cursor capture mode is already handled separately via setRecordingState. --- src/hooks/useScreenRecorder.ts | 79 +++++++++++++++------------------- 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9b26668cf..a7ba5b3b0 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -767,58 +767,47 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } let screenMediaStream: MediaStream; - const platform = await window.electronAPI.getPlatform(); - if (platform === "win32") { - // getDisplayMedia + setDisplayMediaRequestHandler (main.ts) supplies the - // pre-selected source. Editable cursor mode excludes the system cursor so - // the editor can render a replacement; system mode bakes it into the video. - screenMediaStream = await navigator.mediaDevices.getDisplayMedia({ - video: { - cursor: cursorCaptureMode === "editable-overlay" ? "never" : "always", - width: { max: TARGET_WIDTH }, - height: { max: TARGET_HEIGHT }, - frameRate: { ideal: TARGET_FRAME_RATE }, - } as MediaTrackConstraints, - audio: systemAudioEnabled, - } as DisplayMediaStreamOptions); - } else { - const videoConstraints = { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: selectedSource.id, - maxWidth: TARGET_WIDTH, - maxHeight: TARGET_HEIGHT, - maxFrameRate: TARGET_FRAME_RATE, - minFrameRate: MIN_FRAME_RATE, - }, - }; - - if (systemAudioEnabled) { - try { - screenMediaStream = await navigator.mediaDevices.getUserMedia({ - audio: { - mandatory: { - chromeMediaSource: CHROME_MEDIA_SOURCE, - chromeMediaSourceId: selectedSource.id, - }, + // Use getUserMedia with chromeMediaSource on all platforms — this + // supplies the pre-selected source directly and works reliably on + // both macOS and Windows. The previous Windows-specific getDisplayMedia + // path required setDisplayMediaRequestHandler to be wired up in the + // main process (it never was), causing "Not supported" errors. + const videoConstraints = { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, + maxWidth: TARGET_WIDTH, + maxHeight: TARGET_HEIGHT, + maxFrameRate: TARGET_FRAME_RATE, + minFrameRate: MIN_FRAME_RATE, + }, + }; + + if (systemAudioEnabled) { + try { + screenMediaStream = await navigator.mediaDevices.getUserMedia({ + audio: { + mandatory: { + chromeMediaSource: CHROME_MEDIA_SOURCE, + chromeMediaSourceId: selectedSource.id, }, - video: videoConstraints, - } as unknown as MediaStreamConstraints); - } catch (audioErr) { - console.warn("System audio capture failed, falling back to video-only:", audioErr); - toast.error(t("recording.systemAudioUnavailable")); - screenMediaStream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: videoConstraints, - } as unknown as MediaStreamConstraints); - } - } else { + }, + video: videoConstraints, + } as unknown as MediaStreamConstraints); + } catch (audioErr) { + console.warn("System audio capture failed, falling back to video-only:", audioErr); + toast.error(t("recording.systemAudioUnavailable")); screenMediaStream = await navigator.mediaDevices.getUserMedia({ audio: false, video: videoConstraints, } as unknown as MediaStreamConstraints); } + } else { + screenMediaStream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: videoConstraints, + } as unknown as MediaStreamConstraints); } screenStream.current = screenMediaStream; From c8f87421c6b79aa43056ecfc37822235facbe249 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 16:07:13 +0200 Subject: [PATCH 22/43] fix: freeze dialog variant during close animation to prevent flash Use a ref to keep the last non-null confirmDialogVariant stable while the dialog animates out. Previously, setting variant to null caused the fallback ??\"close\" to kick in mid-animation, briefly showing \"Save & Close\" content and firing a spurious sendCloseConfirmResponse(\"cancel\") IPC call. --- src/components/video-editor/VideoEditor.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 5cc8214d9..a4aa24209 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -220,6 +220,13 @@ export default function VideoEditor() { const [confirmDialogVariant, setConfirmDialogVariant] = useState<"close" | "newProject" | null>( null, ); + // Keeps the last non-null variant so the dialog content doesn't snap to the + // "close" fallback during the closing animation (which would briefly flash + // "Save & Close / Discard & Close" and fire a spurious sendCloseConfirmResponse). + const lastConfirmVariantRef = useRef<"close" | "newProject">("close"); + if (confirmDialogVariant !== null) { + lastConfirmVariantRef.current = confirmDialogVariant; + } const playerContainerRef = useRef(null); const cursorTelemetrySourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null); const { samples: cursorTelemetry, error: cursorTelemetryError } = @@ -2511,19 +2518,19 @@ export default function VideoEditor() { setConfirmDialogVariant(null) : handleCloseConfirmCancel } From f25878645ebf6ee9c3b8327a1353861f83f6a66b Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 16:14:13 +0200 Subject: [PATCH 23/43] fix: raise device selector panel above HUD toolbar The mic/webcam selectors were positioned at bottom-[68px] but the HUD bar sits at bottom-20 (80px), causing selectors to render behind the bar. Moved to bottom-[136px] (bar bottom 80px + bar height ~46px + 10px gap). --- src/components/launch/LaunchWindow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 1743dabe9..55be106c0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -389,7 +389,7 @@ export function LaunchWindow() { {(showMicControls || showWebcamControls) && (
{/* Mic selector */} {showMicControls && ( From 0ff83b7d9e685f7b3f9c0e023ddf79d097fee637 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 16:18:02 +0200 Subject: [PATCH 24/43] fix: clear webcam state on new project and video import doNewProject() was not resetting webcamVideoPath/webcamVideoSourcePath, causing the webcam track from a previous recording to bleed into the next project. Also clear them in handleImportVideo since imported external videos never carry a webcam track. --- src/components/video-editor/VideoEditor.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index a4aa24209..ed4322c3e 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -717,6 +717,8 @@ export default function VideoEditor() { await nativeBridgeClient.project.clearCurrentVideoPath(); setVideoPath(null); setVideoSourcePath(null); + setWebcamVideoPath(null); + setWebcamVideoSourcePath(null); setCurrentProjectPath(null); setLastSavedSnapshot(null); }, []); @@ -754,6 +756,10 @@ export default function VideoEditor() { setVideoPath(toFileUrl(result.path)); setVideoSourcePath(result.path); + // Imported videos never have a webcam track — clear any leftover state + // from a previous recording so it doesn't bleed into the new project. + setWebcamVideoPath(null); + setWebcamVideoSourcePath(null); }, [t]); const handleLoadProject = useCallback(async () => { From 800749e26b4f95d0f31ff4759769e74430a2a9a3 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 16:24:13 +0200 Subject: [PATCH 25/43] fix: make drag-and-drop project file actually open the project The drop handler called onProjectLoaded() which was wired to handleLoadProject, causing it to re-open a file picker dialog after the file was already loaded. Added onProjectFileDropped callback so the loaded project data is passed directly to applyLoadedProject without a second dialog. Also improved the empty state UI: moved the drag-and-drop hint below the supported formats line with a small upload icon for visual clarity. --- .../video-editor/EditorEmptyState.tsx | 36 +++++++++++++------ src/components/video-editor/VideoEditor.tsx | 8 +++++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index f99d199b5..6bc288e4f 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -5,9 +5,14 @@ import { nativeBridgeClient } from "@/native"; interface EditorEmptyStateProps { onVideoImported: (videoPath: string) => void; onProjectLoaded: () => void; + onProjectFileDropped: (project: unknown, path: string | null) => void; } -export function EditorEmptyState({ onVideoImported, onProjectLoaded }: EditorEmptyStateProps) { +export function EditorEmptyState({ + onVideoImported, + onProjectLoaded, + onProjectFileDropped, +}: EditorEmptyStateProps) { const [isDraggingOver, setIsDraggingOver] = useState(false); const handleImportVideo = useCallback(async () => { @@ -60,10 +65,13 @@ export function EditorEmptyState({ onVideoImported, onProjectLoaded }: EditorEmp if (!filePath) return; const result = await nativeBridgeClient.project.loadProjectFileFromPath(filePath); - if (result.canceled || !result.success) return; - onProjectLoaded(); + if (!result.success) return; + + // Pass the already-loaded project data up so VideoEditor can apply it + // directly without re-opening a file picker dialog. + onProjectFileDropped(result.project, result.path ?? null); }, - [onProjectLoaded], + [onProjectFileDropped], ); return ( @@ -93,10 +101,7 @@ export function EditorEmptyState({ onVideoImported, onProjectLoaded }: EditorEmp

No project open

- Import a video to start editing, or load an existing Openscreen project. -
- You can also drag and drop a .openscreen file - here. + Import a video to start editing, or load an existing OpenScreen project.

@@ -120,9 +125,18 @@ export function EditorEmptyState({ onVideoImported, onProjectLoaded }: EditorEmp
-

- Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV -

+
+

+ Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV +

+
+ + + or drag & drop a .openscreen{" "} + project file here + +
+
); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ed4322c3e..328101fef 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2194,8 +2194,16 @@ export default function VideoEditor() { onVideoImported={(path) => { setVideoPath(toFileUrl(path)); setVideoSourcePath(path); + setWebcamVideoPath(null); + setWebcamVideoSourcePath(null); }} onProjectLoaded={handleLoadProject} + onProjectFileDropped={async (project, path) => { + const restored = await applyLoadedProject(project, path); + if (!restored) { + toast.error(t("project.invalidFormat")); + } + }} />
)} From 3d27efa811e9974bc0e672c7dfee73565529e269 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 16:24:52 +0200 Subject: [PATCH 26/43] style: add more top padding to drag-and-drop hint on empty state --- src/components/video-editor/EditorEmptyState.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 6bc288e4f..a220502c5 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -129,7 +129,7 @@ export function EditorEmptyState({

Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV

-
+
or drag & drop a .openscreen{" "} From b6439ad11d98503b6ec9e1706d52cbca0835dbbc Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 16:28:41 +0200 Subject: [PATCH 27/43] fix: show error dialog on unsupported or failed drag-and-drop - Non-.openscreen files now show "Unsupported Format" dialog instead of silently doing nothing - Failed project loads (e.g. video path no longer accessible) show a "Could Not Open File" dialog with a helpful message - Both use the same design language as the rest of the app (dark dialog, app icon, close button) --- .../video-editor/EditorEmptyState.tsx | 70 +++++++++++++++---- 1 file changed, 58 insertions(+), 12 deletions(-) diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index a220502c5..757958efe 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -1,5 +1,6 @@ -import { Film, FolderOpen, Upload } from "lucide-react"; +import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react"; import { useCallback, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { nativeBridgeClient } from "@/native"; interface EditorEmptyStateProps { @@ -8,12 +9,15 @@ interface EditorEmptyStateProps { onProjectFileDropped: (project: unknown, path: string | null) => void; } +type DropError = "unsupported-format" | "load-failed" | null; + export function EditorEmptyState({ onVideoImported, onProjectLoaded, onProjectFileDropped, }: EditorEmptyStateProps) { const [isDraggingOver, setIsDraggingOver] = useState(false); + const [dropError, setDropError] = useState(null); const handleImportVideo = useCallback(async () => { const result = await window.electronAPI.openVideoFilePicker(); @@ -33,14 +37,7 @@ export function EditorEmptyState({ const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); - const files = Array.from(e.dataTransfer.items); - const hasProject = files.some( - (item) => - item.kind === "file" && - (item.getAsFile()?.name.endsWith(".openscreen") || - item.type === "application/octet-stream"), - ); - if (hasProject || files.length > 0) { + if (e.dataTransfer.items.length > 0) { setIsDraggingOver(true); } }, []); @@ -57,15 +54,27 @@ export function EditorEmptyState({ setIsDraggingOver(false); const files = Array.from(e.dataTransfer.files); + if (files.length === 0) return; + const projectFile = files.find((f) => f.name.endsWith(".openscreen")); - if (!projectFile) return; + if (!projectFile) { + // Files were dropped but none are .openscreen + setDropError("unsupported-format"); + return; + } // Electron exposes the real filesystem path on the File object const filePath = (projectFile as File & { path: string }).path; - if (!filePath) return; + if (!filePath) { + setDropError("load-failed"); + return; + } const result = await nativeBridgeClient.project.loadProjectFileFromPath(filePath); - if (!result.success) return; + if (!result.success) { + setDropError("load-failed"); + return; + } // Pass the already-loaded project data up so VideoEditor can apply it // directly without re-opening a file picker dialog. @@ -89,6 +98,43 @@ export function EditorEmptyState({
)} + {/* Drop error dialog */} + !open && setDropError(null)}> + + +
+ + + {dropError === "unsupported-format" ? "Unsupported Format" : "Could Not Open File"} + +
+
+ +
+ +

+ {dropError === "unsupported-format" + ? "Sorry, this file format is not supported. Only .openscreen project files can be dropped here." + : "The project file could not be opened. The video it references may have been moved or deleted."} +

+
+ + +
+
+
{/* Logo */} Date: Mon, 11 May 2026 16:37:19 +0200 Subject: [PATCH 28/43] fix: resolve double-dialog and drag-drop load failure - Unified EditorEmptyState to a single onProjectOpened(project, path) callback used by both Load Project button and drag-and-drop, eliminating the double file-picker that was opening (EditorEmptyState + VideoEditor each calling loadProjectFile independently) - Made loadProjectFileFromPath resilient to getApprovedProjectSession failures: path approval errors no longer block the project from loading, they just skip setting the recording session (video player handles the "file not found" case gracefully) --- electron/ipc/handlers.ts | 15 ++++++++++- .../video-editor/EditorEmptyState.tsx | 25 +++++++------------ src/components/video-editor/VideoEditor.tsx | 3 +-- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b922e7745..3c810e326 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1788,7 +1788,20 @@ export function registerIpcHandlers( const content = await fs.readFile(filePath, "utf-8"); const project = JSON.parse(content); currentProjectPath = filePath; - setCurrentRecordingSessionState(await getApprovedProjectSession(project, filePath)); + + // Approve session paths; tolerate failures (e.g. video moved outside + // trusted dirs) so the project still loads and the renderer can surface + // a "video not found" error rather than a generic load failure. + let session: import("../../src/lib/recordingSession").RecordingSession | null = null; + try { + session = await getApprovedProjectSession(project, filePath); + } catch (sessionError) { + console.warn( + "[loadProjectFileFromPath] Could not approve session paths, proceeding without session:", + sessionError, + ); + } + setCurrentRecordingSessionState(session); return { success: true, path: filePath, project }; } catch (error) { console.error("Failed to load project file from path:", error); diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 757958efe..0190e0301 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -5,17 +5,13 @@ import { nativeBridgeClient } from "@/native"; interface EditorEmptyStateProps { onVideoImported: (videoPath: string) => void; - onProjectLoaded: () => void; - onProjectFileDropped: (project: unknown, path: string | null) => void; + /** Called with the loaded project data — handles both button click and drag-drop */ + onProjectOpened: (project: unknown, path: string | null) => void; } type DropError = "unsupported-format" | "load-failed" | null; -export function EditorEmptyState({ - onVideoImported, - onProjectLoaded, - onProjectFileDropped, -}: EditorEmptyStateProps) { +export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) { const [isDraggingOver, setIsDraggingOver] = useState(false); const [dropError, setDropError] = useState(null); @@ -31,9 +27,9 @@ export function EditorEmptyState({ const handleLoadProject = useCallback(async () => { const result = await nativeBridgeClient.project.loadProjectFile(); - if (result.canceled || !result.success) return; - onProjectLoaded(); - }, [onProjectLoaded]); + if (result.canceled || !result.success || !result.project) return; + onProjectOpened(result.project, result.path ?? null); + }, [onProjectOpened]); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -58,7 +54,6 @@ export function EditorEmptyState({ const projectFile = files.find((f) => f.name.endsWith(".openscreen")); if (!projectFile) { - // Files were dropped but none are .openscreen setDropError("unsupported-format"); return; } @@ -71,16 +66,14 @@ export function EditorEmptyState({ } const result = await nativeBridgeClient.project.loadProjectFileFromPath(filePath); - if (!result.success) { + if (!result.success || !result.project) { setDropError("load-failed"); return; } - // Pass the already-loaded project data up so VideoEditor can apply it - // directly without re-opening a file picker dialog. - onProjectFileDropped(result.project, result.path ?? null); + onProjectOpened(result.project, result.path ?? null); }, - [onProjectFileDropped], + [onProjectOpened], ); return ( diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 328101fef..0eb190895 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -2197,8 +2197,7 @@ export default function VideoEditor() { setWebcamVideoPath(null); setWebcamVideoSourcePath(null); }} - onProjectLoaded={handleLoadProject} - onProjectFileDropped={async (project, path) => { + onProjectOpened={async (project, path) => { const restored = await applyLoadedProject(project, path); if (!restored) { toast.error(t("project.invalidFormat")); From 800f90632323bccf78648f9e7951458d481c9b5d Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 18:19:32 +0200 Subject: [PATCH 29/43] fix(electron): expose webUtils.getPathForFile and loadProjectFileFromPath via preload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import webUtils from electron in preload and expose getPathForFile(file) via contextBridge — replaces the removed File.path property (Electron 32+) - Expose loadProjectFileFromPath(filePath) as a direct ipcRenderer.invoke call, bypassing the native-bridge routing layer for drag-drop use - Add matching TypeScript declarations to electron-env.d.ts so the renderer can call window.electronAPI.getPathForFile / window.electronAPI.loadProjectFileFromPath with full type safety Files changed: electron/preload.ts — webUtils import + two new contextBridge entries electron/electron-env.d.ts — type declarations for both new API methods --- electron/electron-env.d.ts | 9 +++++++++ electron/preload.ts | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 96456608f..897738260 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -166,6 +166,15 @@ interface Window { canceled?: boolean; error?: string; }>; + getPathForFile: (file: File) => string; + loadProjectFileFromPath: (filePath: string) => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; onMenuNewProject: (callback: () => void) => () => void; onMenuImportVideo: (callback: () => void) => () => void; onMenuLoadProject: (callback: () => void) => () => void; diff --git a/electron/preload.ts b/electron/preload.ts index ac4a1a38e..6fc95a29b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from "electron"; +import { contextBridge, ipcRenderer, webUtils } from "electron"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; @@ -121,6 +121,10 @@ contextBridge.exposeInMainWorld("electronAPI", { loadProjectFile: () => { return ipcRenderer.invoke("load-project-file"); }, + loadProjectFileFromPath: (filePath: string) => { + return ipcRenderer.invoke("load-project-file-from-path", filePath); + }, + getPathForFile: (file: File) => webUtils.getPathForFile(file), loadCurrentProjectFile: () => { return ipcRenderer.invoke("load-current-project-file"); }, From 655cb054c65b27bea3c6029d9f9ccce0a94564ed Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 18:20:32 +0200 Subject: [PATCH 30/43] fix(ipc): clear project path and recording session when starting a new project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clearCurrentVideoPath() previously only nulled currentVideoPath, leaving currentProjectPath set. On next editor open, loadCurrentProjectFile() would find a stale path and reload the discarded project. Fix by also clearing currentProjectPath and calling setCurrentRecordingSessionState(null) so the main-process state is fully reset when the user chooses New Project. Files changed: electron/ipc/handlers.ts — clearCurrentVideoPath clears all three state vars --- electron/ipc/handlers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3c810e326..e2c4fb1d3 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1895,6 +1895,8 @@ export function registerIpcHandlers( function clearCurrentVideoPath(): ProjectPathResult { currentVideoPath = null; + currentProjectPath = null; + setCurrentRecordingSessionState(null); return { success: true }; } From 23f5fbefe1114a22505105af91939139463ef786 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 18:20:51 +0200 Subject: [PATCH 31/43] =?UTF-8?q?feat(editor):=20rewrite=20EditorEmptyStat?= =?UTF-8?q?e=20=E2=80=94=20webUtils=20drag-drop,=20i18n,=20dialog=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace File.path (removed in Electron 32+) with window.electronAPI.getPathForFile() for drag-drop path resolution - Use window.electronAPI.loadProjectFileFromPath() with try-catch for robust error handling on dropped project files - Wire all UI strings through useScopedT("editor") / useScopedT("common") so the empty state is fully localised across 11 languages - Add frozen-ref flash fix (lastDropErrorRef) so the drop-error dialog doesn't snap to the wrong content during the Radix closing animation - Polish drop-error dialog layout: icon wrapped in a ring container (w-10 h-10 rounded-full bg-white/5 ring-1 ring-white/10) with a smaller w-5 h-5 AlertCircle inside for correct visual balance - Add drag-over overlay and drag-leave boundary guard Files changed: src/components/video-editor/EditorEmptyState.tsx — full rewrite --- .../video-editor/EditorEmptyState.tsx | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index 0190e0301..0f5fbfb17 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -1,6 +1,7 @@ import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useScopedT } from "@/contexts/I18nContext"; import { nativeBridgeClient } from "@/native"; interface EditorEmptyStateProps { @@ -12,8 +13,16 @@ interface EditorEmptyStateProps { type DropError = "unsupported-format" | "load-failed" | null; export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmptyStateProps) { + const te = useScopedT("editor"); + const tc = useScopedT("common"); const [isDraggingOver, setIsDraggingOver] = useState(false); const [dropError, setDropError] = useState(null); + // Freeze the last non-null error type so dialog content doesn't snap to the + // else-branch during the closing animation (same pattern as UnsavedChangesDialog). + const lastDropErrorRef = useRef>("unsupported-format"); + if (dropError !== null) { + lastDropErrorRef.current = dropError; + } const handleImportVideo = useCallback(async () => { const result = await window.electronAPI.openVideoFilePicker(); @@ -58,14 +67,20 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp return; } - // Electron exposes the real filesystem path on the File object - const filePath = (projectFile as File & { path: string }).path; + // Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+ + const filePath = window.electronAPI.getPathForFile(projectFile); if (!filePath) { setDropError("load-failed"); return; } - const result = await nativeBridgeClient.project.loadProjectFileFromPath(filePath); + let result: Awaited>; + try { + result = await window.electronAPI.loadProjectFileFromPath(filePath); + } catch { + setDropError("load-failed"); + return; + } if (!result.success || !result.project) { setDropError("load-failed"); return; @@ -87,14 +102,14 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp {isDraggingOver && (
-

Drop project file to open

+

{te("emptyState.dropOverlay")}

)} {/* Drop error dialog */} !open && setDropError(null)}> - +
- {dropError === "unsupported-format" ? "Unsupported Format" : "Could Not Open File"} + {lastDropErrorRef.current === "unsupported-format" + ? te("emptyState.dropErrors.unsupportedFormatTitle") + : te("emptyState.dropErrors.couldNotOpenTitle")}
-
- +
+
+ +

- {dropError === "unsupported-format" - ? "Sorry, this file format is not supported. Only .openscreen project files can be dropped here." - : "The project file could not be opened. The video it references may have been moved or deleted."} + {lastDropErrorRef.current === "unsupported-format" + ? te("emptyState.dropErrors.unsupportedFormatMessage") + : te("emptyState.dropErrors.couldNotOpenMessage")}

@@ -123,7 +142,7 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]" > - Close + {tc("actions.close")}
@@ -138,9 +157,9 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp />
-

No project open

+

{te("emptyState.title")}

- Import a video to start editing, or load an existing OpenScreen project. + {te("emptyState.description")}

@@ -152,7 +171,7 @@ export function EditorEmptyState({ onVideoImported, onProjectOpened }: EditorEmp className="flex items-center justify-center gap-2.5 w-full px-4 py-3 rounded-xl bg-[#34B27B] hover:bg-[#2d9e6c] active:bg-[#27885c] text-white font-medium text-sm transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#34B27B] focus-visible:ring-offset-2 focus-visible:ring-offset-[#09090b]" > - Import Video File… + {te("emptyState.importVideoButton")}
-

- Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV -

+

{te("emptyState.supportedFormats")}

- - or drag & drop a .openscreen{" "} - project file here - + {te("emptyState.dragDropHint")}
From 8750d6f0ef2ae964527e0f0476677d4994443941 Mon Sep 17 00:00:00 2001 From: makaradam Date: Mon, 11 May 2026 18:21:08 +0200 Subject: [PATCH 32/43] feat(editor): unsaved changes guard for Load Project + New Project header button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UnsavedChangesDialog: - Add "loadProject" as a third variant alongside "close" and "newProject" - Detail text, save label, and discard label all branch on the new variant using nested ternaries for zero-duplication logic VideoEditor: - Rename old handleLoadProject to doLoadProject (the raw IPC work) - New handleLoadProject wrapper: shows confirmDialogVariant="loadProject" when hasUnsavedChanges, otherwise calls doLoadProject directly - Add handleLoadProjectConfirmSave / handleLoadProjectConfirmDiscard callbacks wired to the UnsavedChangesDialog for the loadProject path - Add "New Project" button (FilePlus icon) to the editor header bar, positioned before the existing Load Project button - Expand confirmDialogVariant state to "close"|"newProject"|"loadProject"|null with a matching lastConfirmVariantRef to prevent flash on dialog close Files changed: src/components/video-editor/UnsavedChangesDialog.tsx — loadProject variant src/components/video-editor/VideoEditor.tsx — guard + header button --- .../video-editor/UnsavedChangesDialog.tsx | 28 ++++++---- src/components/video-editor/VideoEditor.tsx | 52 +++++++++++++++---- 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index e213a2c4b..f7423b620 100644 --- a/src/components/video-editor/UnsavedChangesDialog.tsx +++ b/src/components/video-editor/UnsavedChangesDialog.tsx @@ -10,7 +10,7 @@ import { useScopedT } from "@/contexts/I18nContext"; interface UnsavedChangesDialogProps { isOpen: boolean; - variant?: "close" | "newProject"; + variant?: "close" | "newProject" | "loadProject"; onSaveAndClose: () => void; onDiscardAndClose: () => void; onCancel: () => void; @@ -26,14 +26,24 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); - const isNewProject = variant === "newProject"; - const detail = isNewProject ? td("unsavedChanges.detailNewProject") : td("unsavedChanges.detail"); - const saveLabel = isNewProject - ? td("unsavedChanges.saveAndNewProject") - : td("unsavedChanges.saveAndClose"); - const discardLabel = isNewProject - ? td("unsavedChanges.discardAndNewProject") - : td("unsavedChanges.discardAndClose"); + const detail = + variant === "newProject" + ? td("unsavedChanges.detailNewProject") + : variant === "loadProject" + ? td("unsavedChanges.detailLoadProject") + : td("unsavedChanges.detail"); + const saveLabel = + variant === "newProject" + ? td("unsavedChanges.saveAndNewProject") + : variant === "loadProject" + ? td("unsavedChanges.saveAndLoadProject") + : td("unsavedChanges.saveAndClose"); + const discardLabel = + variant === "newProject" + ? td("unsavedChanges.discardAndNewProject") + : variant === "loadProject" + ? td("unsavedChanges.discardAndLoadProject") + : td("unsavedChanges.discardAndClose"); return ( !open && onCancel()}> diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0eb190895..dcee772d9 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1,5 +1,5 @@ import type { Span } from "dnd-timeline"; -import { FolderOpen, Languages, Save, Video } from "lucide-react"; +import { FilePlus, FolderOpen, Languages, Save, Video } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { toast } from "sonner"; @@ -217,13 +217,13 @@ export default function VideoEditor() { format: string; } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); - const [confirmDialogVariant, setConfirmDialogVariant] = useState<"close" | "newProject" | null>( - null, - ); + const [confirmDialogVariant, setConfirmDialogVariant] = useState< + "close" | "newProject" | "loadProject" | null + >(null); // Keeps the last non-null variant so the dialog content doesn't snap to the // "close" fallback during the closing animation (which would briefly flash // "Save & Close / Discard & Close" and fire a spurious sendCloseConfirmResponse). - const lastConfirmVariantRef = useRef<"close" | "newProject">("close"); + const lastConfirmVariantRef = useRef<"close" | "newProject" | "loadProject">("close"); if (confirmDialogVariant !== null) { lastConfirmVariantRef.current = confirmDialogVariant; } @@ -762,7 +762,7 @@ export default function VideoEditor() { setWebcamVideoSourcePath(null); }, [t]); - const handleLoadProject = useCallback(async () => { + const doLoadProject = useCallback(async () => { const result = await nativeBridgeClient.project.loadProjectFile(); if (result.canceled) { @@ -783,6 +783,27 @@ export default function VideoEditor() { toast.success(t("project.loadedFrom", { path: result.path ?? "" })); }, [applyLoadedProject, t]); + const handleLoadProject = useCallback(async () => { + if (hasUnsavedChanges) { + setConfirmDialogVariant("loadProject"); + return; + } + await doLoadProject(); + }, [hasUnsavedChanges, doLoadProject]); + + const handleLoadProjectConfirmSave = useCallback(async () => { + setConfirmDialogVariant(null); + const saved = await saveProject(false); + if (saved) { + await doLoadProject(); + } + }, [saveProject, doLoadProject]); + + const handleLoadProjectConfirmDiscard = useCallback(async () => { + setConfirmDialogVariant(null); + await doLoadProject(); + }, [doLoadProject]); + useEffect(() => { const removeNewProjectListener = window.electronAPI.onMenuNewProject(handleNewProject); const removeImportVideoListener = window.electronAPI.onMenuImportVideo(handleImportVideo); @@ -2168,6 +2189,14 @@ export default function VideoEditor() {