fix(editor): polish cursor sway and HUD capture controls

Keep the recording HUD visible on screen without burning it into captures, and make cursor sway match cursor motion while preserving the old 2x strength at the new 1x slider position.
This commit is contained in:
KBCats
2026-03-15 18:12:38 -07:00
parent 4b3f057389
commit 7d22c49df5
11 changed files with 2279 additions and 2054 deletions
+220 -116
View File
@@ -1,116 +1,220 @@
/// <reference types="vite-plugin-electron/electron-env" />
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<ProcessedDesktopSource[]>
switchToEditor: () => Promise<void>
openSourceSelector: () => Promise<void>
selectSource: (source: any) => Promise<any>
getSelectedSource: () => Promise<any>
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<void>
getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }>
getSystemCursorAssets: () => Promise<{ success: boolean; cursors: Record<string, SystemCursorAsset>; 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<string>
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<Record<string, unknown> | null>
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
hudOverlayHide: () => void;
hudOverlayClose: () => void;
setHasUnsavedChanges: (hasChanges: boolean) => void
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => 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
}
/// <reference types="vite-plugin-electron/electron-env" />
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<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
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<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
getSystemCursorAssets: () => Promise<{
success: boolean;
cursors: Record<string, SystemCursorAsset>;
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<string>;
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<Record<string, unknown> | 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>) => () => 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;
}
+201 -183
View File
@@ -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<void>) => {
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<void>) => {
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"),
});
+157 -134
View File
@@ -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;
}
+396 -310
View File
@@ -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<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
const {
recording,
toggleRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(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<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
const {
recording,
toggleRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(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<string | null>(null);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(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 (
<div className="w-full h-full flex items-end justify-center bg-transparent overflow-hidden">
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
{showMicControls && (
<div
className={`flex items-center gap-2 rounded-full border border-white/15 bg-[rgba(18,18,26,0.92)] px-3 py-2 shadow-xl backdrop-blur-xl ${styles.electronNoDrag}`}
>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(event) => {
setSelectedDeviceId(event.target.value);
setMicrophoneDeviceId(event.target.value);
}}
className={`max-w-[230px] rounded-full border border-white/15 bg-[#131722] px-3 py-1 text-xs text-slate-100 outline-none ${styles.micSelect}`}
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<AudioLevelMeter level={level} className="w-24" />
</div>
)}
setHideHudFromCapture(nextValue);
<div
className={`w-full mx-auto flex items-center gap-1.5 px-3 py-2 ${styles.electronDrag} ${styles.hudBar}`}
style={{
borderRadius: 9999,
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
backdropFilter: "blur(16px) saturate(140%)",
WebkitBackdropFilter: "blur(16px) saturate(140%)",
border: "1px solid rgba(80,80,120,0.25)",
minHeight: 48,
}}
>
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<RxDragHandleDots2 size={16} className="text-white/35" />
</div>
try {
const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue);
<Button
variant="link"
size="sm"
className={`gap-1 text-white/80 bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
<MdMonitor size={14} className="text-white/80" />
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
</Button>
if (!result.success) {
setHideHudFromCapture(!nextValue);
return;
}
<div className={dividerClass} />
setHideHudFromCapture(result.enabled);
} catch (error) {
console.error("Failed to update HUD capture protection:", error);
setHideHudFromCapture(!nextValue);
}
};
<div className={`flex items-center gap-1 ${styles.electronNoDrag}`}>
<Button
variant="link"
size="icon"
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={systemAudioEnabled ? t('recording.disableSystemAudio') : t('recording.enableSystemAudio')}
className="text-white/80 hover:bg-transparent"
>
{systemAudioEnabled ? <MdVolumeUp size={16} className="text-[#2563EB]" /> : <MdVolumeOff size={16} className="text-white/35" />}
</Button>
<Button
variant="link"
size="icon"
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? t('recording.disableMicrophone') : t('recording.enableMicrophone')}
className="text-white/80 hover:bg-transparent"
>
{microphoneEnabled ? <MdMic size={16} className="text-[#2563EB]" /> : <MdMicOff size={16} className="text-white/35" />}
</Button>
</div>
const chooseRecordingsDirectory = async () => {
const result = await window.electronAPI.chooseRecordingsDirectory();
if (result.canceled) {
return;
}
if (result.success && result.path) {
setRecordingsDirectory(result.path);
}
};
<div className={dividerClass} />
useEffect(() => {
const loadRecordingsDirectory = async () => {
const result = await window.electronAPI.getRecordingsDirectory();
if (result.success) {
setRecordingsDirectory(result.path);
}
};
<Button
variant="link"
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
>
{recording ? (
<>
<FaRegStopCircle size={14} className="text-red-400" />
<span className="text-red-400 font-medium tabular-nums">{formatTime(elapsed)}</span>
</>
) : (
<>
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white/85" : "text-white/35"} />
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>{t('recording.record')}</span>
</>
)}
</Button>
void loadRecordingsDirectory();
}, []);
<Button
variant="link"
size="sm"
onClick={chooseRecordingsDirectory}
disabled={recording}
title={recordingsDirectory ? t('recording.recordingFolder', undefined, { path: recordingsDirectory }) : t('recording.chooseRecordingsFolder')}
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
>
<ContentClamp truncateLength={18}>{t('recording.folderPath', undefined, { name: recordingsDirectoryName })}</ContentClamp>
</Button>
const recordingsDirectoryName = recordingsDirectory
? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory
: "recordings";
const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35";
<div className="ml-auto flex items-center gap-0.5">
<div className={dividerClass} />
<Button
variant="link"
size="icon"
onClick={openVideoFile}
disabled={recording}
title={t('recording.openVideoFile')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<MdVideoFile size={15} />
</Button>
<Button
variant="link"
size="icon"
onClick={openProjectFile}
disabled={recording}
title={t('recording.openProject')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FaFolderOpen size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="icon"
title="Language"
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<Languages size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
className="min-w-[90px] bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
>
{SUPPORTED_LOCALES.map((code) => (
<DropdownMenuItem
key={code}
onSelect={() => setLocale(code as AppLocale)}
className={`text-xs cursor-pointer ${
locale === code ? "text-white font-medium" : "text-white/60"
}`}
>
{LOCALE_LABELS[code] ?? code}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className={dividerClass} />
<Button
variant="link"
size="icon"
onClick={sendHudOverlayHide}
title={t('recording.hideHud')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FiMinus size={16} />
</Button>
<Button
variant="link"
size="icon"
onClick={sendHudOverlayClose}
title={t('recording.closeApp')}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FiX size={16} />
</Button>
</div>
</div>
</div>
</div>
);
const toggleMicrophone = () => {
if (!recording) {
setMicrophoneEnabled(!microphoneEnabled);
}
};
return (
<div className="flex h-full w-full items-end justify-center overflow-hidden bg-transparent px-3 pb-3 pt-2">
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
{showMicControls && (
<div
className={`flex items-center gap-2 rounded-full border border-white/15 bg-[rgba(18,18,26,0.92)] px-3 py-2 shadow-xl backdrop-blur-xl ${styles.electronNoDrag}`}
>
<select
value={microphoneDeviceId || selectedDeviceId}
onChange={(event) => {
setSelectedDeviceId(event.target.value);
setMicrophoneDeviceId(event.target.value);
}}
className={`max-w-[230px] rounded-full border border-white/15 bg-[#131722] px-3 py-1 text-xs text-slate-100 outline-none ${styles.micSelect}`}
>
{devices.map((device) => (
<option key={device.deviceId} value={device.deviceId}>
{device.label}
</option>
))}
</select>
<AudioLevelMeter level={level} className="w-24" />
</div>
)}
<div
className={`mx-auto inline-flex max-w-full items-center gap-1.5 px-3 py-2 ${styles.electronDrag} ${styles.hudBar}`}
style={{
borderRadius: 9999,
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
backdropFilter: "blur(16px) saturate(140%)",
WebkitBackdropFilter: "blur(16px) saturate(140%)",
border: "1px solid rgba(80,80,120,0.25)",
minHeight: 48,
}}
>
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<RxDragHandleDots2 size={16} className="text-white/35" />
</div>
<Button
variant="link"
size="sm"
className={`gap-1 text-white/80 bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
onClick={openSourceSelector}
disabled={recording}
title={selectedSource}
>
<MdMonitor size={14} className="text-white/80" />
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
</Button>
<div className={dividerClass} />
<div className={`flex items-center gap-1 ${styles.electronNoDrag}`}>
<Button
variant="link"
size="icon"
onClick={() => void toggleHudCaptureProtection()}
title={
hideHudFromCapture ? t("recording.showHudInVideo") : t("recording.hideHudFromVideo")
}
className="text-white/80 hover:bg-transparent"
>
{hideHudFromCapture ? (
<EyeOff size={16} className="text-[#2563EB]" />
) : (
<Eye size={16} className="text-white/35" />
)}
</Button>
<Button
variant="link"
size="icon"
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
title={
systemAudioEnabled
? t("recording.disableSystemAudio")
: t("recording.enableSystemAudio")
}
className="text-white/80 hover:bg-transparent"
>
{systemAudioEnabled ? (
<MdVolumeUp size={16} className="text-[#2563EB]" />
) : (
<MdVolumeOff size={16} className="text-white/35" />
)}
</Button>
<Button
variant="link"
size="icon"
onClick={toggleMicrophone}
disabled={recording}
title={
microphoneEnabled
? t("recording.disableMicrophone")
: t("recording.enableMicrophone")
}
className="text-white/80 hover:bg-transparent"
>
{microphoneEnabled ? (
<MdMic size={16} className="text-[#2563EB]" />
) : (
<MdMicOff size={16} className="text-white/35" />
)}
</Button>
</div>
<div className={dividerClass} />
<Button
variant="link"
size="sm"
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
disabled={!hasSelectedSource && !recording}
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
>
{recording ? (
<>
<FaRegStopCircle size={14} className="text-red-400" />
<span className="text-red-400 font-medium tabular-nums">{formatTime(elapsed)}</span>
</>
) : (
<>
<BsRecordCircle
size={14}
className={hasSelectedSource ? "text-white/85" : "text-white/35"}
/>
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>
{t("recording.record")}
</span>
</>
)}
</Button>
<Button
variant="link"
size="sm"
onClick={chooseRecordingsDirectory}
disabled={recording}
title={
recordingsDirectory
? t("recording.recordingFolder", undefined, { path: recordingsDirectory })
: t("recording.chooseRecordingsFolder")
}
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
>
<ContentClamp truncateLength={18}>
{t("recording.folderPath", undefined, { name: recordingsDirectoryName })}
</ContentClamp>
</Button>
<div className="ml-auto flex items-center gap-0.5">
<div className={dividerClass} />
<Button
variant="link"
size="icon"
onClick={openVideoFile}
disabled={recording}
title={t("recording.openVideoFile")}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<MdVideoFile size={15} />
</Button>
<Button
variant="link"
size="icon"
onClick={openProjectFile}
disabled={recording}
title={t("recording.openProject")}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FaFolderOpen size={14} />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="link"
size="icon"
title="Language"
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<Languages size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="top"
align="end"
className="min-w-[90px] bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
>
{SUPPORTED_LOCALES.map((code) => (
<DropdownMenuItem
key={code}
onSelect={() => setLocale(code as AppLocale)}
className={`text-xs cursor-pointer ${
locale === code ? "text-white font-medium" : "text-white/60"
}`}
>
{LOCALE_LABELS[code] ?? code}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className={dividerClass} />
<Button
variant="link"
size="icon"
onClick={sendHudOverlayHide}
title={t("recording.hideHud")}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FiMinus size={16} />
</Button>
<Button
variant="link"
size="icon"
onClick={sendHudOverlayClose}
title={t("recording.closeApp")}
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
>
<FiX size={16} />
</Button>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -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);
});
});
@@ -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;
}
+2
View File
@@ -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"
},
+2
View File
@@ -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"
},
+2
View File
@@ -10,6 +10,8 @@
"folderPath": "路径:/{{name}}/",
"openVideoFile": "打开视频文件",
"openProject": "打开项目",
"hideHudFromVideo": "在录制中隐藏 HUD",
"showHudInVideo": "在录制中显示 HUD",
"hideHud": "隐藏 HUD",
"closeApp": "关闭应用"
},
+210 -151
View File
@@ -1,151 +1,210 @@
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
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<ProcessedDesktopSource[]>
switchToEditor: () => Promise<void>
openSourceSelector: () => Promise<void>
selectSource: (source: any) => Promise<any>
getSelectedSource: () => Promise<any>
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<string | null>
setRecordingState: (recording: boolean) => Promise<void>
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean
samples: CursorTelemetryPoint[]
message?: string
error?: string
}>
getSystemCursorAssets: () => Promise<{
success: boolean
cursors: Record<string, SystemCursorAsset>
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>) => () => 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 }>
}
}
/// <reference types="vite/client" />
/// <reference types="../electron/electron-env" />
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<ProcessedDesktopSource[]>;
switchToEditor: () => Promise<void>;
openSourceSelector: () => Promise<void>;
selectSource: (source: any) => Promise<any>;
getSelectedSource: () => Promise<any>;
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<string | null>;
setRecordingState: (recording: boolean) => Promise<void>;
getCursorTelemetry: (videoPath?: string) => Promise<{
success: boolean;
samples: CursorTelemetryPoint[];
message?: string;
error?: string;
}>;
getSystemCursorAssets: () => Promise<{
success: boolean;
cursors: Record<string, SystemCursorAsset>;
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>) => () => 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;
}>;
};
}