mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 23:05:49 +00:00
fix(projects): align HUD browser behavior
This commit is contained in:
+350
-350
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user