diff --git a/electron/main.ts b/electron/main.ts index 156f0663..9503b706 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,28 +1,42 @@ -import { app, BrowserWindow, Tray, Menu, nativeImage, desktopCapturer, session, dialog, ipcMain, systemPreferences } from 'electron' -import { fileURLToPath } from 'node:url' -import path from 'node:path' -import fs from 'node:fs/promises' -import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows' -import { showCursor } from './cursorHider' -import { registerIpcHandlers, getSelectedSourceId, killWindowsCaptureProcess } from './ipc/handlers' +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + app, + BrowserWindow, + desktopCapturer, + dialog, + ipcMain, + Menu, + nativeImage, + session, + systemPreferences, + Tray, +} from "electron"; +import { showCursor } from "./cursorHider"; +import { + getSelectedSourceId, + killWindowsCaptureProcess, + registerIpcHandlers, +} from "./ipc/handlers"; +import { createEditorWindow, createHudOverlayWindow, createSourceSelectorWindow } from "./windows"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -if (process.platform === 'darwin') { - app.commandLine.appendSwitch('disable-features', 'MacCatapLoopbackAudioForScreenShare') +if (process.platform === "darwin") { + app.commandLine.appendSwitch("disable-features", "MacCatapLoopbackAudioForScreenShare"); } -export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings') - +export const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); async function ensureRecordingsDir() { - try { - await fs.mkdir(RECORDINGS_DIR, { recursive: true }) - console.log('RECORDINGS_DIR:', RECORDINGS_DIR) - console.log('User Data Path:', app.getPath('userData')) - } catch (error) { - console.error('Failed to create recordings directory:', error) - } + try { + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + console.log("RECORDINGS_DIR:", RECORDINGS_DIR); + console.log("User Data Path:", app.getPath("userData")); + } catch (error) { + console.error("Failed to create recordings directory:", error); + } } // The built directory structure @@ -34,415 +48,401 @@ async function ensureRecordingsDir() { // │ │ ├── main.js // │ │ └── preload.mjs // │ -process.env.APP_ROOT = path.join(__dirname, '..') +process.env.APP_ROOT = path.join(__dirname, ".."); // Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x -export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'] -export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron') -export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist') +export const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; +export const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); +export const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist"); -process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL + ? path.join(process.env.APP_ROOT, "public") + : RENDERER_DIST; // Window references -let mainWindow: BrowserWindow | null = null -let sourceSelectorWindow: BrowserWindow | null = null -let tray: Tray | null = null -let selectedSourceName = '' -let editorHasUnsavedChanges = false -let isForceClosing = false -const hasSingleInstanceLock = app.requestSingleInstanceLock() +let mainWindow: BrowserWindow | null = null; +let sourceSelectorWindow: BrowserWindow | null = null; +let tray: Tray | null = null; +let selectedSourceName = ""; +let editorHasUnsavedChanges = false; +let isForceClosing = false; +const hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!hasSingleInstanceLock) { - app.quit() + app.quit(); } function closeEditorWindowBypassingUnsavedPrompt(window: BrowserWindow | null) { - if (!window || window.isDestroyed()) { - return - } + if (!window || window.isDestroyed()) { + return; + } - isForceClosing = true - editorHasUnsavedChanges = false - window.close() + isForceClosing = true; + editorHasUnsavedChanges = false; + window.close(); } // Tray Icons -const defaultTrayIcon = getTrayIcon('app-icons/recordly-32.png'); -const recordingTrayIcon = getTrayIcon('rec-button.png'); +const defaultTrayIcon = getTrayIcon("app-icons/recordly-32.png"); +const recordingTrayIcon = getTrayIcon("rec-button.png"); -ipcMain.on('set-has-unsaved-changes', (_event, hasChanges: boolean) => { - editorHasUnsavedChanges = hasChanges -}) +ipcMain.on("set-has-unsaved-changes", (_event, hasChanges: boolean) => { + editorHasUnsavedChanges = hasChanges; +}); function createWindow() { - mainWindow = createHudOverlayWindow() + mainWindow = createHudOverlayWindow(); } function focusOrCreateMainWindow() { - if (BrowserWindow.getAllWindows().length === 0) { - createWindow() - return - } + if (BrowserWindow.getAllWindows().length === 0) { + createWindow(); + return; + } - if (mainWindow && !mainWindow.isDestroyed()) { - // On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram - // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken; - // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new - // window gets focus (creation path works). Only for HUD, not editor. - if ( - process.platform === 'linux' && - !mainWindow.isFocused() && - !isEditorWindow(mainWindow) - ) { - const win = mainWindow - mainWindow = null - win.once('closed', () => createWindow()) - win.destroy() - return - } - mainWindow.show() - if (mainWindow.isMinimized()) mainWindow.restore() - mainWindow.moveTop() - mainWindow.focus() - } + if (mainWindow && !mainWindow.isDestroyed()) { + // On Linux/Wayland, focus() often doesn't take effect (compositor ignores it). Apps like Telegram + // work because they receive an XDG activation token via StatusNotifierItem.ProvideXdgActivationToken; + // Electron's tray doesn't handle that yet. Workaround: destroy and recreate the HUD so the new + // window gets focus (creation path works). Only for HUD, not editor. + if (process.platform === "linux" && !mainWindow.isFocused() && !isEditorWindow(mainWindow)) { + const win = mainWindow; + mainWindow = null; + win.once("closed", () => createWindow()); + win.destroy(); + return; + } + mainWindow.show(); + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.moveTop(); + mainWindow.focus(); + } } function isEditorWindow(window: BrowserWindow) { - return window.webContents.getURL().includes('windowType=editor') + return window.webContents.getURL().includes("windowType=editor"); } -function sendEditorMenuAction(channel: 'menu-load-project' | 'menu-save-project' | 'menu-save-project-as') { - let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow +function sendEditorMenuAction( + channel: "menu-load-project" | "menu-save-project" | "menu-save-project-as", +) { + let targetWindow = BrowserWindow.getFocusedWindow() ?? mainWindow; - if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { - createEditorWindowWrapper() - targetWindow = mainWindow - if (!targetWindow || targetWindow.isDestroyed()) return + if (!targetWindow || targetWindow.isDestroyed() || !isEditorWindow(targetWindow)) { + createEditorWindowWrapper(); + targetWindow = mainWindow; + if (!targetWindow || targetWindow.isDestroyed()) return; - targetWindow.webContents.once('did-finish-load', () => { - if (!targetWindow || targetWindow.isDestroyed()) return - targetWindow.webContents.send(channel) - }) - return - } + targetWindow.webContents.once("did-finish-load", () => { + if (!targetWindow || targetWindow.isDestroyed()) return; + targetWindow.webContents.send(channel); + }); + return; + } - targetWindow.webContents.send(channel) + targetWindow.webContents.send(channel); } function setupApplicationMenu() { - const isMac = process.platform === 'darwin' - const template: Electron.MenuItemConstructorOptions[] = [] + const isMac = process.platform === "darwin"; + const template: Electron.MenuItemConstructorOptions[] = []; - if (isMac) { - template.push({ - label: app.name, - submenu: [ - { role: 'about' }, - { type: 'separator' }, - { role: 'services' }, - { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, - { type: 'separator' }, - { role: 'quit' }, - ], - }) - } + if (isMac) { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } - template.push( - { - label: 'File', - submenu: [ - { - label: 'Load Project…', - accelerator: 'CmdOrCtrl+O', - click: () => sendEditorMenuAction('menu-load-project'), - }, - { - label: 'Save Project…', - accelerator: 'CmdOrCtrl+S', - click: () => sendEditorMenuAction('menu-save-project'), - }, - { - label: 'Save Project As…', - accelerator: 'CmdOrCtrl+Shift+S', - click: () => sendEditorMenuAction('menu-save-project-as'), - }, - ...(isMac ? [] : [{ type: 'separator' as const }, { role: 'quit' as const }]), - ], - }, - { - label: 'Edit', - submenu: [ - { role: 'undo' }, - { role: 'redo' }, - { type: 'separator' }, - { role: 'cut' }, - { role: 'copy' }, - { role: 'paste' }, - { role: 'selectAll' }, - ], - }, - { - label: 'View', - submenu: [ - { role: 'reload' }, - { role: 'forceReload' }, - { role: 'toggleDevTools' }, - { type: 'separator' }, - { role: 'resetZoom' }, - { role: 'zoomIn' }, - { role: 'zoomOut' }, - { type: 'separator' }, - { role: 'togglefullscreen' }, - ], - }, - { - label: 'Window', - submenu: isMac - ? [ - { role: 'minimize' }, - { role: 'zoom' }, - { type: 'separator' }, - { role: 'front' }, - ] - : [ - { role: 'minimize' }, - { role: 'close' }, - ], - }, - ) + template.push( + { + label: "File", + submenu: [ + { + label: "Open Projects…", + accelerator: "CmdOrCtrl+O", + click: () => sendEditorMenuAction("menu-load-project"), + }, + { + label: "Save Project…", + accelerator: "CmdOrCtrl+S", + click: () => sendEditorMenuAction("menu-save-project"), + }, + { + label: "Save Project As…", + accelerator: "CmdOrCtrl+Shift+S", + click: () => sendEditorMenuAction("menu-save-project-as"), + }, + ...(isMac ? [] : [{ type: "separator" as const }, { role: "quit" as const }]), + ], + }, + { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { role: "selectAll" }, + ], + }, + { + label: "View", + submenu: [ + { role: "reload" }, + { role: "forceReload" }, + { role: "toggleDevTools" }, + { type: "separator" }, + { role: "resetZoom" }, + { role: "zoomIn" }, + { role: "zoomOut" }, + { type: "separator" }, + { role: "togglefullscreen" }, + ], + }, + { + label: "Window", + submenu: isMac + ? [{ role: "minimize" }, { role: "zoom" }, { type: "separator" }, { role: "front" }] + : [{ role: "minimize" }, { role: "close" }], + }, + ); - const menu = Menu.buildFromTemplate(template) - Menu.setApplicationMenu(menu) + const menu = Menu.buildFromTemplate(template); + Menu.setApplicationMenu(menu); } function createTray() { - tray = new Tray(defaultTrayIcon); - tray.on('click', () => focusOrCreateMainWindow()) + tray = new Tray(defaultTrayIcon); + tray.on("click", () => focusOrCreateMainWindow()); } function getPublicAssetPath(filename: string) { - return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename) + return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename); } function getAppImage(filename: string) { - return nativeImage.createFromPath(getPublicAssetPath(filename)) + return nativeImage.createFromPath(getPublicAssetPath(filename)); } function getTrayIcon(filename: string) { - return getAppImage(filename).resize({ - width: 24, - height: 24, - quality: 'best' - }); + return getAppImage(filename).resize({ + width: 24, + height: 24, + quality: "best", + }); } function syncDockIcon() { - if (process.platform !== 'darwin' || !app.dock) { - return - } + if (process.platform !== "darwin" || !app.dock) { + return; + } - const dockIcon = getAppImage('app-icons/recordly-512.png') - if (!dockIcon.isEmpty()) { - app.dock.setIcon(dockIcon) - } + const dockIcon = getAppImage("app-icons/recordly-512.png"); + if (!dockIcon.isEmpty()) { + app.dock.setIcon(dockIcon); + } } - function updateTrayMenu(recording: boolean = false) { - if (!tray) return; - const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; - const trayToolTip = recording ? `Recording: ${selectedSourceName}` : 'Recordly'; - const menuTemplate = recording - ? [ - { - label: "Stop Recording", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send("stop-recording-from-tray"); - } - }, - }, - ] - : [ - { - label: "Open", - click: () => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.show(); - mainWindow.focus(); - mainWindow.moveTop(); - } else { - createWindow(); - } - }, - }, - { - label: "Quit", - click: () => { - app.quit(); - }, - }, - ]; - tray.setImage(trayIcon); - tray.setToolTip(trayToolTip); - tray.setContextMenu(Menu.buildFromTemplate(menuTemplate)); + if (!tray) return; + const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; + const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "Recordly"; + const menuTemplate = recording + ? [ + { + label: "Stop Recording", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send("stop-recording-from-tray"); + } + }, + }, + ] + : [ + { + label: "Open", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + mainWindow.moveTop(); + } else { + createWindow(); + } + }, + }, + { + label: "Quit", + click: () => { + app.quit(); + }, + }, + ]; + tray.setImage(trayIcon); + tray.setToolTip(trayToolTip); + tray.setContextMenu(Menu.buildFromTemplate(menuTemplate)); } function createEditorWindowWrapper() { - if (mainWindow) { - closeEditorWindowBypassingUnsavedPrompt(mainWindow) - mainWindow = null - } - mainWindow = createEditorWindow() - editorHasUnsavedChanges = false + if (mainWindow) { + closeEditorWindowBypassingUnsavedPrompt(mainWindow); + mainWindow = null; + } + mainWindow = createEditorWindow(); + editorHasUnsavedChanges = false; - mainWindow.on('closed', () => { - if (mainWindow?.isDestroyed()) { - mainWindow = null - } - isForceClosing = false - editorHasUnsavedChanges = false - }) + mainWindow.on("closed", () => { + if (mainWindow?.isDestroyed()) { + mainWindow = null; + } + isForceClosing = false; + editorHasUnsavedChanges = false; + }); - mainWindow.on('close', (event) => { - if (isForceClosing || !editorHasUnsavedChanges) { - return - } + mainWindow.on("close", (event) => { + if (isForceClosing || !editorHasUnsavedChanges) { + return; + } - event.preventDefault() + event.preventDefault(); - const choice = dialog.showMessageBoxSync(mainWindow!, { - type: 'warning', - buttons: ['Save & Close', 'Discard & Close', 'Cancel'], - defaultId: 0, - cancelId: 2, - title: 'Unsaved Changes', - message: 'You have unsaved changes.', - detail: 'Do you want to save your project before closing?', - }) + const choice = dialog.showMessageBoxSync(mainWindow!, { + type: "warning", + buttons: ["Save & Close", "Discard & Close", "Cancel"], + defaultId: 0, + cancelId: 2, + title: "Unsaved Changes", + message: "You have unsaved changes.", + detail: "Do you want to save your project before closing?", + }); - if (choice === 0) { - mainWindow!.webContents.send('request-save-before-close') - ipcMain.once('save-before-close-done', (_event, saved: boolean) => { - if (saved) { - closeEditorWindowBypassingUnsavedPrompt(mainWindow) - } - }) - } else if (choice === 1) { - closeEditorWindowBypassingUnsavedPrompt(mainWindow) - } - }) + if (choice === 0) { + mainWindow!.webContents.send("request-save-before-close"); + ipcMain.once("save-before-close-done", (_event, saved: boolean) => { + if (saved) { + closeEditorWindowBypassingUnsavedPrompt(mainWindow); + } + }); + } else if (choice === 1) { + closeEditorWindowBypassingUnsavedPrompt(mainWindow); + } + }); } function createSourceSelectorWindowWrapper() { - sourceSelectorWindow = createSourceSelectorWindow() - sourceSelectorWindow.on('closed', () => { - sourceSelectorWindow = null - }) - return sourceSelectorWindow + sourceSelectorWindow = createSourceSelectorWindow(); + sourceSelectorWindow.on("closed", () => { + sourceSelectorWindow = null; + }); + return sourceSelectorWindow; } // On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. -app.on('before-quit', () => { - killWindowsCaptureProcess() - showCursor() -}) +app.on("before-quit", () => { + killWindowsCaptureProcess(); + showCursor(); +}); -app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { - app.quit() - } -}) - -app.on('activate', () => { - // On OS X it's common to re-create a window in the app when the - // dock icon is clicked and there are no other windows open. - focusOrCreateMainWindow() -}) - -app.on('second-instance', () => { - focusOrCreateMainWindow() -}) +app.on("window-all-closed", () => { + if (process.platform !== "darwin") { + app.quit(); + } +}); +app.on("activate", () => { + // On OS X it's common to re-create a window in the app when the + // dock icon is clicked and there are no other windows open. + focusOrCreateMainWindow(); +}); +app.on("second-instance", () => { + focusOrCreateMainWindow(); +}); // Register all IPC handlers when app is ready app.whenReady().then(async () => { - session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { - const allowed = ['media', 'audioCapture', 'microphone'] - return allowed.includes(permission) - }) + session.defaultSession.setPermissionCheckHandler((_webContents, permission) => { + const allowed = ["media", "audioCapture", "microphone"]; + return allowed.includes(permission); + }); - session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { - const allowed = ['media', 'audioCapture', 'microphone'] - callback(allowed.includes(permission)) - }) + session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => { + const allowed = ["media", "audioCapture", "microphone"]; + callback(allowed.includes(permission)); + }); - if (process.platform === 'darwin') { - const micStatus = systemPreferences.getMediaAccessStatus('microphone') - if (micStatus !== 'granted') { - await systemPreferences.askForMediaAccess('microphone') - } - } + if (process.platform === "darwin") { + const micStatus = systemPreferences.getMediaAccessStatus("microphone"); + if (micStatus !== "granted") { + await systemPreferences.askForMediaAccess("microphone"); + } + } - ipcMain.on('hud-overlay-close', () => { - app.quit(); - }); - syncDockIcon() - createTray() - updateTrayMenu() - setupApplicationMenu() - // Ensure recordings directory exists - await ensureRecordingsDir() + ipcMain.on("hud-overlay-close", () => { + app.quit(); + }); + syncDockIcon(); + createTray(); + updateTrayMenu(); + setupApplicationMenu(); + // Ensure recordings directory exists + await ensureRecordingsDir(); - registerIpcHandlers( - createEditorWindowWrapper, - createSourceSelectorWindowWrapper, - () => mainWindow, - () => sourceSelectorWindow, - (recording: boolean, sourceName: string) => { - selectedSourceName = sourceName - if (!tray) createTray(); - updateTrayMenu(recording); - if (!recording) { - if (mainWindow) mainWindow.restore(); - } - } - ) + registerIpcHandlers( + createEditorWindowWrapper, + createSourceSelectorWindowWrapper, + () => mainWindow, + () => sourceSelectorWindow, + (recording: boolean, sourceName: string) => { + selectedSourceName = sourceName; + if (!tray) createTray(); + updateTrayMenu(recording); + if (!recording) { + if (mainWindow) mainWindow.restore(); + } + }, + ); - // Register the display media handler so that renderer's getDisplayMedia() - // calls land on the pre-selected source without showing a system picker. - // - // IMPORTANT: The callback must receive a plain { id, name } Video object. - // Passing the full DesktopCapturerSource (with thumbnail, appIcon, etc.) - // via an unsafe cast breaks Electron's internal cursor-constraint - // propagation and causes cursor: 'never' from the renderer to be silently - // ignored by the native capture pipeline. - session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { - try { - const sources = await desktopCapturer.getSources({ types: ['screen', 'window'] }) - const sourceId = getSelectedSourceId() - const source = sourceId - ? sources.find(s => s.id === sourceId) ?? sources[0] - : sources[0] - if (source) { - callback({ - video: { id: source.id, name: source.name }, - }) - } else { - callback({}) - } - } catch (error) { - console.error('setDisplayMediaRequestHandler error:', error) - callback({}) - } - }) - - createWindow() -}) + // Register the display media handler so that renderer's getDisplayMedia() + // calls land on the pre-selected source without showing a system picker. + // + // IMPORTANT: The callback must receive a plain { id, name } Video object. + // Passing the full DesktopCapturerSource (with thumbnail, appIcon, etc.) + // via an unsafe cast breaks Electron's internal cursor-constraint + // propagation and causes cursor: 'never' from the renderer to be silently + // ignored by the native capture pipeline. + session.defaultSession.setDisplayMediaRequestHandler(async (_request, callback) => { + try { + const sources = await desktopCapturer.getSources({ types: ["screen", "window"] }); + const sourceId = getSelectedSourceId(); + const source = sourceId ? (sources.find((s) => s.id === sourceId) ?? sources[0]) : sources[0]; + if (source) { + callback({ + video: { id: source.id, name: source.name }, + }); + } else { + callback({}); + } + } catch (error) { + console.error("setDisplayMediaRequestHandler error:", error); + callback({}); + } + }); + createWindow(); +}); diff --git a/electron/windows.ts b/electron/windows.ts index eade375c..cb265237 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -101,10 +101,7 @@ function getHudOverlayBounds(expanded: boolean) { const windowBottom = Math.min(preferredBottom, maximumSafeBottom); const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.max( - workArea.y + HUD_EDGE_MARGIN_DIP, - Math.floor(windowBottom - windowHeight), - ); + const y = Math.max(workArea.y + HUD_EDGE_MARGIN_DIP, Math.floor(windowBottom - windowHeight)); return { x, @@ -222,7 +219,10 @@ export function createHudOverlayWindow(): BrowserWindow { height: initialBounds.height, minWidth: HUD_MIN_WINDOW_WIDTH, minHeight: HUD_COMPACT_HEIGHT, - maxHeight: Math.max(HUD_COMPACT_HEIGHT, getScreen().getPrimaryDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2), + maxHeight: Math.max( + HUD_COMPACT_HEIGHT, + getScreen().getPrimaryDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, + ), x: initialBounds.x, y: initialBounds.y, frame: false, @@ -236,6 +236,7 @@ export function createHudOverlayWindow(): BrowserWindow { preload: path.join(__dirname, "preload.mjs"), nodeIntegration: false, contextIsolation: true, + webSecurity: false, backgroundThrottling: false, }, }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 02e011a9..aa4f7c10 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,40 +1,43 @@ -import type { ReactNode } from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { AnimatePresence, motion } from "motion/react"; import { - Monitor, - Mic, - MicOff, - ChevronUp, - Pause, - Square, - X, - Play, - Minus, - MoreVertical, - FolderOpen, - VideoIcon, - Languages, - Volume2, - VolumeX, AppWindow, + ChevronUp, Eye, EyeOff, + FolderOpen, + Languages, + Mic, + MicOff, + Minus, + Monitor, + MoreVertical, + Pause, + Play, + Square, Timer, Video, + VideoIcon, VideoOff, + Volume2, + VolumeX, + X, } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "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 { ContentClamp } from "../ui/content-clamp"; -import { useI18n } from "@/contexts/I18nContext"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; -import type { AppLocale } from "@/i18n/config"; +import ProjectBrowserDialog, { + type ProjectLibraryEntry, +} from "../video-editor/ProjectBrowserDialog"; import styles from "./LaunchWindow.module.css"; interface DesktopSource { @@ -60,15 +63,18 @@ function IconButton({ onClick, title, className = "", + buttonRef, children, }: { onClick?: () => void; title?: string; className?: string; + buttonRef?: React.Ref; children: ReactNode; }) { return ( {microphoneEnabled ? : } @@ -642,15 +716,25 @@ export function LaunchWindow() { - toggleDropdown("more")} title={t("recording.more")}> + toggleDropdown("more")} + title={t("recording.more")} + > - window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}> + window.electronAPI?.hudOverlayHide?.()} + title={t("recording.hideHud")} + > - window.electronAPI?.hudOverlayClose?.()} title={t("recording.closeApp")}> + window.electronAPI?.hudOverlayClose?.()} + title={t("recording.closeApp")} + > @@ -662,214 +746,264 @@ export function LaunchWindow() { style={{ height: "100vh" }} ref={dropdownRef} > -
+
{/* Only the visible HUD content should become interactive. */}
+ {projectBrowserOpen ? ( +
+ { + void openProjectFromLibrary(projectPath); + }} + /> +
+ ) : null} {activeDropdown !== "none" && (
- {activeDropdown === "sources" && ( - <> - {sourcesLoading ? ( -
-
-
- ) : ( - <> - {screenSources.length > 0 && ( - <> -
{t("recording.screens")}
- {screenSources.map((source) => ( - } - selected={selectedSource === source.name} - onClick={() => handleSourceSelect(source)} - > - {source.name} - - ))} - - )} - {windowSources.length > 0 && ( - <> -
0 ? { marginTop: 4 } : undefined}> - {t("recording.windows")} -
- {windowSources.map((source) => ( - } - selected={selectedSource === source.name} - onClick={() => handleSourceSelect(source)} - > - {source.appName && source.appName !== source.name - ? `${source.appName} — ${source.name}` - : source.name} - - ))} - - )} - {screenSources.length === 0 && windowSources.length === 0 && ( -
- {t("recording.noSourcesFound")} -
- )} - - )} - - )} - - {activeDropdown === "mic" && ( - <> -
{t("recording.microphone")}
- : } - selected={systemAudioEnabled} - onClick={() => { - setSystemAudioEnabled(!systemAudioEnabled); - }} - > - {systemAudioEnabled ? t("recording.disableSystemAudio") : t("recording.enableSystemAudio")} - - - {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")} -
- )} - - )} - - {activeDropdown === "webcam" && ( - <> -
{t("recording.webcam")}
- {webcamEnabled && ( - } - onClick={() => { setWebcamEnabled(false); setActiveDropdown("none"); }} - > - {t("recording.turnOffWebcam")} - - )} - {!webcamEnabled && ( -
- {t("recording.selectWebcamToEnable")} -
- )} - {showWebcamControls && ( -
-
-
diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index 5ac943d1..7cd4156b 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -1,5 +1,4 @@ -import { useMemo } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toFileUrl } from "./projectPersistence"; export type ProjectLibraryEntry = { @@ -16,39 +15,165 @@ type ProjectBrowserDialogProps = { onOpenChange: (open: boolean) => void; entries: ProjectLibraryEntry[]; onOpenProject: (projectPath: string) => void; + anchorRef?: React.RefObject; + preferredDirection?: "up" | "down" | "auto"; + onPanelHeightChange?: (height: number) => void; + renderMode?: "floating" | "inline"; }; - -function formatUpdatedAt(updatedAt: number) { - try { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(updatedAt); - } catch { - return new Date(updatedAt).toLocaleString(); - } -} export default function ProjectBrowserDialog({ open, onOpenChange, entries, onOpenProject, + anchorRef, + preferredDirection = "auto", + onPanelHeightChange, + renderMode = "floating", }: ProjectBrowserDialogProps) { + const panelRef = useRef(null); + const [position, setPosition] = useState({ top: 72, left: 16, maxHeight: 360 }); const visibleEntries = useMemo(() => entries.slice(0, 24), [entries]); - return ( - - - - - Projects - - -
+ const updatePosition = useCallback(() => { + if (typeof window === "undefined") { + return; + } + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = 12; + const gap = 8; + const fallbackMaxHeight = Math.min(360, viewportHeight - margin * 2); + const panelWidth = Math.min(280, Math.max(248, viewportWidth - margin * 2)); + const panelHeight = panelRef.current?.offsetHeight ?? fallbackMaxHeight; + const anchorRect = anchorRef?.current?.getBoundingClientRect(); + const availableAbove = anchorRect + ? Math.max(120, anchorRect.top - margin - gap) + : fallbackMaxHeight; + const availableBelow = anchorRect + ? Math.max(120, viewportHeight - anchorRect.bottom - margin - gap) + : fallbackMaxHeight; + const direction = + preferredDirection === "auto" + ? availableAbove > availableBelow + ? "up" + : "down" + : preferredDirection; + const maxHeight = Math.min( + fallbackMaxHeight, + direction === "up" ? availableAbove : availableBelow, + ); + + const nextTop = anchorRect + ? direction === "up" + ? Math.max(margin, anchorRect.top - Math.min(panelHeight, maxHeight) - gap) + : Math.min( + anchorRect.bottom + gap, + Math.max(margin, viewportHeight - Math.min(panelHeight, maxHeight) - margin), + ) + : Math.min(56, Math.max(margin, viewportHeight - Math.min(panelHeight, maxHeight) - margin)); + const alignedLeft = anchorRect + ? anchorRect.right - panelWidth + : viewportWidth - panelWidth - 16; + + setPosition({ + top: Math.max(margin, nextTop), + left: Math.max(margin, Math.min(alignedLeft, viewportWidth - panelWidth - margin)), + maxHeight, + }); + }, [anchorRef, preferredDirection]); + + useEffect(() => { + if (!open) { + return; + } + + updatePosition(); + + const handleViewportChange = () => updatePosition(); + window.addEventListener("resize", handleViewportChange); + window.addEventListener("scroll", handleViewportChange, true); + + return () => { + window.removeEventListener("resize", handleViewportChange); + window.removeEventListener("scroll", handleViewportChange, true); + }; + }, [open, updatePosition]); + + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + + if (panelRef.current?.contains(target) || anchorRef?.current?.contains(target)) { + return; + } + + onOpenChange(false); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onOpenChange(false); + } + }; + + document.addEventListener("pointerdown", handlePointerDown); + window.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("pointerdown", handlePointerDown); + window.removeEventListener("keydown", handleKeyDown); + }; + }, [anchorRef, onOpenChange, open]); + + useEffect(() => { + if (!open) { + onPanelHeightChange?.(0); + return; + } + + onPanelHeightChange?.(panelRef.current?.offsetHeight ?? 0); + + if (!panelRef.current || !onPanelHeightChange || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(() => { + onPanelHeightChange(panelRef.current?.offsetHeight ?? 0); + }); + observer.observe(panelRef.current); + + return () => { + observer.disconnect(); + onPanelHeightChange(0); + }; + }, [onPanelHeightChange, open]); + + if (!open) { + return null; + } + + if (renderMode === "inline") { + return ( +
+
+
Projects
+
+
{visibleEntries.length > 0 ? ( -
+
{visibleEntries.map((entry) => { const thumbnailSrc = entry.thumbnailPath ? toFileUrl(entry.thumbnailPath) : null; return ( @@ -56,48 +181,112 @@ export default function ProjectBrowserDialog({ key={entry.path} type="button" onClick={() => onOpenProject(entry.path)} - className="group flex flex-col gap-2 bg-transparent text-left outline-none transition focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" + className="group flex flex-col gap-1 rounded-lg bg-transparent p-0.5 text-left outline-none transition focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" > -
+
{thumbnailSrc ? ( ) : ( -
+
No preview yet
)} {entry.isCurrent ? ( -
- +
+ Current
) : null}
-
-
+
+
{entry.name}
-
- Updated {formatUpdatedAt(entry.updatedAt)} -
); })}
) : ( -
-
No saved projects yet
+
+
No saved projects yet
)}
- -
+
+ ); + } + + return ( +
+
+
+
Projects
+
+
+ {visibleEntries.length > 0 ? ( +
+ {visibleEntries.map((entry) => { + const thumbnailSrc = entry.thumbnailPath ? toFileUrl(entry.thumbnailPath) : null; + return ( + + ); + })} +
+ ) : ( +
+
No saved projects yet
+
+ )} +
+
+
); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 8f8f35a3..dc11eec5 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -418,6 +418,8 @@ export default function VideoEditor() { const [previewVersion, setPreviewVersion] = useState(0); const videoPlaybackRef = useRef(null); + const projectBrowserTriggerRef = useRef(null); + const projectBrowserFallbackTriggerRef = useRef(null); const nextZoomIdRef = useRef(1); const nextTrimIdRef = useRef(1); const nextSpeedIdRef = useRef(1); @@ -1682,9 +1684,14 @@ export default function VideoEditor() { ); const handleOpenProjectBrowser = useCallback(async () => { + if (projectBrowserOpen) { + setProjectBrowserOpen(false); + return; + } + await refreshProjectLibrary(); setProjectBrowserOpen(true); - }, [refreshProjectLibrary]); + }, [projectBrowserOpen, refreshProjectLibrary]); useEffect(() => { const removeLoadListener = window.electronAPI.onMenuLoadProject(() => { @@ -2888,26 +2895,43 @@ export default function VideoEditor() { }) : t("editor.exportStatus.preparing", "Preparing export..."); + const projectBrowser = ( + { + void handleOpenProjectFromLibrary(projectPath); + }} + /> + ); + if (loading) { return ( -
+
Loading video...
+ {projectBrowser} +
); } if (error) { return ( -
+
{error}
+ {projectBrowser} +
); } @@ -2972,9 +2996,10 @@ export default function VideoEditor() {