diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 2929d39..c51a5fe 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -1,235 +1,239 @@ -/// - -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 }>; - openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; - setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; - setCurrentRecordingSession: (session: { - videoPath: string; - webcamPath?: string | null; - }) => Promise<{ success: boolean }>; - getCurrentRecordingSession: () => Promise<{ - success: boolean; - session?: { videoPath: string; webcamPath?: string | null }; - }>; - 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 }>; - /** Countdown timer before recording */ - getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; - setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; - startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; - cancelCountdown: () => Promise<{ success: boolean }>; - onCountdownTick: (callback: (seconds: number) => void) => () => void; - }; -} - -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; + showSourceHighlight: (source: any) => Promise<{ success: boolean }>; + 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 }>; + openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>; + setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>; + setCurrentRecordingSession: (session: { + videoPath: string; + webcamPath?: string | null; + }) => Promise<{ success: boolean }>; + getCurrentRecordingSession: () => Promise<{ + success: boolean; + session?: { videoPath: string; webcamPath?: string | null }; + }>; + getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; + clearCurrentVideoPath: () => Promise<{ success: boolean }>; + deleteRecordingFile: ( + filePath: string, + ) => Promise<{ success: boolean; error?: string }>; + 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 }>; + /** Countdown timer before recording */ + getCountdownDelay: () => Promise<{ success: boolean; delay: number }>; + setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>; + startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>; + cancelCountdown: () => Promise<{ success: boolean }>; + onCountdownTick: (callback: (seconds: number) => void) => () => void; + }; +} + +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/ipc/handlers.ts b/electron/ipc/handlers.ts index 7c82c97..0a12b9d 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1893,6 +1893,169 @@ export function registerIpcHandlers( return selectedSource }) + ipcMain.handle('show-source-highlight', async (_, source: SelectedSource) => { + try { + const isWindow = source.id?.startsWith('window:') + const windowId = isWindow ? parseWindowId(source.id) : null + + // ── 1. Bring window to front & get its bounds via AppleScript ── + let asBounds: { x: number; y: number; width: number; height: number } | null = null + + if (isWindow && process.platform === 'darwin') { + const appName = source.appName || source.name?.split(' — ')[0]?.trim() + if (appName) { + // Single AppleScript: activate AND return window bounds + try { + const { stdout } = await execFileAsync('osascript', ['-e', + `tell application "${appName}"\n` + + ` activate\n` + + `end tell\n` + + `delay 0.3\n` + + `tell application "System Events"\n` + + ` tell process "${appName}"\n` + + ` set frontWindow to front window\n` + + ` set {x1, y1} to position of frontWindow\n` + + ` set {w1, h1} to size of frontWindow\n` + + ` return (x1 as text) & "," & (y1 as text) & "," & (w1 as text) & "," & (h1 as text)\n` + + ` end tell\n` + + `end tell` + ], { timeout: 4000 }) + const parts = stdout.trim().split(',').map(Number) + if (parts.length === 4 && parts.every(n => Number.isFinite(n))) { + asBounds = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] } + } + } catch { + // Fallback: just activate without bounds + try { + await execFileAsync('osascript', ['-e', + `tell application "${appName}" to activate` + ], { timeout: 2000 }) + await new Promise((resolve) => setTimeout(resolve, 350)) + } catch { /* ignore */ } + } + } + } else if (windowId && process.platform === 'linux') { + try { + await execFileAsync('wmctrl', ['-i', '-a', `0x${windowId.toString(16)}`], { timeout: 1500 }) + } catch { + try { + await execFileAsync('xdotool', ['windowactivate', String(windowId)], { timeout: 1500 }) + } catch { /* not available */ } + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + // ── 2. Resolve bounds ── + let bounds = asBounds + + if (!bounds) { + if (source.id?.startsWith('screen:')) { + bounds = getDisplayBoundsForSource(source) + } else if (isWindow) { + if (process.platform === 'darwin') { + bounds = await resolveMacWindowBounds(source) + } else if (process.platform === 'linux') { + bounds = await resolveLinuxWindowBounds(source) + } + } + } + + if (!bounds || bounds.width <= 0 || bounds.height <= 0) { + bounds = getDisplayBoundsForSource(source) + } + + // ── 3. Show traveling wave highlight ── + const pad = 6 + const highlightWin = new BrowserWindow({ + x: bounds.x - pad, + y: bounds.y - pad, + width: bounds.width + pad * 2, + height: bounds.height + pad * 2, + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + hasShadow: false, + resizable: false, + focusable: false, + webPreferences: { nodeIntegration: false, contextIsolation: true }, + }) + + highlightWin.setIgnoreMouseEvents(true) + + const html = ` + +
+
+` + + await highlightWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`) + + setTimeout(() => { + if (!highlightWin.isDestroyed()) highlightWin.close() + }, 1700) + + return { success: true } + } catch (error) { + console.error('Failed to show source highlight:', error) + return { success: false } + } + }) + ipcMain.handle('get-selected-source', () => { return selectedSource }) @@ -2976,6 +3139,25 @@ export function registerIpcHandlers( return { success: true }; }); + ipcMain.handle('delete-recording-file', async (_, filePath: string) => { + try { + if (!filePath || !isAutoRecordingPath(filePath)) { + return { success: false, error: 'Only auto-generated recordings can be deleted' }; + } + await fs.unlink(filePath); + // Also delete the cursor telemetry sidecar if it exists + const telemetryPath = getTelemetryPathForVideo(filePath); + await fs.unlink(telemetryPath).catch(() => {}); + if (currentVideoPath === filePath) { + currentVideoPath = null; + currentRecordingSession = null; + } + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + ipcMain.handle('get-platform', () => { return process.platform; }); diff --git a/electron/preload.ts b/electron/preload.ts index d9c3ec1..55fd26b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -31,6 +31,9 @@ contextBridge.exposeInMainWorld("electronAPI", { selectSource: (source: any) => { return ipcRenderer.invoke("select-source", source); }, + showSourceHighlight: (source: any) => { + return ipcRenderer.invoke("show-source-highlight", source); + }, getSelectedSource: () => { return ipcRenderer.invoke("get-selected-source"); }, @@ -147,6 +150,9 @@ contextBridge.exposeInMainWorld("electronAPI", { clearCurrentVideoPath: () => { return ipcRenderer.invoke("clear-current-video-path"); }, + deleteRecordingFile: (filePath: string) => { + return ipcRenderer.invoke("delete-recording-file", filePath); + }, saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => { return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath); }, diff --git a/electron/windows.ts b/electron/windows.ts index 762f0ae..81a3d26 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -107,8 +107,8 @@ export function createHudOverlayWindow(): BrowserWindow { const primaryDisplay = getScreen().getPrimaryDisplay(); const { workArea } = primaryDisplay; - const windowWidth = 660; - const windowHeight = 170; + const windowWidth = 720; + const windowHeight = 520; const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); const y = Math.floor(workArea.y + workArea.height - windowHeight - 5); @@ -116,10 +116,10 @@ export function createHudOverlayWindow(): BrowserWindow { const win = new BrowserWindow({ width: windowWidth, height: windowHeight, - minWidth: windowWidth, - maxWidth: windowWidth, - minHeight: windowHeight, - maxHeight: windowHeight, + minWidth: 720, + maxWidth: 720, + minHeight: 520, + maxHeight: 520, x: x, y: y, frame: false, diff --git a/src/App.tsx b/src/App.tsx index 67c5df2..332df09 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,69 +1,75 @@ -import { useEffect, useState } from "react"; -import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; -import { LaunchWindow } from "./components/launch/LaunchWindow"; -import { SourceSelector } from "./components/launch/SourceSelector"; -import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; -import VideoEditor from "./components/video-editor/VideoEditor"; -import { useI18n } from "./contexts/I18nContext"; -import { ShortcutsProvider } from "./contexts/ShortcutsContext"; -import { loadAllCustomFonts } from "./lib/customFonts"; - -export default function App() { - const [windowType, setWindowType] = useState(""); - const { locale, t } = useI18n(); - - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const type = params.get("windowType") || ""; - setWindowType(type); - - if (type === "hud-overlay" || type === "source-selector" || type === "countdown") { - document.body.style.background = "transparent"; - document.documentElement.style.background = "transparent"; - document.getElementById("root")?.style.setProperty("background", "transparent"); - } - - loadAllCustomFonts().catch((error) => { - console.error("Failed to load custom fonts:", error); - }); - }, []); - - useEffect(() => { - document.title = - windowType === "editor" ? t("app.editorTitle", "Recordly Editor") : t("app.name", "Recordly"); - }, [windowType, locale, t]); - - switch (windowType) { - case "hud-overlay": - return ; - case "source-selector": - return ; - case "countdown": - return ; - case "editor": - return ( - - - - - ); - default: - return ( -
-
- {t("app.name", -
-

{t("app.name", "Recordly")}

-

- {t("app.subtitle", "Screen recording and editing")} -

-
-
-
- ); - } -} +import { useEffect, useState } from "react"; +import { CountdownOverlay } from "./components/countdown/CountdownOverlay"; +import { LaunchWindow } from "./components/launch/LaunchWindow"; +import { SourceSelector } from "./components/launch/SourceSelector"; +import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog"; +import VideoEditor from "./components/video-editor/VideoEditor"; +import { useI18n } from "./contexts/I18nContext"; +import { ShortcutsProvider } from "./contexts/ShortcutsContext"; +import { loadAllCustomFonts } from "./lib/customFonts"; + +export default function App() { + const [windowType, setWindowType] = useState(""); + const { locale, t } = useI18n(); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const type = params.get("windowType") || ""; + setWindowType(type); + + if (type === "hud-overlay" || type === "source-selector" || type === "countdown") { + document.body.style.background = "transparent"; + document.documentElement.style.background = "transparent"; + document.getElementById("root")?.style.setProperty("background", "transparent"); + } + + if (type === "hud-overlay") { + document.documentElement.style.overflow = "visible"; + document.body.style.overflow = "visible"; + document.getElementById("root")?.style.setProperty("overflow", "visible"); + } + + loadAllCustomFonts().catch((error) => { + console.error("Failed to load custom fonts:", error); + }); + }, []); + + useEffect(() => { + document.title = + windowType === "editor" ? t("app.editorTitle", "Recordly Editor") : t("app.name", "Recordly"); + }, [windowType, locale, t]); + + switch (windowType) { + case "hud-overlay": + return ; + case "source-selector": + return ; + case "countdown": + return ; + case "editor": + return ( + + + + + ); + default: + return ( +
+
+ {t("app.name", +
+

{t("app.name", "Recordly")}

+

+ {t("app.subtitle", "Screen recording and editing")} +

+
+
+
+ ); + } +} diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 8e01e55..2ca0623 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -1,51 +1,244 @@ .electronDrag { - -webkit-app-region: drag; + -webkit-app-region: drag; } -.hudBar { - isolation: isolate; - box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18); +.electronNoDrag { + -webkit-app-region: no-drag; } -.electronNoDrag { - -webkit-app-region: no-drag; +.bar { + display: flex; + align-items: center; + gap: 8px; + background: rgba(18, 18, 24, 0.97); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 18px; + padding: 10px 14px; + box-shadow: + 0 8px 40px rgba(0, 0, 0, 0.45), + inset 0 1px 0 rgba(255, 255, 255, 0.04); + overflow: visible; + position: relative; +} + +.sep { + width: 1px; + height: 26px; + background: #2a2a34; + margin: 0 4px; + flex-shrink: 0; +} + +.ib { + position: relative; + width: 40px; + height: 40px; + border-radius: 11px; + border: none; + background: transparent; + color: #6b6b78; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.ib:hover { + background: rgba(255, 255, 255, 0.07); + color: #eeeef2; +} + +.ibActive { + color: #6360f5; +} + +.ibActive:hover { + color: #7b78ff; +} + +.ibRed { + color: #f43f5e; +} + +.ibRed:hover { + color: #ff5a75; +} + +.ibGreen { + color: #34d399; +} + +.ibGreen:hover { + color: #4eeeb0; +} + +.screenSel { + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 40px; + padding: 0 14px 0 12px; + border-radius: 11px; + border: 1px solid #2a2a34; + background: #1a1a22; + color: #eeeef2; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.screenSel:hover { + border-color: #3e3e4c; + background: #20202a; } +.menuArea { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; + overflow: hidden; + min-height: 0; +} + +.menuCard { + width: 300px; + max-height: 400px; + overflow-y: auto; + background: rgba(22, 22, 30, 0.96); + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 14px; + padding: 8px; + margin-top: auto; + margin-bottom: 8px; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); + pointer-events: auto; + animation: menuCardIn 0.18s ease; +} + +@keyframes menuCardIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.menuCard::-webkit-scrollbar { + width: 4px; +} + +.menuCard::-webkit-scrollbar-track { + background: transparent; +} + +.menuCard::-webkit-scrollbar-thumb { + background: #2a2a34; + border-radius: 2px; +} + +.ddLabel { + font-size: 9px; + font-weight: 600; + color: #6b6b78; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 6px 10px 4px; +} + +.ddItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: none; + background: transparent; + font-size: 12px; + color: #6b6b78; + cursor: pointer; + transition: all 0.12s ease; + text-align: left; +} + +.ddItem:hover { + background: rgba(255, 255, 255, 0.06); + color: #eeeef2; +} + +.ddItemSelected { + color: #6360f5; +} + +.recBtn { + position: relative; + width: 46px; + height: 46px; + border-radius: 50%; + border: none; + background: #f43f5e; + color: #fff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.3); +} -.folderButton { - cursor: pointer; - display: flex; - align-items: center; - gap: 4px; +.recBtn:hover { + background: #ff5a75; + box-shadow: 0 0 0 6px rgba(244, 63, 94, 0.15); } -.folderText { - color: #cbd5e1; - transition: text-decoration 0.15s; +.recBtn:disabled { + opacity: 0.4; + cursor: not-allowed; } -.folderButton:hover .folderText { - text-decoration: underline; +.recBtn:disabled:hover { + background: #f43f5e; + box-shadow: none; } -.hudOverlayButton { - cursor: pointer; - background: none; - border: none; - color: #fff; - opacity: 0.7; - transition: opacity 0.15s; +.recDot { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: all 0.2s ease; } -.hudOverlayButton:hover { - opacity: 0.7; - background: none !important; + +.recDotBlink { + animation: blink 1.2s ease-in-out infinite; +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.2; + } } .micSelect { - color-scheme: dark; + color-scheme: dark; } .micSelect option { - background-color: #131722; - color: #e5e7eb; + background-color: #1a1a22; + color: #eeeef2; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d178b4d..69863fd 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,48 +1,152 @@ -import { Eye, EyeOff, Languages, Timer } from "lucide-react"; -import { useEffect, useRef, 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 type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { - MdMic, - MdMicOff, - MdMonitor, - MdOutlineVideocam, - MdOutlineVideocamOff, - MdVideoFile, - MdVolumeOff, - MdVolumeUp, -} from "react-icons/md"; + Monitor, + Mic, + MicOff, + ChevronUp, + Pause, + Square, + X, + Play, + Minus, + MoreVertical, + FolderOpen, + VideoIcon, + Languages, + Volume2, + VolumeX, + AppWindow, + Eye, + EyeOff, + Timer, + Video, + VideoOff, +} 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 { useVideoDevices } from "../../hooks/useVideoDevices"; import { AudioLevelMeter } from "../ui/audio-level-meter"; -import { Button } from "../ui/button"; import { ContentClamp } from "../ui/content-clamp"; -import { - 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"; +interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +const LOCALE_LABELS: Record = { + en: "EN", + es: "ES", + "zh-CN": "中文", +}; + +const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; + +function IconButton({ + onClick, + title, + className = "", + children, +}: { + onClick?: () => void; + title?: string; + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + +function DropdownItem({ + onClick, + selected, + icon, + children, + trailing, +}: { + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; +}) { + return ( + + ); +} + +function Separator() { + return
; +} + +function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: { deviceId: string; label: string }; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + + ); +} + export function LaunchWindow() { const { locale, setLocale } = useI18n(); const t = useScopedT("launch"); - const LOCALE_LABELS: Record = { en: "EN", es: "ES", "zh-CN": "中文" }; const { recording, + paused, countdownActive, toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -56,22 +160,34 @@ export function LaunchWindow() { countdownDelay, setCountdownDelay, } = useScreenRecorder(); + const [recordingStart, setRecordingStart] = useState(null); const [elapsed, setElapsed] = useState(0); + const [pausedAt, setPausedAt] = useState(null); + const [pausedTotal, setPausedTotal] = useState(0); + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [activeDropdown, setActiveDropdown] = useState<"none" | "sources" | "more" | "mic" | "countdown" | "webcam">("none"); + const [sources, setSources] = useState([]); + const [sourcesLoading, setSourcesLoading] = useState(false); + const [hideHudFromCapture, setHideHudFromCapture] = useState(true); + const [platform, setPlatform] = useState(null); + const dropdownRef = useRef(null); const webcamPreviewRef = useRef(null); - const showMicControls = microphoneEnabled && !recording; + + const micDropdownOpen = activeDropdown === "mic"; + const webcamDropdownOpen = activeDropdown === "webcam"; const showWebcamControls = webcamEnabled && !recording; const { devices, selectedDeviceId, setSelectedDeviceId } = - useMicrophoneDevices(microphoneEnabled); + useMicrophoneDevices(microphoneEnabled || micDropdownOpen); const { devices: videoDevices, selectedDeviceId: selectedVideoDeviceId, setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled); - const { level } = useAudioLevelMeter({ - enabled: showMicControls, - deviceId: microphoneDeviceId, - }); + } = useVideoDevices(webcamEnabled || webcamDropdownOpen); + + const supportsHudCaptureProtection = platform !== "linux"; useEffect(() => { if (selectedDeviceId && selectedDeviceId !== "default") { @@ -141,79 +257,83 @@ export function LaunchWindow() { 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)); + if (!recordingStart) { + setRecordingStart(Date.now()); + setPausedTotal(0); + } + if (paused) { + if (!pausedAt) setPausedAt(Date.now()); + if (timer) clearInterval(timer); + } else { + if (pausedAt) { + setPausedTotal((prev) => prev + (Date.now() - pausedAt)); + setPausedAt(null); } - }, 1000); + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); + } + }, 1000); + } } else { setRecordingStart(null); setElapsed(0); + setPausedAt(null); + setPausedTotal(0); if (timer) clearInterval(timer); } return () => { if (timer) clearInterval(timer); }; - }, [recording, recordingStart]); + }, [recording, recordingStart, paused, pausedAt, pausedTotal]); const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, "0"); + 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 [hideHudFromCapture, setHideHudFromCapture] = useState(true); - const [platform, setPlatform] = useState(null); - 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); - } + if (!window.electronAPI) return; + 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); }, []); useEffect(() => { - let cancelled = false; + const load = async () => { + const result = await window.electronAPI.getRecordingsDirectory(); + if (result.success) setRecordingsDirectory(result.path); + }; + void load(); + }, []); + useEffect(() => { + let cancelled = false; const loadPlatform = async () => { try { const nextPlatform = await window.electronAPI.getPlatform(); - if (!cancelled) { - setPlatform(nextPlatform); - } + if (!cancelled) setPlatform(nextPlatform); } catch (error) { console.error("Failed to load platform:", error); } }; - void loadPlatform(); - - return () => { - cancelled = true; - }; + return () => { cancelled = true; }; }, []); useEffect(() => { let cancelled = false; - const loadHudCaptureProtection = async () => { try { const result = await window.electronAPI.getHudOverlayCaptureProtection(); @@ -224,24 +344,82 @@ export function LaunchWindow() { console.error("Failed to load HUD capture protection state:", error); } }; - void loadHudCaptureProtection(); + return () => { cancelled = true; }; + }, []); - return () => { - cancelled = true; + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setActiveDropdown("none"); + } }; + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + const fetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setSourcesLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setSources( + rawSources.map((s) => { + const isWindow = s.id.startsWith("window:"); + const type = s.sourceType ?? (isWindow ? "window" : "screen"); + let displayName = s.name; + let appName = s.appName; + if (isWindow && !appName && s.name.includes(" — ")) { + const parts = s.name.split(" — "); + appName = parts[0]?.trim(); + displayName = parts.slice(1).join(" — ").trim() || s.name; + } else if (isWindow && s.windowTitle) { + displayName = s.windowTitle; + } + return { + id: s.id, + name: displayName, + thumbnail: s.thumbnail, + display_id: s.display_id, + appIcon: s.appIcon, + sourceType: type, + appName, + windowTitle: s.windowTitle ?? displayName, + }; + }), + ); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setSourcesLoading(false); + } }, []); - const openSourceSelector = () => { - window.electronAPI?.openSourceSelector(); + const toggleDropdown = (which: "sources" | "more" | "mic" | "countdown" | "webcam") => { + setActiveDropdown(activeDropdown === which ? "none" : which); + if (activeDropdown !== which && which === "sources") fetchSources(); + }; + + const handleSourceSelect = async (source: DesktopSource) => { + await window.electronAPI.selectSource(source); + setSelectedSource(source.name); + setHasSelectedSource(true); + setActiveDropdown("none"); + window.electronAPI.showSourceHighlight?.({ + ...source, + name: source.appName ? `${source.appName} — ${source.name}` : source.name, + appName: source.appName, + }); }; const openVideoFile = async () => { + setActiveDropdown("none"); const result = await window.electronAPI.openVideoFilePicker(); - if (result.canceled) { - return; - } - + if (result.canceled) return; if (result.success && result.path) { await window.electronAPI.setCurrentVideoPath(result.path); await window.electronAPI.switchToEditor(); @@ -249,34 +427,33 @@ export function LaunchWindow() { }; const openProjectFile = async () => { + setActiveDropdown("none"); const result = await window.electronAPI.loadProjectFile(); - if (result.canceled || !result.success) { - return; - } + if (result.canceled || !result.success) return; await window.electronAPI.switchToEditor(); }; - const sendHudOverlayHide = () => { - window.electronAPI?.hudOverlayHide?.(); + const chooseRecordingsDirectory = async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) return; + if (result.success && result.path) setRecordingsDirectory(result.path); }; - const sendHudOverlayClose = () => { - window.electronAPI?.hudOverlayClose?.(); + const toggleMicrophone = () => { + if (recording) return; + toggleDropdown("mic"); }; const toggleHudCaptureProtection = async () => { const nextValue = !hideHudFromCapture; - 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); @@ -284,346 +461,330 @@ export function LaunchWindow() { } }; - const chooseRecordingsDirectory = async () => { - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) { - return; - } - if (result.success && result.path) { - setRecordingsDirectory(result.path); - } - }; + const screenSources = sources.filter((s) => s.sourceType === "screen"); + const windowSources = sources.filter((s) => s.sourceType === "window"); - 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"; - const supportsHudCaptureProtection = platform !== "linux"; - - const toggleMicrophone = () => { - if (!recording) { - setMicrophoneEnabled(!microphoneEnabled); - } + const toggleWebcam = () => { + if (recording) return; + toggleDropdown("webcam"); }; return ( -
-
- {showMicControls && ( -
- - -
- )} - - {showWebcamControls && ( -
-
-
- -
- )} - -
-
- -
- - - -
- -
- {supportsHudCaptureProtection && ( - + )} - - - -
-
+ {activeDropdown === "mic" && ( + <> +
{t("recording.microphone")}
+ {microphoneEnabled && ( + } + onClick={() => { setMicrophoneEnabled(false); setActiveDropdown("none"); }} + > + {t("recording.turnOffMicrophone")} + + )} + {!microphoneEnabled && ( +
+ {t("recording.selectMicToEnable")} +
+ )} + {devices.map((device) => ( + { + setMicrophoneEnabled(true); + setSelectedDeviceId(device.deviceId); + setMicrophoneDeviceId(device.deviceId); + }} + /> + ))} + {devices.length === 0 && ( +
+ {t("recording.noMicrophonesFound")} +
+ )} + + )} - - - - - - {[0, 3, 5, 10].map((delay) => ( - setCountdownDelay(delay)} - className={`cursor-pointer text-xs ${ - countdownDelay === delay ? "font-medium text-white" : "text-white/60" - }`} - > - {delay === 0 ? t("recording.noDelay") : `${delay}s`} - - ))} - - - - - - - -
-
- - - - - - - + + {activeDropdown === "more" && ( + <> + } onClick={chooseRecordingsDirectory}> + {t("recording.recordingsFolder")} + + } onClick={openVideoFile}> + {t("recording.openVideoFile")} + + } onClick={openProjectFile}> + {t("recording.openProject")} + +
{t("recording.language")}
{SUPPORTED_LOCALES.map((code) => ( - setLocale(code as AppLocale)} - className={`text-xs cursor-pointer ${ - locale === code ? "text-white font-medium" : "text-white/60" - }`} + icon={} + selected={locale === code} + onClick={() => { setLocale(code as AppLocale); setActiveDropdown("none"); }} > {LOCALE_LABELS[code] ?? code} - + ))} -
-
-
-
+ )} +
+ + {/* Bottom section — fixed height, bar always stays here */} +
+
+
+ +
+ + {recording ? ( + <> +
+
+ + {paused ? t("recording.paused") : t("recording.rec")} + +
+ + + {formatTime(elapsed)} + + + + + + {microphoneEnabled ? : } + + + + + + {paused ? : } + + + + + + + + + + + ) : ( + <> + - + + + + - - -
+ {microphoneEnabled ? : } + + + setSystemAudioEnabled(!systemAudioEnabled)} + title={systemAudioEnabled ? t("recording.disableSystemAudio") : t("recording.enableSystemAudio")} + className={systemAudioEnabled ? styles.ibActive : ""} + > + {systemAudioEnabled ? : } + + + + {webcamEnabled ? + + {supportsHudCaptureProtection && ( + void toggleHudCaptureProtection()} + title={hideHudFromCapture ? t("recording.showHudInVideo") : t("recording.hideHudFromVideo")} + > + {hideHudFromCapture ? : } + + )} + + toggleDropdown("countdown")} + title={t("recording.countdownDelay")} + className={countdownDelay > 0 ? styles.ibActive : ""} + > + + + + + + + + + + toggleDropdown("more")} title={t("recording.more")}> + + + + window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}> + + + + window.electronAPI?.hudOverlayClose?.()} title={t("recording.closeApp")}> + + + + )}
diff --git a/src/components/video-editor/editorPreferences.test.ts b/src/components/video-editor/editorPreferences.test.ts index 4d18569..23fa07e 100644 --- a/src/components/video-editor/editorPreferences.test.ts +++ b/src/components/video-editor/editorPreferences.test.ts @@ -94,6 +94,7 @@ describe("editorPreferences", () => { gifFrameRate: 30, gifLoop: false, gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset, + webcam: DEFAULT_EDITOR_PREFERENCES.webcam, customAspectWidth: "21", customAspectHeight: "9", customWallpapers: ["data:image/jpeg;base64,abc"], @@ -134,6 +135,7 @@ describe("editorPreferences", () => { gifFrameRate: DEFAULT_EDITOR_PREFERENCES.gifFrameRate, gifLoop: DEFAULT_EDITOR_PREFERENCES.gifLoop, gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset, + webcam: DEFAULT_EDITOR_PREFERENCES.webcam, customAspectWidth: "21", customAspectHeight: "9", customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers, @@ -193,6 +195,7 @@ describe("editorPreferences", () => { gifFrameRate: 20, gifLoop: false, gifSizePreset: "large", + webcam: DEFAULT_EDITOR_PREFERENCES.webcam, customAspectWidth: "4", customAspectHeight: "5", customWallpapers: ["data:image/jpeg;base64,abc"], diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts new file mode 100644 index 0000000..e8c2b6a --- /dev/null +++ b/src/hooks/useScreenRecorder.test.ts @@ -0,0 +1,441 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type RecordingState = "inactive" | "recording" | "paused"; + +function createMockMediaRecorder(initialState: RecordingState = "inactive") { + let _state: RecordingState = initialState; + return { + get state() { + return _state; + }, + pause: vi.fn(() => { + if (_state === "recording") _state = "paused"; + }), + resume: vi.fn(() => { + if (_state === "paused") _state = "recording"; + }), + stop: vi.fn(() => { + _state = "inactive"; + }), + start: vi.fn(() => { + _state = "recording"; + }), + }; +} + +function stopRecording( + recorder: ReturnType, + isNativeRecording: boolean, + webcamRecorder?: ReturnType | null, +) { + if (isNativeRecording) { + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + return { stopped: true, wasNative: true }; + } + + const recorderState = recorder.state; + if (recorderState === "recording" || recorderState === "paused") { + if (recorderState === "paused") { + recorder.resume(); + } + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + recorder.stop(); + return { stopped: true, wasNative: false }; + } + return { stopped: false, wasNative: false }; +} + +function pauseRecording( + recorder: ReturnType, + recording: boolean, + paused: boolean, + isNativeRecording: boolean, + webcamRecorder?: ReturnType | null, +): boolean { + if (!recording || paused) return false; + if (isNativeRecording) { + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + return true; + } + if (recorder.state === "recording") { + recorder.pause(); + if (webcamRecorder?.state === "recording") { + webcamRecorder.pause(); + } + return true; + } + return false; +} + +function resumeRecording( + recorder: ReturnType, + recording: boolean, + paused: boolean, + isNativeRecording: boolean, + webcamRecorder?: ReturnType | null, +): boolean { + if (!recording || !paused) return false; + if (isNativeRecording) { + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + return true; + } + if (recorder.state === "paused") { + recorder.resume(); + if (webcamRecorder?.state === "paused") { + webcamRecorder.resume(); + } + return true; + } + return false; +} + +function cancelRecording( + recorder: ReturnType, + isNativeRecording: boolean, + chunks: { current: Blob[] }, + webcamRecorder?: ReturnType | null, + webcamChunks?: { current: Blob[] }, +) { + if (webcamChunks) webcamChunks.current = []; + if (webcamRecorder && webcamRecorder.state !== "inactive") { + webcamRecorder.stop(); + } + + if (isNativeRecording) { + return { cancelled: true, wasNative: true }; + } + + chunks.current = []; + if (recorder.state !== "inactive") { + recorder.stop(); + } + return { cancelled: true, wasNative: false }; +} + +describe("useScreenRecorder state machine", () => { + let recorder: ReturnType; + + beforeEach(() => { + recorder = createMockMediaRecorder("recording"); + }); + + describe("stopRecording", () => { + it("stops from recording state", () => { + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.resume).not.toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("resumes then stops from paused state", () => { + recorder.pause(); + expect(recorder.state).toBe("paused"); + + const result = stopRecording(recorder, false); + + expect(result.stopped).toBe(true); + expect(recorder.resume).toHaveBeenCalled(); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("resume is called before stop when paused", () => { + recorder.pause(); + const callOrder: string[] = []; + recorder.resume.mockImplementation(() => { + callOrder.push("resume"); + }); + recorder.stop.mockImplementation(() => { + callOrder.push("stop"); + }); + + stopRecording(recorder, false); + + expect(callOrder).toEqual(["resume", "stop"]); + }); + + it("does nothing when already inactive", () => { + const inactiveRecorder = createMockMediaRecorder("inactive"); + + const result = stopRecording(inactiveRecorder, false); + + expect(result.stopped).toBe(false); + expect(inactiveRecorder.stop).not.toHaveBeenCalled(); + }); + + it("delegates to native path for native recordings", () => { + const result = stopRecording(recorder, true); + + expect(result.stopped).toBe(true); + expect(result.wasNative).toBe(true); + expect(recorder.stop).not.toHaveBeenCalled(); + }); + + it("stops webcam when stopping browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + + stopRecording(recorder, false, webcam); + + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + + it("stops webcam when stopping native recording", () => { + const webcam = createMockMediaRecorder("recording"); + + stopRecording(recorder, true, webcam); + + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + }); + + describe("pauseRecording", () => { + it("pauses an active recording", () => { + const result = pauseRecording(recorder, true, false, false); + + expect(result).toBe(true); + expect(recorder.pause).toHaveBeenCalled(); + expect(recorder.state).toBe("paused"); + }); + + it("does nothing when already paused", () => { + recorder.pause(); + recorder.pause.mockClear(); + + const result = pauseRecording(recorder, true, true, false); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + + it("does nothing when not recording", () => { + const result = pauseRecording(recorder, false, false, false); + + expect(result).toBe(false); + expect(recorder.pause).not.toHaveBeenCalled(); + }); + + it("allows pause for native recordings", () => { + const result = pauseRecording(recorder, true, false, true); + + expect(result).toBe(true); + }); + + it("pauses webcam alongside browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, false, webcam); + + expect(recorder.state).toBe("paused"); + expect(webcam.state).toBe("paused"); + }); + + it("pauses webcam during native recording pause", () => { + const webcam = createMockMediaRecorder("recording"); + + const result = pauseRecording(recorder, true, false, true, webcam); + + expect(result).toBe(true); + expect(webcam.state).toBe("paused"); + }); + + it("skips webcam pause when webcam is not recording", () => { + const webcam = createMockMediaRecorder("inactive"); + + pauseRecording(recorder, true, false, false, webcam); + + expect(webcam.pause).not.toHaveBeenCalled(); + }); + }); + + describe("resumeRecording", () => { + it("resumes a paused recording", () => { + recorder.pause(); + + const result = resumeRecording(recorder, true, true, false); + + expect(result).toBe(true); + expect(recorder.resume).toHaveBeenCalled(); + expect(recorder.state).toBe("recording"); + }); + + it("does nothing when not paused", () => { + const result = resumeRecording(recorder, true, false, false); + + expect(result).toBe(false); + expect(recorder.resume).not.toHaveBeenCalled(); + }); + + it("does nothing when not recording", () => { + const result = resumeRecording(recorder, false, true, false); + + expect(result).toBe(false); + }); + + it("resumes webcam alongside browser recording", () => { + const webcam = createMockMediaRecorder("recording"); + recorder.pause(); + webcam.pause(); + + resumeRecording(recorder, true, true, false, webcam); + + expect(recorder.state).toBe("recording"); + expect(webcam.state).toBe("recording"); + }); + + it("resumes webcam during native recording resume", () => { + const webcam = createMockMediaRecorder("recording"); + webcam.pause(); + + const result = resumeRecording(recorder, true, true, true, webcam); + + expect(result).toBe(true); + expect(webcam.state).toBe("recording"); + }); + + it("skips webcam resume when webcam is not paused", () => { + recorder.pause(); + const webcam = createMockMediaRecorder("inactive"); + + resumeRecording(recorder, true, true, false, webcam); + + expect(webcam.resume).not.toHaveBeenCalled(); + }); + }); + + describe("cancelRecording", () => { + it("clears chunks and stops browser recording", () => { + const chunks = { current: [new Blob(["data"])] }; + + const result = cancelRecording(recorder, false, chunks); + + expect(result.cancelled).toBe(true); + expect(result.wasNative).toBe(false); + expect(chunks.current).toEqual([]); + expect(recorder.stop).toHaveBeenCalled(); + expect(recorder.state).toBe("inactive"); + }); + + it("clears webcam chunks and stops webcam on cancel", () => { + const chunks = { current: [new Blob(["data"])] }; + const webcamChunks = { current: [new Blob(["cam"])] }; + const webcam = createMockMediaRecorder("recording"); + + cancelRecording(recorder, false, chunks, webcam, webcamChunks); + + expect(webcamChunks.current).toEqual([]); + expect(webcam.stop).toHaveBeenCalled(); + expect(webcam.state).toBe("inactive"); + }); + + it("stops webcam when cancelling native recording", () => { + const chunks = { current: [] as Blob[] }; + const webcam = createMockMediaRecorder("recording"); + + const result = cancelRecording(recorder, true, chunks, webcam); + + expect(result.wasNative).toBe(true); + expect(webcam.stop).toHaveBeenCalled(); + expect(recorder.stop).not.toHaveBeenCalled(); + }); + + it("handles cancel when recorder is already inactive", () => { + const inactiveRecorder = createMockMediaRecorder("inactive"); + const chunks = { current: [new Blob(["data"])] }; + + const result = cancelRecording(inactiveRecorder, false, chunks); + + expect(result.cancelled).toBe(true); + expect(chunks.current).toEqual([]); + expect(inactiveRecorder.stop).not.toHaveBeenCalled(); + }); + + it("handles cancel when webcam is already inactive", () => { + const chunks = { current: [] as Blob[] }; + const webcam = createMockMediaRecorder("inactive"); + + cancelRecording(recorder, false, chunks, webcam); + + expect(webcam.stop).not.toHaveBeenCalled(); + }); + }); + + describe("pause → stop → editor flow", () => { + it("record → pause → stop completes cleanly", () => { + expect(recorder.state).toBe("recording"); + + pauseRecording(recorder, true, false, false); + expect(recorder.state).toBe("paused"); + + const result = stopRecording(recorder, false); + expect(result.stopped).toBe(true); + expect(recorder.state).toBe("inactive"); + }); + + it("record → pause → resume → stop completes cleanly", () => { + expect(recorder.state).toBe("recording"); + + pauseRecording(recorder, true, false, false); + expect(recorder.state).toBe("paused"); + + resumeRecording(recorder, true, true, false); + expect(recorder.state).toBe("recording"); + + const result = stopRecording(recorder, false); + expect(result.stopped).toBe(true); + expect(recorder.state).toBe("inactive"); + }); + + it("webcam stays in sync through full pause/resume/stop cycle", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, false, webcam); + expect(recorder.state).toBe("paused"); + expect(webcam.state).toBe("paused"); + + resumeRecording(recorder, true, true, false, webcam); + expect(recorder.state).toBe("recording"); + expect(webcam.state).toBe("recording"); + + stopRecording(recorder, false, webcam); + expect(recorder.state).toBe("inactive"); + expect(webcam.state).toBe("inactive"); + }); + + it("native recording: webcam pauses/resumes while screen keeps capturing", () => { + const webcam = createMockMediaRecorder("recording"); + + pauseRecording(recorder, true, false, true, webcam); + expect(webcam.state).toBe("paused"); + expect(recorder.pause).not.toHaveBeenCalled(); + + resumeRecording(recorder, true, true, true, webcam); + expect(webcam.state).toBe("recording"); + expect(recorder.resume).not.toHaveBeenCalled(); + }); + + it("cancel discards both screen and webcam recordings", () => { + const webcam = createMockMediaRecorder("recording"); + const chunks = { current: [new Blob(["screen"])] }; + const webcamChunks = { current: [new Blob(["cam"])] }; + + cancelRecording(recorder, false, chunks, webcam, webcamChunks); + + expect(chunks.current).toEqual([]); + expect(webcamChunks.current).toEqual([]); + expect(recorder.state).toBe("inactive"); + expect(webcam.state).toBe("inactive"); + }); + }); +}); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index a49a23c..10905d1 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -34,8 +34,12 @@ const WEBCAM_SUFFIX = "-webcam"; type UseScreenRecorderReturn = { recording: boolean; + paused: boolean; countdownActive: boolean; toggleRecording: () => void; + pauseRecording: () => void; + resumeRecording: () => void; + cancelRecording: () => void; preparePermissions: (options?: { startup?: boolean }) => Promise; isMacOS: boolean; microphoneEnabled: boolean; @@ -54,6 +58,7 @@ type UseScreenRecorderReturn = { export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); + const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); const [countdownActive, setCountdownActive] = useState(false); const [isMacOS, setIsMacOS] = useState(false); @@ -299,6 +304,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }, [webcamDeviceId, webcamEnabled]); const stopRecording = useRef(() => { + setPaused(false); if (nativeScreenRecording.current) { nativeScreenRecording.current = false; setRecording(false); @@ -328,10 +334,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - if (mediaRecorder.current?.state === "recording") { + const recorder = mediaRecorder.current; + const recorderState = recorder?.state; + if (recorder && (recorderState === "recording" || recorderState === "paused")) { + if (recorderState === "paused") { + recorder.resume(); + } pendingWebcamPathPromise.current = stopWebcamRecorder(); cleanupCapturedMedia(); - mediaRecorder.current.stop(); + recorder.stop(); setRecording(false); window.electronAPI?.setRecordingState(false); } @@ -737,6 +748,86 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + const pauseRecording = useCallback(() => { + if (!recording || paused) return; + if (nativeScreenRecording.current) { + // Native captures cannot truly pause, but we pause the timer/UI and webcam + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + setPaused(true); + return; + } + if (mediaRecorder.current?.state === "recording") { + mediaRecorder.current.pause(); + if (webcamRecorder.current?.state === "recording") { + webcamRecorder.current.pause(); + } + setPaused(true); + } + }, [recording, paused]); + + const resumeRecording = useCallback(() => { + if (!recording || !paused) return; + if (nativeScreenRecording.current) { + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + setPaused(false); + return; + } + if (mediaRecorder.current?.state === "paused") { + mediaRecorder.current.resume(); + if (webcamRecorder.current?.state === "paused") { + webcamRecorder.current.resume(); + } + setPaused(false); + } + }, [recording, paused]); + + const cancelRecording = useCallback(() => { + if (!recording) return; + setPaused(false); + + // Discard webcam recording regardless of recording mode + webcamChunks.current = []; + if (webcamRecorder.current && webcamRecorder.current.state !== "inactive") { + webcamRecorder.current.stop(); + } + webcamRecorder.current = null; + webcamStream.current?.getTracks().forEach((t) => t.stop()); + webcamStream.current = null; + pendingWebcamPathPromise.current = null; + + if (nativeScreenRecording.current) { + nativeScreenRecording.current = false; + wgcRecording.current = false; + setRecording(false); + window.electronAPI?.setRecordingState(false); + void (async () => { + try { + const result = await window.electronAPI.stopNativeScreenRecording(); + if (result?.path) { + await window.electronAPI.deleteRecordingFile(result.path); + } + } catch { + // Best-effort cleanup + } + })(); + return; + } + + if (mediaRecorder.current) { + chunks.current = []; + cleanupCapturedMedia(); + if (mediaRecorder.current.state !== "inactive") { + mediaRecorder.current.stop(); + } + setRecording(false); + window.electronAPI?.setRecordingState(false); + } + }, [recording, cleanupCapturedMedia]); + const toggleRecording = async () => { if (starting || countdownActive) { return; @@ -765,8 +856,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return { recording, + paused, countdownActive, toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, preparePermissions, isMacOS, microphoneEnabled, @@ -783,4 +878,3 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setCountdownDelay, }; } - diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 9d3a9fa..1e7eced 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -17,7 +17,27 @@ "hideHudFromVideo": "Hide HUD from recording", "showHudInVideo": "Show HUD in recording", "hideHud": "Hide HUD", - "closeApp": "Close App" + "closeApp": "Close App", + "screens": "Screens", + "windows": "Windows", + "noSourcesFound": "No sources found", + "microphone": "Microphone", + "turnOffMicrophone": "Turn Off Microphone", + "selectMicToEnable": "Select a microphone to enable", + "noMicrophonesFound": "No microphones found", + "webcam": "Webcam", + "turnOffWebcam": "Turn Off Webcam", + "selectWebcamToEnable": "Select a webcam to enable", + "noWebcamsFound": "No webcams found", + "recordingsFolder": "Recordings Folder", + "language": "Language", + "paused": "PAUSED", + "rec": "REC", + "resume": "Resume", + "pause": "Pause", + "stop": "Stop", + "cancel": "Cancel", + "more": "More" }, "sourceSelector": { "loadingSources": "Loading sources...", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index d8a12ba..e4d50f3 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -17,7 +17,27 @@ "hideHudFromVideo": "Ocultar HUD en la grabación", "showHudInVideo": "Mostrar HUD en la grabación", "hideHud": "Ocultar HUD", - "closeApp": "Cerrar aplicación" + "closeApp": "Cerrar aplicación", + "screens": "Pantallas", + "windows": "Ventanas", + "noSourcesFound": "No se encontraron fuentes", + "microphone": "Micrófono", + "turnOffMicrophone": "Desactivar micrófono", + "selectMicToEnable": "Selecciona un micrófono para activar", + "noMicrophonesFound": "No se encontraron micrófonos", + "webcam": "Cámara", + "turnOffWebcam": "Desactivar cámara", + "selectWebcamToEnable": "Selecciona una cámara para activar", + "noWebcamsFound": "No se encontraron cámaras", + "recordingsFolder": "Carpeta de grabaciones", + "language": "Idioma", + "paused": "PAUSADO", + "rec": "REC", + "resume": "Reanudar", + "pause": "Pausa", + "stop": "Detener", + "cancel": "Cancelar", + "more": "Más" }, "sourceSelector": { "loadingSources": "Cargando fuentes...", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 6c3d91d..72c9573 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -17,7 +17,27 @@ "hideHudFromVideo": "在录制中隐藏 HUD", "showHudInVideo": "在录制中显示 HUD", "hideHud": "隐藏 HUD", - "closeApp": "关闭应用" + "closeApp": "关闭应用", + "screens": "屏幕", + "windows": "窗口", + "noSourcesFound": "未找到源", + "microphone": "麦克风", + "turnOffMicrophone": "关闭麦克风", + "selectMicToEnable": "选择一个麦克风以启用", + "noMicrophonesFound": "未找到麦克风", + "webcam": "摄像头", + "turnOffWebcam": "关闭摄像头", + "selectWebcamToEnable": "选择一个摄像头以启用", + "noWebcamsFound": "未找到摄像头", + "recordingsFolder": "录制文件夹", + "language": "语言", + "paused": "已暂停", + "rec": "录制中", + "resume": "恢复", + "pause": "暂停", + "stop": "停止", + "cancel": "取消", + "more": "更多" }, "sourceSelector": { "loadingSources": "正在加载源...",