fix(projects): align HUD browser behavior

This commit is contained in:
webadderall
2026-03-23 21:18:18 +11:00
parent dd16ad61f0
commit 1df365b5a5
5 changed files with 996 additions and 654 deletions
+350 -350
View File
@@ -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();
});
+6 -5
View File
@@ -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,
},
});
+380 -246
View File
@@ -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<HTMLButtonElement>;
children: ReactNode;
}) {
return (
<button
ref={buttonRef}
type="button"
className={`${styles.ib} ${styles.electronNoDrag} ${className}`}
onClick={onClick}
@@ -169,7 +175,11 @@ export function LaunchWindow() {
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [, setRecordingsDirectory] = useState<string | null>(null);
const [activeDropdown, setActiveDropdown] = useState<"none" | "sources" | "more" | "mic" | "countdown" | "webcam">("none");
const [activeDropdown, setActiveDropdown] = useState<
"none" | "sources" | "more" | "mic" | "countdown" | "webcam"
>("none");
const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]);
const [projectBrowserOpen, setProjectBrowserOpen] = useState(false);
const [sources, setSources] = useState<DesktopSource[]>([]);
const [sourcesLoading, setSourcesLoading] = useState(false);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
@@ -177,13 +187,15 @@ export function LaunchWindow() {
const dropdownRef = useRef<HTMLDivElement>(null);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
const moreButtonRef = useRef<HTMLButtonElement | null>(null);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const micDropdownOpen = activeDropdown === "mic";
const webcamDropdownOpen = activeDropdown === "webcam";
const showWebcamControls = webcamEnabled && !recording;
const { devices, selectedDeviceId, setSelectedDeviceId } =
useMicrophoneDevices(microphoneEnabled || micDropdownOpen);
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(
microphoneEnabled || micDropdownOpen,
);
const {
devices: videoDevices,
selectedDeviceId: selectedVideoDeviceId,
@@ -238,7 +250,9 @@ export function LaunchWindow() {
webcamPreviewRef.current.srcObject = previewStream;
const playPromise = webcamPreviewRef.current.play();
if (playPromise) {
playPromise.catch(() => {});
playPromise.catch(() => {
// Ignore autoplay interruptions while the preview element mounts.
});
}
} catch (error) {
console.warn("Failed to start live webcam preview:", error);
@@ -291,7 +305,9 @@ export function LaunchWindow() {
}, [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}`;
};
@@ -347,7 +363,9 @@ export function LaunchWindow() {
}
};
void loadPlatform();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
@@ -363,17 +381,19 @@ export function LaunchWindow() {
}
};
void loadHudCaptureProtection();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const expanded = activeDropdown !== "none";
const expanded = activeDropdown !== "none" || projectBrowserOpen;
window.electronAPI.setHudOverlayExpanded(expanded);
return () => {
window.electronAPI.setHudOverlayExpanded(false);
};
}, [activeDropdown]);
}, [activeDropdown, projectBrowserOpen]);
useEffect(() => {
const hudContent = hudContentRef.current;
@@ -399,7 +419,7 @@ export function LaunchWindow() {
window.electronAPI.setHudOverlayCompactWidth(measuredWidth);
window.electronAPI.setHudOverlayMeasuredHeight(
measuredHeight,
activeDropdown !== "none",
activeDropdown !== "none" || projectBrowserOpen,
);
};
@@ -424,12 +444,13 @@ export function LaunchWindow() {
cancelAnimationFrame(frameId);
}
};
}, [selectedSource, recording, paused, activeDropdown]);
}, [activeDropdown, projectBrowserOpen]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setActiveDropdown("none");
setProjectBrowserOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
@@ -478,6 +499,7 @@ export function LaunchWindow() {
}, []);
const toggleDropdown = (which: "sources" | "more" | "mic" | "countdown" | "webcam") => {
setProjectBrowserOpen(false);
setActiveDropdown(activeDropdown === which ? "none" : which);
if (activeDropdown !== which && which === "sources") fetchSources();
};
@@ -504,12 +526,41 @@ export function LaunchWindow() {
}
};
const openProjectFile = async () => {
const refreshProjectLibrary = useCallback(async () => {
try {
const result = await window.electronAPI.listProjectFiles();
if (!result.success) return;
setProjectLibraryEntries(result.entries);
} catch (error) {
console.error("Failed to load project library:", error);
}
}, []);
const openProjectBrowser = useCallback(async () => {
if (projectBrowserOpen) {
setProjectBrowserOpen(false);
return;
}
setActiveDropdown("none");
const result = await window.electronAPI.loadProjectFile();
if (result.canceled || !result.success) return;
await window.electronAPI.switchToEditor();
};
await refreshProjectLibrary();
setProjectBrowserOpen(true);
}, [projectBrowserOpen, refreshProjectLibrary]);
const openProjectFromLibrary = useCallback(async (projectPath: string) => {
try {
const result = await window.electronAPI.openProjectFileAtPath(projectPath);
if (result.canceled || !result.success) {
return;
}
setProjectBrowserOpen(false);
await window.electronAPI.switchToEditor();
} catch (error) {
console.error("Failed to open project from library:", error);
}
}, []);
const chooseRecordingsDirectory = async () => {
setActiveDropdown("none");
@@ -551,25 +602,40 @@ export function LaunchWindow() {
const recordingControls = (
<>
<div className="flex items-center gap-[5px]">
<div className={`w-[7px] h-[7px] rounded-full ${paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}`}`} />
<span className={`text-[10px] font-bold tracking-[0.06em] ${paused ? "text-[#fbbf24]" : "text-[#f43f5e]"}`}>
<div
className={`w-[7px] h-[7px] rounded-full ${paused ? "bg-[#fbbf24]" : `bg-[#f43f5e] ${styles.recDotBlink}`}`}
/>
<span
className={`text-[10px] font-bold tracking-[0.06em] ${paused ? "text-[#fbbf24]" : "text-[#f43f5e]"}`}
>
{paused ? t("recording.paused") : t("recording.rec")}
</span>
</div>
<span className={`font-mono text-xs font-semibold min-w-[52px] text-center tracking-[0.02em] ${paused ? "text-[#fbbf24]" : "text-[#eeeef2]"}`}>
<span
className={`font-mono text-xs font-semibold min-w-[52px] text-center tracking-[0.02em] ${paused ? "text-[#fbbf24]" : "text-[#eeeef2]"}`}
>
{formatTime(elapsed)}
</span>
<Separator />
<IconButton title={microphoneEnabled ? t("recording.disableMicrophone") : t("recording.enableMicrophone")} className={microphoneEnabled ? styles.ibActive : ""}>
<IconButton
title={
microphoneEnabled ? t("recording.disableMicrophone") : t("recording.enableMicrophone")
}
className={microphoneEnabled ? styles.ibActive : ""}
>
{microphoneEnabled ? <Mic size={18} /> : <MicOff size={18} />}
</IconButton>
<Separator />
<IconButton onClick={paused ? resumeRecording : pauseRecording} title={paused ? t("recording.resume") : t("recording.pause")} className={paused ? styles.ibGreen : ""}>
<IconButton
onClick={paused ? resumeRecording : pauseRecording}
title={paused ? t("recording.resume") : t("recording.pause")}
className={paused ? styles.ibGreen : ""}
>
{paused ? <Play size={18} fill="currentColor" strokeWidth={0} /> : <Pause size={18} />}
</IconButton>
@@ -577,7 +643,10 @@ export function LaunchWindow() {
<Square size={16} fill="currentColor" strokeWidth={0} />
</IconButton>
<IconButton onClick={() => window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}>
<IconButton
onClick={() => window.electronAPI?.hudOverlayHide?.()}
title={t("recording.hideHud")}
>
<Minus size={16} />
</IconButton>
@@ -599,14 +668,19 @@ export function LaunchWindow() {
<ContentClamp className={styles.sourceLabel} truncateLength={36}>
{selectedSource}
</ContentClamp>
<ChevronUp size={10} className={`text-[#6b6b78] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`} />
<ChevronUp
size={10}
className={`text-[#6b6b78] ml-0.5 transition-transform duration-200 ${activeDropdown === "sources" ? "" : "rotate-180"}`}
/>
</button>
<Separator />
<IconButton
onClick={toggleMicrophone}
title={microphoneEnabled ? t("recording.disableMicrophone") : t("recording.enableMicrophone")}
title={
microphoneEnabled ? t("recording.disableMicrophone") : t("recording.enableMicrophone")
}
className={microphoneEnabled ? styles.ibActive : ""}
>
{microphoneEnabled ? <Mic size={18} /> : <MicOff size={18} />}
@@ -642,15 +716,25 @@ export function LaunchWindow() {
<Separator />
<IconButton onClick={() => toggleDropdown("more")} title={t("recording.more")}>
<IconButton
buttonRef={moreButtonRef}
onClick={() => toggleDropdown("more")}
title={t("recording.more")}
>
<MoreVertical size={18} />
</IconButton>
<IconButton onClick={() => window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}>
<IconButton
onClick={() => window.electronAPI?.hudOverlayHide?.()}
title={t("recording.hideHud")}
>
<Minus size={16} />
</IconButton>
<IconButton onClick={() => window.electronAPI?.hudOverlayClose?.()} title={t("recording.closeApp")}>
<IconButton
onClick={() => window.electronAPI?.hudOverlayClose?.()}
title={t("recording.closeApp")}
>
<X size={16} />
</IconButton>
</>
@@ -662,214 +746,264 @@ export function LaunchWindow() {
style={{ height: "100vh" }}
ref={dropdownRef}
>
<div
ref={hudContentRef}
className="flex flex-col items-center overflow-visible"
>
<div ref={hudContentRef} className="flex flex-col items-center overflow-visible">
{/* Only the visible HUD content should become interactive. */}
<div className={styles.menuArea}>
{projectBrowserOpen ? (
<div className={styles.electronNoDrag}>
<ProjectBrowserDialog
open={projectBrowserOpen}
onOpenChange={setProjectBrowserOpen}
entries={projectLibraryEntries}
renderMode="inline"
onOpenProject={(projectPath) => {
void openProjectFromLibrary(projectPath);
}}
/>
</div>
) : null}
{activeDropdown !== "none" && (
<div className={`${styles.menuCard} ${styles.electronNoDrag}`}>
{activeDropdown === "sources" && (
<>
{sourcesLoading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#6b6b78]" />
</div>
) : (
<>
{screenSources.length > 0 && (
<>
<div className={styles.ddLabel}>{t("recording.screens")}</div>
{screenSources.map((source) => (
<DropdownItem
key={source.id}
icon={<Monitor size={16} />}
selected={selectedSource === source.name}
onClick={() => handleSourceSelect(source)}
>
{source.name}
</DropdownItem>
))}
</>
)}
{windowSources.length > 0 && (
<>
<div className={styles.ddLabel} style={screenSources.length > 0 ? { marginTop: 4 } : undefined}>
{t("recording.windows")}
</div>
{windowSources.map((source) => (
<DropdownItem
key={source.id}
icon={<AppWindow size={16} />}
selected={selectedSource === source.name}
onClick={() => handleSourceSelect(source)}
>
{source.appName && source.appName !== source.name
? `${source.appName} — ${source.name}`
: source.name}
</DropdownItem>
))}
</>
)}
{screenSources.length === 0 && windowSources.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noSourcesFound")}
</div>
)}
</>
)}
</>
)}
{activeDropdown === "mic" && (
<>
<div className={styles.ddLabel}>{t("recording.microphone")}</div>
<DropdownItem
icon={systemAudioEnabled ? <Volume2 size={16} /> : <VolumeX size={16} />}
selected={systemAudioEnabled}
onClick={() => {
setSystemAudioEnabled(!systemAudioEnabled);
}}
>
{systemAudioEnabled ? t("recording.disableSystemAudio") : t("recording.enableSystemAudio")}
</DropdownItem>
<Separator />
{microphoneEnabled && (
<DropdownItem
icon={<MicOff size={16} />}
onClick={() => { setMicrophoneEnabled(false); setActiveDropdown("none"); }}
>
{t("recording.turnOffMicrophone")}
</DropdownItem>
)}
{!microphoneEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
{t("recording.selectMicToEnable")}
</div>
)}
{devices.map((device) => (
<MicDeviceRow
key={device.deviceId}
device={device}
selected={microphoneEnabled && (microphoneDeviceId === device.deviceId || selectedDeviceId === device.deviceId)}
onSelect={() => {
setMicrophoneEnabled(true);
setSelectedDeviceId(device.deviceId);
setMicrophoneDeviceId(device.deviceId);
}}
/>
))}
{devices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noMicrophonesFound")}
</div>
)}
</>
)}
{activeDropdown === "webcam" && (
<>
<div className={styles.ddLabel}>{t("recording.webcam")}</div>
{webcamEnabled && (
<DropdownItem
icon={<VideoOff size={16} />}
onClick={() => { setWebcamEnabled(false); setActiveDropdown("none"); }}
>
{t("recording.turnOffWebcam")}
</DropdownItem>
)}
{!webcamEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
{t("recording.selectWebcamToEnable")}
</div>
)}
{showWebcamControls && (
<div className="flex justify-center px-3 py-2">
<div className="h-24 w-24 overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10">
<video
ref={webcamPreviewRef}
className="h-full w-full object-cover"
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
{activeDropdown === "sources" && (
<>
{sourcesLoading ? (
<div className="flex items-center justify-center py-6">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[#6b6b78]" />
</div>
</div>
)}
{videoDevices.map((device) => (
) : (
<>
{screenSources.length > 0 && (
<>
<div className={styles.ddLabel}>{t("recording.screens")}</div>
{screenSources.map((source) => (
<DropdownItem
key={source.id}
icon={<Monitor size={16} />}
selected={selectedSource === source.name}
onClick={() => handleSourceSelect(source)}
>
{source.name}
</DropdownItem>
))}
</>
)}
{windowSources.length > 0 && (
<>
<div
className={styles.ddLabel}
style={screenSources.length > 0 ? { marginTop: 4 } : undefined}
>
{t("recording.windows")}
</div>
{windowSources.map((source) => (
<DropdownItem
key={source.id}
icon={<AppWindow size={16} />}
selected={selectedSource === source.name}
onClick={() => handleSourceSelect(source)}
>
{source.appName && source.appName !== source.name
? `${source.appName} — ${source.name}`
: source.name}
</DropdownItem>
))}
</>
)}
{screenSources.length === 0 && windowSources.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noSourcesFound")}
</div>
)}
</>
)}
</>
)}
{activeDropdown === "mic" && (
<>
<div className={styles.ddLabel}>{t("recording.microphone")}</div>
<DropdownItem
key={device.deviceId}
icon={webcamEnabled && (webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? <Video size={16} /> : <VideoOff size={16} />}
selected={webcamEnabled && (webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId)}
icon={systemAudioEnabled ? <Volume2 size={16} /> : <VolumeX size={16} />}
selected={systemAudioEnabled}
onClick={() => {
setWebcamEnabled(true);
setSelectedVideoDeviceId(device.deviceId);
setWebcamDeviceId(device.deviceId);
setSystemAudioEnabled(!systemAudioEnabled);
}}
>
{device.label}
{systemAudioEnabled
? t("recording.disableSystemAudio")
: t("recording.enableSystemAudio")}
</DropdownItem>
))}
{videoDevices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noWebcamsFound")}
<Separator />
{microphoneEnabled && (
<DropdownItem
icon={<MicOff size={16} />}
onClick={() => {
setMicrophoneEnabled(false);
setActiveDropdown("none");
}}
>
{t("recording.turnOffMicrophone")}
</DropdownItem>
)}
{!microphoneEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
{t("recording.selectMicToEnable")}
</div>
)}
{devices.map((device) => (
<MicDeviceRow
key={device.deviceId}
device={device}
selected={
microphoneEnabled &&
(microphoneDeviceId === device.deviceId ||
selectedDeviceId === device.deviceId)
}
onSelect={() => {
setMicrophoneEnabled(true);
setSelectedDeviceId(device.deviceId);
setMicrophoneDeviceId(device.deviceId);
}}
/>
))}
{devices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noMicrophonesFound")}
</div>
)}
</>
)}
{activeDropdown === "webcam" && (
<>
<div className={styles.ddLabel}>{t("recording.webcam")}</div>
{webcamEnabled && (
<DropdownItem
icon={<VideoOff size={16} />}
onClick={() => {
setWebcamEnabled(false);
setActiveDropdown("none");
}}
>
{t("recording.turnOffWebcam")}
</DropdownItem>
)}
{!webcamEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
{t("recording.selectWebcamToEnable")}
</div>
)}
{showWebcamControls && (
<div className="flex justify-center px-3 py-2">
<div className="h-24 w-24 overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10">
<video
ref={webcamPreviewRef}
className="h-full w-full object-cover"
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
</div>
)}
{videoDevices.map((device) => (
<DropdownItem
key={device.deviceId}
icon={
webcamEnabled &&
(webcamDeviceId === device.deviceId ||
selectedVideoDeviceId === device.deviceId) ? (
<Video size={16} />
) : (
<VideoOff size={16} />
)
}
selected={
webcamEnabled &&
(webcamDeviceId === device.deviceId ||
selectedVideoDeviceId === device.deviceId)
}
onClick={() => {
setWebcamEnabled(true);
setSelectedVideoDeviceId(device.deviceId);
setWebcamDeviceId(device.deviceId);
}}
>
{device.label}
</DropdownItem>
))}
{videoDevices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noWebcamsFound")}
</div>
)}
</>
)}
{activeDropdown === "countdown" && (
<>
<div className={styles.ddLabel}>{t("recording.countdownDelay")}</div>
{COUNTDOWN_OPTIONS.map((delay) => (
<DropdownItem
key={delay}
icon={<Timer size={16} />}
selected={countdownDelay === delay}
onClick={() => {
setCountdownDelay(delay);
setActiveDropdown("none");
}}
>
{delay === 0 ? t("recording.noDelay") : `${delay}s`}
</DropdownItem>
))}
</>
)}
{activeDropdown === "more" && (
<>
{supportsHudCaptureProtection && (
<DropdownItem
icon={hideHudFromCapture ? <EyeOff size={16} /> : <Eye size={16} />}
selected={hideHudFromCapture}
onClick={() => {
void toggleHudCaptureProtection();
}}
>
{hideHudFromCapture
? t("recording.hideHudFromVideo")
: t("recording.showHudInVideo")}
</DropdownItem>
)}
<DropdownItem icon={<FolderOpen size={16} />} onClick={chooseRecordingsDirectory}>
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem icon={<VideoIcon size={16} />} onClick={openVideoFile}>
{t("recording.openVideoFile")}
</DropdownItem>
<DropdownItem
icon={<FolderOpen size={16} />}
onClick={() => void openProjectBrowser()}
>
{t("recording.openProject")}
</DropdownItem>
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
)}
</>
)}
{activeDropdown === "countdown" && (
<>
<div className={styles.ddLabel}>{t("recording.countdownDelay")}</div>
{COUNTDOWN_OPTIONS.map((delay) => (
<DropdownItem
key={delay}
icon={<Timer size={16} />}
selected={countdownDelay === delay}
onClick={() => { setCountdownDelay(delay); setActiveDropdown("none"); }}
>
{delay === 0 ? t("recording.noDelay") : `${delay}s`}
</DropdownItem>
))}
</>
)}
{activeDropdown === "more" && (
<>
{supportsHudCaptureProtection && (
<DropdownItem
icon={hideHudFromCapture ? <EyeOff size={16} /> : <Eye size={16} />}
selected={hideHudFromCapture}
onClick={() => {
void toggleHudCaptureProtection();
}}
>
{hideHudFromCapture ? t("recording.hideHudFromVideo") : t("recording.showHudInVideo")}
</DropdownItem>
)}
<DropdownItem icon={<FolderOpen size={16} />} onClick={chooseRecordingsDirectory}>
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem icon={<VideoIcon size={16} />} onClick={openVideoFile}>
{t("recording.openVideoFile")}
</DropdownItem>
<DropdownItem icon={<FolderOpen size={16} />} onClick={openProjectFile}>
{t("recording.openProject")}
</DropdownItem>
<div className={styles.ddLabel} style={{ marginTop: 4 }}>{t("recording.language")}</div>
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
icon={<Languages size={16} />}
selected={locale === code}
onClick={() => { setLocale(code as AppLocale); setActiveDropdown("none"); }}
>
{LOCALE_LABELS[code] ?? code}
</DropdownItem>
))}
</>
)}
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
icon={<Languages size={16} />}
selected={locale === code}
onClick={() => {
setLocale(code as AppLocale);
setActiveDropdown("none");
}}
>
{LOCALE_LABELS[code] ?? code}
</DropdownItem>
))}
</>
)}
</div>
)}
</div>
@@ -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<HTMLElement | null>;
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<HTMLDivElement | null>(null);
const [position, setPosition] = useState({ top: 72, left: 16, maxHeight: 360 });
const visibleEntries = useMemo(() => entries.slice(0, 24), [entries]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-7xl border-white/10 bg-[#131317] p-0 text-slate-200 shadow-2xl">
<DialogHeader className="border-b border-white/10 px-6 py-4">
<DialogTitle className="text-lg font-semibold tracking-tight text-white">
Projects
</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto px-5 py-5">
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 (
<div
ref={panelRef}
role="dialog"
aria-label="Projects"
className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-white/[0.07] bg-[rgba(22,22,30,0.96)] text-slate-200 shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150"
>
<div className="border-b border-white/10 px-3 py-2.5">
<div className="text-sm font-medium tracking-tight text-white">Projects</div>
</div>
<div className="max-h-[360px] overflow-y-auto px-2.5 py-2.5">
{visibleEntries.length > 0 ? (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4 xl:grid-cols-4">
<div className="grid grid-cols-2 gap-2">
{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"
>
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-2xl bg-[#0d0d11]">
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-[5px] bg-[#0d0d11] shadow-[0_10px_18px_rgba(0,0,0,0.28)] transition duration-200 group-hover:-translate-y-0.5 group-hover:shadow-[0_16px_30px_rgba(0,0,0,0.38)]">
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt=""
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.015]"
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(37,99,235,0.18),_transparent_55%),linear-gradient(180deg,_rgba(255,255,255,0.05),_rgba(255,255,255,0.02))] text-sm font-medium text-slate-400">
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(180deg,_rgba(37,99,235,0.22),_rgba(13,17,23,0.92))] text-[10px] font-medium text-slate-300">
No preview yet
</div>
)}
{entry.isCurrent ? (
<div className="absolute right-3 top-3">
<span className="rounded-full bg-[#2563EB]/92 px-2 py-1 text-[10px] font-medium uppercase tracking-[0.18em] text-white shadow-[0_8px_20px_rgba(37,99,235,0.28)]">
<div className="absolute right-1.5 top-1.5">
<span className="rounded-[5px] bg-[#2563EB] px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.14em] text-white shadow-[0_8px_20px_rgba(37,99,235,0.28)]">
Current
</span>
</div>
) : null}
</div>
<div className="flex flex-1 flex-col gap-1 px-1 py-1">
<div className="truncate text-[15px] font-semibold tracking-tight text-white">
<div className="flex flex-1 flex-col px-0.5 py-0.5">
<div className="truncate text-[11px] font-semibold tracking-tight text-white">
{entry.name}
</div>
<div className="text-[11px] text-slate-400">
Updated {formatUpdatedAt(entry.updatedAt)}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex min-h-[280px] flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-8 text-center">
<div className="text-lg font-semibold text-white">No saved projects yet</div>
<div className="flex min-h-[140px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/10 bg-[#111215] px-4 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className="text-sm font-semibold text-white">No saved projects yet</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
return (
<div className="pointer-events-none fixed inset-0 z-[90]">
<div
ref={panelRef}
role="dialog"
aria-label="Projects"
style={{ top: `${position.top}px`, left: `${position.left}px` }}
className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-white/10 bg-[#17171a] text-slate-200 shadow-2xl animate-in fade-in-0 duration-150"
>
<div className="border-b border-white/10 px-3 py-2.5">
<div className="text-sm font-medium tracking-tight text-white">Projects</div>
</div>
<div
className="overflow-y-auto px-2.5 py-2.5"
style={{ maxHeight: `${position.maxHeight}px` }}
>
{visibleEntries.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{visibleEntries.map((entry) => {
const thumbnailSrc = entry.thumbnailPath ? toFileUrl(entry.thumbnailPath) : null;
return (
<button
key={entry.path}
type="button"
onClick={() => onOpenProject(entry.path)}
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"
>
<div className="relative aspect-[16/10] w-full overflow-hidden rounded-[5px] bg-[#0d0d11] shadow-[0_10px_18px_rgba(0,0,0,0.28)] transition duration-200 group-hover:-translate-y-0.5 group-hover:shadow-[0_16px_30px_rgba(0,0,0,0.38)]">
{thumbnailSrc ? (
<img
src={thumbnailSrc}
alt=""
className="h-full w-full object-cover transition duration-200 group-hover:scale-[1.02]"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[linear-gradient(180deg,_rgba(37,99,235,0.22),_rgba(13,17,23,0.92))] text-[10px] font-medium text-slate-300">
No preview yet
</div>
)}
{entry.isCurrent ? (
<div className="absolute right-1.5 top-1.5">
<span className="rounded-[5px] bg-[#2563EB] px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.14em] text-white shadow-[0_8px_20px_rgba(37,99,235,0.28)]">
Current
</span>
</div>
) : null}
</div>
<div className="flex flex-1 flex-col px-0.5 py-0.5">
<div className="truncate text-[11px] font-semibold tracking-tight text-white">
{entry.name}
</div>
</div>
</button>
);
})}
</div>
) : (
<div className="flex min-h-[140px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/10 bg-[#111215] px-4 text-center shadow-[inset_0_1px_0_rgba(255,255,255,0.04)]">
<div className="text-sm font-semibold text-white">No saved projects yet</div>
</div>
)}
</div>
</div>
</div>
);
}
+31 -13
View File
@@ -418,6 +418,8 @@ export default function VideoEditor() {
const [previewVersion, setPreviewVersion] = useState(0);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const projectBrowserTriggerRef = useRef<HTMLButtonElement | null>(null);
const projectBrowserFallbackTriggerRef = useRef<HTMLButtonElement | null>(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 = (
<ProjectBrowserDialog
open={projectBrowserOpen}
onOpenChange={setProjectBrowserOpen}
entries={projectLibraryEntries}
anchorRef={error ? projectBrowserFallbackTriggerRef : projectBrowserTriggerRef}
onOpenProject={(projectPath) => {
void handleOpenProjectFromLibrary(projectPath);
}}
/>
);
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="flex h-screen items-center justify-center bg-background">
<div className="text-foreground">Loading video...</div>
{projectBrowser}
<Toaster theme="dark" className="pointer-events-auto" />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-screen bg-background">
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="text-destructive">{error}</div>
<button
ref={projectBrowserFallbackTriggerRef}
type="button"
onClick={handleOpenProjectBrowser}
className="px-3 py-1.5 rounded-md bg-[#2563EB] text-white text-sm hover:bg-[#2563EB]/90"
className="rounded-[5px] bg-white px-3 py-1.5 text-sm font-semibold text-black shadow-[0_14px_32px_rgba(0,0,0,0.18)] transition-colors hover:bg-white/92"
>
Open Projects
</button>
</div>
{projectBrowser}
<Toaster theme="dark" className="pointer-events-auto" />
</div>
);
}
@@ -2972,9 +2996,10 @@ export default function VideoEditor() {
</Button>
<div className="mx-1 h-5 w-px bg-white/10" />
<Button
ref={projectBrowserTriggerRef}
type="button"
onClick={handleOpenProjectBrowser}
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-[5px] bg-white px-4 text-black transition-colors hover:bg-white/92"
className="inline-flex h-8 min-w-[96px] items-center justify-center gap-1.5 rounded-[5px] bg-white px-4 text-black shadow-[0_14px_32px_rgba(0,0,0,0.18)] transition-colors hover:bg-white/92"
>
<FolderOpen className="h-4 w-4" />
<span className="text-sm font-semibold tracking-tight">
@@ -3481,14 +3506,7 @@ export default function VideoEditor() {
</>
) : null}
<ProjectBrowserDialog
open={projectBrowserOpen}
onOpenChange={setProjectBrowserOpen}
entries={projectLibraryEntries}
onOpenProject={(projectPath) => {
void handleOpenProjectFromLibrary(projectPath);
}}
/>
{projectBrowser}
<Toaster theme="dark" className="pointer-events-auto" />
</div>