diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index f859957d..cf5c2e88 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,116 +1,220 @@ -/// - -declare namespace NodeJS { - interface ProcessEnv { - /** - * The built directory structure - * - * ```tree - * ├─┬─┬ dist - * │ │ └── index.html - * │ │ - * │ ├─┬ dist-electron - * │ │ ├── main.js - * │ │ └── preload.js - * │ - * ``` - */ - APP_ROOT: string - /** /dist/ or /public/ */ - VITE_PUBLIC: string - } -} - -// Used in Renderer process, expose in `preload.ts` -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise - switchToEditor: () => Promise - openSourceSelector: () => Promise - selectSource: (source: any) => Promise - getSelectedSource: () => Promise - startNativeScreenRecording: ( - source: any, - options?: { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string - } - ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - stopNativeScreenRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - startFfmpegRecording: (source: any) => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - stopFfmpegRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> - readLocalFile: (filePath: string) => Promise<{ success: boolean; data?: Uint8Array; error?: string }> - setRecordingState: (recording: boolean) => Promise - getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }> - getSystemCursorAssets: () => Promise<{ success: boolean; cursors: Record; error?: string }> - onStopRecordingFromTray: (callback: () => void) => () => void - onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => () => void - onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => () => void - onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => () => void - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - getAccessibilityPermissionStatus: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - requestAccessibilityPermission: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - getScreenRecordingPermissionStatus: () => Promise<{ success: boolean; status: string; error?: string }> - openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }> - openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean; error?: string }> - loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }> - loadCurrentProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }> - onMenuLoadProject: (callback: () => void) => () => void - onMenuSaveProject: (callback: () => void) => () => void - onMenuSaveProjectAs: (callback: () => void) => () => void - getPlatform: () => Promise - revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>, - openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>, - getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }> - chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }> - getShortcuts: () => Promise | null> - saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }> - hudOverlayHide: () => void; - hudOverlayClose: () => void; - setHasUnsavedChanges: (hasChanges: boolean) => void - onRequestSaveBeforeClose: (callback: () => Promise) => () => void - isWgcAvailable: () => Promise<{ available: boolean }> - muxWgcRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }> - /** Hide the OS cursor before browser capture starts. */ - hideOsCursor: () => Promise<{ success: boolean }> - } -} - -interface ProcessedDesktopSource { - id: string - name: string - display_id: string - thumbnail: string | null - appIcon: string | null - originalName?: string - sourceType?: 'screen' | 'window' - appName?: string - windowTitle?: string -} - -interface CursorTelemetryPoint { - timeMs: number - cx: number - cy: number - interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup' - cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed' -} - -interface SystemCursorAsset { - dataUrl: string - hotspotX: number - hotspotY: number - width: number - height: number -} - +/// + +declare namespace NodeJS { + interface ProcessEnv { + /** + * The built directory structure + * + * ```tree + * ├─┬─┬ dist + * │ │ └── index.html + * │ │ + * │ ├─┬ dist-electron + * │ │ ├── main.js + * │ │ └── preload.js + * │ + * ``` + */ + APP_ROOT: string; + /** /dist/ or /public/ */ + VITE_PUBLIC: string; + } +} + +// Used in Renderer process, expose in `preload.ts` +interface Window { + electronAPI: { + getSources: (opts: Electron.SourcesOptions) => Promise; + switchToEditor: () => Promise; + openSourceSelector: () => Promise; + selectSource: (source: any) => Promise; + getSelectedSource: () => Promise; + startNativeScreenRecording: ( + source: any, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; + stopNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + startFfmpegRecording: ( + source: any, + ) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>; + stopFfmpegRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + storeRecordedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string }>; + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>; + readLocalFile: ( + filePath: string, + ) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>; + setRecordingState: (recording: boolean) => Promise; + getCursorTelemetry: (videoPath?: string) => Promise<{ + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; + }>; + getSystemCursorAssets: () => Promise<{ + success: boolean; + cursors: Record; + error?: string; + }>; + onStopRecordingFromTray: (callback: () => void) => () => void; + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => () => void; + onRecordingInterrupted: ( + callback: (state: { reason: string; message: string }) => void, + ) => () => void; + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => () => void; + openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; + getAccessibilityPermissionStatus: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + requestAccessibilityPermission: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + getScreenRecordingPermissionStatus: () => Promise<{ + success: boolean; + status: string; + error?: string; + }>; + openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>; + openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>; + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>; + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + clearCurrentVideoPath: () => Promise<{ success: boolean }>; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadCurrentProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + onMenuLoadProject: (callback: () => void) => () => void; + onMenuSaveProject: (callback: () => void) => () => void; + onMenuSaveProjectAs: (callback: () => void) => () => void; + getPlatform: () => Promise; + revealInFolder: ( + filePath: string, + ) => Promise<{ success: boolean; error?: string; message?: string }>; + openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>; + getRecordingsDirectory: () => Promise<{ + success: boolean; + path: string; + isDefault: boolean; + error?: string; + }>; + chooseRecordingsDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + path?: string; + isDefault?: boolean; + message?: string; + error?: string; + }>; + getShortcuts: () => Promise | null>; + saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; + hudOverlayHide: () => void; + hudOverlayClose: () => void; + getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; + setHudOverlayCaptureProtection: ( + enabled: boolean, + ) => Promise<{ success: boolean; enabled: boolean }>; + setHasUnsavedChanges: (hasChanges: boolean) => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + isWgcAvailable: () => Promise<{ available: boolean }>; + muxWgcRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + /** Hide the OS cursor before browser capture starts. */ + hideOsCursor: () => Promise<{ success: boolean }>; + }; +} + +interface ProcessedDesktopSource { + id: string; + name: string; + display_id: string; + thumbnail: string | null; + appIcon: string | null; + originalName?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; +} + +interface SystemCursorAsset { + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +} diff --git a/electron/preload.ts b/electron/preload.ts index ebc4fed1..d149dc91 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,186 +1,204 @@ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer } from "electron"; -contextBridge.exposeInMainWorld('electronAPI', { - hudOverlayHide: () => { - ipcRenderer.send('hud-overlay-hide'); - }, - hudOverlayClose: () => { - ipcRenderer.send('hud-overlay-close'); - }, - getAssetBasePath: async () => { - // ask main process for the correct base path (production vs dev) - return await ipcRenderer.invoke('get-asset-base-path') - }, - readLocalFile: (filePath: string) => { - return ipcRenderer.invoke('read-local-file', filePath) - }, - getSources: async (opts: Electron.SourcesOptions) => { - return await ipcRenderer.invoke('get-sources', opts) - }, - switchToEditor: () => { - return ipcRenderer.invoke('switch-to-editor') - }, - openSourceSelector: () => { - return ipcRenderer.invoke('open-source-selector') - }, - selectSource: (source: any) => { - return ipcRenderer.invoke('select-source', source) - }, - getSelectedSource: () => { - return ipcRenderer.invoke('get-selected-source') - }, - startNativeScreenRecording: ( - source: any, - options?: { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string - }, - ) => { - return ipcRenderer.invoke('start-native-screen-recording', source, options) - }, - stopNativeScreenRecording: () => { - return ipcRenderer.invoke('stop-native-screen-recording') - }, - startFfmpegRecording: (source: any) => { - return ipcRenderer.invoke('start-ffmpeg-recording', source) - }, - stopFfmpegRecording: () => { - return ipcRenderer.invoke('stop-ffmpeg-recording') - }, +contextBridge.exposeInMainWorld("electronAPI", { + hudOverlayHide: () => { + ipcRenderer.send("hud-overlay-hide"); + }, + hudOverlayClose: () => { + ipcRenderer.send("hud-overlay-close"); + }, + getHudOverlayCaptureProtection: () => { + return ipcRenderer.invoke("get-hud-overlay-capture-protection"); + }, + setHudOverlayCaptureProtection: (enabled: boolean) => { + return ipcRenderer.invoke("set-hud-overlay-capture-protection", enabled); + }, + getAssetBasePath: async () => { + // ask main process for the correct base path (production vs dev) + return await ipcRenderer.invoke("get-asset-base-path"); + }, + readLocalFile: (filePath: string) => { + return ipcRenderer.invoke("read-local-file", filePath); + }, + getSources: async (opts: Electron.SourcesOptions) => { + return await ipcRenderer.invoke("get-sources", opts); + }, + switchToEditor: () => { + return ipcRenderer.invoke("switch-to-editor"); + }, + openSourceSelector: () => { + return ipcRenderer.invoke("open-source-selector"); + }, + selectSource: (source: any) => { + return ipcRenderer.invoke("select-source", source); + }, + getSelectedSource: () => { + return ipcRenderer.invoke("get-selected-source"); + }, + startNativeScreenRecording: ( + source: any, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => { + return ipcRenderer.invoke("start-native-screen-recording", source, options); + }, + stopNativeScreenRecording: () => { + return ipcRenderer.invoke("stop-native-screen-recording"); + }, + startFfmpegRecording: (source: any) => { + return ipcRenderer.invoke("start-ffmpeg-recording", source); + }, + stopFfmpegRecording: () => { + return ipcRenderer.invoke("stop-ffmpeg-recording"); + }, - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke('store-recorded-video', videoData, fileName) - }, - - getRecordedVideoPath: () => { - return ipcRenderer.invoke('get-recorded-video-path') - }, - setRecordingState: (recording: boolean) => { - return ipcRenderer.invoke('set-recording-state', recording) - }, - setCursorScale: (scale: number) => { - return ipcRenderer.invoke('set-cursor-scale', scale) - }, - getCursorTelemetry: (videoPath?: string) => { - return ipcRenderer.invoke('get-cursor-telemetry', videoPath) - }, - getSystemCursorAssets: () => { - return ipcRenderer.invoke('get-system-cursor-assets') - }, - onStopRecordingFromTray: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('stop-recording-from-tray', listener) - return () => ipcRenderer.removeListener('stop-recording-from-tray', listener) - }, - onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { recording: boolean; sourceName: string }) => callback(payload) - ipcRenderer.on('recording-state-changed', listener) - return () => ipcRenderer.removeListener('recording-state-changed', listener) - }, - onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { reason: string; message: string }) => callback(payload) - ipcRenderer.on('recording-interrupted', listener) - return () => ipcRenderer.removeListener('recording-interrupted', listener) - }, - onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => { - const listener = (_event: Electron.IpcRendererEvent, payload: { cursorType: CursorTelemetryPoint['cursorType'] }) => callback(payload) - ipcRenderer.on('cursor-state-changed', listener) - return () => ipcRenderer.removeListener('cursor-state-changed', listener) - }, - openExternalUrl: (url: string) => { - return ipcRenderer.invoke('open-external-url', url) - }, - getAccessibilityPermissionStatus: () => { - return ipcRenderer.invoke('get-accessibility-permission-status') - }, - requestAccessibilityPermission: () => { - return ipcRenderer.invoke('request-accessibility-permission') - }, - getScreenRecordingPermissionStatus: () => { - return ipcRenderer.invoke('get-screen-recording-permission-status') - }, - openScreenRecordingPreferences: () => { - return ipcRenderer.invoke('open-screen-recording-preferences') - }, - openAccessibilityPreferences: () => { - return ipcRenderer.invoke('open-accessibility-preferences') - }, - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { - return ipcRenderer.invoke('save-exported-video', videoData, fileName) - }, - openVideoFilePicker: () => { - return ipcRenderer.invoke('open-video-file-picker') - }, - setCurrentVideoPath: (path: string) => { - return ipcRenderer.invoke('set-current-video-path', path) - }, - getCurrentVideoPath: () => { - return ipcRenderer.invoke('get-current-video-path') - }, - clearCurrentVideoPath: () => { - return ipcRenderer.invoke('clear-current-video-path') - }, - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { - return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath) - }, - loadProjectFile: () => { - return ipcRenderer.invoke('load-project-file') - }, - loadCurrentProjectFile: () => { - return ipcRenderer.invoke('load-current-project-file') - }, - onMenuLoadProject: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('menu-load-project', listener) - return () => ipcRenderer.removeListener('menu-load-project', listener) - }, - onMenuSaveProject: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('menu-save-project', listener) - return () => ipcRenderer.removeListener('menu-save-project', listener) - }, - onMenuSaveProjectAs: (callback: () => void) => { - const listener = () => callback() - ipcRenderer.on('menu-save-project-as', listener) - return () => ipcRenderer.removeListener('menu-save-project-as', listener) - }, - getPlatform: () => { - return ipcRenderer.invoke('get-platform') - }, - revealInFolder: (filePath: string) => { - return ipcRenderer.invoke('reveal-in-folder', filePath) - }, - openRecordingsFolder: () => { - return ipcRenderer.invoke('open-recordings-folder') - }, - getRecordingsDirectory: () => { - return ipcRenderer.invoke('get-recordings-directory') - }, - chooseRecordingsDirectory: () => { - return ipcRenderer.invoke('choose-recordings-directory') - }, - getShortcuts: () => { - return ipcRenderer.invoke('get-shortcuts') - }, - saveShortcuts: (shortcuts: unknown) => { - return ipcRenderer.invoke('save-shortcuts', shortcuts) - }, - setHasUnsavedChanges: (hasChanges: boolean) => { - ipcRenderer.send('set-has-unsaved-changes', hasChanges) - }, - onRequestSaveBeforeClose: (callback: () => Promise) => { - const listener = async () => { - await callback() - ipcRenderer.send('save-before-close-done') - } - ipcRenderer.on('request-save-before-close', listener) - return () => ipcRenderer.removeListener('request-save-before-close', listener) - }, - isWgcAvailable: () => ipcRenderer.invoke('is-wgc-available'), - muxWgcRecording: () => ipcRenderer.invoke('mux-wgc-recording'), - // Cursor visibility control for cursor-free browser capture fallback - hideOsCursor: () => ipcRenderer.invoke('hide-cursor'), -}) + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke("store-recorded-video", videoData, fileName); + }, + getRecordedVideoPath: () => { + return ipcRenderer.invoke("get-recorded-video-path"); + }, + setRecordingState: (recording: boolean) => { + return ipcRenderer.invoke("set-recording-state", recording); + }, + setCursorScale: (scale: number) => { + return ipcRenderer.invoke("set-cursor-scale", scale); + }, + getCursorTelemetry: (videoPath?: string) => { + return ipcRenderer.invoke("get-cursor-telemetry", videoPath); + }, + getSystemCursorAssets: () => { + return ipcRenderer.invoke("get-system-cursor-assets"); + }, + onStopRecordingFromTray: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("stop-recording-from-tray", listener); + return () => ipcRenderer.removeListener("stop-recording-from-tray", listener); + }, + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { recording: boolean; sourceName: string }, + ) => callback(payload); + ipcRenderer.on("recording-state-changed", listener); + return () => ipcRenderer.removeListener("recording-state-changed", listener); + }, + onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { reason: string; message: string }, + ) => callback(payload); + ipcRenderer.on("recording-interrupted", listener); + return () => ipcRenderer.removeListener("recording-interrupted", listener); + }, + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + payload: { cursorType: CursorTelemetryPoint["cursorType"] }, + ) => callback(payload); + ipcRenderer.on("cursor-state-changed", listener); + return () => ipcRenderer.removeListener("cursor-state-changed", listener); + }, + openExternalUrl: (url: string) => { + return ipcRenderer.invoke("open-external-url", url); + }, + getAccessibilityPermissionStatus: () => { + return ipcRenderer.invoke("get-accessibility-permission-status"); + }, + requestAccessibilityPermission: () => { + return ipcRenderer.invoke("request-accessibility-permission"); + }, + getScreenRecordingPermissionStatus: () => { + return ipcRenderer.invoke("get-screen-recording-permission-status"); + }, + openScreenRecordingPreferences: () => { + return ipcRenderer.invoke("open-screen-recording-preferences"); + }, + openAccessibilityPreferences: () => { + return ipcRenderer.invoke("open-accessibility-preferences"); + }, + saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke("save-exported-video", videoData, fileName); + }, + openVideoFilePicker: () => { + return ipcRenderer.invoke("open-video-file-picker"); + }, + setCurrentVideoPath: (path: string) => { + return ipcRenderer.invoke("set-current-video-path", path); + }, + getCurrentVideoPath: () => { + return ipcRenderer.invoke("get-current-video-path"); + }, + clearCurrentVideoPath: () => { + return ipcRenderer.invoke("clear-current-video-path"); + }, + saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { + return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath); + }, + loadProjectFile: () => { + return ipcRenderer.invoke("load-project-file"); + }, + loadCurrentProjectFile: () => { + return ipcRenderer.invoke("load-current-project-file"); + }, + onMenuLoadProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-load-project", listener); + return () => ipcRenderer.removeListener("menu-load-project", listener); + }, + onMenuSaveProject: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project", listener); + return () => ipcRenderer.removeListener("menu-save-project", listener); + }, + onMenuSaveProjectAs: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("menu-save-project-as", listener); + return () => ipcRenderer.removeListener("menu-save-project-as", listener); + }, + getPlatform: () => { + return ipcRenderer.invoke("get-platform"); + }, + revealInFolder: (filePath: string) => { + return ipcRenderer.invoke("reveal-in-folder", filePath); + }, + openRecordingsFolder: () => { + return ipcRenderer.invoke("open-recordings-folder"); + }, + getRecordingsDirectory: () => { + return ipcRenderer.invoke("get-recordings-directory"); + }, + chooseRecordingsDirectory: () => { + return ipcRenderer.invoke("choose-recordings-directory"); + }, + getShortcuts: () => { + return ipcRenderer.invoke("get-shortcuts"); + }, + saveShortcuts: (shortcuts: unknown) => { + return ipcRenderer.invoke("save-shortcuts", shortcuts); + }, + setHasUnsavedChanges: (hasChanges: boolean) => { + ipcRenderer.send("set-has-unsaved-changes", hasChanges); + }, + onRequestSaveBeforeClose: (callback: () => Promise) => { + const listener = async () => { + await callback(); + ipcRenderer.send("save-before-close-done"); + }; + ipcRenderer.on("request-save-before-close", listener); + return () => ipcRenderer.removeListener("request-save-before-close", listener); + }, + isWgcAvailable: () => ipcRenderer.invoke("is-wgc-available"), + muxWgcRecording: () => ipcRenderer.invoke("mux-wgc-recording"), + // Cursor visibility control for cursor-free browser capture fallback + hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), +}); diff --git a/electron/windows.ts b/electron/windows.ts index d2f7466d..2420743e 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -1,168 +1,191 @@ -import { BrowserWindow, ipcMain } from 'electron' -import { createRequire } from 'node:module' -import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { BrowserWindow, ipcMain } from "electron"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const nodeRequire = createRequire(import.meta.url) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nodeRequire = createRequire(import.meta.url); -const APP_ROOT = path.join(__dirname, '..') -const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] -const RENDERER_DIST = path.join(APP_ROOT, 'dist') -const WINDOW_ICON_PATH = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'app-icons', 'recordly-512.png') +const APP_ROOT = path.join(__dirname, ".."); +const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; +const RENDERER_DIST = path.join(APP_ROOT, "dist"); +const WINDOW_ICON_PATH = path.join( + process.env.VITE_PUBLIC || RENDERER_DIST, + "app-icons", + "recordly-512.png", +); let hudOverlayWindow: BrowserWindow | null = null; +let hudOverlayHiddenFromCapture = false; function getScreen() { - return nodeRequire('electron').screen as typeof import('electron').screen + return nodeRequire("electron").screen as typeof import("electron").screen; } -ipcMain.on('hud-overlay-hide', () => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); - } +ipcMain.on("hud-overlay-hide", () => { + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.minimize(); + } +}); + +ipcMain.handle("get-hud-overlay-capture-protection", () => { + return { + success: true, + enabled: hudOverlayHiddenFromCapture, + }; +}); + +ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) => { + hudOverlayHiddenFromCapture = Boolean(enabled); + + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture); + } + + return { + success: true, + enabled: hudOverlayHiddenFromCapture, + }; }); export function createHudOverlayWindow(): BrowserWindow { - const primaryDisplay = getScreen().getPrimaryDisplay(); - const { workArea } = primaryDisplay; + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { workArea } = primaryDisplay; + const windowWidth = 660; + const windowHeight = 170; - const windowWidth = 600; - const windowHeight = 155; + const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); + const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); - const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); + const win = new BrowserWindow({ + width: windowWidth, + height: windowHeight, + minWidth: windowWidth, + maxWidth: windowWidth, + minHeight: windowHeight, + maxHeight: windowHeight, + x: x, + y: y, + frame: false, + transparent: true, + resizable: false, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + backgroundThrottling: false, + }, + }); - const win = new BrowserWindow({ - width: windowWidth, - height: windowHeight, - minWidth: 600, - maxWidth: 600, - minHeight: 155, - maxHeight: 155, - x: x, - y: y, - frame: false, - transparent: true, - resizable: false, - alwaysOnTop: true, - skipTaskbar: true, - hasShadow: false, - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - backgroundThrottling: false, - }, - }) + win.setContentProtection(hudOverlayHiddenFromCapture); + win.webContents.on("did-finish-load", () => { + win?.webContents.send("main-process-message", new Date().toLocaleString()); + }); - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) + hudOverlayWindow = win; - hudOverlayWindow = win; + win.on("closed", () => { + if (hudOverlayWindow === win) { + hudOverlayWindow = null; + } + }); - win.on('closed', () => { - if (hudOverlayWindow === win) { - hudOverlayWindow = null; - } - }); + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "hud-overlay" }, + }); + } - - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'hud-overlay' } - }) - } - - return win + return win; } export function createEditorWindow(): BrowserWindow { - const isMac = process.platform === 'darwin'; + const isMac = process.platform === "darwin"; - const win = new BrowserWindow({ - width: 1200, - height: 800, - minWidth: 800, - minHeight: 600, - ...(process.platform !== 'darwin' && { - icon: WINDOW_ICON_PATH, - }), - ...(isMac && { - titleBarStyle: 'hiddenInset', - trafficLightPosition: { x: 12, y: 12 }, - }), - transparent: false, - resizable: true, - alwaysOnTop: false, - skipTaskbar: false, - title: 'Recordly', - backgroundColor: '#000000', - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - webSecurity: false, - backgroundThrottling: false, - }, - }) + const win = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + ...(isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 }, + }), + transparent: false, + resizable: true, + alwaysOnTop: false, + skipTaskbar: false, + title: "Recordly", + backgroundColor: "#000000", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + webSecurity: false, + backgroundThrottling: false, + }, + }); - // Maximize the window by default - win.maximize(); + // Maximize the window by default + win.maximize(); - win.webContents.on('did-finish-load', () => { - win?.webContents.send('main-process-message', (new Date).toLocaleString()) - }) + win.webContents.on("did-finish-load", () => { + win?.webContents.send("main-process-message", new Date().toLocaleString()); + }); - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'editor' } - }) - } + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=editor"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "editor" }, + }); + } - return win + return win; } export function createSourceSelectorWindow(): BrowserWindow { - const { width, height } = getScreen().getPrimaryDisplay().workAreaSize - - const win = new BrowserWindow({ - width: 620, - height: 420, - minHeight: 350, - maxHeight: 500, - x: Math.round((width - 620) / 2), - y: Math.round((height - 420) / 2), - frame: false, - resizable: false, - alwaysOnTop: true, - transparent: true, - ...(process.platform !== 'darwin' && { - icon: WINDOW_ICON_PATH, - }), - backgroundColor: '#00000000', - webPreferences: { - preload: path.join(__dirname, 'preload.mjs'), - nodeIntegration: false, - contextIsolation: true, - }, - }) + const { width, height } = getScreen().getPrimaryDisplay().workAreaSize; - if (VITE_DEV_SERVER_URL) { - win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector') - } else { - win.loadFile(path.join(RENDERER_DIST, 'index.html'), { - query: { windowType: 'source-selector' } - }) - } + const win = new BrowserWindow({ + width: 620, + height: 420, + minHeight: 350, + maxHeight: 500, + x: Math.round((width - 620) / 2), + y: Math.round((height - 420) / 2), + frame: false, + resizable: false, + alwaysOnTop: true, + transparent: true, + ...(process.platform !== "darwin" && { + icon: WINDOW_ICON_PATH, + }), + backgroundColor: "#00000000", + webPreferences: { + preload: path.join(__dirname, "preload.mjs"), + nodeIntegration: false, + contextIsolation: true, + }, + }); - return win + if (VITE_DEV_SERVER_URL) { + win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector"); + } else { + win.loadFile(path.join(RENDERER_DIST, "index.html"), { + query: { windowType: "source-selector" }, + }); + } + + return win; } - diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 20d121b0..f4f69e38 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,355 +1,441 @@ +import { Eye, EyeOff, Languages } from "lucide-react"; import { useEffect, useState } from "react"; import { BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md"; -import { Languages } from "lucide-react"; import { RxDragHandleDots2 } from "react-icons/rx"; +import { useI18n } from "@/contexts/I18nContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { useScopedT } from "../../contexts/I18nContext"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; -import { useScopedT } from "../../contexts/I18nContext"; -import { Button } from "../ui/button"; import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { Button } from "../ui/button"; import { ContentClamp } from "../ui/content-clamp"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import { useI18n } from "@/contexts/I18nContext"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import type { AppLocale } from "@/i18n/config"; import styles from "./LaunchWindow.module.css"; export function LaunchWindow() { - const { locale, setLocale } = useI18n(); - const t = useScopedT('launch'); + const { locale, setLocale } = useI18n(); + const t = useScopedT("launch"); - const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; - const { - recording, - toggleRecording, - microphoneEnabled, - setMicrophoneEnabled, - microphoneDeviceId, - setMicrophoneDeviceId, - systemAudioEnabled, - setSystemAudioEnabled, - } = useScreenRecorder(); - const [recordingStart, setRecordingStart] = useState(null); - const [elapsed, setElapsed] = useState(0); - const showMicControls = microphoneEnabled && !recording; - const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled); - const { level } = useAudioLevelMeter({ - enabled: showMicControls, - deviceId: microphoneDeviceId, - }); + const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; + const { + recording, + toggleRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + } = useScreenRecorder(); + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); + const showMicControls = microphoneEnabled && !recording; + const { devices, selectedDeviceId, setSelectedDeviceId } = + useMicrophoneDevices(microphoneEnabled); + const { level } = useAudioLevelMeter({ + enabled: showMicControls, + deviceId: microphoneDeviceId, + }); - useEffect(() => { - if (selectedDeviceId && selectedDeviceId !== "default") { - setMicrophoneDeviceId(selectedDeviceId); - } - }, [selectedDeviceId, setMicrophoneDeviceId]); + useEffect(() => { + if (selectedDeviceId && selectedDeviceId !== "default") { + setMicrophoneDeviceId(selectedDeviceId); + } + }, [selectedDeviceId, setMicrophoneDeviceId]); - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - if (recording) { - if (!recordingStart) setRecordingStart(Date.now()); - timer = setInterval(() => { - if (recordingStart) { - setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); - } - }, 1000); - } else { - setRecordingStart(null); - setElapsed(0); - if (timer) clearInterval(timer); - } - return () => { - if (timer) clearInterval(timer); - }; - }, [recording, recordingStart]); + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) setRecordingStart(Date.now()); + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart) / 1000)); + } + }, 1000); + } else { + setRecordingStart(null); + setElapsed(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart]); - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60).toString().padStart(2, "0"); - const s = (seconds % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }; - const [selectedSource, setSelectedSource] = useState("Screen"); - const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [hideHudFromCapture, setHideHudFromCapture] = useState(false); - useEffect(() => { - const checkSelectedSource = async () => { - if (window.electronAPI) { - const source = await window.electronAPI.getSelectedSource(); - if (source) { - setSelectedSource(source.name); - setHasSelectedSource(true); - } else { - setSelectedSource("Screen"); - setHasSelectedSource(false); - } - } - }; + useEffect(() => { + const checkSelectedSource = async () => { + if (window.electronAPI) { + const source = await window.electronAPI.getSelectedSource(); + if (source) { + setSelectedSource(source.name); + setHasSelectedSource(true); + } else { + setSelectedSource("Screen"); + setHasSelectedSource(false); + } + } + }; - void checkSelectedSource(); - const interval = setInterval(checkSelectedSource, 500); - return () => clearInterval(interval); - }, []); + void checkSelectedSource(); + const interval = setInterval(checkSelectedSource, 500); + return () => clearInterval(interval); + }, []); - const openSourceSelector = () => { - window.electronAPI?.openSourceSelector(); - }; + useEffect(() => { + let cancelled = false; - const openVideoFile = async () => { - const result = await window.electronAPI.openVideoFilePicker(); - if (result.canceled) { - return; - } + const loadHudCaptureProtection = async () => { + try { + const result = await window.electronAPI.getHudOverlayCaptureProtection(); + if (!cancelled && result.success) { + setHideHudFromCapture(result.enabled); + } + } catch (error) { + console.error("Failed to load HUD capture protection state:", error); + } + }; - if (result.success && result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); - await window.electronAPI.switchToEditor(); - } - }; + void loadHudCaptureProtection(); - const openProjectFile = async () => { - const result = await window.electronAPI.loadProjectFile(); - if (result.canceled || !result.success) { - return; - } - await window.electronAPI.switchToEditor(); - }; + return () => { + cancelled = true; + }; + }, []); - const sendHudOverlayHide = () => { - window.electronAPI?.hudOverlayHide?.(); - }; + const openSourceSelector = () => { + window.electronAPI?.openSourceSelector(); + }; - const sendHudOverlayClose = () => { - window.electronAPI?.hudOverlayClose?.(); - }; + const openVideoFile = async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled) { + return; + } - const chooseRecordingsDirectory = async () => { - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) { - return; - } - if (result.success && result.path) { - setRecordingsDirectory(result.path); - } - }; + if (result.success && result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + await window.electronAPI.switchToEditor(); + } + }; - useEffect(() => { - const loadRecordingsDirectory = async () => { - const result = await window.electronAPI.getRecordingsDirectory(); - if (result.success) { - setRecordingsDirectory(result.path); - } - }; + const openProjectFile = async () => { + const result = await window.electronAPI.loadProjectFile(); + if (result.canceled || !result.success) { + return; + } + await window.electronAPI.switchToEditor(); + }; - void loadRecordingsDirectory(); - }, []); + const sendHudOverlayHide = () => { + window.electronAPI?.hudOverlayHide?.(); + }; - const recordingsDirectoryName = recordingsDirectory - ? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory - : "recordings"; - const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35"; + const sendHudOverlayClose = () => { + window.electronAPI?.hudOverlayClose?.(); + }; - const toggleMicrophone = () => { - if (!recording) { - setMicrophoneEnabled(!microphoneEnabled); - } - }; + const toggleHudCaptureProtection = async () => { + const nextValue = !hideHudFromCapture; - return ( -
-
- {showMicControls && ( -
- - -
- )} + setHideHudFromCapture(nextValue); -
-
- -
+ try { + const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); - + if (!result.success) { + setHideHudFromCapture(!nextValue); + return; + } -
+ setHideHudFromCapture(result.enabled); + } catch (error) { + console.error("Failed to update HUD capture protection:", error); + setHideHudFromCapture(!nextValue); + } + }; -
- - -
+ const chooseRecordingsDirectory = async () => { + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) { + return; + } + if (result.success && result.path) { + setRecordingsDirectory(result.path); + } + }; -
+ useEffect(() => { + const loadRecordingsDirectory = async () => { + const result = await window.electronAPI.getRecordingsDirectory(); + if (result.success) { + setRecordingsDirectory(result.path); + } + }; - + void loadRecordingsDirectory(); + }, []); - + const recordingsDirectoryName = recordingsDirectory + ? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory + : "recordings"; + const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35"; -
-
- - - - - - - - {SUPPORTED_LOCALES.map((code) => ( - setLocale(code as AppLocale)} - className={`text-xs cursor-pointer ${ - locale === code ? "text-white font-medium" : "text-white/60" - }`} - > - {LOCALE_LABELS[code] ?? code} - - ))} - - -
- - -
-
-
-
- ); + const toggleMicrophone = () => { + if (!recording) { + setMicrophoneEnabled(!microphoneEnabled); + } + }; + + return ( +
+
+ {showMicControls && ( +
+ + +
+ )} + +
+
+ +
+ + + +
+ +
+ + + +
+ +
+ + + + + +
+
+ + + + + + + + {SUPPORTED_LOCALES.map((code) => ( + setLocale(code as AppLocale)} + className={`text-xs cursor-pointer ${ + locale === code ? "text-white font-medium" : "text-white/60" + }`} + > + {LOCALE_LABELS[code] ?? code} + + ))} + + +
+ + +
+
+
+
+ ); } - diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 131e974d..303fd9c4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,1167 +1,1098 @@ -import { cn } from "@/lib/utils"; -import { useEffect, useRef } from "react"; -import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; -import { - BUILT_IN_WALLPAPERS, - WALLPAPER_PATHS, - WALLPAPER_RELATIVE_PATHS, -} from "@/lib/wallpapers"; -import { SliderControl } from "./SliderControl"; -import { Switch } from "@/components/ui/switch"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { useState } from "react"; import Block from "@uiw/react-color-block"; import { - Trash2, - Download, - Crop, - X, - Bug, - Upload, - Star, - Film, - Image, - Sparkles, - Palette, - Save, - FolderOpen, + Bug, + Crop, + Download, + Film, + FolderOpen, + Image, + Palette, + Save, + Sparkles, + Star, + Trash2, + Upload, + X, } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; -import { useI18n, useScopedT } from "../../contexts/I18nContext"; -import type { - ZoomDepth, - CropRegion, - AnnotationRegion, - AnnotationType, - PlaybackSpeed, -} from "./types"; import { - SPEED_OPTIONS, - DEFAULT_CURSOR_SIZE, - DEFAULT_CURSOR_SMOOTHING, - DEFAULT_CURSOR_MOTION_BLUR, - DEFAULT_CURSOR_CLICK_BOUNCE, - DEFAULT_CURSOR_SWAY, - DEFAULT_ZOOM_MOTION_BLUR, -} from "./types"; + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath"; +import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; +import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; +import { cn } from "@/lib/utils"; +import { BUILT_IN_WALLPAPERS, WALLPAPER_PATHS, WALLPAPER_RELATIVE_PATHS } from "@/lib/wallpapers"; +import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { useI18n, useScopedT } from "../../contexts/I18nContext"; +import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; -import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; -import { type AspectRatio } from "@/utils/aspectRatioUtils"; +import { SliderControl } from "./SliderControl"; import type { - ExportQuality, - ExportFormat, - GifFrameRate, - GifSizePreset, -} from "@/lib/exporter"; -import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; + AnnotationRegion, + AnnotationType, + CropRegion, + PlaybackSpeed, + ZoomDepth, +} from "./types"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; + DEFAULT_CURSOR_CLICK_BOUNCE, + DEFAULT_CURSOR_MOTION_BLUR, + DEFAULT_CURSOR_SIZE, + DEFAULT_CURSOR_SMOOTHING, + DEFAULT_CURSOR_SWAY, + DEFAULT_ZOOM_MOTION_BLUR, + SPEED_OPTIONS, +} from "./types"; +import { fromCursorSwaySliderValue, toCursorSwaySliderValue } from "./videoPlayback/cursorSway"; + const GRADIENTS = [ - "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", - "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", - "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", - "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", - "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", - "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", - "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", - "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", - "linear-gradient(135deg, #FBC8B4, #2447B1)", - "linear-gradient(109.6deg, #F635A6, #36D860)", - "linear-gradient(90deg, #FF0101, #4DFF01)", - "linear-gradient(315deg, #EC0101, #5044A9)", - "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", - "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", - "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", - "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", - "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", - "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", - "linear-gradient(to right, #fa709a 0%, #fee140 100%)", - "linear-gradient(to top, #30cfd0 0%, #330867 100%)", - "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", - "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", - "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", - "linear-gradient(to right, #0acffe 0%, #495aff 100%)", + "linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )", + "linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)", + "radial-gradient( circle farthest-corner at 3.2% 49.6%, rgba(80,12,139,0.87) 0%, rgba(161,10,144,0.72) 83.6% )", + "linear-gradient( 111.6deg, rgba(0,56,68,1) 0%, rgba(163,217,185,1) 51.5%, rgba(231, 148, 6, 1) 88.6% )", + "linear-gradient( 107.7deg, rgba(235,230,44,0.55) 8.4%, rgba(252,152,15,1) 90.3% )", + "linear-gradient( 91deg, rgba(72,154,78,1) 5.2%, rgba(251,206,70,1) 95.9% )", + "radial-gradient( circle farthest-corner at 10% 20%, rgba(2,37,78,1) 0%, rgba(4,56,126,1) 19.7%, rgba(85,245,221,1) 100.2% )", + "linear-gradient( 109.6deg, rgba(15,2,2,1) 11.2%, rgba(36,163,190,1) 91.1% )", + "linear-gradient(135deg, #FBC8B4, #2447B1)", + "linear-gradient(109.6deg, #F635A6, #36D860)", + "linear-gradient(90deg, #FF0101, #4DFF01)", + "linear-gradient(315deg, #EC0101, #5044A9)", + "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%)", + "linear-gradient(to top, #a18cd1 0%, #fbc2eb 100%)", + "linear-gradient(to right, #ff8177 0%, #ff867a 0%, #ff8c7f 21%, #f99185 52%, #cf556c 78%, #b12a5b 100%)", + "linear-gradient(120deg, #84fab0 0%, #8fd3f4 100%)", + "linear-gradient(to right, #4facfe 0%, #00f2fe 100%)", + "linear-gradient(to top, #fcc5e4 0%, #fda34b 15%, #ff7882 35%, #c8699e 52%, #7046aa 71%, #0c1db8 87%, #020f75 100%)", + "linear-gradient(to right, #fa709a 0%, #fee140 100%)", + "linear-gradient(to top, #30cfd0 0%, #330867 100%)", + "linear-gradient(to top, #c471f5 0%, #fa71cd 100%)", + "linear-gradient(to right, #f78ca0 0%, #f9748f 19%, #fd868c 60%, #fe9a8b 100%)", + "linear-gradient(to top, #48c6ef 0%, #6f86d6 100%)", + "linear-gradient(to right, #0acffe 0%, #495aff 100%)", ]; interface SettingsPanelProps { - selected: string; - onWallpaperChange: (path: string) => void; - selectedZoomDepth?: ZoomDepth | null; - onZoomDepthChange?: (depth: ZoomDepth) => void; - selectedZoomId?: string | null; - onZoomDelete?: (id: string) => void; - selectedTrimId?: string | null; - onTrimDelete?: (id: string) => void; - shadowIntensity?: number; - onShadowChange?: (intensity: number) => void; - backgroundBlur?: number; - onBackgroundBlurChange?: (amount: number) => void; - zoomMotionBlur?: number; - onZoomMotionBlurChange?: (amount: number) => void; - connectZooms?: boolean; - onConnectZoomsChange?: (enabled: boolean) => void; - showCursor?: boolean; - onShowCursorChange?: (enabled: boolean) => void; - loopCursor?: boolean; - onLoopCursorChange?: (enabled: boolean) => void; - cursorSize?: number; - onCursorSizeChange?: (size: number) => void; - cursorSmoothing?: number; - onCursorSmoothingChange?: (smoothing: number) => void; - cursorMotionBlur?: number; - onCursorMotionBlurChange?: (amount: number) => void; - cursorClickBounce?: number; - onCursorClickBounceChange?: (amount: number) => void; - cursorSway?: number; - onCursorSwayChange?: (amount: number) => void; - borderRadius?: number; - onBorderRadiusChange?: (radius: number) => void; - padding?: number; - onPaddingChange?: (padding: number) => void; - cropRegion?: CropRegion; - onCropChange?: (region: CropRegion) => void; - aspectRatio: AspectRatio; - videoElement?: HTMLVideoElement | null; - exportQuality?: ExportQuality; - onExportQualityChange?: (quality: ExportQuality) => void; - // Export format settings - exportFormat?: ExportFormat; - onExportFormatChange?: (format: ExportFormat) => void; - gifFrameRate?: GifFrameRate; - onGifFrameRateChange?: (rate: GifFrameRate) => void; - gifLoop?: boolean; - onGifLoopChange?: (loop: boolean) => void; - gifSizePreset?: GifSizePreset; - onGifSizePresetChange?: (preset: GifSizePreset) => void; - gifOutputDimensions?: { width: number; height: number }; - onSaveProject?: () => void; - onLoadProject?: () => void; - onExport?: () => void; - selectedAnnotationId?: string | null; - annotationRegions?: AnnotationRegion[]; - onAnnotationContentChange?: (id: string, content: string) => void; - onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; - onAnnotationStyleChange?: ( - id: string, - style: Partial, - ) => void; - onAnnotationFigureDataChange?: (id: string, figureData: any) => void; - onAnnotationDelete?: (id: string) => void; - selectedSpeedId?: string | null; - selectedSpeedValue?: PlaybackSpeed | null; - onSpeedChange?: (speed: PlaybackSpeed) => void; - onSpeedDelete?: (id: string) => void; + selected: string; + onWallpaperChange: (path: string) => void; + selectedZoomDepth?: ZoomDepth | null; + onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomId?: string | null; + onZoomDelete?: (id: string) => void; + selectedTrimId?: string | null; + onTrimDelete?: (id: string) => void; + shadowIntensity?: number; + onShadowChange?: (intensity: number) => void; + backgroundBlur?: number; + onBackgroundBlurChange?: (amount: number) => void; + zoomMotionBlur?: number; + onZoomMotionBlurChange?: (amount: number) => void; + connectZooms?: boolean; + onConnectZoomsChange?: (enabled: boolean) => void; + showCursor?: boolean; + onShowCursorChange?: (enabled: boolean) => void; + loopCursor?: boolean; + onLoopCursorChange?: (enabled: boolean) => void; + cursorSize?: number; + onCursorSizeChange?: (size: number) => void; + cursorSmoothing?: number; + onCursorSmoothingChange?: (smoothing: number) => void; + cursorMotionBlur?: number; + onCursorMotionBlurChange?: (amount: number) => void; + cursorClickBounce?: number; + onCursorClickBounceChange?: (amount: number) => void; + cursorSway?: number; + onCursorSwayChange?: (amount: number) => void; + borderRadius?: number; + onBorderRadiusChange?: (radius: number) => void; + padding?: number; + onPaddingChange?: (padding: number) => void; + cropRegion?: CropRegion; + onCropChange?: (region: CropRegion) => void; + aspectRatio: AspectRatio; + videoElement?: HTMLVideoElement | null; + exportQuality?: ExportQuality; + onExportQualityChange?: (quality: ExportQuality) => void; + // Export format settings + exportFormat?: ExportFormat; + onExportFormatChange?: (format: ExportFormat) => void; + gifFrameRate?: GifFrameRate; + onGifFrameRateChange?: (rate: GifFrameRate) => void; + gifLoop?: boolean; + onGifLoopChange?: (loop: boolean) => void; + gifSizePreset?: GifSizePreset; + onGifSizePresetChange?: (preset: GifSizePreset) => void; + gifOutputDimensions?: { width: number; height: number }; + onSaveProject?: () => void; + onLoadProject?: () => void; + onExport?: () => void; + selectedAnnotationId?: string | null; + annotationRegions?: AnnotationRegion[]; + onAnnotationContentChange?: (id: string, content: string) => void; + onAnnotationTypeChange?: (id: string, type: AnnotationType) => void; + onAnnotationStyleChange?: (id: string, style: Partial) => void; + onAnnotationFigureDataChange?: (id: string, figureData: any) => void; + onAnnotationDelete?: (id: string) => void; + selectedSpeedId?: string | null; + selectedSpeedValue?: PlaybackSpeed | null; + onSpeedChange?: (speed: PlaybackSpeed) => void; + onSpeedDelete?: (id: string) => void; } export default SettingsPanel; const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [ - { depth: 1, label: "1.25×" }, - { depth: 2, label: "1.5×" }, - { depth: 3, label: "1.8×" }, - { depth: 4, label: "2.2×" }, - { depth: 5, label: "3.5×" }, - { depth: 6, label: "5×" }, + { depth: 1, label: "1.25×" }, + { depth: 2, label: "1.5×" }, + { depth: 3, label: "1.8×" }, + { depth: 4, label: "2.2×" }, + { depth: 5, label: "3.5×" }, + { depth: 6, label: "5×" }, ]; export function SettingsPanel({ - selected, - onWallpaperChange, - selectedZoomDepth, - onZoomDepthChange, - selectedZoomId, - onZoomDelete, - selectedTrimId, - onTrimDelete, - shadowIntensity = 0.67, - onShadowChange, - backgroundBlur = 0, - onBackgroundBlurChange, - zoomMotionBlur = 0, - onZoomMotionBlurChange, - connectZooms = true, - onConnectZoomsChange, - showCursor = false, - onShowCursorChange, - loopCursor = false, - onLoopCursorChange, - cursorSize = 5, - onCursorSizeChange, - cursorSmoothing = 2, - onCursorSmoothingChange, - cursorMotionBlur = 0.35, - onCursorMotionBlurChange, - cursorClickBounce = 1, - onCursorClickBounceChange, - cursorSway = DEFAULT_CURSOR_SWAY, - onCursorSwayChange, - borderRadius = 12.5, - onBorderRadiusChange, - padding = 50, - onPaddingChange, - cropRegion, - onCropChange, - aspectRatio, - videoElement, - exportQuality = "good", - onExportQualityChange, - exportFormat = "mp4", - onExportFormatChange, - gifFrameRate = 15, - onGifFrameRateChange, - gifLoop = true, - onGifLoopChange, - gifSizePreset = "medium", - onGifSizePresetChange, - gifOutputDimensions = { width: 1280, height: 720 }, - onSaveProject, - onLoadProject, - onExport, - selectedAnnotationId, - annotationRegions = [], - onAnnotationContentChange, - onAnnotationTypeChange, - onAnnotationStyleChange, - onAnnotationFigureDataChange, - onAnnotationDelete, - selectedSpeedId, - selectedSpeedValue, - onSpeedChange, - onSpeedDelete, + selected, + onWallpaperChange, + selectedZoomDepth, + onZoomDepthChange, + selectedZoomId, + onZoomDelete, + selectedTrimId, + onTrimDelete, + shadowIntensity = 0.67, + onShadowChange, + backgroundBlur = 0, + onBackgroundBlurChange, + zoomMotionBlur = 0, + onZoomMotionBlurChange, + connectZooms = true, + onConnectZoomsChange, + showCursor = false, + onShowCursorChange, + loopCursor = false, + onLoopCursorChange, + cursorSize = 5, + onCursorSizeChange, + cursorSmoothing = 2, + onCursorSmoothingChange, + cursorMotionBlur = 0.35, + onCursorMotionBlurChange, + cursorClickBounce = 1, + onCursorClickBounceChange, + cursorSway = DEFAULT_CURSOR_SWAY, + onCursorSwayChange, + borderRadius = 12.5, + onBorderRadiusChange, + padding = 50, + onPaddingChange, + cropRegion, + onCropChange, + aspectRatio, + videoElement, + exportQuality = "good", + onExportQualityChange, + exportFormat = "mp4", + onExportFormatChange, + gifFrameRate = 15, + onGifFrameRateChange, + gifLoop = true, + onGifLoopChange, + gifSizePreset = "medium", + onGifSizePresetChange, + gifOutputDimensions = { width: 1280, height: 720 }, + onSaveProject, + onLoadProject, + onExport, + selectedAnnotationId, + annotationRegions = [], + onAnnotationContentChange, + onAnnotationTypeChange, + onAnnotationStyleChange, + onAnnotationFigureDataChange, + onAnnotationDelete, + selectedSpeedId, + selectedSpeedValue, + onSpeedChange, + onSpeedDelete, }: SettingsPanelProps) { - const tSettings = useScopedT("settings"); - const { t } = useI18n(); - const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState( - [], - ); - const [customImages, setCustomImages] = useState([]); - const fileInputRef = useRef(null); + const tSettings = useScopedT("settings"); + const { t } = useI18n(); + const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]); + const [customImages, setCustomImages] = useState([]); + const fileInputRef = useRef(null); - useEffect(() => { - let mounted = true; - (async () => { - try { - const resolved = await Promise.all( - WALLPAPER_RELATIVE_PATHS.map(async (path) => - getRenderableAssetUrl(await getAssetPath(path)), - ), - ); - if (mounted) setWallpaperPreviewPaths(resolved); - } catch (err) { - if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); - } - })(); - return () => { - mounted = false; - }; - }, []); - const colorPalette = [ - "#FF0000", - "#FFD700", - "#00FF00", - "#FFFFFF", - "#0000FF", - "#FF6B00", - "#9B59B6", - "#E91E63", - "#00BCD4", - "#FF5722", - "#8BC34A", - "#FFC107", - "#2563EB", - "#000000", - "#607D8B", - "#795548", - ]; + useEffect(() => { + let mounted = true; + (async () => { + try { + const resolved = await Promise.all( + WALLPAPER_RELATIVE_PATHS.map(async (path) => + getRenderableAssetUrl(await getAssetPath(path)), + ), + ); + if (mounted) setWallpaperPreviewPaths(resolved); + } catch (err) { + if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS); + } + })(); + return () => { + mounted = false; + }; + }, []); + const colorPalette = [ + "#FF0000", + "#FFD700", + "#00FF00", + "#FFFFFF", + "#0000FF", + "#FF6B00", + "#9B59B6", + "#E91E63", + "#00BCD4", + "#FF5722", + "#8BC34A", + "#FFC107", + "#2563EB", + "#000000", + "#607D8B", + "#795548", + ]; - const [selectedColor, setSelectedColor] = useState("#ADADAD"); - const [gradient, setGradient] = useState(GRADIENTS[0]); - const [showCropModal, setShowCropModal] = useState(false); - const cropSnapshotRef = useRef(null); + const [selectedColor, setSelectedColor] = useState("#ADADAD"); + const [gradient, setGradient] = useState(GRADIENTS[0]); + const [showCropModal, setShowCropModal] = useState(false); + const cropSnapshotRef = useRef(null); - const zoomEnabled = Boolean(selectedZoomDepth); - const trimEnabled = Boolean(selectedTrimId); + const zoomEnabled = Boolean(selectedZoomDepth); + const trimEnabled = Boolean(selectedTrimId); - const handleDeleteClick = () => { - if (selectedZoomId && onZoomDelete) { - onZoomDelete(selectedZoomId); - } - }; + const handleDeleteClick = () => { + if (selectedZoomId && onZoomDelete) { + onZoomDelete(selectedZoomId); + } + }; - const handleTrimDeleteClick = () => { - if (selectedTrimId && onTrimDelete) { - onTrimDelete(selectedTrimId); - } - }; + const handleTrimDeleteClick = () => { + if (selectedTrimId && onTrimDelete) { + onTrimDelete(selectedTrimId); + } + }; - const handleCropToggle = () => { - if (!showCropModal && cropRegion) { - cropSnapshotRef.current = { ...cropRegion }; - } - setShowCropModal(!showCropModal); - }; + const handleCropToggle = () => { + if (!showCropModal && cropRegion) { + cropSnapshotRef.current = { ...cropRegion }; + } + setShowCropModal(!showCropModal); + }; - const handleCropCancel = () => { - if (cropSnapshotRef.current && onCropChange) { - onCropChange(cropSnapshotRef.current); - } - setShowCropModal(false); - }; + const handleCropCancel = () => { + if (cropSnapshotRef.current && onCropChange) { + onCropChange(cropSnapshotRef.current); + } + setShowCropModal(false); + }; - const handleImageUpload = (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; + const handleImageUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; - const file = files[0]; + const file = files[0]; - // Validate file type - only allow JPG/JPEG - const validTypes = ["image/jpeg", "image/jpg"]; - if (!validTypes.includes(file.type)) { - toast.error(tSettings("background.uploadError"), { - description: tSettings("background.uploadErrorDescription"), - }); - event.target.value = ""; - return; - } + // Validate file type - only allow JPG/JPEG + const validTypes = ["image/jpeg", "image/jpg"]; + if (!validTypes.includes(file.type)) { + toast.error(tSettings("background.uploadError"), { + description: tSettings("background.uploadErrorDescription"), + }); + event.target.value = ""; + return; + } - const reader = new FileReader(); + const reader = new FileReader(); - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - setCustomImages((prev) => [...prev, dataUrl]); - onWallpaperChange(dataUrl); - toast.success(tSettings("background.uploadSuccess")); - } - }; + reader.onload = (e) => { + const dataUrl = e.target?.result as string; + if (dataUrl) { + setCustomImages((prev) => [...prev, dataUrl]); + onWallpaperChange(dataUrl); + toast.success(tSettings("background.uploadSuccess")); + } + }; - reader.onerror = () => { - toast.error(t("common.failedToUploadImage"), { - description: t("common.errorReadingFile"), - }); - }; + reader.onerror = () => { + toast.error(t("common.failedToUploadImage"), { + description: t("common.errorReadingFile"), + }); + }; - reader.readAsDataURL(file); - // Reset input so the same file can be selected again - event.target.value = ""; - }; + reader.readAsDataURL(file); + // Reset input so the same file can be selected again + event.target.value = ""; + }; - const handleRemoveCustomImage = ( - imageUrl: string, - event: React.MouseEvent, - ) => { - event.stopPropagation(); - setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); - // If the removed image was selected, clear selection - if (selected === imageUrl) { - onWallpaperChange(WALLPAPER_PATHS[0]); - } - }; + const handleRemoveCustomImage = (imageUrl: string, event: React.MouseEvent) => { + event.stopPropagation(); + setCustomImages((prev) => prev.filter((img) => img !== imageUrl)); + // If the removed image was selected, clear selection + if (selected === imageUrl) { + onWallpaperChange(WALLPAPER_PATHS[0]); + } + }; - // Find selected annotation - const selectedAnnotation = selectedAnnotationId - ? annotationRegions.find((a) => a.id === selectedAnnotationId) - : null; + // Find selected annotation + const selectedAnnotation = selectedAnnotationId + ? annotationRegions.find((a) => a.id === selectedAnnotationId) + : null; - // If an annotation is selected, show annotation settings instead - if ( - selectedAnnotation && - onAnnotationContentChange && - onAnnotationTypeChange && - onAnnotationStyleChange && - onAnnotationDelete - ) { - return ( - - onAnnotationContentChange(selectedAnnotation.id, content) - } - onTypeChange={(type) => - onAnnotationTypeChange(selectedAnnotation.id, type) - } - onStyleChange={(style) => - onAnnotationStyleChange(selectedAnnotation.id, style) - } - onFigureDataChange={ - onAnnotationFigureDataChange - ? (figureData) => - onAnnotationFigureDataChange(selectedAnnotation.id, figureData) - : undefined - } - onDelete={() => onAnnotationDelete(selectedAnnotation.id)} - /> - ); - } + // If an annotation is selected, show annotation settings instead + if ( + selectedAnnotation && + onAnnotationContentChange && + onAnnotationTypeChange && + onAnnotationStyleChange && + onAnnotationDelete + ) { + return ( + onAnnotationContentChange(selectedAnnotation.id, content)} + onTypeChange={(type) => onAnnotationTypeChange(selectedAnnotation.id, type)} + onStyleChange={(style) => onAnnotationStyleChange(selectedAnnotation.id, style)} + onFigureDataChange={ + onAnnotationFigureDataChange + ? (figureData) => onAnnotationFigureDataChange(selectedAnnotation.id, figureData) + : undefined + } + onDelete={() => onAnnotationDelete(selectedAnnotation.id)} + /> + ); + } - return ( -
-
-
-
- - {tSettings("zoom.level")} - -
- {zoomEnabled && selectedZoomDepth && ( - - { - ZOOM_DEPTH_OPTIONS.find( - (o) => o.depth === selectedZoomDepth, - )?.label - } - - )} - -
-
-
- {ZOOM_DEPTH_OPTIONS.map((option) => { - const isActive = selectedZoomDepth === option.depth; - return ( - - ); - })} -
- {!zoomEnabled && ( -

- {tSettings("zoom.selectRegion")} -

- )} - {zoomEnabled && ( - - )} -
+ return ( +
+
+
+
+ {tSettings("zoom.level")} +
+ {zoomEnabled && selectedZoomDepth && ( + + {ZOOM_DEPTH_OPTIONS.find((o) => o.depth === selectedZoomDepth)?.label} + + )} + +
+
+
+ {ZOOM_DEPTH_OPTIONS.map((option) => { + const isActive = selectedZoomDepth === option.depth; + return ( + + ); + })} +
+ {!zoomEnabled && ( +

+ {tSettings("zoom.selectRegion")} +

+ )} + {zoomEnabled && ( + + )} +
- {trimEnabled && ( -
- -
- )} + {trimEnabled && ( +
+ +
+ )} -
-
- - {tSettings("speed.playbackSpeed")} - - {selectedSpeedId && selectedSpeedValue && ( - - {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue) - ?.label ?? `${selectedSpeedValue}×`} - - )} -
-
- {SPEED_OPTIONS.map((option) => { - const isActive = selectedSpeedValue === option.speed; - return ( - - ); - })} -
- {!selectedSpeedId && ( -

- {tSettings("speed.selectRegion")} -

- )} - {selectedSpeedId && ( - - )} -
+
+
+ + {tSettings("speed.playbackSpeed")} + + {selectedSpeedId && selectedSpeedValue && ( + + {SPEED_OPTIONS.find((o) => o.speed === selectedSpeedValue)?.label ?? + `${selectedSpeedValue}×`} + + )} +
+
+ {SPEED_OPTIONS.map((option) => { + const isActive = selectedSpeedValue === option.speed; + return ( + + ); + })} +
+ {!selectedSpeedId && ( +

+ {tSettings("speed.selectRegion")} +

+ )} + {selectedSpeedId && ( + + )} +
- - - -
- - - {tSettings("effects.title")} - -
-
- -
-
-
- {tSettings("effects.showCursor")} -
- -
-
-
-
- {tSettings("effects.loopCursor")} -
-
- -
-
- onBackgroundBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(1)}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
-
+ + + +
+ + {tSettings("effects.title")} +
+
+ +
+
+
+ {tSettings("effects.showCursor")} +
+ +
+
+
+
+ {tSettings("effects.loopCursor")} +
+
+ +
+
+ onBackgroundBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(1)}px`} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} + /> +
+
-
-
- onZoomMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
+
+
+ onZoomMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
-
-
- {tSettings("effects.connectZooms")} -
- -
-
+
+
+ {tSettings("effects.connectZooms")} +
+ +
+
-
-
- onCursorSizeChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- onCursorSmoothingChange?.(v)} - formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} - parseInput={(t) => parseFloat(t)} - /> -
-
+
+
+ onCursorSizeChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
+
+ onCursorSmoothingChange?.(v)} + formatValue={(v) => (v <= 0 ? "Off" : v.toFixed(2))} + parseInput={(t) => parseFloat(t)} + /> +
+
-
-
- onCursorMotionBlurChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
- onCursorClickBounceChange?.(v)} - formatValue={(v) => `${v.toFixed(2)}×`} - parseInput={(t) => parseFloat(t.replace(/×$/, ""))} - /> -
-
+
+
+ onCursorMotionBlurChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
+
+ onCursorClickBounceChange?.(v)} + formatValue={(v) => `${v.toFixed(2)}×`} + parseInput={(t) => parseFloat(t.replace(/×$/, ""))} + /> +
+
-
-
- onCursorSwayChange?.(v)} - formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} - parseInput={(t) => { - const normalized = t.trim().toLowerCase(); - if (normalized === "off") { - return 0; - } +
+
+ onCursorSwayChange?.(fromCursorSwaySliderValue(v))} + formatValue={(v) => (v <= 0 ? "Off" : `${v.toFixed(2)}×`)} + parseInput={(t) => { + const normalized = t.trim().toLowerCase(); + if (normalized === "off") { + return 0; + } - return parseFloat(t.replace(/×$/, "")); - }} - /> -
-
- onShadowChange?.(v)} - formatValue={(v) => `${Math.round(v * 100)}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} - /> -
-
- onBorderRadiusChange?.(v)} - formatValue={(v) => `${v}px`} - parseInput={(t) => parseFloat(t.replace(/px$/, ""))} - /> -
-
- onPaddingChange?.(v)} - formatValue={(v) => `${v}%`} - parseInput={(t) => parseFloat(t.replace(/%$/, ""))} - /> -
-
+ return parseFloat(t.replace(/×$/, "")); + }} + /> +
+
+ onShadowChange?.(v)} + formatValue={(v) => `${Math.round(v * 100)}%`} + parseInput={(t) => parseFloat(t.replace(/%$/, "")) / 100} + /> +
+
+ onBorderRadiusChange?.(v)} + formatValue={(v) => `${v}px`} + parseInput={(t) => parseFloat(t.replace(/px$/, ""))} + /> +
+
+ onPaddingChange?.(v)} + formatValue={(v) => `${v}%`} + parseInput={(t) => parseFloat(t.replace(/%$/, ""))} + /> +
+
- -
-
+ +
+
- - -
- - - {tSettings("background.title")} - -
-
- - - - - {tSettings("background.image")} - - - {tSettings("background.color")} - - - {tSettings("background.gradient")} - - + + +
+ + {tSettings("background.title")} +
+
+ + + + + {tSettings("background.image")} + + + {tSettings("background.color")} + + + {tSettings("background.gradient")} + + -
- - - +
+ + + -
- {customImages.map((imageUrl, idx) => { - const isSelected = selected === imageUrl; - return ( -
onWallpaperChange(imageUrl)} - role="button" - > - -
- ); - })} +
+ {customImages.map((imageUrl, idx) => { + const isSelected = selected === imageUrl; + return ( +
onWallpaperChange(imageUrl)} + role="button" + > + +
+ ); + })} - {(wallpaperPreviewPaths.length > 0 - ? wallpaperPreviewPaths - : WALLPAPER_PATHS - ).map((previewPath, index) => { - const wallpaper = BUILT_IN_WALLPAPERS[index]; - const wallpaperValue = - WALLPAPER_PATHS[index] ?? previewPath; - const isSelected = (() => { - if (!selected) return false; - if ( - selected === wallpaperValue || - selected === previewPath - ) - return true; - try { - const clean = (s: string) => - s.replace(/^file:\/\//, "").replace(/^\//, ""); - if (clean(selected).endsWith(clean(wallpaperValue))) - return true; - if (clean(wallpaperValue).endsWith(clean(selected))) - return true; - if (clean(selected).endsWith(clean(previewPath))) - return true; - if (clean(previewPath).endsWith(clean(selected))) - return true; - } catch {} - return false; - })(); - return ( -
onWallpaperChange(wallpaperValue)} - role="button" - /> - ); - })} -
- + {(wallpaperPreviewPaths.length > 0 + ? wallpaperPreviewPaths + : WALLPAPER_PATHS + ).map((previewPath, index) => { + const wallpaper = BUILT_IN_WALLPAPERS[index]; + const wallpaperValue = WALLPAPER_PATHS[index] ?? previewPath; + const isSelected = (() => { + if (!selected) return false; + if (selected === wallpaperValue || selected === previewPath) return true; + try { + const clean = (s: string) => + s.replace(/^file:\/\//, "").replace(/^\//, ""); + if (clean(selected).endsWith(clean(wallpaperValue))) return true; + if (clean(wallpaperValue).endsWith(clean(selected))) return true; + if (clean(selected).endsWith(clean(previewPath))) return true; + if (clean(previewPath).endsWith(clean(selected))) return true; + } catch {} + return false; + })(); + return ( +
onWallpaperChange(wallpaperValue)} + role="button" + /> + ); + })} +
+ - -
- { - setSelectedColor(color.hex); - onWallpaperChange(color.hex); - }} - style={{ - width: "100%", - borderRadius: "8px", - }} - /> -
-
+ +
+ { + setSelectedColor(color.hex); + onWallpaperChange(color.hex); + }} + style={{ + width: "100%", + borderRadius: "8px", + }} + /> +
+
- -
- {GRADIENTS.map((g, idx) => ( -
{ - setGradient(g); - onWallpaperChange(g); - }} - role="button" - /> - ))} -
- -
- - - - -
+ +
+ {GRADIENTS.map((g, idx) => ( +
{ + setGradient(g); + onWallpaperChange(g); + }} + role="button" + /> + ))} +
+ +
+ + + + +
- {showCropModal && cropRegion && onCropChange && ( - <> -
-
-
-
- - {tSettings("crop.title")} - -

- {tSettings("crop.instruction")} -

-
- -
- -
- -
-
- - )} + {showCropModal && cropRegion && onCropChange && ( + <> +
+
+
+
+ {tSettings("crop.title")} +

{tSettings("crop.instruction")}

+
+ +
+ +
+ +
+
+ + )} -
-
- - -
+
+
+ + +
- {exportFormat === "mp4" && ( -
- - - -
- )} + {exportFormat === "mp4" && ( +
+ + + +
+ )} - {exportFormat === "gif" && ( -
-
-
- {GIF_FRAME_RATES.map((rate) => ( - - ))} -
-
- {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( - - ))} -
-
-
- - {gifOutputDimensions.width} × {gifOutputDimensions.height}px - -
- - {tSettings("export.loop")} - - -
-
-
- )} + {exportFormat === "gif" && ( +
+
+
+ {GIF_FRAME_RATES.map((rate) => ( + + ))} +
+
+ {Object.entries(GIF_SIZE_PRESETS).map(([key, _preset]) => ( + + ))} +
+
+
+ + {gifOutputDimensions.width} × {gifOutputDimensions.height}px + +
+ {tSettings("export.loop")} + +
+
+
+ )} -
- - -
+
+ + +
- + -
- - -
-
-
- ); +
+ + +
+
+
+ ); } diff --git a/src/components/video-editor/videoPlayback/cursorSway.test.ts b/src/components/video-editor/videoPlayback/cursorSway.test.ts index 842eb020..1f10a51a 100644 --- a/src/components/video-editor/videoPlayback/cursorSway.test.ts +++ b/src/components/video-editor/videoPlayback/cursorSway.test.ts @@ -1,31 +1,37 @@ import { describe, expect, it } from "vitest"; -import { computeCursorSwayRotation } from "./cursorSway"; +import { + computeCursorSwayRotation, + fromCursorSwaySliderValue, + toCursorSwaySliderValue, +} from "./cursorSway"; describe("computeCursorSwayRotation", () => { - it("returns zero when sway is disabled or there is no movement", () => { - expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0); - expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0); - }); + it("returns zero when sway is disabled or there is no movement", () => { + expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0); + expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0); + }); - it("leans opposite the motion direction", () => { - expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeLessThan(0); - expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeGreaterThan(0); - expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeLessThan(0); - expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeGreaterThan(0); - }); + it("leans with the motion direction", () => { + expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeLessThan(0); + expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeGreaterThan(0); + expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeLessThan(0); + }); - it("increases with faster movement for the same direction", () => { - const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1)); - const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1)); + it("increases with faster movement for the same direction", () => { + const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1)); + const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1)); - expect(fast).toBeGreaterThan(slow); - }); + expect(fast).toBeGreaterThan(slow); + }); - it("maps a 2x slider value to a 6x sway intensity", () => { - expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo( - Math.PI / 3, - 6, - ); - }); + it("maps a 2x slider value to a 6x sway intensity", () => { + expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo(-(Math.PI / 3), 6); + }); + + it("maps a 1x slider value to the previous 2x sway strength", () => { + expect(fromCursorSwaySliderValue(1)).toBe(2); + expect(toCursorSwaySliderValue(2)).toBe(1); + }); }); diff --git a/src/components/video-editor/videoPlayback/cursorSway.ts b/src/components/video-editor/videoPlayback/cursorSway.ts index 08c9c55e..dcbeef41 100644 --- a/src/components/video-editor/videoPlayback/cursorSway.ts +++ b/src/components/video-editor/videoPlayback/cursorSway.ts @@ -4,46 +4,38 @@ const CURSOR_SWAY_MAX_ROTATION = Math.PI / 18; const CURSOR_SWAY_SPEED_REFERENCE = 1400; const CURSOR_SWAY_VERTICAL_WEIGHT = 0.65; const CURSOR_SWAY_INTENSITY_SCALE = 3; +export const CURSOR_SWAY_SLIDER_SCALE = 2; function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); + return Math.min(max, Math.max(min, value)); } -export function computeCursorSwayRotation( - dx: number, - dy: number, - deltaMs: number, - sway: number, -) { - if (sway <= 0) { - return 0; - } +export function computeCursorSwayRotation(dx: number, dy: number, deltaMs: number, sway: number) { + if (sway <= 0) { + return 0; + } - const distance = Math.hypot(dx, dy); - if (!Number.isFinite(distance) || distance < 0.01) { - return 0; - } + const distance = Math.hypot(dx, dy); + if (!Number.isFinite(distance) || distance < 0.01) { + return 0; + } - const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000); - const speedFactor = clamp( - speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, - 0, - 1, - ); - if (speedFactor <= 0) { - return 0; - } + const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000); + const speedFactor = clamp(speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, 0, 1); + if (speedFactor <= 0) { + return 0; + } - const directionalBias = clamp( - (-dx - dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, - -1, - 1, - ); - return ( - directionalBias * - speedFactor * - CURSOR_SWAY_MAX_ROTATION * - sway * - CURSOR_SWAY_INTENSITY_SCALE - ); + const directionalBias = clamp((dx + dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, -1, 1); + return ( + directionalBias * speedFactor * CURSOR_SWAY_MAX_ROTATION * sway * CURSOR_SWAY_INTENSITY_SCALE + ); +} + +export function toCursorSwaySliderValue(sway: number) { + return sway / CURSOR_SWAY_SLIDER_SCALE; +} + +export function fromCursorSwaySliderValue(sliderValue: number) { + return sliderValue * CURSOR_SWAY_SLIDER_SCALE; } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 4829693f..18ef6790 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -10,6 +10,8 @@ "folderPath": "Path: /{{name}}/", "openVideoFile": "Open video file", "openProject": "Open project", + "hideHudFromVideo": "Hide HUD from recording", + "showHudInVideo": "Show HUD in recording", "hideHud": "Hide HUD", "closeApp": "Close App" }, diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 0bd6d05e..8bd42e14 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -10,6 +10,8 @@ "folderPath": "Ruta: /{{name}}/", "openVideoFile": "Abrir archivo de video", "openProject": "Abrir proyecto", + "hideHudFromVideo": "Ocultar HUD en la grabación", + "showHudInVideo": "Mostrar HUD en la grabación", "hideHud": "Ocultar HUD", "closeApp": "Cerrar aplicación" }, diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 9ac43f3a..32f42328 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -10,6 +10,8 @@ "folderPath": "路径:/{{name}}/", "openVideoFile": "打开视频文件", "openProject": "打开项目", + "hideHudFromVideo": "在录制中隐藏 HUD", + "showHudInVideo": "在录制中显示 HUD", "hideHud": "隐藏 HUD", "closeApp": "关闭应用" }, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 845f5960..b101b2fe 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,151 +1,210 @@ -/// -/// - -interface ProcessedDesktopSource { - id: string; - name: string; - display_id: string; - thumbnail: string | null; - appIcon: string | null; - originalName?: string; - sourceType?: 'screen' | 'window'; - appName?: string; - windowTitle?: string; -} - -interface CursorTelemetryPoint { - timeMs: number; - cx: number; - cy: number; - interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup'; - cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed'; -} - -interface SystemCursorAsset { - dataUrl: string; - hotspotX: number; - hotspotY: number; - width: number; - height: number; -} - -interface Window { - electronAPI: { - getSources: (opts: Electron.SourcesOptions) => Promise - switchToEditor: () => Promise - openSourceSelector: () => Promise - selectSource: (source: any) => Promise - getSelectedSource: () => Promise - startNativeScreenRecording: (source: any, options?: { - capturesSystemAudio?: boolean - capturesMicrophone?: boolean - microphoneDeviceId?: string - microphoneLabel?: string - }) => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - stopNativeScreenRecording: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - startFfmpegRecording: (source: any) => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - stopFfmpegRecording: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message: string - error?: string - }> - getRecordedVideoPath: () => Promise<{ - success: boolean - path?: string - message?: string - error?: string - }> - readLocalFile: (filePath: string) => Promise<{ - success: boolean - data?: Uint8Array - error?: string - }> - getAssetBasePath: () => Promise - setRecordingState: (recording: boolean) => Promise - getCursorTelemetry: (videoPath?: string) => Promise<{ - success: boolean - samples: CursorTelemetryPoint[] - message?: string - error?: string - }> - getSystemCursorAssets: () => Promise<{ - success: boolean - cursors: Record - error?: string - }> - onStopRecordingFromTray: (callback: () => void) => () => void - onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => () => void - onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => () => void - onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => () => void - openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> - getAccessibilityPermissionStatus: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - requestAccessibilityPermission: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }> - getScreenRecordingPermissionStatus: () => Promise<{ success: boolean; status: string; error?: string }> - openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }> - openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }> - saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ - success: boolean - path?: string - message?: string - canceled?: boolean - }> - openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }> - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }> - getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }> - clearCurrentVideoPath: () => Promise<{ success: boolean }> - saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ - success: boolean - path?: string - message?: string - canceled?: boolean - error?: string - }> - loadProjectFile: () => Promise<{ - success: boolean - path?: string - project?: unknown - message?: string - canceled?: boolean - error?: string - }> - loadCurrentProjectFile: () => Promise<{ - success: boolean - path?: string - project?: unknown - message?: string - canceled?: boolean - error?: string - }> - onMenuLoadProject: (callback: () => void) => () => void - onMenuSaveProject: (callback: () => void) => () => void - onMenuSaveProjectAs: (callback: () => void) => () => void - hideOsCursor: () => Promise<{ success: boolean }> - setHasUnsavedChanges: (hasChanges: boolean) => void - onRequestSaveBeforeClose: (callback: () => Promise) => () => void - getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }> - chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }> - } -} - +/// +/// + +interface ProcessedDesktopSource { + id: string; + name: string; + display_id: string; + thumbnail: string | null; + appIcon: string | null; + originalName?: string; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; +} + +interface SystemCursorAsset { + dataUrl: string; + hotspotX: number; + hotspotY: number; + width: number; + height: number; +} + +interface Window { + electronAPI: { + hudOverlayHide: () => void; + hudOverlayClose: () => void; + getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; + setHudOverlayCaptureProtection: ( + enabled: boolean, + ) => Promise<{ success: boolean; enabled: boolean }>; + getSources: (opts: Electron.SourcesOptions) => Promise; + switchToEditor: () => Promise; + openSourceSelector: () => Promise; + selectSource: (source: any) => Promise; + getSelectedSource: () => Promise; + startNativeScreenRecording: ( + source: any, + options?: { + capturesSystemAudio?: boolean; + capturesMicrophone?: boolean; + microphoneDeviceId?: string; + microphoneLabel?: string; + }, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + stopNativeScreenRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + startFfmpegRecording: (source: any) => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + stopFfmpegRecording: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + storeRecordedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ + success: boolean; + path?: string; + message: string; + error?: string; + }>; + getRecordedVideoPath: () => Promise<{ + success: boolean; + path?: string; + message?: string; + error?: string; + }>; + readLocalFile: (filePath: string) => Promise<{ + success: boolean; + data?: Uint8Array; + error?: string; + }>; + getAssetBasePath: () => Promise; + setRecordingState: (recording: boolean) => Promise; + getCursorTelemetry: (videoPath?: string) => Promise<{ + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; + }>; + getSystemCursorAssets: () => Promise<{ + success: boolean; + cursors: Record; + error?: string; + }>; + onStopRecordingFromTray: (callback: () => void) => () => void; + onRecordingStateChanged: ( + callback: (state: { recording: boolean; sourceName: string }) => void, + ) => () => void; + onRecordingInterrupted: ( + callback: (state: { reason: string; message: string }) => void, + ) => () => void; + onCursorStateChanged: ( + callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void, + ) => () => void; + openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>; + getAccessibilityPermissionStatus: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + requestAccessibilityPermission: () => Promise<{ + success: boolean; + trusted: boolean; + prompted: boolean; + error?: string; + }>; + getScreenRecordingPermissionStatus: () => Promise<{ + success: boolean; + status: string; + error?: string; + }>; + openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>; + openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>; + saveExportedVideo: ( + videoData: ArrayBuffer, + fileName: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + }>; + openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + clearCurrentVideoPath: () => Promise<{ success: boolean }>; + saveProjectFile: ( + projectData: unknown, + suggestedName?: string, + existingProjectPath?: string, + ) => Promise<{ + success: boolean; + path?: string; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + loadCurrentProjectFile: () => Promise<{ + success: boolean; + path?: string; + project?: unknown; + message?: string; + canceled?: boolean; + error?: string; + }>; + onMenuLoadProject: (callback: () => void) => () => void; + onMenuSaveProject: (callback: () => void) => () => void; + onMenuSaveProjectAs: (callback: () => void) => () => void; + hideOsCursor: () => Promise<{ success: boolean }>; + setHasUnsavedChanges: (hasChanges: boolean) => void; + onRequestSaveBeforeClose: (callback: () => Promise) => () => void; + getRecordingsDirectory: () => Promise<{ + success: boolean; + path: string; + isDefault: boolean; + error?: string; + }>; + chooseRecordingsDirectory: () => Promise<{ + success: boolean; + canceled?: boolean; + path?: string; + isDefault?: boolean; + message?: string; + error?: string; + }>; + }; +}