diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e92ce19f..89773826 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -166,6 +166,17 @@ 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; onMenuSaveProject: (callback: () => void) => () => void; onMenuSaveProjectAs: (callback: () => void) => () => void; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9797c95c..8dc19f92 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. @@ -985,29 +995,39 @@ export function registerIpcHandlers( }); ipcMain.handle("switch-to-editor", () => { - const mainWin = getMainWindow(); - if (mainWin) { - mainWin.close(); - } + // createEditorWindow is createEditorWindowWrapper — it already closes + // the current mainWindow (the HUD) before opening the editor. Closing + // it here too causes a double-close which leaves ghost transparent + // windows and makes the HUD shadow compound on each cycle. 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()) { 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); }); @@ -1533,7 +1553,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 +1768,51 @@ 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 (path.extname(filePath).toLowerCase() !== `.${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; + + // 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); + return { + success: false, + message: "Failed to load project file", + error: String(error), + }; + } + } + ipcMain.handle("load-current-project-file", async () => { return loadCurrentProjectFile(); }); @@ -1830,6 +1895,8 @@ export function registerIpcHandlers( function clearCurrentVideoPath(): ProjectPathResult { currentVideoPath = null; + currentProjectPath = null; + setCurrentRecordingSessionState(null); return { success: true }; } @@ -1904,6 +1971,7 @@ export function registerIpcHandlers( saveProjectFile, loadProjectFile, loadCurrentProjectFile, + loadProjectFileFromPath, setCurrentVideoPath, getCurrentVideoPathResult, clearCurrentVideoPath, diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index 7f7b24b5..425f93e1 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 24d06ac1..e9018a15 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -109,7 +109,7 @@ 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", ) { let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -168,6 +168,12 @@ 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.loadProject") || "Load Project…", accelerator: "CmdOrCtrl+O", diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 965b4fb7..9e96aa22 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 8302b959..f446ccef 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,9 +121,29 @@ 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) => { + try { + return webUtils.getPathForFile(file); + } catch { + return ""; + } + }, 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/electron/windows.ts b/electron/windows.ts index 4d4e7520..0f09a708 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -39,19 +39,25 @@ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = screen.getPrimaryDisplay(); const { workArea } = primaryDisplay; - const windowWidth = 600; - const windowHeight = 160; - + // Extra padding around the visible pill so CSS box-shadows (60px blur) + // aren't clipped by the transparent window boundary. + // The pill sits at CSS `bottom-20` (80px from window bottom) so the + // downward shadow has ~80px of transparent space to expand into. + // The window is positioned so the pill's screen position stays unchanged. + const windowWidth = 800; + const windowHeight = 320; + // Pill is bottom-20 (80px) instead of bottom-5 (20px), so shift window + // down 60px to keep the pill at the same visual screen position. const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); + const y = Math.floor(workArea.y + workArea.height - windowHeight + 55); const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 160, - maxHeight: 160, + minWidth: 800, + maxWidth: 800, + minHeight: 320, + maxHeight: 320, x: x, y: y, frame: false, @@ -60,7 +66,7 @@ export function createHudOverlayWindow(): BrowserWindow { alwaysOnTop: true, skipTaskbar: true, hasShadow: false, - show: !HEADLESS, + show: false, // shown via ready-to-show to avoid black flash webPreferences: { preload: path.join(__dirname, "preload.mjs"), additionalArguments: [ASSET_BASE_URL_ARG], @@ -77,6 +83,11 @@ export function createHudOverlayWindow(): BrowserWindow { win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } + // Show only once content is painted — prevents black rectangle flash + win.once("ready-to-show", () => { + if (!HEADLESS) win.show(); + }); + win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); }); @@ -121,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], @@ -136,6 +147,19 @@ 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(); + }); + + // 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()); }); diff --git a/index.html b/index.html index ce1c274a..510c15dd 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,12 @@ - + - +
diff --git a/src/App.tsx b/src/App.tsx index 6f737b9b..6c36aa8c 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/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 3b2bebfe..55be106c 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"; @@ -318,29 +318,6 @@ export function LaunchWindow() { } }; - const openVideoFile = async () => { - const result = await window.electronAPI.openVideoFilePicker(); - - if (result.canceled) { - return; - } - - if (result.success && result.path) { - const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path); - if (!setVideoPathResult.success) { - console.error("Failed to set current video path:", setVideoPathResult); - return; - } - await window.electronAPI.switchToEditor(); - } - }; - - const openProjectFile = async () => { - const result = await nativeBridgeClient.project.loadProjectFile(); - if (result.canceled || !result.success) return; - await window.electronAPI.switchToEditor(); - }; - const sendHudOverlayHide = () => { if (window.electronAPI && window.electronAPI.hudOverlayHide) { window.electronAPI.hudOverlayHide(); @@ -412,7 +389,7 @@ export function LaunchWindow() { {(showMicControls || showWebcamControls) && (
{/* Mic selector */} {showMicControls && ( @@ -546,7 +523,7 @@ export function LaunchWindow() { {/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
{/* Drag handle */}
@@ -688,29 +665,15 @@ export function LaunchWindow() { )} {!recording && ( - <> - {/* Open video file */} - - - - - {/* Open project */} - - - - + + + )} {/* Right sidebar controls */} diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx new file mode 100644 index 00000000..511323ab --- /dev/null +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -0,0 +1,202 @@ +import { AlertCircle, Film, FolderOpen, Upload, X } from "lucide-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 { + onVideoImported: (videoPath: string) => 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, 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(); + 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 || !result.project) return; + onProjectOpened(result.project, result.path ?? null); + }, [onProjectOpened]); + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + if (e.dataTransfer.items.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); + if (files.length === 0) return; + + const projectFile = files.find((f) => f.name.endsWith(".openscreen")); + if (!projectFile) { + setDropError("unsupported-format"); + return; + } + + // Use Electron's webUtils.getPathForFile — File.path was removed in Electron 32+ + let filePath: string; + try { + filePath = window.electronAPI.getPathForFile(projectFile); + } catch { + setDropError("load-failed"); + return; + } + if (!filePath) { + setDropError("load-failed"); + return; + } + + let result: Awaited>; + try { + result = await window.electronAPI.loadProjectFileFromPath(filePath); + } catch { + setDropError("load-failed"); + return; + } + if (!result.success || !result.project) { + setDropError("load-failed"); + return; + } + + onProjectOpened(result.project, result.path ?? null); + }, + [onProjectOpened], + ); + + return ( +
+ {/* Drop overlay */} + {isDraggingOver && ( +
+ +

{te("emptyState.dropOverlay")}

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

+ {lastDropErrorRef.current === "unsupported-format" + ? te("emptyState.dropErrors.unsupportedFormatMessage") + : te("emptyState.dropErrors.couldNotOpenMessage")} +

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

{te("emptyState.title")}

+

+ {te("emptyState.description")} +

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

{te("emptyState.supportedFormats")}

+
+ + {te("emptyState.dragDropHint")} +
+
+
+
+ ); +} diff --git a/src/components/video-editor/UnsavedChangesDialog.tsx b/src/components/video-editor/UnsavedChangesDialog.tsx index 902b1427..f7423b62 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" | "loadProject"; onSaveAndClose: () => void; onDiscardAndClose: () => void; onCancel: () => void; @@ -17,6 +18,7 @@ interface UnsavedChangesDialogProps { export function UnsavedChangesDialog({ isOpen, + variant = "close", onSaveAndClose, onDiscardAndClose, onCancel, @@ -24,6 +26,25 @@ export function UnsavedChangesDialog({ const td = useScopedT("dialogs"); const tc = useScopedT("common"); + 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()}> @@ -42,9 +63,7 @@ export function UnsavedChangesDialog({

{td("unsavedChanges.message")}

- - {td("unsavedChanges.detail")} - + {detail}
+
-
- - {/* 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} - /> + {/* Empty state — shown when no video is loaded */} + {!videoPath && ( +
+ { + setVideoPath(toFileUrl(path)); + setVideoSourcePath(path); + setWebcamVideoPath(null); + setWebcamVideoSourcePath(null); + }} + onProjectOpened={async (project, path) => { + const restored = await applyLoadedProject(project, path); + if (!restored) { + toast.error(t("project.invalidFormat")); + } + }} + /> +
+ )} + + {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, - }) - } - /> -
-
-
-
+ + +
+ )} setConfirmDialogVariant(null) + : handleCloseConfirmCancel + } />
); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 8b53ff9a..8dfae757 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -59,13 +59,13 @@ import { DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SMOOTHING, DEFAULT_ROTATION_3D, + getZoomScale, isRotation3DIdentity, lerpRotation3D, rotation3DPerspective, type SpeedRegion, type TrimRegion, ZOOM_DEPTH_SCALES, - type ZoomDepth, type ZoomFocus, type ZoomRegion, } from "./types"; @@ -83,7 +83,7 @@ import { PixiCursorOverlay, preloadCursorAssets, } from "./videoPlayback/cursorRenderer"; -import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; +import { clampFocusToScale } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; @@ -444,8 +444,19 @@ const VideoPlayback = forwardRef( [onDurationChange, syncResolvedDuration], ); - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); + // IMPORTANT: must use clampFocusToScale(focus, getZoomScale(region)) here, + // NOT clampFocusToStage(focus, region.depth). + // + // region.depth is the preset slot (1×/2×/4×) and ignores customScale entirely. + // getZoomScale(region) returns customScale when set, falling back to the preset + // depth scale — so drag-to-reposition respects the actual zoom level the user + // configured, not the preset bucket it sits in. + // + // This was previously broken (invisible drag boundaries near canvas edges) and + // has been fixed twice. If you're refactoring this drag handler, keep this call + // as clampFocusForRegion(focus, region) — do not switch it back to region.depth. + const clampFocusForRegion = useCallback((focus: ZoomFocus, region: ZoomRegion) => { + return clampFocusToScale(focus, getZoomScale(region)); }, []); const updateOverlayForRegion = useCallback( @@ -628,7 +639,7 @@ const VideoPlayback = forwardRef( cx: clamp01(localX / stageWidth), cy: clamp01(localY / stageHeight), }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + const clampedFocus = clampFocusForRegion(unclampedFocus, region); onZoomFocusChange(region.id, clampedFocus); updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts index bd410dae..c131a3e7 100644 --- a/src/hooks/useEditorHistory.ts +++ b/src/hooks/useEditorHistory.ts @@ -130,6 +130,11 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { dirtyRef.current = false; }, []); + const resetState = useCallback((newInitial: EditorState = INITIAL_EDITOR_STATE) => { + setHistory({ past: [], present: newInitial, future: [] }); + dirtyRef.current = false; + }, []); + return { state: history.present, pushState, @@ -137,6 +142,7 @@ export function useEditorHistory(initial: EditorState = INITIAL_EDITOR_STATE) { commitState, undo, redo, + resetState, canUndo: history.past.length > 0, canRedo: history.future.length > 0, }; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 45aa7b3b..80ea532b 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)) { @@ -769,58 +767,48 @@ 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")); + setSystemAudioEnabled(false); 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; diff --git a/src/i18n/locales/ar/dialogs.json b/src/i18n/locales/ar/dialogs.json index 2263f600..568c542c 100644 --- a/src/i18n/locales/ar/dialogs.json +++ b/src/i18n/locales/ar/dialogs.json @@ -51,6 +51,12 @@ "detail": "هل تريد حفظ مشروعك قبل الإغلاق؟", "saveAndClose": "حفظ وإغلاق", "discardAndClose": "تجاهل وإغلاق", + "detailNewProject": "هل تريد حفظ مشروعك قبل إنشاء مشروع جديد؟", + "saveAndNewProject": "حفظ وإنشاء مشروع جديد", + "discardAndNewProject": "تجاهل وإنشاء مشروع جديد", + "detailLoadProject": "هل تريد حفظ مشروعك قبل تحميل مشروع آخر؟", + "saveAndLoadProject": "حفظ وتحميل مشروع", + "discardAndLoadProject": "تجاهل وتحميل مشروع", "loadProject": "تحميل مشروع...", "saveProject": "حفظ المشروع...", "saveProjectAs": "حفظ المشروع باسم..." diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index a246f011..4bd93ed6 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": "الفيديو غير جاهز", @@ -41,5 +42,20 @@ "cameraDisconnected": "تم فصل كاميرا الويب.", "cameraNotFound": "لم يتم العثور على كاميرا.", "permissionDenied": "تم رفض إذن التسجيل. يرجى السماح بتسجيل الشاشة." + }, + "emptyState": { + "title": "لا يوجد مشروع مفتوح", + "description": "استورد مقطع فيديو للبدء في التحرير، أو حمّل مشروع OpenScreen موجود.", + "importVideoButton": "استيراد ملف فيديو...", + "loadProjectButton": "تحميل مشروع...", + "supportedFormats": "الصيغ المدعومة: MP4، MOV، WebM، MKV، AVI، M4V، WMV", + "dragDropHint": "أو اسحب وأفلت ملف مشروع .openscreen هنا", + "dropOverlay": "أفلت ملف المشروع لفتحه", + "dropErrors": { + "unsupportedFormatTitle": "تنسيق غير مدعوم", + "unsupportedFormatMessage": "يمكن إسقاط ملفات مشروع .openscreen فقط هنا. لاستيراد مقطع فيديو، استخدم زر \"استيراد ملف فيديو...\" بدلاً من ذلك.", + "couldNotOpenTitle": "تعذّر فتح الملف", + "couldNotOpenMessage": "تعذّر فتح ملف المشروع. ربما تم نقل الفيديو المرجعي أو حذفه." + } } } diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json index 47fdca49..36b96e7b 100644 --- a/src/i18n/locales/ar/settings.json +++ b/src/i18n/locales/ar/settings.json @@ -81,7 +81,8 @@ }, "project": { "save": "حفظ المشروع", - "load": "تحميل المشروع" + "load": "تحميل المشروع", + "new": "مشروع جديد" }, "export": { "videoButton": "تصدير الفيديو", diff --git a/src/i18n/locales/en/dialogs.json b/src/i18n/locales/en/dialogs.json index a84b5fda..f4d8d4e5 100644 --- a/src/i18n/locales/en/dialogs.json +++ b/src/i18n/locales/en/dialogs.json @@ -52,6 +52,14 @@ "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", + "detailLoadProject": "Do you want to save your project before loading another one?", + "saveAndLoadProject": "Save & Load Project", + "discardAndLoadProject": "Discard & Load Project", + "newProject": "New Project", + "importVideo": "Import Video File…", "loadProject": "Load Project…", "saveProject": "Save Project…", "saveProjectAs": "Save Project As…" diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 13e2e139..6fcf3b6f 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", @@ -41,5 +42,20 @@ "cameraDisconnected": "Webcam disconnected.", "cameraNotFound": "Camera not found.", "permissionDenied": "Recording permission denied. Please allow screen recording." + }, + "emptyState": { + "title": "No project open", + "description": "Import a video to start editing, or load an existing OpenScreen project.", + "importVideoButton": "Import Video File…", + "loadProjectButton": "Load Project…", + "supportedFormats": "Supported formats: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "or drag & drop a .openscreen project file here", + "dropOverlay": "Drop project file to open", + "dropErrors": { + "unsupportedFormatTitle": "Unsupported Format", + "unsupportedFormatMessage": "Only .openscreen project files can be dropped here. To import a video file, use the \"Import Video File…\" button on this screen.", + "couldNotOpenTitle": "Could Not Open File", + "couldNotOpenMessage": "The project file could not be opened. The video it references may have been moved or deleted." + } } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 133a9612..04420407 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/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9d417361..0e5cd72b 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -97,7 +97,8 @@ }, "project": { "save": "Save Project", - "load": "Load Project" + "load": "Load Project", + "new": "New Project" }, "export": { "videoButton": "Export Video", diff --git a/src/i18n/locales/es/dialogs.json b/src/i18n/locales/es/dialogs.json index f8a5e63f..0b9090f1 100644 --- a/src/i18n/locales/es/dialogs.json +++ b/src/i18n/locales/es/dialogs.json @@ -51,6 +51,12 @@ "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", + "detailLoadProject": "¿Deseas guardar tu proyecto antes de cargar otro?", + "saveAndLoadProject": "Guardar y cargar proyecto", + "discardAndLoadProject": "Descartar y cargar proyecto", "loadProject": "Cargar proyecto…", "saveProject": "Guardar proyecto…", "saveProjectAs": "Guardar proyecto como…" diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 8f6ad13e..bdb01255 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -36,10 +36,26 @@ "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.", "cancel": "Cancelar", "confirm": "Confirmar" + }, + "emptyState": { + "title": "No hay proyecto abierto", + "description": "Importa un video para empezar a editar o carga un proyecto de OpenScreen existente.", + "importVideoButton": "Importar archivo de video…", + "loadProjectButton": "Cargar proyecto…", + "supportedFormats": "Formatos compatibles: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "o arrastra y suelta un archivo .openscreen aquí", + "dropOverlay": "Suelta el archivo de proyecto para abrirlo", + "dropErrors": { + "unsupportedFormatTitle": "Formato no compatible", + "unsupportedFormatMessage": "Solo se pueden soltar aquí archivos de proyecto .openscreen. Para importar un video, usa el botón \"Importar archivo de video...\" en su lugar.", + "couldNotOpenTitle": "No se pudo abrir el archivo", + "couldNotOpenMessage": "No se pudo abrir el archivo de proyecto. El video al que hace referencia puede haber sido movido o eliminado." + } } } diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 20a9ec39..496d4574 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "Guardar proyecto", - "load": "Cargar proyecto" + "load": "Cargar proyecto", + "new": "Nuevo proyecto" }, "export": { "videoButton": "Exportar video", diff --git a/src/i18n/locales/fr/dialogs.json b/src/i18n/locales/fr/dialogs.json index dbaae385..e67e1863 100644 --- a/src/i18n/locales/fr/dialogs.json +++ b/src/i18n/locales/fr/dialogs.json @@ -51,6 +51,12 @@ "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", + "detailLoadProject": "Voulez-vous enregistrer votre projet avant d'en charger un autre ?", + "saveAndLoadProject": "Enregistrer et charger un projet", + "discardAndLoadProject": "Ignorer et charger un projet", "loadProject": "Charger un projet…", "saveProject": "Enregistrer le projet…", "saveProjectAs": "Enregistrer le projet sous…" diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 6380c6b3..bf5c8a4e 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -41,5 +41,21 @@ "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...", + "emptyState": { + "title": "Aucun projet ouvert", + "description": "Importez une vidéo pour commencer à éditer, ou chargez un projet OpenScreen existant.", + "importVideoButton": "Importer un fichier vidéo…", + "loadProjectButton": "Charger un projet…", + "supportedFormats": "Formats pris en charge : MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "ou glissez-déposez un fichier .openscreen ici", + "dropOverlay": "Déposez le fichier de projet pour l'ouvrir", + "dropErrors": { + "unsupportedFormatTitle": "Format non pris en charge", + "unsupportedFormatMessage": "Seuls les fichiers .openscreen peuvent être déposés ici. Pour importer une vidéo, utilisez plutôt le bouton \"Importer un fichier vidéo...\".", + "couldNotOpenTitle": "Impossible d'ouvrir le fichier", + "couldNotOpenMessage": "Le fichier de projet n'a pas pu être ouvert. La vidéo qu'il référence a peut-être été déplacée ou supprimée." + } + } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 66a6d74c..143a5c63 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -95,7 +95,8 @@ }, "project": { "save": "Enregistrer le projet", - "load": "Charger un projet" + "load": "Charger un projet", + "new": "Nouveau projet" }, "export": { "videoButton": "Exporter la vidéo", diff --git a/src/i18n/locales/ja-JP/dialogs.json b/src/i18n/locales/ja-JP/dialogs.json index a59cde7c..e523ce4d 100644 --- a/src/i18n/locales/ja-JP/dialogs.json +++ b/src/i18n/locales/ja-JP/dialogs.json @@ -52,6 +52,12 @@ "detail": "閉じる前にプロジェクトを保存しますか?", "saveAndClose": "保存して閉じる", "discardAndClose": "破棄して閉じる", + "detailNewProject": "新しいプロジェクトを作成する前に保存しますか?", + "saveAndNewProject": "保存して新規プロジェクト", + "discardAndNewProject": "破棄して新規プロジェクト", + "detailLoadProject": "別のプロジェクトを読み込む前にプロジェクトを保存しますか?", + "saveAndLoadProject": "保存してプロジェクトを読み込む", + "discardAndLoadProject": "破棄してプロジェクトを読み込む", "loadProject": "プロジェクトを読み込む…", "saveProject": "プロジェクトを保存…", "saveProjectAs": "プロジェクトを名前を付けて保存…" diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 051335f3..799a8269 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": "動画の準備ができていません", @@ -41,5 +42,20 @@ "permissionDenied": "録画の権限が拒否されました。画面録画を許可してください。", "cameraDisconnected": "ウェブカメラが切断されました。", "cameraNotFound": "カメラが見つかりません。" + }, + "emptyState": { + "title": "プロジェクトが開かれていません", + "description": "動画をインポートして編集を開始するか、既存の OpenScreen プロジェクトを読み込んでください。", + "importVideoButton": "動画ファイルをインポート…", + "loadProjectButton": "プロジェクトを読み込む…", + "supportedFormats": "対応フォーマット:MP4、MOV、WebM、MKV、AVI、M4V、WMV", + "dragDropHint": ".openscreen プロジェクトファイルをここにドラッグ&ドロップ", + "dropOverlay": "プロジェクトファイルをドロップして開く", + "dropErrors": { + "unsupportedFormatTitle": "非対応フォーマット", + "unsupportedFormatMessage": "ここにドロップできるのは .openscreen プロジェクトファイルのみです。動画をインポートするには「動画ファイルをインポート...」ボタンをご使用ください。", + "couldNotOpenTitle": "ファイルを開けませんでした", + "couldNotOpenMessage": "プロジェクトファイルを開けませんでした。参照している動画が移動または削除された可能性があります。" + } } } diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 1ccc3815..2a6f9cbf 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "プロジェクトを保存", - "load": "プロジェクトを読み込む" + "load": "プロジェクトを読み込む", + "new": "新規プロジェクト" }, "export": { "videoButton": "動画をエクスポート", diff --git a/src/i18n/locales/ko-KR/dialogs.json b/src/i18n/locales/ko-KR/dialogs.json index 3093cdfd..6bff11dc 100644 --- a/src/i18n/locales/ko-KR/dialogs.json +++ b/src/i18n/locales/ko-KR/dialogs.json @@ -51,6 +51,12 @@ "detail": "닫기 전에 프로젝트를 저장하시겠습니까?", "saveAndClose": "저장 후 닫기", "discardAndClose": "저장하지 않고 닫기", + "detailNewProject": "새 프로젝트를 만들기 전에 저장하시겠습니까?", + "saveAndNewProject": "저장 후 새 프로젝트", + "discardAndNewProject": "저장하지 않고 새 프로젝트", + "detailLoadProject": "다른 프로젝트를 불러오기 전에 저장하시겠습니까?", + "saveAndLoadProject": "저장 후 프로젝트 불러오기", + "discardAndLoadProject": "저장하지 않고 프로젝트 불러오기", "loadProject": "프로젝트 불러오기...", "saveProject": "프로젝트 저장...", "saveProjectAs": "다른 이름으로 프로젝트 저장..." diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ce162447..7d85e5b5 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": "비디오가 준비되지 않았습니다", @@ -41,5 +42,20 @@ "permissionDenied": "녹화 권한이 거부되었습니다. 화면 녹화를 허용해 주세요.", "cameraDisconnected": "웹캠 연결이 끊어졌습니다.", "cameraNotFound": "카메라를 찾을 수 없습니다." + }, + "emptyState": { + "title": "열린 프로젝트 없음", + "description": "동영상을 가져와 편집을 시작하거나 기존 OpenScreen 프로젝트를 불러오세요.", + "importVideoButton": "동영상 파일 가져오기…", + "loadProjectButton": "프로젝트 불러오기…", + "supportedFormats": "지원 형식: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": ".openscreen 프로젝트 파일을 여기에 드래그 앤 드롭", + "dropOverlay": "프로젝트 파일을 드롭하여 열기", + "dropErrors": { + "unsupportedFormatTitle": "지원되지 않는 형식", + "unsupportedFormatMessage": ".openscreen 프로젝트 파일만 여기에 드롭할 수 있습니다. 동영상을 가져오려면 \"동영상 파일 가져오기...\" 버튼을 사용하세요.", + "couldNotOpenTitle": "파일을 열 수 없음", + "couldNotOpenMessage": "프로젝트 파일을 열 수 없습니다. 참조된 동영상이 이동되었거나 삭제되었을 수 있습니다." + } } } diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json index 268ba57c..7f497cab 100644 --- a/src/i18n/locales/ko-KR/settings.json +++ b/src/i18n/locales/ko-KR/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "프로젝트 저장", - "load": "프로젝트 불러오기" + "load": "프로젝트 불러오기", + "new": "새 프로젝트" }, "export": { "videoButton": "비디오 내보내기", diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index 40b4113a..8d1f0aa2 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -51,6 +51,12 @@ "detail": "Хотите сохранить проект перед закрытием?", "saveAndClose": "Сохранить и закрыть", "discardAndClose": "Отменить и закрыть", + "detailNewProject": "Хотите сохранить проект перед созданием нового?", + "saveAndNewProject": "Сохранить и новый проект", + "discardAndNewProject": "Отменить и новый проект", + "detailLoadProject": "Хотите сохранить проект перед загрузкой другого?", + "saveAndLoadProject": "Сохранить и загрузить проект", + "discardAndLoadProject": "Отменить и загрузить проект", "loadProject": "Загрузить проект…", "saveProject": "Сохранить проект…", "saveProjectAs": "Сохранить проект как…" diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index c5616d2f..3e25f699 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": "Видео не готово", @@ -41,5 +42,20 @@ "cameraDisconnected": "Веб-камера отключена.", "cameraNotFound": "Камера не найдена.", "permissionDenied": "Разрешение на запись запрещено. Пожалуйста, разрешите запись экрана." + }, + "emptyState": { + "title": "Нет открытых проектов", + "description": "Импортируйте видео для начала редактирования или загрузите существующий проект OpenScreen.", + "importVideoButton": "Импортировать видеофайл…", + "loadProjectButton": "Загрузить проект…", + "supportedFormats": "Поддерживаемые форматы: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "или перетащите файл проекта .openscreen сюда", + "dropOverlay": "Перетащите файл проекта для открытия", + "dropErrors": { + "unsupportedFormatTitle": "Неподдерживаемый формат", + "unsupportedFormatMessage": "Сюда можно перетаскивать только файлы проекта .openscreen. Для импорта видео используйте кнопку «Импортировать видеофайл...».", + "couldNotOpenTitle": "Не удалось открыть файл", + "couldNotOpenMessage": "Не удалось открыть файл проекта. Видео, на которое он ссылается, возможно, было перемещено или удалено." + } } } diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index dc15c3f5..4f185e05 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -89,7 +89,8 @@ }, "project": { "save": "Сохранить проект", - "load": "Загрузить проект" + "load": "Загрузить проект", + "new": "Новый проект" }, "export": { "videoButton": "Экспорт видео", diff --git a/src/i18n/locales/tr/dialogs.json b/src/i18n/locales/tr/dialogs.json index 9fab50df..1b62e707 100644 --- a/src/i18n/locales/tr/dialogs.json +++ b/src/i18n/locales/tr/dialogs.json @@ -51,6 +51,12 @@ "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", + "detailLoadProject": "Başka bir proje yüklemeden önce kaydetmek ister misiniz?", + "saveAndLoadProject": "Kaydet ve Proje Yükle", + "discardAndLoadProject": "Kaydetmeden Proje Yükle", "loadProject": "Proje Yükle…", "saveProject": "Proje Kaydet…", "saveProjectAs": "Farklı Kaydet…" diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 0aece8ae..f544c6dc 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -36,10 +36,26 @@ "cameraNotFound": "Kamera bulunamadı." }, "loadingVideo": "Video yükleniyor...", + "loadingEditor": "Editör yükleniyor...", "newRecording": { "title": "Kaydediciye Dön", "description": "Mevcut oturumunuz kaydedildi.", "cancel": "İptal", "confirm": "Onayla" + }, + "emptyState": { + "title": "Açık proje yok", + "description": "Düzenlemeye başlamak için bir video içe aktarın veya mevcut bir OpenScreen projesi yükleyin.", + "importVideoButton": "Video Dosyası İçe Aktar…", + "loadProjectButton": "Proje Yükle…", + "supportedFormats": "Desteklenen formatlar: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "veya bir .openscreen proje dosyasını buraya sürükleyip bırakın", + "dropOverlay": "Açmak için proje dosyasını bırakın", + "dropErrors": { + "unsupportedFormatTitle": "Desteklenmeyen Format", + "unsupportedFormatMessage": "Buraya yalnızca .openscreen proje dosyaları bırakılabilir. Video içe aktarmak için \"Video Dosyası İçe Aktar...\" düğmesini kullanın.", + "couldNotOpenTitle": "Dosya Açılamadı", + "couldNotOpenMessage": "Proje dosyası açılamadı. Başvurulan video taşınmış veya silinmiş olabilir." + } } } diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index f6395582..ce837a77 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "Projeyi Kaydet", - "load": "Proje Yükle" + "load": "Proje Yükle", + "new": "Yeni Proje" }, "export": { "videoButton": "Videoyu Dışa Aktar", diff --git a/src/i18n/locales/vi/dialogs.json b/src/i18n/locales/vi/dialogs.json index c94dbaa1..b7216ad1 100644 --- a/src/i18n/locales/vi/dialogs.json +++ b/src/i18n/locales/vi/dialogs.json @@ -51,6 +51,12 @@ "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", + "detailLoadProject": "Bạn có muốn lưu dự án của mình trước khi tải dự án khác không?", + "saveAndLoadProject": "Lưu & Tải dự án", + "discardAndLoadProject": "Bỏ qua & Tải dự án", "loadProject": "Tải dự án…", "saveProject": "Lưu dự án…", "saveProjectAs": "Lưu dự án thành…" diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index a45bf3e2..5045ccc8 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", @@ -41,5 +42,20 @@ "cameraDisconnected": "Webcam bị ngắt kết nối.", "cameraNotFound": "Không tìm thấy máy ảnh.", "permissionDenied": "Quyền ghi hình bị từ chối. Vui lòng cho phép ghi màn hình." + }, + "emptyState": { + "title": "Không có dự án nào được mở", + "description": "Nhập video để bắt đầu chỉnh sửa hoặc tải một dự án OpenScreen hiện có.", + "importVideoButton": "Nhập tệp video…", + "loadProjectButton": "Tải dự án…", + "supportedFormats": "Định dạng được hỗ trợ: MP4, MOV, WebM, MKV, AVI, M4V, WMV", + "dragDropHint": "hoặc kéo và thả tệp dự án .openscreen vào đây", + "dropOverlay": "Thả tệp dự án để mở", + "dropErrors": { + "unsupportedFormatTitle": "Định dạng không được hỗ trợ", + "unsupportedFormatMessage": "Chỉ có thể thả các tệp dự án .openscreen vào đây. Để nhập video, hãy sử dụng nút \"Nhập tệp video...\" thay thế.", + "couldNotOpenTitle": "Không thể mở tệp", + "couldNotOpenMessage": "Không thể mở tệp dự án. Video mà nó tham chiếu có thể đã bị di chuyển hoặc xóa." + } } } diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json index e6a897d8..38c78939 100644 --- a/src/i18n/locales/vi/settings.json +++ b/src/i18n/locales/vi/settings.json @@ -78,7 +78,8 @@ }, "project": { "save": "Lưu dự án", - "load": "Tải dự án" + "load": "Tải dự án", + "new": "Dự án mới" }, "export": { "videoButton": "Xuất Video", diff --git a/src/i18n/locales/zh-CN/dialogs.json b/src/i18n/locales/zh-CN/dialogs.json index 0385b36f..246ea485 100644 --- a/src/i18n/locales/zh-CN/dialogs.json +++ b/src/i18n/locales/zh-CN/dialogs.json @@ -51,6 +51,12 @@ "detail": "是否在关闭前保存项目?", "saveAndClose": "保存并关闭", "discardAndClose": "放弃并关闭", + "detailNewProject": "是否在创建新项目前保存当前项目?", + "saveAndNewProject": "保存并新建项目", + "discardAndNewProject": "放弃并新建项目", + "detailLoadProject": "是否在加载其他项目前保存当前项目?", + "saveAndLoadProject": "保存并加载项目", + "discardAndLoadProject": "放弃并加载项目", "loadProject": "加载项目…", "saveProject": "保存项目…", "saveProjectAs": "项目另存为…" diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index f6c02d4c..fda75250 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": "视频未就绪", @@ -41,5 +42,20 @@ "cameraDisconnected": "摄像头已断开连接。", "cameraNotFound": "未找到摄像头。", "permissionDenied": "录屏权限被拒绝。请允许屏幕录制。" + }, + "emptyState": { + "title": "未打开任何项目", + "description": "导入视频开始编辑,或加载已有的 OpenScreen 项目。", + "importVideoButton": "导入视频文件…", + "loadProjectButton": "加载项目…", + "supportedFormats": "支持的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV", + "dragDropHint": "或将 .openscreen 项目文件拖放到此处", + "dropOverlay": "将项目文件拖放至此以打开", + "dropErrors": { + "unsupportedFormatTitle": "不支持的格式", + "unsupportedFormatMessage": "此处只能拖放 .openscreen 项目文件。要导入视频,请使用\"导入视频文件...\"按钮。", + "couldNotOpenTitle": "无法打开文件", + "couldNotOpenMessage": "无法打开项目文件。它引用的视频可能已被移动或删除。" + } } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ff157dcd..93dd3fcd 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -88,7 +88,8 @@ }, "project": { "save": "保存项目", - "load": "加载项目" + "load": "加载项目", + "new": "新建项目" }, "export": { "videoButton": "导出视频", diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index b582aba8..f5ba7f9a 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -51,6 +51,12 @@ "detail": "是否在關閉前儲存專案?", "saveAndClose": "儲存並關閉", "discardAndClose": "捨棄並關閉", + "detailNewProject": "是否在建立新專案前儲存目前的專案?", + "saveAndNewProject": "儲存並建立新專案", + "discardAndNewProject": "捨棄並建立新專案", + "detailLoadProject": "是否在載入其他專案前儲存目前的專案?", + "saveAndLoadProject": "儲存並載入專案", + "discardAndLoadProject": "捨棄並載入專案", "loadProject": "載入專案…", "saveProject": "儲存專案…", "saveProjectAs": "專案另存新檔…" diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 21f3ba6f..445c304a 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": "影片未就緒", @@ -41,5 +42,20 @@ "permissionDenied": "錄影權限被拒絕。請允許螢幕錄製。", "cameraDisconnected": "網路攝影機已中斷連線。", "cameraNotFound": "找不到攝影機。" + }, + "emptyState": { + "title": "未開啟任何專案", + "description": "匯入影片以開始編輯,或載入現有的 OpenScreen 專案。", + "importVideoButton": "匯入影片檔案…", + "loadProjectButton": "載入專案…", + "supportedFormats": "支援的格式:MP4、MOV、WebM、MKV、AVI、M4V、WMV", + "dragDropHint": "或將 .openscreen 專案檔案拖放至此", + "dropOverlay": "將專案檔案拖放至此以開啟", + "dropErrors": { + "unsupportedFormatTitle": "不支援的格式", + "unsupportedFormatMessage": "此處只能拖放 .openscreen 專案檔案。要匯入影片,請使用「匯入影片檔案...」按鈕。", + "couldNotOpenTitle": "無法開啟檔案", + "couldNotOpenMessage": "無法開啟專案檔案。它所參照的影片可能已被移動或刪除。" + } } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 50ca00cb..82dcb842 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -95,7 +95,8 @@ }, "project": { "save": "儲存專案", - "load": "載入專案" + "load": "載入專案", + "new": "新增專案" }, "export": { "videoButton": "匯出影片", diff --git a/src/native/client.ts b/src/native/client.ts index 3f53ce48..9ff60d35 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 6836095a..77afa6f4 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";