diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 773784ae4..4959d22c0 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -88,9 +88,7 @@ interface Window { hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; hudOverlayHide: () => void; hudOverlayClose: () => void; - setHudOverlayExpanded: (expanded: boolean) => void; - setHudOverlayCompactWidth: (width: number) => void; - setHudOverlayMeasuredHeight: (height: number, expanded: boolean) => void; + hudOverlayRendererReady: () => void; getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; getHudOverlayMousePassthroughSupported: () => Promise<{ success: boolean; diff --git a/electron/ipc/register/sources.ts b/electron/ipc/register/sources.ts index a5e6d601e..0ce77fcbc 100644 --- a/electron/ipc/register/sources.ts +++ b/electron/ipc/register/sources.ts @@ -15,25 +15,19 @@ import { } from "../cursor/bounds"; const execFileAsync = promisify(execFile); +const SOURCE_LIST_CACHE_TTL_MS = 1200; +let sourceListCache: + | { + key: string; + expiresAt: number; + value: Array>; + } + | null = null; function normalizeDesktopSourceName(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); } -function hasUsableSourceThumbnail( - thumbnail: - | { - isEmpty: () => boolean; - getSize: () => { width: number; height: number }; - } - | null - | undefined, -) { - if (!thumbnail || thumbnail.isEmpty()) return false; - const size = thumbnail.getSize(); - return size.width > 1 && size.height > 1; -} - function broadcastSelectedSourceChange() { for (const window of BrowserWindow.getAllWindows()) { if (!window.isDestroyed()) { @@ -52,8 +46,18 @@ export function registerSourceHandlers({ getSourceSelectorWindow: () => BrowserWindow | null; }) { ipcMain.handle("get-sources", async (_, opts) => { + const cacheKey = JSON.stringify({ + types: opts?.types, + thumbnailSize: opts?.thumbnailSize, + fetchWindowIcons: opts?.fetchWindowIcons, + }); + if (sourceListCache && sourceListCache.key === cacheKey && sourceListCache.expiresAt > Date.now()) { + return sourceListCache.value; + } + const includeScreens = Array.isArray(opts?.types) ? opts.types.includes("screen") : true; const includeWindows = Array.isArray(opts?.types) ? opts.types.includes("window") : true; + const includeWindowIcons = Boolean(opts?.fetchWindowIcons); const electronTypes = [ ...(includeScreens ? ["screen" as const] : []), ...(includeWindows ? ["window" as const] : []), @@ -125,7 +129,7 @@ export function registerSourceHandlers({ originalName: matchedSource?.name ?? displayName, display_id: displayId, thumbnail: matchedSource?.thumbnail ? matchedSource.thumbnail.toDataURL() : null, - appIcon: matchedSource?.appIcon ? matchedSource.appIcon.toDataURL() : null, + appIcon: null, sourceType: "screen" as const, }; }); @@ -133,7 +137,6 @@ export function registerSourceHandlers({ if (process.platform !== "darwin" || !includeWindows) { const windowSources = electronSources .filter((source) => source.id.startsWith("window:")) - .filter((source) => hasUsableSourceThumbnail(source.thumbnail)) .filter((source) => { const normalizedName = normalizeDesktopSourceName(source.name); if (!normalizedName) { @@ -159,11 +162,17 @@ export function registerSourceHandlers({ originalName: source.name, display_id: source.display_id, thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null, + appIcon: + includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null, sourceType: "window" as const, })); - - return [...screenSources, ...windowSources]; + const result = [...screenSources, ...windowSources]; + sourceListCache = { + key: cacheKey, + expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS, + value: result, + }; + return result; } try { @@ -221,17 +230,25 @@ export function registerSourceHandlers({ ? electronWindowSource.thumbnail.toDataURL() : null, appIcon: - source.appIcon ?? - (electronWindowSource?.appIcon - ? electronWindowSource.appIcon.toDataURL() - : null), + includeWindowIcons + ? (source.appIcon ?? + (electronWindowSource?.appIcon + ? electronWindowSource.appIcon.toDataURL() + : null)) + : null, appName: source.appName, windowTitle: source.windowTitle, sourceType: "window" as const, }; }); - return [...screenSources, ...mergedWindowSources]; + const result = [...screenSources, ...mergedWindowSources]; + sourceListCache = { + key: cacheKey, + expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS, + value: result, + }; + return result; } catch (error) { console.warn("Falling back to Electron window enumeration on macOS:", error); @@ -266,11 +283,18 @@ export function registerSourceHandlers({ originalName: source.name, display_id: source.display_id, thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, - appIcon: source.appIcon ? source.appIcon.toDataURL() : null, + appIcon: + includeWindowIcons && source.appIcon ? source.appIcon.toDataURL() : null, sourceType: "window" as const, })); - return [...screenSources, ...windowSources]; + const result = [...screenSources, ...windowSources]; + sourceListCache = { + key: cacheKey, + expiresAt: Date.now() + SOURCE_LIST_CACHE_TTL_MS, + value: result, + }; + return result; } }); diff --git a/electron/preload.ts b/electron/preload.ts index 4d988b353..b76617fd2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -84,14 +84,8 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlayClose: () => { ipcRenderer.send("hud-overlay-close"); }, - setHudOverlayExpanded: (expanded: boolean) => { - ipcRenderer.send("set-hud-overlay-expanded", expanded); - }, - setHudOverlayCompactWidth: (width: number) => { - ipcRenderer.send("set-hud-overlay-compact-width", width); - }, - setHudOverlayMeasuredHeight: (height: number, expanded: boolean) => { - ipcRenderer.send("set-hud-overlay-measured-height", height, expanded); + hudOverlayRendererReady: () => { + ipcRenderer.send("hud-overlay-renderer-ready"); }, getHudOverlayCaptureProtection: () => { return ipcRenderer.invoke("get-hud-overlay-capture-protection"); diff --git a/electron/windows.ts b/electron/windows.ts index 6f51f7423..f4d59b5da 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -26,23 +26,11 @@ let countdownWindow: BrowserWindow | null = null; let updateToastWindow: BrowserWindow | null = null; const HUD_OVERLAY_SETTINGS_FILE = path.join(USER_DATA_PATH, "hud-overlay-settings.json"); -const HUD_BOTTOM_CLEARANCE_CM = 3.5; -const DIP_PER_INCH = 96; -const CM_PER_INCH = 2.54; const HUD_EDGE_MARGIN_DIP = 16; -const HUD_SHADOW_BLEED_DIP = 36; -const HUD_MIN_WINDOW_WIDTH = 560; -const HUD_COMPACT_HEIGHT = 96; -const HUD_MIN_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP; const UPDATE_TOAST_WIDTH = 456; const UPDATE_TOAST_HEIGHT = 252; const UPDATE_TOAST_GAP_DIP = 18; -let hudOverlayExpanded = false; -let hudOverlayCompactWidth = HUD_MIN_WINDOW_WIDTH; -let hudOverlayCompactHeight = HUD_COMPACT_HEIGHT; -let hudOverlayExpandedHeight = HUD_MIN_EXPANDED_HEIGHT; - function getEditorWindowQuery(): Record { const query: Record = { windowType: "editor", @@ -182,61 +170,21 @@ function getHudOverlayDisplay() { return getScreen().getPrimaryDisplay(); } -function getHudOverlayBounds(expanded: boolean) { - const { bounds, workArea } = getHudOverlayDisplay(); - const maxWindowWidth = Math.max(HUD_MIN_WINDOW_WIDTH, workArea.width - HUD_EDGE_MARGIN_DIP * 2); - const windowWidth = Math.min( - maxWindowWidth, - Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(hudOverlayCompactWidth)), - ); - const maxWindowHeight = Math.max(HUD_COMPACT_HEIGHT, workArea.height - HUD_EDGE_MARGIN_DIP * 2); - const desiredHeight = expanded - ? Math.max(HUD_MIN_EXPANDED_HEIGHT, Math.round(hudOverlayExpandedHeight)) - : Math.max(HUD_COMPACT_HEIGHT, Math.round(hudOverlayCompactHeight)); - const windowHeight = Math.min(maxWindowHeight, desiredHeight); - const bottomClearanceDip = Math.round((HUD_BOTTOM_CLEARANCE_CM / CM_PER_INCH) * DIP_PER_INCH); - const screenBottom = bounds.y + bounds.height; - const workAreaBottom = workArea.y + workArea.height; - const preferredBottom = screenBottom - bottomClearanceDip; - const maximumSafeBottom = workAreaBottom - HUD_EDGE_MARGIN_DIP; - 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)); - +function getHudOverlayBounds() { + const { workArea } = getHudOverlayDisplay(); return { - x, - y, - width: windowWidth, - height: windowHeight, + x: workArea.x, + y: workArea.y, + width: workArea.width, + height: workArea.height, }; } -function applyHudOverlayBounds(expanded: boolean) { +function applyHudOverlayBounds() { if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { return; } - - hudOverlayExpanded = expanded; - - const computed = getHudOverlayBounds(expanded); - - if (hudUserPosition) { - // Resize in-place at the user's dragged position, clamped so the - // window stays fully within the current display's work area. - const { workArea } = getHudOverlayDisplay(); - const x = Math.max( - workArea.x, - Math.min(hudUserPosition.x, workArea.x + workArea.width - computed.width), - ); - const y = Math.max( - workArea.y, - Math.min(hudUserPosition.y, workArea.y + workArea.height - computed.height), - ); - hudOverlayWindow.setBounds({ x, y, width: computed.width, height: computed.height }, false); - } else { - hudOverlayWindow.setBounds(computed, false); - } + hudOverlayWindow.setBounds(getHudOverlayBounds(), false); positionUpdateToastWindow(); if (!hudOverlayWindow.isVisible()) { @@ -299,9 +247,7 @@ ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { } }); -// When the user drags the HUD, remember their chosen position so that -// subsequent size changes (e.g. idle → recording UI swap) resize in-place -// instead of snapping back to the default centered location. +// Keep compatibility with existing drag IPC/state. let hudUserPosition: { x: number; y: number } | null = null; let hudDragOffset: { x: number; y: number } | null = null; let hudDragLastCursor: { x: number; y: number } | null = null; @@ -315,7 +261,7 @@ ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY: // the HUD appears "stuck". The renderer marks the drag handle as // -webkit-app-region: drag on Linux, letting the OS move the window for us. // The resulting position is captured by the win.on("moved", ...) listener - // below so `hudUserPosition` stays in sync for in-place resize. + // below so `hudUserPosition` stays in sync. if (process.platform === "linux") { return; } @@ -359,58 +305,13 @@ ipcMain.on("hud-overlay-drag", (_event, phase: string, screenX: number, screenY: }); ipcMain.on("hud-overlay-hide", () => { + // Keep the HUD persistent: accidental hide events should not minimize it. if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - hudOverlayWindow.minimize(); - } -}); - -ipcMain.on("set-hud-overlay-expanded", (_event, expanded: boolean) => { - applyHudOverlayBounds(Boolean(expanded)); -}); - -ipcMain.on("set-hud-overlay-compact-width", (_event, width: number) => { - if (!Number.isFinite(width)) { - return; - } - - const maxWindowWidth = Math.max( - HUD_MIN_WINDOW_WIDTH, - getHudOverlayDisplay().workArea.width - HUD_EDGE_MARGIN_DIP * 2, - ); - const nextWidth = Math.min(maxWindowWidth, Math.max(HUD_MIN_WINDOW_WIDTH, Math.round(width))); - - if (nextWidth === hudOverlayCompactWidth) { - return; - } - - hudOverlayCompactWidth = nextWidth; - applyHudOverlayBounds(hudOverlayExpanded); -}); - -ipcMain.on("set-hud-overlay-measured-height", (_event, height: number, expanded: boolean) => { - if (!Number.isFinite(height)) { - return; - } - - const maxWindowHeight = Math.max( - HUD_COMPACT_HEIGHT, - getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, - ); - const nextHeight = Math.min(maxWindowHeight, Math.max(HUD_COMPACT_HEIGHT, Math.round(height))); - - if (expanded) { - if (nextHeight === hudOverlayExpandedHeight) { - return; + if (!hudOverlayWindow.isVisible()) { + hudOverlayWindow.show(); } - hudOverlayExpandedHeight = Math.max(HUD_MIN_EXPANDED_HEIGHT, nextHeight); - } else { - if (nextHeight === hudOverlayCompactHeight) { - return; - } - hudOverlayCompactHeight = nextHeight; + hudOverlayWindow.moveTop(); } - - applyHudOverlayBounds(hudOverlayExpanded); }); ipcMain.handle("get-hud-overlay-capture-protection", () => { @@ -450,21 +351,17 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) export function createHudOverlayWindow(): BrowserWindow { loadHudOverlayCaptureProtectionSetting(); - const initialBounds = getHudOverlayBounds(false); + const initialBounds = getHudOverlayBounds(); + let hasShownHudWindow = false; const win = new BrowserWindow({ width: initialBounds.width, height: initialBounds.height, - minWidth: HUD_MIN_WINDOW_WIDTH, - minHeight: HUD_COMPACT_HEIGHT, - maxHeight: Math.max( - HUD_COMPACT_HEIGHT, - getHudOverlayDisplay().workArea.height - HUD_EDGE_MARGIN_DIP * 2, - ), x: initialBounds.x, y: initialBounds.y, frame: false, transparent: true, + backgroundColor: "#00000000", resizable: false, alwaysOnTop: true, skipTaskbar: true, @@ -479,6 +376,23 @@ export function createHudOverlayWindow(): BrowserWindow { }, }); + const showHudWindow = () => { + if (hasShownHudWindow || win.isDestroyed()) { + return; + } + hasShownHudWindow = true; + win.show(); + win.moveTop(); + if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { + win.setIgnoreMouseEvents(false); + setTimeout(() => { + if (!win.isDestroyed()) { + win.setIgnoreMouseEvents(true, { forward: true }); + } + }, 50); + } + }; + if (isHudOverlayCaptureProtectionSupported()) { win.setContentProtection(hudOverlayHiddenFromCapture); } @@ -508,39 +422,23 @@ export function createHudOverlayWindow(): BrowserWindow { win.webContents.on("did-finish-load", () => { win?.webContents.send("main-process-message", new Date().toLocaleString()); + // Safety fallback if renderer-ready signal never arrives. setTimeout(() => { - if (!win.isDestroyed()) { - win.show(); - win.moveTop(); - if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { - win.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); - } - }, 50); - } - } - }, 100); + showHudWindow(); + }, 1800); }); - // Safety net: on Linux the renderer may fail to fire did-finish-load - // (e.g. GPU/VAAPI errors). Show the window after ready-to-show as fallback. - win.once("ready-to-show", () => { - setTimeout(() => { - if (!win.isDestroyed() && !win.isVisible()) { - win.show(); - win.moveTop(); - } - }, 500); - }); + const handleHudRendererReady = () => { + if (!win.isDestroyed()) { + showHudWindow(); + } + }; + ipcMain.on("hud-overlay-renderer-ready", handleHudRendererReady); hudOverlayWindow = win; // On Linux the HUD is dragged by the OS via -webkit-app-region (Wayland - // forbids client-side positioning). Mirror the resulting bounds into - // hudUserPosition so subsequent expand/collapse resizes stay in place - // instead of snapping back to the default centered spot. + // forbids client-side positioning). Mirror moved bounds into drag state. if (process.platform === "linux") { win.on("moved", () => { if (win.isDestroyed()) return; @@ -569,12 +467,13 @@ export function createHudOverlayWindow(): BrowserWindow { hudUserPosition = null; } } - applyHudOverlayBounds(hudOverlayExpanded); + applyHudOverlayBounds(); }; screen.on("display-removed", handleDisplayRemoved); screen.on("display-metrics-changed", handleDisplayMetricsChanged); win.on("closed", () => { + ipcMain.removeListener("hud-overlay-renderer-ready", handleHudRendererReady); screen.removeListener("display-removed", handleDisplayRemoved); screen.removeListener("display-metrics-changed", handleDisplayMetricsChanged); if (hudOverlayWindow === win) { diff --git a/src/App.tsx b/src/App.tsx index fa6e9d520..6b7794305 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ export default function App() { const type = params.get("windowType") || ""; const isMacOS = /mac/i.test(navigator.platform); setWindowType(type); + document.documentElement.dataset.windowType = type; if ( type === "hud-overlay" || @@ -31,7 +32,12 @@ export default function App() { document.getElementById("root")?.style.setProperty("background", "transparent"); } - if (type === "hud-overlay" || type === "update-toast") { + if (type === "hud-overlay") { + document.documentElement.classList.add("hud-overlay-window"); + document.body.classList.add("hud-overlay-window"); + document.getElementById("root")?.classList.add("hud-overlay-window"); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } else if (type === "update-toast") { document.documentElement.style.overflow = "visible"; document.body.style.overflow = "visible"; document.getElementById("root")?.style.setProperty("overflow", "visible"); diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 59ab66c1e..3638e7563 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -12,14 +12,11 @@ gap: 10px; width: max-content; max-width: 1200px; - background: rgba(18, 18, 24, 0.97); - border: 1px solid rgba(255, 255, 255, 0.07); + background: var(--launch-bar-bg); + border: 1px solid var(--launch-bar-border); border-radius: 20px; padding: 11px 20px 11px 18px; - box-shadow: - 0 10px 30px rgba(0, 0, 0, 0.24), - 0 2px 10px rgba(0, 0, 0, 0.12), - inset 0 1px 0 rgba(255, 255, 255, 0.04); + box-shadow: var(--launch-bar-shadow); overflow: visible; position: relative; transform-origin: center bottom; @@ -48,7 +45,7 @@ .sep { width: 1px; height: 26px; - background: #2a2a34; + background: var(--launch-border-strong); margin: 0 6px; flex-shrink: 0; } @@ -60,7 +57,7 @@ border-radius: 11px; border: none; background: transparent; - color: #6b6b78; + color: var(--launch-text-muted); cursor: pointer; display: inline-flex; align-items: center; @@ -70,8 +67,8 @@ } .ib:hover { - background: rgba(255, 255, 255, 0.07); - color: #eeeef2; + background: var(--launch-hover); + color: var(--launch-text); } .ibActive { @@ -98,63 +95,23 @@ color: #4eeeb0; } -.screenSel { - position: relative; - display: inline-flex; - align-items: center; - gap: 7px; - height: 40px; - min-width: 0; - max-width: 640px; - padding: 0 14px 0 12px; - border-radius: 11px; - border: 1px solid #2a2a34; - background: #1a1a22; - color: #eeeef2; - font-size: 12px; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - flex: 0 1 auto; -} -.screenSel:hover { - border-color: #3e3e4c; - background: #20202a; -} - -.sourceLabel { - display: inline-block; - min-width: 0; - max-width: 520px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.menuArea { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - pointer-events: none; - overflow: visible; - min-height: 0; -} .menuCard { width: 300px; max-height: 400px; overflow-y: auto; - background: rgba(22, 22, 30, 0.96); - border: 1px solid rgba(255, 255, 255, 0.07); - border-radius: 14px; + background: var(--launch-surface); + border: 1px solid var(--launch-border); + border-radius: 16px; + backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + -webkit-backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); padding: 8px; margin-top: auto; margin-bottom: 8px; - box-shadow: - 0 12px 32px rgba(0, 0, 0, 0.22), - 0 2px 10px rgba(0, 0, 0, 0.1); + box-shadow: var(--launch-bar-shadow); pointer-events: auto; animation: menuCardIn 0.18s ease; } @@ -179,14 +136,14 @@ } .menuCard::-webkit-scrollbar-thumb { - background: #2a2a34; - border-radius: 2px; + background: var(--launch-border-strong); + border-radius: 10px; } .ddLabel { font-size: 9px; font-weight: 600; - color: #6b6b78; + color: var(--launch-label); text-transform: uppercase; letter-spacing: 0.08em; padding: 6px 10px 4px; @@ -202,19 +159,20 @@ border: none; background: transparent; font-size: 12px; - color: #6b6b78; + color: var(--launch-text-muted); cursor: pointer; transition: all 0.12s ease; text-align: left; } .ddItem:hover { - background: rgba(255, 255, 255, 0.06); - color: #eeeef2; + background: var(--launch-hover); + color: var(--launch-text); } .ddItemSelected { - color: #3d8bff; + background: var(--launch-selected); + color: var(--launch-accent); } .finalizingState { @@ -306,11 +264,9 @@ width: 288px; overflow: hidden; border-radius: 24px; - background: rgba(22, 22, 30, 0.95); - border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: - 0 10px 24px rgba(0, 0, 0, 0.28), - inset 0 1px 0 rgba(255, 255, 255, 0.08); + background: var(--launch-surface); + border: 1px solid var(--launch-border-strong); + box-shadow: var(--launch-bar-shadow); cursor: grab; touch-action: none; user-select: none; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 9c3004756..80d9d2220 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,168 +1,60 @@ import { - AppWindow, - CaretUp as ChevronUp, - Eye, - EyeSlash as EyeOff, - FolderOpen, - Translate as Languages, - Microphone as Mic, - MicrophoneSlash as MicOff, - Minus, - Monitor, - DotsThreeVertical as MoreVertical, - Pause, - Play, - ArrowClockwise as RefreshCw, - Stop as Square, - Timer, - VideoCamera as Video, - VideoCamera as VideoIcon, - VideoCameraSlash as VideoOff, - SpeakerHigh as Volume2, - SpeakerX as VolumeX, - X, + CaretUpIcon, + MicrophoneIcon, + MicrophoneSlashIcon, + MinusIcon, + MonitorIcon, + DotsThreeVerticalIcon, + TimerIcon, + VideoCameraIcon, + VideoCameraSlashIcon, + XIcon, + ArrowClockwiseIcon, } from "@phosphor-icons/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 { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; +import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; +import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; +import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; +import { useRecordingTimer } from "./hooks/useRecordingTimer"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; -import { AudioLevelMeter } from "../ui/audio-level-meter"; -import { ContentClamp } from "../ui/content-clamp"; -import ProjectBrowserDialog, { - type ProjectLibraryEntry, -} from "../video-editor/ProjectBrowserDialog"; +import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; import { - canShowFloatingWebcamPreview, canToggleFloatingWebcamPreview, } from "./floatingWebcamPreview"; -import { - mergeHudInteractiveBounds, - shouldRestoreHudMousePassthroughAfterDrag, -} from "./hudMousePassthrough"; +import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator"; +import { CountdownPopover } from "./popovers/CountdownPopover"; +import { MicPopover } from "./popovers/MicPopover"; +import { MorePopover } from "./popovers/MorePopover"; +import { ProjectPopover } from "./popovers/ProjectPopover"; +import { SourcePopover } from "./popovers/SourcePopover"; +import { WebcamPopover } from "./popovers/WebcamPopover"; +import { HudInteractionContext } from "./contexts/HudInteractionContext"; +import { MarqueeText } from "./SourceSelector"; import styles from "./LaunchWindow.module.css"; -interface DesktopSource { - id: string; - name: string; - thumbnail: string | null; - display_id: string; - appIcon: string | null; - sourceType?: "screen" | "window"; - appName?: string; - windowTitle?: string; -} - -const LOCALE_LABELS: Record = { - en: "English", - es: "Español", - fr: "Français", - nl: "Nederlands", - ko: "한국어", - "pt-BR": "Português", - "zh-CN": "簡體中文", - "zh-TW": "繁體中文", -}; +import { Separator } from "@/components/ui/separator"; +import { Button } from "../ui/button"; +import { RecordingControls } from "./RecordingControls"; +import { useEffect, useRef } from "react"; -const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; -const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; -const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; -const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; -function IconButton({ - onClick, - title, - className = "", - buttonRef, - children, -}: { - onClick?: () => void; - title?: string; - className?: string; - buttonRef?: React.Ref; - children: ReactNode; -}) { - return ( - - ); -} - -function DropdownItem({ - onClick, - selected, - icon, - children, - trailing, -}: { - onClick: () => void; - selected?: boolean; - icon: ReactNode; - children: ReactNode; - trailing?: ReactNode; -}) { - return ( - - ); -} - -function Separator({ dropdown = false }: { dropdown?: boolean }) { - return
; -} - -function MicDeviceRow({ - device, - selected, - onSelect, -}: { - device: { deviceId: string; label: string }; - selected: boolean; - onSelect: () => void; -}) { - const { level } = useAudioLevelMeter({ - enabled: true, - deviceId: device.deviceId, - }); - +export function LaunchWindow() { return ( - + + + ); } -export function LaunchWindow() { - const { locale, setLocale } = useI18n(); +function LaunchWindowContent() { const t = useScopedT("launch"); + const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); const { recording, @@ -188,91 +80,41 @@ export function LaunchWindow() { preparePermissions, } = useScreenRecorder(); - const [recordingStart, setRecordingStart] = useState(null); - const [elapsed, setElapsed] = useState(0); - const [pausedAt, setPausedAt] = useState(null); - const [pausedTotal, setPausedTotal] = useState(0); - const [selectedSource, setSelectedSource] = useState("Screen"); - const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [, setRecordingsDirectory] = useState(null); - const [activeDropdown, setActiveDropdown] = useState< - "none" | "sources" | "more" | "mic" | "countdown" | "webcam" - >("none"); - const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); - const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); - const [sources, setSources] = useState([]); - const [sourcesLoading, setSourcesLoading] = useState(false); - const [hideHudFromCapture, setHideHudFromCapture] = useState(true); - const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); - const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); - const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); - const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< - boolean | null - >(null); - const [platform, setPlatform] = useState(null); - const [appVersion, setAppVersion] = useState(null); - const dropdownRef = useRef(null); + const { elapsed, formatTime } = useRecordingTimer(recording, paused); const hudContentRef = useRef(null); const hudBarRef = useRef(null); - const moreButtonRef = useRef(null); - const webcamPreviewRef = useRef(null); - const recordingWebcamPreviewRef = useRef(null); - const recordingWebcamPreviewContainerRef = useRef(null); - const previewStreamRef = useRef(null); - const webcamPreviewDragStartRef = useRef<{ - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - previewWidth: number; - previewHeight: number; - dragging: boolean; - } | null>(null); - const hudDragStartRef = useRef< - | { - pointerId: number; - mode: "webcam-preview"; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - hudWidth: number; - hudHeight: number; - } - | { - pointerId: number; - mode: "overlay"; - } - | null - >(null); - const isHudDraggingRef = useRef(false); - const isWebcamPreviewDraggingRef = useRef(false); - const micDropdownOpen = activeDropdown === "mic"; - const webcamDropdownOpen = activeDropdown === "webcam"; + + const { + selectedSource, + hasSelectedSource, + projectLibraryEntries, + handleSourceSelect, + openVideoFile, + openProjectFromLibrary, + syncSelectedSource, + refreshProjectLibrary, + } = useLaunchWindowActions(); + const showWebcamControls = webcamEnabled && !recording; - const showRecordingWebcamPreview = - webcamEnabled && - canShowFloatingWebcamPreview( - showFloatingWebcamPreview, - hudOverlayMousePassthroughSupported, - ); - const shouldStreamWebcamPreview = - webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamDropdownOpen)); const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( - microphoneEnabled || micDropdownOpen, + microphoneEnabled || openId === "mic", microphoneDeviceId, ); const { devices: videoDevices, selectedDeviceId: selectedVideoDeviceId, setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled || webcamDropdownOpen); + } = useVideoDevices(webcamEnabled || openId === "webcam"); + + const { + hudOverlayMousePassthroughSupported, + platform, + appVersion, + hideHudFromCapture, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + } = useLaunchWindowSystemState(preparePermissions); const supportsHudCaptureProtection = platform !== "linux"; @@ -290,805 +132,203 @@ export function LaunchWindow() { } }, [selectedVideoDeviceId, setWebcamDeviceId]); - useEffect(() => { - if (!webcamEnabled) { - setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); - setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); - webcamPreviewDragStartRef.current = null; - isWebcamPreviewDraggingRef.current = false; - setShowFloatingWebcamPreview(true); - } - }, [webcamEnabled]); - - useEffect(() => { - if (!showRecordingWebcamPreview) { - setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); - } - }, [showRecordingWebcamPreview]); - - const handleWebcamPreviewPointerDown = (event: React.PointerEvent) => { - if (event.button !== 0) { - return; - } - - const previewRect = event.currentTarget.getBoundingClientRect(); - - event.preventDefault(); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - webcamPreviewDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: webcamPreviewOffset.x, - originY: webcamPreviewOffset.y, - initialLeft: previewRect.left, - initialTop: previewRect.top, - previewWidth: previewRect.width, - previewHeight: previewRect.height, - dragging: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }; - - const handleWebcamPreviewPointerMove = (event: React.PointerEvent) => { - const dragState = webcamPreviewDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - const deltaX = event.clientX - dragState.startX; - const deltaY = event.clientY - dragState.startY; - - if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) { - return; - } - - if (!dragState.dragging) { - dragState.dragging = true; - isWebcamPreviewDraggingRef.current = true; - } - - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - const unclampedLeft = dragState.initialLeft + deltaX; - const unclampedTop = dragState.initialTop + deltaY; - const clampedLeft = Math.min( - Math.max(0, unclampedLeft), - Math.max(0, viewportWidth - dragState.previewWidth), - ); - const clampedTop = Math.min( - Math.max(0, unclampedTop), - Math.max(0, viewportHeight - dragState.previewHeight), - ); - - setWebcamPreviewOffset({ - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }); - }; - - const handleWebcamPreviewPointerUp = (event: React.PointerEvent) => { - const dragState = webcamPreviewDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - const wasDragging = dragState.dragging; - webcamPreviewDragStartRef.current = null; - isWebcamPreviewDraggingRef.current = false; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - if (wasDragging) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }; - - const handleHudBarPointerDown = (event: React.PointerEvent) => { - if (event.button !== 0) { - return; - } - - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - isHudDraggingRef.current = true; - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - - if (showRecordingWebcamPreview && hudBarRef.current) { - const hudRect = hudBarRef.current.getBoundingClientRect(); - hudDragStartRef.current = { - pointerId: event.pointerId, - mode: "webcam-preview", - startX: event.clientX, - startY: event.clientY, - originX: recordingHudOffset.x, - originY: recordingHudOffset.y, - initialLeft: hudRect.left, - initialTop: hudRect.top, - hudWidth: hudRect.width, - hudHeight: hudRect.height, - }; - return; - } - - hudDragStartRef.current = { - pointerId: event.pointerId, - mode: "overlay", - }; - window.electronAPI?.hudOverlayDrag?.("start", event.screenX, event.screenY); - }; - - const handleHudBarPointerMove = (event: React.PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - if (dragState.mode === "webcam-preview") { - const deltaX = event.clientX - dragState.startX; - const deltaY = event.clientY - dragState.startY; - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - const unclampedLeft = dragState.initialLeft + deltaX; - const unclampedTop = dragState.initialTop + deltaY; - const clampedLeft = Math.min( - Math.max(0, unclampedLeft), - Math.max(0, viewportWidth - dragState.hudWidth), - ); - const clampedTop = Math.min( - Math.max(0, unclampedTop), - Math.max(0, viewportHeight - dragState.hudHeight), - ); - - setRecordingHudOffset({ - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }); - return; - } - - window.electronAPI?.hudOverlayDrag?.("move", event.screenX, event.screenY); - }; - - const handleHudBarPointerUp = (event: React.PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - if (dragState.mode === "overlay") { - window.electronAPI?.hudOverlayDrag?.("end", 0, 0); - } - - hudDragStartRef.current = null; - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - const hudBounds = mergeHudInteractiveBounds( - [ - dropdownRef.current?.getBoundingClientRect(), - hudBarRef.current?.getBoundingClientRect(), - recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), - ].map((bounds) => - bounds - ? { - left: bounds.left, - top: bounds.top, - right: bounds.right, - bottom: bounds.bottom, - } - : null, - ), - ); - if ( - wasDragging && - shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY) - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }; - - const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => { - const previewStream = previewStreamRef.current; - if (!videoElement || !previewStream || videoElement.srcObject === previewStream) { - return; - } - - videoElement.srcObject = previewStream; - const playPromise = videoElement.play(); - if (playPromise) { - playPromise.catch(() => { - // Ignore autoplay interruptions while the preview element mounts. - }); - } - }, []); - - const setWebcamPreviewNode = useCallback( - (node: HTMLVideoElement | null) => { - webcamPreviewRef.current = node; - attachPreviewStreamToNode(node); - }, - [attachPreviewStreamToNode], - ); - - const setRecordingWebcamPreviewNode = useCallback( - (node: HTMLVideoElement | null) => { - recordingWebcamPreviewRef.current = node; - attachPreviewStreamToNode(node); - }, - [attachPreviewStreamToNode], - ); - - useEffect(() => { - let mounted = true; - - const startPreview = async () => { - if (!shouldStreamWebcamPreview) { - return; - } - - try { - const previewStream = await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - } - : { - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - }, - audio: false, - }); - - if (!mounted) { - previewStream.getTracks().forEach((track) => track.stop()); - return; - } - - previewStreamRef.current = previewStream; - attachPreviewStreamToNode(webcamPreviewRef.current); - attachPreviewStreamToNode(recordingWebcamPreviewRef.current); - } catch (error) { - console.warn("Failed to start live webcam preview:", error); - } - }; - - void startPreview(); - - return () => { - mounted = false; - const previewNode = webcamPreviewRef.current; - const recordingPreviewNode = recordingWebcamPreviewRef.current; - const previewStream = previewStreamRef.current; - - [previewNode, recordingPreviewNode] - .filter((node): node is HTMLVideoElement => Boolean(node)) - .forEach((videoElement) => { - videoElement.pause(); - videoElement.srcObject = null; - }); - previewStream?.getTracks().forEach((track) => track.stop()); - if (previewStreamRef.current === previewStream) { - previewStreamRef.current = null; - } - }; - }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); + const { + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + showRecordingWebcamPreview, + webcamPreviewOffset, + recordingWebcamPreviewContainerRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + setWebcamPreviewNode, + setRecordingWebcamPreviewNode, + } = useWebcamPreviewOverlay({ + webcamEnabled, + webcamDeviceId, + showWebcamControls, + webcamPopoverOpen: openId === "webcam", + hudOverlayMousePassthroughSupported, + }); - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - if (recording) { - if (!recordingStart) { - setRecordingStart(Date.now()); - setPausedTotal(0); - } - if (paused) { - if (!pausedAt) setPausedAt(Date.now()); - if (timer) clearInterval(timer); - } else { - if (pausedAt) { - setPausedTotal((prev) => prev + (Date.now() - pausedAt)); - setPausedAt(null); - } - timer = setInterval(() => { - if (recordingStart) { - setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); - } - }, 1000); - } - } else { - setRecordingStart(null); - setElapsed(0); - setPausedAt(null); - setPausedTotal(0); - if (timer) clearInterval(timer); - } - return () => { - if (timer) clearInterval(timer); - }; - }, [recording, recordingStart, paused, pausedAt, pausedTotal]); + const { + recordingHudOffset, + isHudDragging, + hudBarTransformRef, + isHudDraggingRef, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + } = useHudBarDrag({ + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, + }); - const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, "0"); - const s = (seconds % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; + const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + }); useEffect(() => { let mounted = true; - const applySelectedSource = (source: { name?: string } | null | undefined) => { - if (!mounted) { - return; - } - - if (source?.name) { - setSelectedSource(source.name); - setHasSelectedSource(true); - return; - } - - setSelectedSource("Screen"); - setHasSelectedSource(false); - }; - void window.electronAPI.getSelectedSource().then((source) => { - applySelectedSource(source); + if (mounted) syncSelectedSource(source); }); const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { - applySelectedSource(source); + if (mounted) syncSelectedSource(source); }); return () => { mounted = false; cleanup?.(); }; - }, []); - - useEffect(() => { - const load = async () => { - const result = await window.electronAPI.getRecordingsDirectory(); - if (result.success) setRecordingsDirectory(result.path); - }; - void load(); - }, []); - - useEffect(() => { - let cancelled = false; - const loadPlatform = async () => { - try { - const nextPlatform = await window.electronAPI.getPlatform(); - if (!cancelled) setPlatform(nextPlatform); - } catch (error) { - console.error("Failed to load platform:", error); - } - }; - void loadPlatform(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - const loadHudOverlayMousePassthroughSupport = async () => { - try { - const result = await window.electronAPI.getHudOverlayMousePassthroughSupported(); - if (!cancelled && result.success) { - setHudOverlayMousePassthroughSupported(result.supported); - } - } catch (error) { - console.error("Failed to load HUD overlay mouse passthrough support:", error); - } - }; - void loadHudOverlayMousePassthroughSupport(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - void preparePermissions({ startup: true }); - }, [preparePermissions]); - - useEffect(() => { - let cancelled = false; - const loadVersion = async () => { - try { - const version = await window.electronAPI.getAppVersion(); - if (!cancelled) setAppVersion(version); - } catch (error) { - console.error("Failed to load app version:", error); - } - }; - void loadVersion(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - const loadHudCaptureProtection = async () => { - try { - const result = await window.electronAPI.getHudOverlayCaptureProtection(); - if (!cancelled && result.success) { - setHideHudFromCapture(result.enabled); - } - } catch (error) { - console.error("Failed to load HUD capture protection state:", error); - } - }; - void loadHudCaptureProtection(); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - const expanded = - activeDropdown !== "none" || projectBrowserOpen || showRecordingWebcamPreview; - window.electronAPI.setHudOverlayExpanded(expanded); - - return () => { - window.electronAPI.setHudOverlayExpanded(false); - }; - }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); - - const reportHudSize = useCallback(() => { - const hudContent = hudContentRef.current; - const hudBar = hudBarRef.current; - if (!hudContent || !hudBar) { - return; - } - - if (showRecordingWebcamPreview) { - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - window.electronAPI.setHudOverlayCompactWidth(Math.ceil(viewportWidth)); - window.electronAPI.setHudOverlayMeasuredHeight(Math.ceil(viewportHeight), true); - return; - } - - const hudContentRect = hudContent.getBoundingClientRect(); - const hudBarRect = hudBar.getBoundingClientRect(); - const standardWidth = Math.max( - hudBarRect.width, - hudBar.scrollWidth, - hudContentRect.width, - hudContent.scrollWidth, - ); - const standardHeight = Math.max(hudContentRect.height, hudContent.scrollHeight); - - window.electronAPI.setHudOverlayCompactWidth(Math.ceil(standardWidth + 24)); - window.electronAPI.setHudOverlayMeasuredHeight( - Math.ceil(standardHeight + 24), - activeDropdown !== "none" || projectBrowserOpen, - ); - }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); - - useEffect(() => { - const hudContent = hudContentRef.current; - const hudBar = hudBarRef.current; - const previewContainer = recordingWebcamPreviewContainerRef.current; - if (!hudContent || !hudBar || typeof ResizeObserver === "undefined") { - return; - } - - let frameId = 0; - const scheduleHudSizeReport = () => { - if (frameId !== 0) { - cancelAnimationFrame(frameId); - } - frameId = requestAnimationFrame(() => { - frameId = 0; - reportHudSize(); - }); - }; - - scheduleHudSizeReport(); - - const resizeObserver = new ResizeObserver(() => { - scheduleHudSizeReport(); - }); - resizeObserver.observe(hudContent); - resizeObserver.observe(hudBar); - if (previewContainer) { - resizeObserver.observe(previewContainer); - } - - return () => { - resizeObserver.disconnect(); - if (frameId !== 0) { - cancelAnimationFrame(frameId); - } - }; - }, [reportHudSize]); - - useEffect(() => { - const handleClick = (e: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { - setActiveDropdown("none"); - setProjectBrowserOpen(false); - } - }; - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, []); - - const fetchSources = useCallback(async () => { - if (!window.electronAPI) return; - setSourcesLoading(true); - try { - const rawSources = await window.electronAPI.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 160, height: 90 }, - fetchWindowIcons: true, - }); - setSources( - rawSources.map((s) => { - const isWindow = s.id.startsWith("window:"); - const type = s.sourceType ?? (isWindow ? "window" : "screen"); - let displayName = s.name; - let appName = s.appName; - if (isWindow && !appName && s.name.includes(" — ")) { - const parts = s.name.split(" — "); - appName = parts[0]?.trim(); - displayName = parts.slice(1).join(" — ").trim() || s.name; - } else if (isWindow && s.windowTitle) { - displayName = s.windowTitle; - } - return { - id: s.id, - name: displayName, - thumbnail: s.thumbnail, - display_id: s.display_id, - appIcon: s.appIcon, - sourceType: type, - appName, - windowTitle: s.windowTitle ?? displayName, - }; - }), - ); - } catch (error) { - console.error("Failed to fetch sources:", error); - } finally { - setSourcesLoading(false); - } - }, []); - - const toggleDropdown = (which: "sources" | "more" | "mic" | "countdown" | "webcam") => { - setProjectBrowserOpen(false); - setActiveDropdown(activeDropdown === which ? "none" : which); - if (activeDropdown !== which && which === "sources") fetchSources(); - }; - - const handleSourceSelect = async (source: DesktopSource) => { - await window.electronAPI.selectSource(source); - setSelectedSource(source.name); - setHasSelectedSource(true); - setActiveDropdown("none"); - window.electronAPI.showSourceHighlight?.({ - ...source, - name: source.appName ? `${source.appName} — ${source.name}` : source.name, - appName: source.appName, - }); - }; - - const openVideoFile = async () => { - setActiveDropdown("none"); - const result = await window.electronAPI.openVideoFilePicker(); - if (result.canceled) return; - if (result.success && result.path) { - await window.electronAPI.setCurrentVideoPath(result.path); - await window.electronAPI.switchToEditor(); - } - }; - - 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); - } - }, []); + }, [syncSelectedSource]); - const openProjectBrowser = useCallback(async () => { - if (projectBrowserOpen) { - setProjectBrowserOpen(false); - return; - } - - setActiveDropdown("none"); - 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"); - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) return; - if (result.success && result.path) setRecordingsDirectory(result.path); - }; - - const toggleMicrophone = () => { - if (recording) return; - toggleDropdown("mic"); - }; - - const toggleHudCaptureProtection = async () => { - const nextValue = !hideHudFromCapture; - setHideHudFromCapture(nextValue); - try { - const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); - if (!result.success) { - setHideHudFromCapture(!nextValue); - return; - } - setHideHudFromCapture(result.enabled); - } catch (error) { - console.error("Failed to update HUD capture protection:", error); - setHideHudFromCapture(!nextValue); - } - }; - - const screenSources = sources.filter((s) => s.sourceType === "screen"); - const windowSources = sources.filter((s) => s.sourceType === "window"); const hudStateTransition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const, }; - const toggleWebcam = () => { - if (recording) return; - toggleDropdown("webcam"); - }; const recordingControls = ( - <> -
-
- - {paused ? t("recording.paused") : t("recording.rec")} - -
- - - {formatTime(elapsed)} - - - - - - {microphoneEnabled ? : } - - - - - - {paused ? ( - - ) : ( - - )} - - - - - - - window.electronAPI?.hudOverlayHide?.()} - title={t("recording.hideHud")} - > - - - - - - - + setMicrophoneEnabled(!microphoneEnabled)} + onPauseResume={paused ? resumeRecording : pauseRecording} + onStopRecording={toggleRecording} + onHideHud={() => window.electronAPI?.hudOverlayHide?.()} + onCancelRecording={cancelRecording} + formatTime={formatTime} + /> ); const idleControls = ( <> {platform !== "linux" && ( <> - + + +
+ +
+ + + } + /> - + )} - setSystemAudioEnabled(!systemAudioEnabled)} + microphoneEnabled={microphoneEnabled} + onDisableMicrophone={() => setMicrophoneEnabled(false)} + devices={devices} + microphoneDeviceId={microphoneDeviceId} + selectedDeviceId={selectedDeviceId} + onSelectDevice={(deviceId) => { + setMicrophoneEnabled(true); + setSelectedDeviceId(deviceId); + setMicrophoneDeviceId(deviceId === "default" ? undefined : deviceId); + }} + trigger={ + } - className={microphoneEnabled ? styles.ibActive : ""} - > - {microphoneEnabled ? : } - - - - {webcamEnabled ? - - toggleDropdown("countdown")} - title={t("recording.countdownDelay")} - className={countdownDelay > 0 ? styles.ibActive : ""} - > - - + /> + + setWebcamEnabled(false)} + canToggleFloatingPreview={canToggleFloatingWebcamPreview( + hudOverlayMousePassthroughSupported, + )} + showFloatingWebcamPreview={showFloatingWebcamPreview} + onToggleFloatingPreview={() => + setShowFloatingWebcamPreview((current) => !current) + } + showWebcamControls={showWebcamControls} + setWebcamPreviewNode={setWebcamPreviewNode} + videoDevices={videoDevices} + webcamDeviceId={webcamDeviceId} + selectedVideoDeviceId={selectedVideoDeviceId} + onSelectVideoDevice={(deviceId) => { + setWebcamEnabled(true); + setSelectedVideoDeviceId(deviceId); + setWebcamDeviceId(deviceId); + }} + trigger={ + + } + /> + + 0 ? styles.ibActive : ""} + > + + + } + /> - - + - toggleDropdown("more")} - title={t("recording.more")} - > - - +
+ } + /> +
+ + { + void toggleHudCaptureProtection(); + }} + onChooseRecordingsDirectory={() => { + void chooseRecordingsDirectory(); + }} + onOpenVideoFile={() => { + void openVideoFile(); + }} + onOpenProjectBrowser={() => { + refreshProjectLibrary().then(() => { + requestOpen("projects"); + }); + }} + showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW} + onPreviewUpdateUi={() => { + if (openId) requestClose(openId); + void window.electronAPI.previewUpdateToast().catch((error) => { + console.warn("Failed to preview update toast:", error); + }); + }} + appVersion={appVersion} + trigger={ + + } + /> - window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")} > - - + + - window.electronAPI?.hudOverlayClose?.()} title={t("recording.closeApp")} > - - + + ); const finalizingControls = (
- +
{t("recording.preparing", "Preparing recording")} {t("recording.preparingSubtitle", "Opening the editor in a moment")} @@ -1143,404 +429,31 @@ export function LaunchWindow() { const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; return ( -
+ +
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)} - onMouseLeave={() => { - if ( - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }} + className="flex items-center overflow-visible flex-col-reverse pointer-events-none" > - {/* Only the visible HUD content should become interactive. */}
- {projectBrowserOpen ? ( -
- { - void openProjectFromLibrary(projectPath); - }} - /> -
- ) : null} - {activeDropdown !== "none" && ( -
- {activeDropdown === "sources" && ( - <> - {sourcesLoading ? ( -
-
-
- ) : ( - <> - {screenSources.length > 0 && ( - <> -
- {t("recording.screens")} -
- {screenSources.map((source) => ( - } - selected={ - selectedSource === source.name - } - onClick={() => - handleSourceSelect(source) - } - > - {source.name} - - ))} - - )} - {windowSources.length > 0 && ( - <> -
0 - ? { - marginTop: 4, - } - : undefined - } - > - {t("recording.windows")} -
- {windowSources.map((source) => ( - } - selected={ - selectedSource === source.name - } - onClick={() => - handleSourceSelect(source) - } - > - {source.appName && - source.appName !== source.name - ? `${source.appName} — ${source.name}` - : source.name} - - ))} - - )} - {screenSources.length === 0 && - windowSources.length === 0 && ( -
- {t("recording.noSourcesFound")} -
- )} - - )} - - )} - - {activeDropdown === "mic" && ( - <> -
- {t("recording.microphone")} -
- - ) : ( - - ) - } - selected={systemAudioEnabled} - onClick={() => { - setSystemAudioEnabled(!systemAudioEnabled); - }} - > - {systemAudioEnabled - ? t("recording.disableSystemAudio") - : t("recording.enableSystemAudio")} - - {microphoneEnabled && ( - } - onClick={() => { - setMicrophoneEnabled(false); - setActiveDropdown("none"); - }} - > - {t("recording.turnOffMicrophone")} - - )} - {!microphoneEnabled && ( -
- {t("recording.selectMicToEnable")} -
- )} - {devices.map((device) => ( - { - setMicrophoneEnabled(true); - setSelectedDeviceId(device.deviceId); - setMicrophoneDeviceId( - device.deviceId === "default" - ? undefined - : device.deviceId, - ); - }} - /> - ))} - {devices.length === 0 && ( -
- {t("recording.noMicrophonesFound")} -
- )} - - )} - - {activeDropdown === "webcam" && ( - <> -
{t("recording.webcam")}
- {webcamEnabled && ( - <> - } - onClick={() => { - setWebcamEnabled(false); - setActiveDropdown("none"); - }} - > - {t("recording.turnOffWebcam")} - - {canToggleFloatingWebcamPreview( - hudOverlayMousePassthroughSupported, - ) ? ( - - ) : ( - - ) - } - selected={showFloatingWebcamPreview} - onClick={() => { - setShowFloatingWebcamPreview( - (current) => !current, - ); - }} - > - {showFloatingWebcamPreview - ? t("recording.hideFloatingWebcamPreview") - : t("recording.showFloatingWebcamPreview")} - - ) : null} - - )} - {!webcamEnabled && ( -
- {t("recording.selectWebcamToEnable")} -
- )} - {showWebcamControls && ( -
-
-
-
- )} - {videoDevices.map((device) => ( - - ) : ( - - ) - } - selected={ - webcamEnabled && - (webcamDeviceId === device.deviceId || - selectedVideoDeviceId === device.deviceId) - } - onClick={() => { - setWebcamEnabled(true); - setSelectedVideoDeviceId(device.deviceId); - setWebcamDeviceId(device.deviceId); - }} - > - {device.label} - - ))} - {videoDevices.length === 0 && ( -
- {t("recording.noWebcamsFound")} -
- )} - - )} - - {activeDropdown === "countdown" && ( - <> -
- {t("recording.countdownDelay")} -
- {COUNTDOWN_OPTIONS.map((delay) => ( - } - selected={countdownDelay === delay} - onClick={() => { - setCountdownDelay(delay); - setActiveDropdown("none"); - }} - > - {delay === 0 ? t("recording.noDelay") : `${delay}s`} - - ))} - - )} - - {activeDropdown === "more" && ( - <> - {supportsHudCaptureProtection && ( - - ) : ( - - ) - } - selected={hideHudFromCapture} - onClick={() => { - void toggleHudCaptureProtection(); - }} - > - {hideHudFromCapture - ? t("recording.hideHudFromVideo") - : t("recording.showHudInVideo")} - - )} - } - onClick={chooseRecordingsDirectory} - > - {t("recording.recordingsFolder")} - - } - onClick={openVideoFile} - > - {t("recording.openVideoFile")} - - } - onClick={() => void openProjectBrowser()} - > - {t("recording.openProject")} - - {SHOW_DEV_UPDATE_PREVIEW ? ( - } - onClick={() => { - setActiveDropdown("none"); - void window.electronAPI - .previewUpdateToast() - .catch((error) => { - console.warn( - "Failed to preview update toast:", - error, - ); - }); - }} - > - {t("recording.previewUpdateUi", "Preview Update UI")} - - ) : null} -
- {t("recording.language")} -
- {SUPPORTED_LOCALES.map((code) => ( - } - selected={locale === code} - onClick={() => { - setLocale(code as AppLocale); - setActiveDropdown("none"); - }} - > - {LOCALE_LABELS[code] ?? code} - - ))} - {appVersion && ( -
- v{appVersion} -
- )} - - )} -
- )} -
- -
)}
+
+ ); } diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx new file mode 100644 index 000000000..4e24f943a --- /dev/null +++ b/src/components/launch/RecordingControls.tsx @@ -0,0 +1,146 @@ +import { MicrophoneIcon, MicrophoneSlashIcon, MinusIcon, PauseIcon, PlayIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; +import { useMemo } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import styles from "./LaunchWindow.module.css"; + +interface RecordingControlsProps { + paused: boolean; + microphoneEnabled: boolean; + elapsed: number; + onToggleMicrophone: () => void; + onPauseResume: () => void; + onStopRecording: () => void; + onHideHud: () => void; + onCancelRecording: () => void; + formatTime: (seconds: number) => string; +} + +export const RecordingControls = ({ + paused, + microphoneEnabled, + elapsed, + onToggleMicrophone, + onPauseResume, + onStopRecording, + onHideHud, + onCancelRecording, + formatTime, +}: RecordingControlsProps) => { + const t = useScopedT("launch"); + + const memoizedControls = useMemo(() => { + return ( + <> +
+
+ + {paused ? t("recording.paused") : t("recording.rec")} + +
+ + + {formatTime(elapsed)} + + + + + + + + + + + + + + + + + + + ); + }, [ + paused, + microphoneEnabled, + elapsed, + onToggleMicrophone, + onPauseResume, + onStopRecording, + onHideHud, + onCancelRecording, + formatTime, + t, + ]); + + return memoizedControls; +}; diff --git a/src/components/launch/SourceSelector.css b/src/components/launch/SourceSelector.css new file mode 100644 index 000000000..56fbc2a02 --- /dev/null +++ b/src/components/launch/SourceSelector.css @@ -0,0 +1,142 @@ +.source-selector-popover { + background: var(--launch-surface); + border: 1px solid var(--launch-border); + border-radius: 16px; + backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + -webkit-backdrop-filter: blur(var(--launch-popover-blur, 24px)) + saturate(var(--launch-popover-saturate, 145%)); + box-shadow: var(--launch-bar-shadow); +} + +.source-selector-item { + color: var(--launch-text-muted); +} + +.source-selector-item:hover { + background: var(--launch-hover); + color: var(--launch-text); +} + +.source-selector-item-selected { + background: var(--launch-selected); + color: var(--launch-accent); +} + +.source-selector-thumb-fallback { + background: var(--launch-panel); +} + +.source-selector-text { + color: var(--launch-text); +} + +.source-selector-muted { + color: var(--launch-text-muted); +} + +.source-selector-subtle { + color: var(--launch-text-muted); + opacity: 0.7; +} + +.source-selector-label { + color: var(--launch-label); +} + +.source-selector-dot { + background: var(--launch-accent); +} + +.source-selector-dot-inner { + background: #f8fbff; +} + +.source-selector-accent-border { + border-color: var(--launch-accent); +} + +/* Custom Scrollbar for Source Selector */ +.source-selector-scroll::-webkit-scrollbar { + width: 5px; + height: 5px; +} + +.source-selector-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.source-selector-scroll::-webkit-scrollbar-thumb { + background: rgba(107, 107, 120, 0.2); + border-radius: 20px; + transition: background 0.2s ease; +} + +.source-selector-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(107, 107, 120, 0.4); +} + +/* Support for Firefox */ +.source-selector-scroll { + scrollbar-width: thin; + scrollbar-color: rgba(107, 107, 120, 0.2) transparent; +} + +.source-selector-marquee { + position: relative; + overflow: hidden; + white-space: nowrap; +} + +.source-selector-marquee-static { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-selector-marquee-animated { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; +} + +.source-selector-marquee-track { + display: inline-flex; + max-width: none; + white-space: nowrap; + will-change: transform; + animation: source-selector-marquee 10s linear infinite; + animation-play-state: paused; +} + +.source-selector-marquee-segment { + display: inline-block; +} + +.source-selector-marquee-duplicate { + padding-left: 20px; +} + +.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-static { + opacity: 0; +} + +.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-animated { + opacity: 1; +} + +.group:hover .source-selector-marquee[data-overflowing="true"] .source-selector-marquee-track { + animation-play-state: running; +} + +@keyframes source-selector-marquee { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index fec7af9b0..158b53e16 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,322 +1,374 @@ -import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useState } from "react"; -import { MdCheck } from "react-icons/md"; -import { useScopedT } from "../../contexts/I18nContext"; -import { Button } from "../ui/button"; -import { Card } from "../ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import styles from "./SourceSelector.module.css"; +import * as React from "react"; +import { MonitorIcon, AppWindowIcon, CaretUpIcon } from "@phosphor-icons/react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { + mapRawSource, + isScreenSource, + isWindowSource, + type DesktopSource, +} from "./popovers/launchPopoverTypes"; +import "./launchTheme.css"; +import "./SourceSelector.css"; +import { useHudInteraction } from "./contexts/HudInteractionContext"; -interface DesktopSource { - id: string; - name: string; - thumbnail: string | null; - display_id: string; - appIcon: string | null; - originalName: string; - sourceType: "screen" | "window"; - appName?: string; - windowTitle?: string; +interface SourceSelectorProps { + /** List of available screen sources */ + screenSources?: DesktopSource[]; + /** List of available window sources */ + windowSources?: DesktopSource[]; + /** Currently selected source name */ + selectedSource?: string; + /** Loading state */ + loading?: boolean; + /** Callback when a source is selected */ + onSourceSelect?: (source: DesktopSource) => void; + /** Callback to fetch sources */ + onFetchSources?: () => Promise; + /** Whether the popover is open */ + open?: boolean; + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void; + /** Optional custom trigger element */ + children?: React.ReactNode; } -function parseSourceMetadata(source: ProcessedDesktopSource) { - if (source.sourceType === "window" && (source.appName || source.windowTitle)) { - return { - sourceType: "window" as const, - appName: source.appName, - windowTitle: source.windowTitle ?? source.name, - displayName: source.windowTitle ?? source.name, - }; - } - - const sourceType: "screen" | "window" = source.id.startsWith("window:") ? "window" : "screen"; - if (sourceType === "window") { - const [appNamePart, ...windowTitleParts] = source.name.split(" — "); - const appName = appNamePart?.trim() || undefined; - const windowTitle = windowTitleParts.join(" — ").trim() || source.name.trim(); +export function MarqueeText({ text }: { text: string }) { + const staticRef = useRef(null); + const [overflowing, setOverflowing] = useState(false); - return { - sourceType, - appName, - windowTitle, - displayName: windowTitle, + useLayoutEffect(() => { + const node = staticRef.current; + if (!node) return; + const checkOverflow = () => { + setOverflowing(node.scrollWidth > node.clientWidth + 1); }; - } + checkOverflow(); + const observer = new ResizeObserver(checkOverflow); + observer.observe(node); + return () => observer.disconnect(); + }, [text]); - return { - sourceType, - appName: undefined, - windowTitle: undefined, - displayName: source.name, - }; + return ( +
+ + {text} + + + + {text} + + {text} + + + +
+ ); } -export function SourceSelector() { +/** + * SourceSelectorContent - The actual list of sources + */ +export const SourceSelectorContent = ({ + screenSources = [], + windowSources = [], + selectedSource = "Screen", + loading = false, + onSourceSelect = () => {}, +}: Pick) => { const t = useScopedT("launch"); - const [sources, setSources] = useState([]); - const [selectedSource, setSelectedSource] = useState(null); - const [activeTab, setActiveTab] = useState<"screens" | "windows">("screens"); - const [loading, setLoading] = useState(true); + const renderSourceItem = (source: DesktopSource, index: number) => { + const isSelected = selectedSource === source.name; + return ( + + ); + }; - return { - id: source.id, - name: metadata.displayName, - thumbnail: source.thumbnail, - display_id: source.display_id, - appIcon: source.appIcon, - originalName: source.name, - sourceType: metadata.sourceType, - appName: metadata.appName, - windowTitle: metadata.windowTitle, - }; - }), - ); - } catch (error) { - console.error("Error loading sources:", error); - } finally { - setLoading(false); - } + const hasAnySources = screenSources.length > 0 || windowSources.length > 0; + + if (loading && !hasAnySources) { + return ( +
+
+
+ ); + } + + return ( +
+ {hasAnySources ? ( + <> + {screenSources.length > 0 ? ( +
+
+ {t("recording.screens")} + + {t("common.loading", "Refreshing...")} + +
+
+ {screenSources.map((source, index) => renderSourceItem(source, index))} +
+
+ ) : null} + {windowSources.length > 0 ? ( +
+
+ {t("recording.windows")} +
+
+ {windowSources.map((source, index) => renderSourceItem(source, index))} +
+
+ ) : null} + + ) : ( +
+ {t("recording.noSourcesFound")} +
+ )} +
+ ); +}; + +/** + * SourceSelector - A rich source selection component with thumbnails + * Uses Radix UI Popover for positioning and accessibility + */ +export const SourceSelector = React.memo(function SourceSelector({ + screenSources: propsScreenSources, + windowSources: propsWindowSources, + selectedSource: propsSelectedSource, + loading: propsLoading, + onSourceSelect: propsOnSourceSelect, + onFetchSources: propsOnFetchSources, + open: propsOpen, + onOpenChange: propsOnOpenChange, + children, +}: SourceSelectorProps) { + // Internal state for standalone/uncontrolled use + const [internalOpen, setInternalOpen] = useState(false); + const [internalSources, setInternalSources] = useState([]); + const [internalLoading, setInternalLoading] = useState(false); + const [internalSelectedSource, setInternalSelectedSource] = useState("Screen"); + + // Determine if we should use internal or external state/logic + const isAutonomous = propsOpen === undefined; + const open = propsOpen ?? internalOpen; + const onOpenChange = propsOnOpenChange ?? setInternalOpen; + const loading = propsLoading ?? internalLoading; + const selectedSource = propsSelectedSource ?? internalSelectedSource; + + // Default fetching logic + const defaultFetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setInternalLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setInternalSources(rawSources.map((s) => mapRawSource(s as DesktopSource))); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setInternalLoading(false); } - fetchSources(); }, []); - const screenSources = sources.filter((s) => s.id.startsWith("screen:")); - const windowSources = sources.filter((s) => s.id.startsWith("window:")); + const onFetchSources = propsOnFetchSources ?? defaultFetchSources; - useEffect(() => { - if (loading) { - return; - } + // Default selection logic + const onSourceSelect = useCallback( + async (source: DesktopSource) => { + if (propsOnSourceSelect) { + propsOnSourceSelect(source); + return; + } + if (!window.electronAPI) return; + try { + const result = await window.electronAPI.selectSource(source); + if (result) { + setInternalSelectedSource(source.name); + } + } catch (error) { + console.error("Failed to select source:", error); + } + }, + [propsOnSourceSelect], + ); - if (screenSources.length === 0 && windowSources.length > 0) { - setActiveTab("windows"); - return; - } + // Split sources for internal use + const internalScreenSources = useMemo( + () => internalSources.filter(isScreenSource), + [internalSources], + ); + const internalWindowSources = useMemo( + () => internalSources.filter(isWindowSource), + [internalSources], + ); - if (windowSources.length === 0 && screenSources.length > 0) { - setActiveTab("screens"); - } - }, [loading, screenSources.length, windowSources.length]); + const screenSources = propsScreenSources ?? internalScreenSources; + const windowSources = propsWindowSources ?? internalWindowSources; + + const hasPrefetchedRef = useRef(false); + const fetchInFlightRef = useRef(false); + const lastFetchedAtRef = useRef(0); + + const fetchSourcesOnce = useCallback( + async (allowRecentSkip: boolean) => { + if (fetchInFlightRef.current) { + return; + } + if (allowRecentSkip && Date.now() - lastFetchedAtRef.current < 750) { + return; + } + fetchInFlightRef.current = true; + try { + await onFetchSources(); + lastFetchedAtRef.current = Date.now(); + } finally { + fetchInFlightRef.current = false; + } + }, + [onFetchSources], + ); - const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source); - const handleSourceKeyDown = ( - event: ReactKeyboardEvent, - source: DesktopSource, - ) => { - if (event.key !== "Enter" && event.key !== " ") { + const prefetchSources = React.useCallback(() => { + if (hasPrefetchedRef.current) { return; } + hasPrefetchedRef.current = true; + void fetchSourcesOnce(false); + }, [fetchSourcesOnce]); - event.preventDefault(); - handleSourceSelect(source); - }; - - const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); - }; + // Fetch sources when popover opens + useEffect(() => { + if (open) { + void fetchSourcesOnce(true); + } + }, [open, fetchSourcesOnce]); - if (loading) { - return ( -
-
-
-

{t("sourceSelector.loadingSources")}

-
-
- ); - } + // In autonomous mode, we might want to start open + useEffect(() => { + if (isAutonomous) { + setInternalOpen(true); + } + }, [isAutonomous]); - return ( -
>, { + onPointerEnter: prefetchSources, + onFocusCapture: prefetchSources, + }) + ) : ( + children + ) + ) : ( + + ); - return ( - handleSourceSelect(source)} - onKeyDown={(event) => - handleSourceKeyDown(event, source) - } - role="button" - tabIndex={0} - aria-pressed={isSelected} - > -
-
- {source.name} - {isSelected && ( -
-
- -
-
- )} -
-
- {source.name} -
-
-
- ); - })} -
- - -

- {t("sourceSelector.windowsNote")} -

-
- {windowSources.length === 0 && ( -
- {t("sourceSelector.noWindowsAvailable")} -
- )} - {windowSources.map((source) => { - const isSelected = selectedSource?.id === source.id; + const { onMouseEnter } = useHudInteraction(); - return ( - handleSourceSelect(source)} - onKeyDown={(event) => - handleSourceKeyDown(event, source) - } - role="button" - tabIndex={0} - aria-pressed={isSelected} - > -
-
- {source.thumbnail ? ( - {source.name} - ) : ( -
- {source.appIcon ? ( - App icon - ) : ( -
- )} -
- {t( - "sourceSelector.windowPlaceholder", - )} -
-
- )} - {isSelected && ( -
-
- -
-
- )} -
-
- {source.appIcon && ( - App icon - )} -
- {source.name} -
-
-
- - ); - })} -
- -
- -
-
-
- - -
-
-
+ return ( + + {trigger} + + + + ); -} +}); + +SourceSelector.displayName = "SourceSelector"; diff --git a/src/components/launch/contexts/HudInteractionContext.tsx b/src/components/launch/contexts/HudInteractionContext.tsx new file mode 100644 index 000000000..65a1accb8 --- /dev/null +++ b/src/components/launch/contexts/HudInteractionContext.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; + +interface HudInteractionContextType { + onMouseEnter: () => void; + onMouseLeave: (event: any) => void; +} + +export const HudInteractionContext = createContext(null); + +export function useHudInteraction() { + const context = useContext(HudInteractionContext); + if (!context) { + throw new Error("useHudInteraction must be used within a HudInteractionProvider"); + } + return context; +} diff --git a/src/components/launch/hooks/useHudBarDrag.ts b/src/components/launch/hooks/useHudBarDrag.ts new file mode 100644 index 000000000..92c294cb1 --- /dev/null +++ b/src/components/launch/hooks/useHudBarDrag.ts @@ -0,0 +1,197 @@ +import { useCallback, useEffect, useRef, useState, type PointerEvent, type RefObject } from "react"; +import { mergeHudInteractiveBounds, shouldRestoreHudMousePassthroughAfterDrag } from "../hudMousePassthrough"; + +const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; + +export function useHudBarDrag({ + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, +}: { + hudContentRef: RefObject; + hudBarRef: RefObject; + recordingWebcamPreviewContainerRef: RefObject; +}) { + const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); + const [isHudDragging, setIsHudDragging] = useState(false); + const hudBarTransformRef = useRef(null); + const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET); + const hudDragStartRef = useRef< + | { + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + hudWidth: number; + hudHeight: number; + } + | null + >(null); + const isHudDraggingRef = useRef(false); + const hudDragMoveRafRef = useRef(null); + const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + + useEffect(() => { + recordingHudOffsetRef.current = recordingHudOffset; + if (!isHudDraggingRef.current && hudBarTransformRef.current) { + hudBarTransformRef.current.style.transform = `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`; + } + }, [recordingHudOffset]); + + const handleHudBarPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + isHudDraggingRef.current = true; + setIsHudDragging(true); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + if (!hudBarRef.current) { + return; + } + const hudRect = hudBarRef.current.getBoundingClientRect(); + hudDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: recordingHudOffsetRef.current.x, + originY: recordingHudOffsetRef.current.y, + initialLeft: hudRect.left, + initialTop: hudRect.top, + hudWidth: hudRect.width, + hudHeight: hudRect.height, + }; + }, [hudBarRef]); + + const handleHudBarPointerMove = useCallback((event: PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + hudDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY }; + if (hudDragMoveRafRef.current !== null) { + return; + } + + hudDragMoveRafRef.current = requestAnimationFrame(() => { + hudDragMoveRafRef.current = null; + const latestDragState = hudDragStartRef.current; + const pointer = hudDragPendingPointerRef.current; + if (!latestDragState || !pointer) { + return; + } + + const deltaX = pointer.clientX - latestDragState.startX; + const deltaY = pointer.clientY - latestDragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const unclampedLeft = latestDragState.initialLeft + deltaX; + const unclampedTop = latestDragState.initialTop + deltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - latestDragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - latestDragState.hudHeight), + ); + + const nextOffset = { + x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft), + y: latestDragState.originY + (clampedTop - latestDragState.initialTop), + }; + recordingHudOffsetRef.current = nextOffset; + if (hudBarTransformRef.current) { + hudBarTransformRef.current.style.transform = `translate3d(${nextOffset.x}px, ${nextOffset.y}px, 0)`; + } + }); + }, []); + + const handleHudBarPointerUp = useCallback((event: PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const pointer = hudDragPendingPointerRef.current || { clientX: event.clientX, clientY: event.clientY }; + const deltaX = pointer.clientX - dragState.startX; + const deltaY = pointer.clientY - dragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + const clampedLeft = Math.min( + Math.max(0, dragState.initialLeft + deltaX), + Math.max(0, viewportWidth - dragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, dragState.initialTop + deltaY), + Math.max(0, viewportHeight - dragState.hudHeight), + ); + + recordingHudOffsetRef.current = { + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }; + + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + hudDragMoveRafRef.current = null; + } + hudDragPendingPointerRef.current = null; + + hudDragStartRef.current = null; + const wasDragging = isHudDraggingRef.current; + isHudDraggingRef.current = false; + setRecordingHudOffset({ ...recordingHudOffsetRef.current }); + setIsHudDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const hudBounds = mergeHudInteractiveBounds( + [ + hudContentRef.current?.getBoundingClientRect(), + hudBarRef.current?.getBoundingClientRect(), + recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), + ].map((bounds) => + bounds + ? { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + } + : null, + ), + ); + if (wasDragging && shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef]); + + useEffect(() => { + return () => { + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + } + hudDragMoveRafRef.current = null; + hudDragPendingPointerRef.current = null; + hudDragStartRef.current = null; + }; + }, []); + + return { + recordingHudOffset, + isHudDragging, + hudBarTransformRef, + isHudDraggingRef, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + }; +} diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts new file mode 100644 index 000000000..3b1fd51e6 --- /dev/null +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useRef, type MouseEvent, type RefObject } from "react"; + +export function useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, +}: { + openId: string | null; + isHudDraggingRef: RefObject; + isWebcamPreviewDraggingRef: RefObject; + webcamPreviewDragStartRef: RefObject; +}) { + const anyPopoverOpenRef = useRef(false); + const isMouseOverHudRef = useRef(false); + + useEffect(() => { + anyPopoverOpenRef.current = openId !== null; + if (openId !== null) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + } else { + // Proactively check if we should ignore mouse when popover closes + setTimeout(() => { + if (!isMouseOverHudRef.current && !anyPopoverOpenRef.current) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 150); + } + }, [openId]); + + const beginInteractiveHudAction = useCallback(() => { + isMouseOverHudRef.current = true; + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + }, []); + + const handleHudMouseEnter = useCallback(() => { + isMouseOverHudRef.current = true; + if (timeoutRef.current) clearTimeout(timeoutRef.current); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + }, []); + + const timeoutRef = useRef(null); + + const handleHudMouseLeave = useCallback((event: MouseEvent) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; + } + + isMouseOverHudRef.current = false; + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current && + !anyPopoverOpenRef.current + ) { + // If a popover is open, we can still ignore mouse if the mouse is truly gone, + // but we give a bit more breathing room (the 300ms timeout). + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 300); + }, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + + return { + handleHudMouseEnter, + handleHudMouseLeave, + beginInteractiveHudAction, + }; +} diff --git a/src/components/launch/hooks/useLaunchWindowActions.ts b/src/components/launch/hooks/useLaunchWindowActions.ts new file mode 100644 index 000000000..90acfe577 --- /dev/null +++ b/src/components/launch/hooks/useLaunchWindowActions.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from "react"; +import type { ProjectLibraryEntry } from "@/components/video-editor/ProjectBrowserDialog"; +import type { DesktopSource } from "../popovers/launchPopoverTypes"; + +export function useLaunchWindowActions() { + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); + + const handleSourceSelect = useCallback(async (source: DesktopSource) => { + await window.electronAPI.selectSource(source); + setSelectedSource(source.name); + setHasSelectedSource(true); + window.electronAPI.showSourceHighlight?.({ + ...source, + name: source.appName ? `${source.appName} — ${source.name}` : source.name, + appName: source.appName, + }); + }, []); + + const openVideoFile = useCallback(async () => { + const result = await window.electronAPI.openVideoFilePicker(); + if (result.canceled) return; + if (result.success && result.path) { + await window.electronAPI.setCurrentVideoPath(result.path); + await window.electronAPI.switchToEditor(); + } + }, []); + + 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 openProjectFromLibrary = useCallback(async (projectPath: string) => { + try { + const result = await window.electronAPI.openProjectFileAtPath(projectPath); + if (result.canceled || !result.success) { + return; + } + await window.electronAPI.switchToEditor(); + } catch (error) { + console.error("Failed to open project from library:", error); + } + }, []); + + const syncSelectedSource = useCallback((source: { name?: string } | null | undefined) => { + if (source?.name) { + setSelectedSource(source.name); + setHasSelectedSource(true); + return; + } + setSelectedSource("Screen"); + setHasSelectedSource(false); + }, []); + + return { + selectedSource, + hasSelectedSource, + projectLibraryEntries, + handleSourceSelect, + openVideoFile, + openProjectFromLibrary, + syncSelectedSource, + refreshProjectLibrary, + }; +} diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts new file mode 100644 index 000000000..56793fa36 --- /dev/null +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from "react"; + +export function useLaunchWindowSystemState( + preparePermissions: (args: { startup?: boolean }) => Promise, +) { + const [recordingsDirectory, setRecordingsDirectory] = useState(null); + const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< + boolean | null + >(null); + const [platform, setPlatform] = useState(null); + const [appVersion, setAppVersion] = useState(null); + const [hideHudFromCapture, setHideHudFromCapture] = useState(true); + + useEffect(() => { + window.electronAPI?.hudOverlayRendererReady?.(); + }, []); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const result = await window.electronAPI.getRecordingsDirectory(); + if (!cancelled && result.success) setRecordingsDirectory(result.path); + } catch (error) { + console.error("Failed to load recordings directory:", error); + } + }; + void load(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const loadPlatform = async () => { + try { + const nextPlatform = await window.electronAPI.getPlatform(); + if (!cancelled) setPlatform(nextPlatform); + } catch (error) { + console.error("Failed to load platform:", error); + } + }; + void loadPlatform(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const loadSupport = async () => { + try { + const result = await window.electronAPI.getHudOverlayMousePassthroughSupported(); + if (!cancelled && result.success) { + setHudOverlayMousePassthroughSupported(result.supported); + } + } catch (error) { + console.error("Failed to load HUD overlay mouse passthrough support:", error); + } + }; + void loadSupport(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + void preparePermissions({ startup: true }); + }, [preparePermissions]); + + useEffect(() => { + let cancelled = false; + const loadVersion = async () => { + try { + const version = await window.electronAPI.getAppVersion(); + if (!cancelled) setAppVersion(version); + } catch (error) { + console.error("Failed to load app version:", error); + } + }; + void loadVersion(); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const loadCaptureProtection = async () => { + try { + const result = await window.electronAPI.getHudOverlayCaptureProtection(); + if (!cancelled && result.success) { + setHideHudFromCapture(result.enabled); + } + } catch (error) { + console.error("Failed to load HUD capture protection state:", error); + } + }; + void loadCaptureProtection(); + return () => { + cancelled = true; + }; + }, []); + + const chooseRecordingsDirectory = useCallback(async () => { + try { + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) return; + if (result.success && result.path) setRecordingsDirectory(result.path); + } catch (error) { + console.error("Failed to choose recordings directory:", error); + } + }, []); + + const toggleHudCaptureProtection = useCallback(async () => { + const nextValue = !hideHudFromCapture; + setHideHudFromCapture(nextValue); + try { + const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue); + if (!result.success) { + setHideHudFromCapture(!nextValue); + return; + } + setHideHudFromCapture(result.enabled); + } catch (error) { + console.error("Failed to update HUD capture protection:", error); + setHideHudFromCapture(!nextValue); + } + }, [hideHudFromCapture]); + + return { + recordingsDirectory, + hudOverlayMousePassthroughSupported, + platform, + appVersion, + hideHudFromCapture, + setHideHudFromCapture, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + }; +} diff --git a/src/components/launch/hooks/useRecordingTimer.ts b/src/components/launch/hooks/useRecordingTimer.ts new file mode 100644 index 000000000..ccdccfe81 --- /dev/null +++ b/src/components/launch/hooks/useRecordingTimer.ts @@ -0,0 +1,54 @@ +import { useEffect, useMemo, useState } from "react"; + +export function useRecordingTimer(recording: boolean, paused: boolean) { + const [recordingStart, setRecordingStart] = useState(null); + const [elapsed, setElapsed] = useState(0); + const [pausedAt, setPausedAt] = useState(null); + const [pausedTotal, setPausedTotal] = useState(0); + + useEffect(() => { + let timer: NodeJS.Timeout | null = null; + if (recording) { + if (!recordingStart) { + setRecordingStart(Date.now()); + setPausedTotal(0); + } + if (paused) { + if (!pausedAt) setPausedAt(Date.now()); + if (timer) clearInterval(timer); + } else { + if (pausedAt) { + setPausedTotal((prev) => prev + (Date.now() - pausedAt)); + setPausedAt(null); + } + timer = setInterval(() => { + if (recordingStart) { + setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000)); + } + }, 1000); + } + } else { + setRecordingStart(null); + setElapsed(0); + setPausedAt(null); + setPausedTotal(0); + if (timer) clearInterval(timer); + } + return () => { + if (timer) clearInterval(timer); + }; + }, [recording, recordingStart, paused, pausedAt, pausedTotal]); + + const formatTime = useMemo( + () => (seconds: number) => { + const m = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; + }, + [], + ); + + return { elapsed, formatTime }; +} diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts new file mode 100644 index 000000000..7c93899a0 --- /dev/null +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; +import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; + +const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; +const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; + +export function useWebcamPreviewOverlay({ + webcamEnabled, + webcamDeviceId, + showWebcamControls, + webcamPopoverOpen, + hudOverlayMousePassthroughSupported, +}: { + webcamEnabled: boolean; + webcamDeviceId?: string; + showWebcamControls: boolean; + webcamPopoverOpen: boolean; + hudOverlayMousePassthroughSupported: boolean | null; +}) { + const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); + const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); + const webcamPreviewOffsetRef = useRef(DEFAULT_WEBCAM_PREVIEW_OFFSET); + const webcamPreviewRef = useRef(null); + const recordingWebcamPreviewRef = useRef(null); + const recordingWebcamPreviewContainerRef = useRef(null); + const previewStreamRef = useRef(null); + const previewDragMoveRafRef = useRef(null); + const previewDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + const webcamPreviewDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + previewWidth: number; + previewHeight: number; + dragging: boolean; + } | null>(null); + const isWebcamPreviewDraggingRef = useRef(false); + const showRecordingWebcamPreview = + webcamEnabled && + canShowFloatingWebcamPreview( + showFloatingWebcamPreview, + hudOverlayMousePassthroughSupported, + ); + const shouldStreamWebcamPreview = + webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen)); + + useEffect(() => { + if (!webcamEnabled) { + webcamPreviewOffsetRef.current = DEFAULT_WEBCAM_PREVIEW_OFFSET; + setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); + if (recordingWebcamPreviewContainerRef.current) { + recordingWebcamPreviewContainerRef.current.style.transform = "translate(0px, 0px)"; + } + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + setShowFloatingWebcamPreview(true); + } + }, [webcamEnabled]); + + const handleWebcamPreviewPointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + + const previewRect = event.currentTarget.getBoundingClientRect(); + + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffsetRef.current.x, + originY: webcamPreviewOffsetRef.current.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, + [], + ); + + const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + + if (!dragState.dragging && Math.hypot(deltaX, deltaY) < WEBCAM_PREVIEW_DRAG_THRESHOLD) { + return; + } + + if (!dragState.dragging) { + dragState.dragging = true; + isWebcamPreviewDraggingRef.current = true; + } + + previewDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY }; + if (previewDragMoveRafRef.current !== null) { + return; + } + + previewDragMoveRafRef.current = requestAnimationFrame(() => { + previewDragMoveRafRef.current = null; + const latestDragState = webcamPreviewDragStartRef.current; + const pointer = previewDragPendingPointerRef.current; + if (!latestDragState || !pointer) { + return; + } + + const latestDeltaX = pointer.clientX - latestDragState.startX; + const latestDeltaY = pointer.clientY - latestDragState.startY; + const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); + const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); + const unclampedLeft = latestDragState.initialLeft + latestDeltaX; + const unclampedTop = latestDragState.initialTop + latestDeltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max(0, viewportWidth - latestDragState.previewWidth), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max(0, viewportHeight - latestDragState.previewHeight), + ); + + const nextOffset = { + x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft), + y: latestDragState.originY + (clampedTop - latestDragState.initialTop), + }; + webcamPreviewOffsetRef.current = nextOffset; + if (recordingWebcamPreviewContainerRef.current) { + recordingWebcamPreviewContainerRef.current.style.transform = `translate(${nextOffset.x}px, ${nextOffset.y}px)`; + } + }); + }, []); + + const handleWebcamPreviewPointerUp = useCallback((event: PointerEvent) => { + const dragState = webcamPreviewDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } + if (previewDragMoveRafRef.current !== null) { + cancelAnimationFrame(previewDragMoveRafRef.current); + previewDragMoveRafRef.current = null; + } + previewDragPendingPointerRef.current = null; + + const wasDragging = dragState.dragging; + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + setWebcamPreviewOffset({ ...webcamPreviewOffsetRef.current }); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, []); + + const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => { + const previewStream = previewStreamRef.current; + if (!videoElement || !previewStream || videoElement.srcObject === previewStream) { + return; + } + + videoElement.srcObject = previewStream; + const playPromise = videoElement.play(); + if (playPromise) { + playPromise.catch(() => { + // Ignore autoplay interruptions while the preview element mounts. + }); + } + }, []); + + const setWebcamPreviewNode = useCallback( + (node: HTMLVideoElement | null) => { + webcamPreviewRef.current = node; + attachPreviewStreamToNode(node); + }, + [attachPreviewStreamToNode], + ); + + const setRecordingWebcamPreviewNode = useCallback( + (node: HTMLVideoElement | null) => { + recordingWebcamPreviewRef.current = node; + attachPreviewStreamToNode(node); + }, + [attachPreviewStreamToNode], + ); + + useEffect(() => { + return () => { + if (previewDragMoveRafRef.current !== null) { + cancelAnimationFrame(previewDragMoveRafRef.current); + } + previewDragMoveRafRef.current = null; + previewDragPendingPointerRef.current = null; + }; + }, []); + + useEffect(() => { + let mounted = true; + + const startPreview = async () => { + if (!shouldStreamWebcamPreview) { + return; + } + + try { + const previewStream = await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + } + : { + width: { ideal: 320 }, + height: { ideal: 320 }, + frameRate: { ideal: 24, max: 30 }, + }, + audio: false, + }); + + if (!mounted) { + previewStream.getTracks().forEach((track) => track.stop()); + return; + } + + previewStreamRef.current = previewStream; + attachPreviewStreamToNode(webcamPreviewRef.current); + attachPreviewStreamToNode(recordingWebcamPreviewRef.current); + } catch (error) { + console.warn("Failed to start live webcam preview:", error); + } + }; + + void startPreview(); + + return () => { + mounted = false; + const previewNode = webcamPreviewRef.current; + const recordingPreviewNode = recordingWebcamPreviewRef.current; + const previewStream = previewStreamRef.current; + + [previewNode, recordingPreviewNode] + .filter((node): node is HTMLVideoElement => Boolean(node)) + .forEach((videoElement) => { + videoElement.pause(); + videoElement.srcObject = null; + }); + previewStream?.getTracks().forEach((track) => track.stop()); + if (previewStreamRef.current === previewStream) { + previewStreamRef.current = null; + } + }; + }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); + + return { + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + webcamPreviewOffset, + recordingWebcamPreviewContainerRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + setWebcamPreviewNode, + setRecordingWebcamPreviewNode, + showRecordingWebcamPreview, + }; +} diff --git a/src/components/launch/launchTheme.css b/src/components/launch/launchTheme.css new file mode 100644 index 000000000..4adca96dd --- /dev/null +++ b/src/components/launch/launchTheme.css @@ -0,0 +1,40 @@ +.launch-theme { + /* Ultra-Refined Light Mode (Premium OS style) */ + --launch-surface: #ffffff; + --launch-panel: #f8fafc; + --launch-border: rgba(0, 0, 0, 0.08); + --launch-border-strong: rgba(0, 0, 0, 0.16); + --launch-text: #020617; /* Deepest Navy/Black */ + --launch-text-muted: #475569; /* Slate 600 */ + --launch-label: #64748b; /* Slate 500 - increased contrast */ + --launch-hover: rgba(0, 0, 0, 0.045); + --launch-selected: rgba(37, 99, 235, 0.12); /* Blue 600 at 12% */ + --launch-accent: #2563eb; /* Blue 600 */ + --launch-accent-soft: #f1f5f9; /* Slate 100 */ + --launch-popover-blur: 40px; + --launch-popover-saturate: 200%; + --launch-bar-bg: rgba(255, 255, 255, 0.98); + --launch-bar-border: rgba(0, 0, 0, 0.08); + --launch-bar-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06); + + color: var(--launch-text); +} + +.dark .launch-theme, +.launch-theme.dark { + /* Dark Mode values */ + --launch-surface: rgba(18, 18, 24, 0.97); + --launch-panel: rgba(18, 18, 24, 0.82); + --launch-border: rgba(255, 255, 255, 0.07); + --launch-border-strong: rgba(255, 255, 255, 0.12); + --launch-text: #f3f6ff; + --launch-text-muted: #aab3c7; + --launch-label: #8f99b1; + --launch-hover: rgba(255, 255, 255, 0.1); + --launch-selected: rgba(92, 153, 255, 0.22); + --launch-accent: #3d8bff; + --launch-accent-soft: #dbeafe; + --launch-bar-bg: rgba(18, 18, 24, 0.97); + --launch-bar-border: rgba(255, 255, 255, 0.07); + --launch-bar-shadow: 0 10px 30px rgba(0, 0, 0, 0.24), 0 2px 10px rgba(0, 0, 0, 0.12); +} diff --git a/src/components/launch/popovers/CountdownPopover.tsx b/src/components/launch/popovers/CountdownPopover.tsx new file mode 100644 index 000000000..a0c4b6698 --- /dev/null +++ b/src/components/launch/popovers/CountdownPopover.tsx @@ -0,0 +1,53 @@ +import { TimerIcon } from "@phosphor-icons/react"; +import type { ReactElement } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import styles from "../LaunchWindow.module.css"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; + +const POPOVER_ID = "countdown"; +const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; + +export function CountdownPopover({ + trigger, + countdownDelay, + onSelectDelay, +}: { + trigger: ReactElement; + countdownDelay: number; + onSelectDelay: (delay: number) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="center" + > +
{t("recording.countdownDelay")}
+ {COUNTDOWN_OPTIONS.map((delay) => ( + } + selected={countdownDelay === delay} + onClick={() => { + onSelectDelay(delay); + requestClose(POPOVER_ID); + }} + > + {delay === 0 ? t("recording.noDelay") : `${delay}s`} + + ))} +
+ ); +} diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx new file mode 100644 index 000000000..ead6cdc45 --- /dev/null +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -0,0 +1,48 @@ +import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; + +interface LaunchPopoverCoordinatorValue { + openId: string | null; + requestOpen: (id: string) => void; + requestClose: (id: string) => void; + isOpen: (id: string) => boolean; +} + +const LaunchPopoverCoordinatorContext = createContext(null); + +export function LaunchPopoverCoordinatorProvider({ children }: { children: ReactNode }) { + const [openId, setOpenId] = useState(null); + + const requestOpen = useCallback((id: string) => { + setOpenId(id); + }, []); + + const requestClose = useCallback((id: string) => { + setOpenId((currentId) => (currentId === id ? null : currentId)); + }, []); + + const isOpen = useCallback((id: string) => openId === id, [openId]); + + const value = useMemo( + () => ({ + openId, + requestOpen, + requestClose, + isOpen, + }), + [isOpen, openId, requestClose, requestOpen], + ); + + return ( + + {children} + + ); +} + +export function useLaunchPopoverCoordinator() { + const context = useContext(LaunchPopoverCoordinatorContext); + if (!context) { + throw new Error("useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider"); + } + return context; +} diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx new file mode 100644 index 000000000..02247d662 --- /dev/null +++ b/src/components/launch/popovers/MicPopover.tsx @@ -0,0 +1,94 @@ +import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import type { DeviceOption } from "./launchPopoverTypes"; +import type { ReactElement } from "react"; +import styles from "../LaunchWindow.module.css"; + +const POPOVER_ID = "mic"; + +export function MicPopover({ + trigger, + disabled, + systemAudioEnabled, + onToggleSystemAudio, + microphoneEnabled, + onDisableMicrophone, + devices, + microphoneDeviceId, + selectedDeviceId, + onSelectDevice, +}: { + trigger: ReactElement; + disabled?: boolean; + systemAudioEnabled: boolean; + onToggleSystemAudio: () => void; + microphoneEnabled: boolean; + onDisableMicrophone: () => void; + devices: DeviceOption[]; + microphoneDeviceId?: string; + selectedDeviceId?: string; + onSelectDevice: (deviceId: string) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + if (disabled) { + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="start" + > +
{t("recording.microphone")}
+ : } + selected={systemAudioEnabled} + onClick={onToggleSystemAudio} + > + {systemAudioEnabled + ? t("recording.disableSystemAudio") + : t("recording.enableSystemAudio")} + + {microphoneEnabled && ( + } + onClick={() => { + onDisableMicrophone(); + requestClose(POPOVER_ID); + }} + > + {t("recording.turnOffMicrophone")} + + )} + {!microphoneEnabled && ( +
{t("recording.selectMicToEnable")}
+ )} + {devices.map((device) => ( + onSelectDevice(device.deviceId)} + /> + ))} + {devices.length === 0 && ( +
{t("recording.noMicrophonesFound")}
+ )} +
+ ); +} diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx new file mode 100644 index 000000000..94687308a --- /dev/null +++ b/src/components/launch/popovers/MorePopover.tsx @@ -0,0 +1,191 @@ +import { + EyeIcon, + EyeSlashIcon, + FolderOpenIcon, + TranslateIcon, + VideoCameraIcon, + ArrowClockwiseIcon, + SunIcon, + MoonIcon, + DesktopIcon, +} from "@phosphor-icons/react"; +import type { ReactElement } from "react"; +import { useI18n } from "@/contexts/I18nContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useTheme } from "@/contexts/ThemeContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import styles from "../LaunchWindow.module.css"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; + +const POPOVER_ID = "more"; + +const LOCALE_LABELS: Record = { + en: "English", + es: "Español", + fr: "Français", + nl: "Nederlands", + ko: "한국어", + "pt-BR": "Português", + "zh-CN": "簡體中文", + "zh-TW": "繁體中文", +}; + +export function MorePopover({ + trigger, + supportsHudCaptureProtection, + hideHudFromCapture, + onToggleHudCaptureProtection, + onChooseRecordingsDirectory, + onOpenVideoFile, + onOpenProjectBrowser, + showDevUpdatePreview, + onPreviewUpdateUi, + appVersion, +}: { + trigger: ReactElement; + supportsHudCaptureProtection: boolean; + hideHudFromCapture: boolean; + onToggleHudCaptureProtection: () => void; + onChooseRecordingsDirectory: () => void; + onOpenVideoFile: () => void; + onOpenProjectBrowser: () => void; + showDevUpdatePreview: boolean; + onPreviewUpdateUi: () => void; + appVersion: string | null; +}) { + const t = useScopedT("launch"); + const { locale, setLocale } = useI18n(); + const { preference, setPreference } = useTheme(); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="end" + > + {supportsHudCaptureProtection && ( + : } + selected={hideHudFromCapture} + onClick={onToggleHudCaptureProtection} + > + {hideHudFromCapture + ? t("recording.hideHudFromVideo") + : t("recording.showHudInVideo")} + + )} + } + onClick={() => { + requestClose(POPOVER_ID); + onChooseRecordingsDirectory(); + }} + > + {t("recording.recordingsFolder")} + + } + onClick={() => { + requestClose(POPOVER_ID); + onOpenVideoFile(); + }} + > + {t("recording.openVideoFile")} + + } + onClick={() => { + requestClose(POPOVER_ID); + onOpenProjectBrowser(); + }} + > + {t("recording.openProject")} + + {showDevUpdatePreview ? ( + } + onClick={() => { + requestClose(POPOVER_ID); + onPreviewUpdateUi(); + }} + > + {t("recording.previewUpdateUi", "Preview Update UI")} + + ) : null} +
+ {t("recording.appearance", "Appearance")} +
+ } + selected={preference === "light"} + onClick={() => { + setPreference("light"); + requestClose(POPOVER_ID); + }} + > + {t("common.light", "Light")} + + } + selected={preference === "dark"} + onClick={() => { + setPreference("dark"); + requestClose(POPOVER_ID); + }} + > + {t("common.dark", "Dark")} + + } + selected={preference === "system"} + onClick={() => { + setPreference("system"); + requestClose(POPOVER_ID); + }} + > + {t("common.system", "System")} + +
+ {t("recording.language")} +
+ {SUPPORTED_LOCALES.map((code) => ( + } + selected={locale === code} + onClick={() => { + setLocale(code as AppLocale); + requestClose(POPOVER_ID); + }} + > + {LOCALE_LABELS[code] ?? code} + + ))} + {appVersion && ( +
+ v{appVersion} +
+ )} +
+ ); +} diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx new file mode 100644 index 000000000..856de855d --- /dev/null +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -0,0 +1,96 @@ +import { MicrophoneIcon, MicrophoneSlashIcon } from "@phosphor-icons/react"; +import type { ReactElement, ReactNode } from "react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; +import styles from "../LaunchWindow.module.css"; +import "../launchTheme.css"; +import type { DeviceOption } from "./launchPopoverTypes"; +import { useHudInteraction } from "../contexts/HudInteractionContext"; + +export function DropdownItem({ + onClick, + selected, + icon, + children, + trailing, +}: { + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; +}) { + return ( + + ); +} + +export function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: DeviceOption; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + + ); +} + +export function HudPopover({ + open, + onOpenChange, + trigger, + children, + align = "center", +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactElement; + children: ReactNode; + align?: "start" | "center" | "end"; +}) { + const { onMouseEnter } = useHudInteraction(); + return ( + + {trigger} + + {children} + + + ); +} diff --git a/src/components/launch/popovers/ProjectPopover.tsx b/src/components/launch/popovers/ProjectPopover.tsx new file mode 100644 index 000000000..22df11d05 --- /dev/null +++ b/src/components/launch/popovers/ProjectPopover.tsx @@ -0,0 +1,48 @@ +import type { ReactElement } from "react"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { HudPopover } from "./PopoverScaffold"; +import ProjectBrowserDialog from "../../video-editor/ProjectBrowserDialog"; +import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog"; + +const POPOVER_ID = "projects"; + +export function ProjectPopover({ + trigger, + entries, + onOpenProject, +}: { + trigger: ReactElement; + entries: ProjectLibraryEntry[]; + onOpenProject: (projectPath: string) => void; +}) { + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="center" + > + { + if (!nextOpen) requestClose(POPOVER_ID); + }} + entries={entries} + renderMode="inline" + onOpenProject={(path) => { + onOpenProject(path); + requestClose(POPOVER_ID); + }} + /> + + ); +} diff --git a/src/components/launch/popovers/SourcePopover.tsx b/src/components/launch/popovers/SourcePopover.tsx new file mode 100644 index 000000000..5459fae14 --- /dev/null +++ b/src/components/launch/popovers/SourcePopover.tsx @@ -0,0 +1,77 @@ +import { useCallback, useMemo, type ReactNode, useState } from "react"; +import { SourceSelector } from "../SourceSelector"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { + mapRawSource, + isScreenSource, + isWindowSource, + type DesktopSource, +} from "./launchPopoverTypes"; + +const POPOVER_ID = "sources"; + +export function SourcePopover({ + trigger, + selectedSource, + onSourceSelect, + onOpen, +}: { + trigger: ReactNode; + selectedSource: string; + onSourceSelect: (source: DesktopSource) => Promise | void; + onOpen?: () => void; +}) { + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const [sources, setSources] = useState([]); + const [loading, setLoading] = useState(false); + const open = isOpen(POPOVER_ID); + + const fetchSources = useCallback(async () => { + if (!window.electronAPI) return; + setLoading(true); + try { + const rawSources = await window.electronAPI.getSources({ + types: ["screen", "window"], + thumbnailSize: { width: 160, height: 90 }, + fetchWindowIcons: true, + }); + setSources(rawSources.map((s) => mapRawSource(s as DesktopSource))); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setLoading(false); + } + }, []); + + const screenSources = useMemo(() => sources.filter(isScreenSource), [sources]); + const windowSources = useMemo(() => sources.filter(isWindowSource), [sources]); + + return ( + { + try { + await onSourceSelect(source); + requestClose(POPOVER_ID); + } catch (error) { + console.error("Failed to select source:", error); + } + }} + onFetchSources={fetchSources} + open={open} + onOpenChange={(nextOpen) => { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + onOpen?.(); + requestOpen(POPOVER_ID); + }} + > + {trigger} + + ); +} diff --git a/src/components/launch/popovers/WebcamPopover.tsx b/src/components/launch/popovers/WebcamPopover.tsx new file mode 100644 index 000000000..945ffac61 --- /dev/null +++ b/src/components/launch/popovers/WebcamPopover.tsx @@ -0,0 +1,129 @@ +import { + Eye, + EyeSlash as EyeOff, + VideoCamera as Video, + VideoCameraSlash as VideoOff, +} from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import type { DeviceOption } from "./launchPopoverTypes"; +import type { ReactElement } from "react"; + +const POPOVER_ID = "webcam"; + +export function WebcamPopover({ + trigger, + disabled, + webcamEnabled, + onDisableWebcam, + canToggleFloatingPreview, + showFloatingWebcamPreview, + onToggleFloatingPreview, + showWebcamControls, + setWebcamPreviewNode, + videoDevices, + webcamDeviceId, + selectedVideoDeviceId, + onSelectVideoDevice, +}: { + trigger: ReactElement; + disabled?: boolean; + webcamEnabled: boolean; + onDisableWebcam: () => void; + canToggleFloatingPreview: boolean; + showFloatingWebcamPreview: boolean; + onToggleFloatingPreview: () => void; + showWebcamControls: boolean; + setWebcamPreviewNode: (node: HTMLVideoElement | null) => void; + videoDevices: DeviceOption[]; + webcamDeviceId?: string; + selectedVideoDeviceId?: string; + onSelectVideoDevice: (deviceId: string) => void; +}) { + const t = useScopedT("launch"); + const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator(); + const open = isOpen(POPOVER_ID); + + return ( + { + if (!nextOpen) { + requestClose(POPOVER_ID); + return; + } + if (disabled) { + return; + } + requestOpen(POPOVER_ID); + }} + trigger={trigger} + align="center" + > +
+ {t("recording.webcam")} +
+ {webcamEnabled && ( + <> + } onClick={() => { + onDisableWebcam(); + requestClose(POPOVER_ID); + }}> + {t("recording.turnOffWebcam")} + + {canToggleFloatingPreview ? ( + : } + selected={showFloatingWebcamPreview} + onClick={onToggleFloatingPreview} + > + {showFloatingWebcamPreview + ? t("recording.hideFloatingWebcamPreview") + : t("recording.showFloatingWebcamPreview")} + + ) : null} + + )} + {!webcamEnabled && ( +
{t("recording.selectWebcamToEnable")}
+ )} + {showWebcamControls && ( +
+
+
+
+ )} + {videoDevices.map((device) => ( + + ) : ( + + ) + } + selected={ + webcamEnabled && + (webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) + } + onClick={() => onSelectVideoDevice(device.deviceId)} + > + {device.label} + + ))} + {videoDevices.length === 0 && ( +
{t("recording.noWebcamsFound")}
+ )} +
+ ); +} diff --git a/src/components/launch/popovers/launchPopoverTypes.ts b/src/components/launch/popovers/launchPopoverTypes.ts new file mode 100644 index 000000000..1585aaed5 --- /dev/null +++ b/src/components/launch/popovers/launchPopoverTypes.ts @@ -0,0 +1,53 @@ +export interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +/** + * Check if a source is a screen/display + */ +export function isScreenSource(s: DesktopSource): boolean { + return s.sourceType === "screen" || s.id.startsWith("screen:"); +} + +/** + * Check if a source is an application window + */ +export function isWindowSource(s: DesktopSource): boolean { + return s.sourceType === "window" || s.id.startsWith("window:"); +} + +export function mapRawSource(s: DesktopSource): DesktopSource { + const isWindow = isWindowSource(s); + const type = s.sourceType ?? (isWindow ? "window" : "screen"); + let displayName = s.name; + let appName = s.appName; + if (isWindow && s.windowTitle) { + displayName = s.windowTitle; + } else if (isWindow && !appName && s.name.includes(" — ")) { + const parts = s.name.split(" — "); + appName = parts[0]?.trim(); + displayName = parts.slice(1).join(" — ").trim() || s.name; + } + return { + id: s.id, + name: displayName, + thumbnail: s.thumbnail, + display_id: s.display_id, + appIcon: s.appIcon, + sourceType: type, + appName, + windowTitle: s.windowTitle ?? displayName, + }; +} + +export interface DeviceOption { + deviceId: string; + label: string; +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 8dc9d07cd..2ea10d35c 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { @@ -24,10 +24,18 @@ const buttonVariants = cva( lg: "h-10 rounded-md px-8", icon: "h-9 w-9", }, + // Special variant for icon buttons with consistent sizing + iconSize: { + default: "[&_svg]:size-4", + sm: "[&_svg]:size-3.5", + lg: "[&_svg]:size-5", + xl: "[&_svg]:size-6", + }, }, defaultVariants: { variant: "default", size: "default", + iconSize: "default", }, }, ); @@ -35,15 +43,18 @@ const buttonVariants = cva( export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { + /** Whether to render the button as a child component (useful for composition) */ asChild?: boolean; + /** Size of the icon inside the button */ + iconSize?: "default" | "sm" | "lg" | "xl"; } const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ({ className, variant, size, iconSize, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; return ( @@ -53,3 +64,4 @@ const Button = React.forwardRef( Button.displayName = "Button"; export { Button, buttonVariants }; + diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx index 8d7239080..cd6bb4ae7 100644 --- a/src/components/ui/popover.tsx +++ b/src/components/ui/popover.tsx @@ -18,26 +18,36 @@ function PopoverContent({ align = "center", sideOffset = 4, animated = true, + usePortal = true, + unstyled = false, ...props }: React.ComponentProps & { animated?: boolean; + usePortal?: boolean; + unstyled?: boolean; }) { - return ( - - - + unstyled && "z-50 outline-hidden", + animated && + "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-popover-content-transform-origin)", + className, + )} + {...props} + /> ); + + if (usePortal === false) { + return content; + } + + return {content}; } function PopoverAnchor({ ...props }: React.ComponentProps) { diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 000000000..be280e48c --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,29 @@ +import { cn } from "@/lib/utils"; + +interface SeparatorProps { + /** Orientation of the separator */ + orientation?: "horizontal" | "vertical"; + /** Additional CSS classes */ + className?: string; +} + +/** + * A reusable separator component that can be used as a horizontal or vertical divider. + * Replaces the inline div separators used throughout the application. + */ +export function Separator({ + orientation = "horizontal", + className, +}: SeparatorProps) { + return ( +
+ ); +} diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 6155d26ef..9dfcbe038 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "Enable system audio", "disableMicrophone": "Disable microphone", "enableMicrophone": "Enable microphone", + "micToggleDisabledTip": "Microphone cannot be toggled during recording", "disableWebcam": "Disable webcam overlay", "enableWebcam": "Enable webcam overlay", "countdownDelay": "Countdown delay", @@ -20,6 +21,8 @@ "closeApp": "Close App", "screens": "Screens", "windows": "Windows", + "screen": "Screen", + "window": "Window", "noSourcesFound": "No sources found", "microphone": "Microphone", "turnOffMicrophone": "Turn Off Microphone", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index b72b24044..a458844a2 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "Activar audio del sistema", "disableMicrophone": "Desactivar micrófono", "enableMicrophone": "Activar micrófono", + "micToggleDisabledTip": "El micrófono no se puede alternar durante la grabación", "disableWebcam": "Desactivar superposición de cámara", "enableWebcam": "Activar superposición de cámara", "countdownDelay": "Retraso de cuenta regresiva", @@ -20,6 +21,8 @@ "closeApp": "Cerrar aplicación", "screens": "Pantallas", "windows": "Ventanas", + "screen": "Pantalla", + "window": "Ventana", "noSourcesFound": "No se encontraron fuentes", "microphone": "Micrófono", "turnOffMicrophone": "Desactivar micrófono", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 304b97c8f..df2e43b2b 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "Activer l’audio système", "disableMicrophone": "Désactiver le microphone", "enableMicrophone": "Activer le microphone", + "micToggleDisabledTip": "Le microphone ne peut pas être basculé pendant l'enregistrement", "disableWebcam": "Désactiver l’incrustation webcam", "enableWebcam": "Activer l’incrustation webcam", "countdownDelay": "Délai du compte à rebours", @@ -20,6 +21,8 @@ "closeApp": "Fermer l’application", "screens": "Écrans", "windows": "Fenêtres", + "screen": "Écran", + "window": "Fenêtre", "noSourcesFound": "Aucune source trouvée", "microphone": "Microphone", "turnOffMicrophone": "Désactiver le microphone", diff --git a/src/i18n/locales/ko/launch.json b/src/i18n/locales/ko/launch.json index 3981a5d79..345000399 100644 --- a/src/i18n/locales/ko/launch.json +++ b/src/i18n/locales/ko/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "시스템 오디오 켜기", "disableMicrophone": "마이크 끄기", "enableMicrophone": "마이크 켜기", + "micToggleDisabledTip": "녹음 중에는 마이크를 전환할 수 없습니다", "disableWebcam": "웹캠 오버레이 끄기", "enableWebcam": "웹캠 오버레이 켜기", "countdownDelay": "카운트다운 지연", @@ -20,6 +21,8 @@ "closeApp": "앱 닫기", "screens": "화면", "windows": "창", + "screen": "화면", + "window": "창", "noSourcesFound": "사용 가능한 소스를 찾을 수 없습니다", "microphone": "마이크", "turnOffMicrophone": "마이크 끄기", diff --git a/src/i18n/locales/nl/launch.json b/src/i18n/locales/nl/launch.json index 9f88a4461..d3d5870c9 100644 --- a/src/i18n/locales/nl/launch.json +++ b/src/i18n/locales/nl/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "Systeemaudio inschakelen", "disableMicrophone": "Microfoon uitschakelen", "enableMicrophone": "Microfoon inschakelen", + "micToggleDisabledTip": "Microfoon kan niet worden gewijzigd tijdens de opname", "disableWebcam": "Webcam-overlay uitschakelen", "enableWebcam": "Webcam-overlay inschakelen", "countdownDelay": "Aftelvertraging", @@ -20,6 +21,8 @@ "closeApp": "App sluiten", "screens": "Schermen", "windows": "Vensters", + "screen": "Scherm", + "window": "Venster", "noSourcesFound": "Geen bronnen gevonden", "microphone": "Microfoon", "turnOffMicrophone": "Microfoon uitschakelen", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 5cc1f066c..8d19ac7db 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "Ativar áudio do sistema", "disableMicrophone": "Desativar microfone", "enableMicrophone": "Ativar microfone", + "micToggleDisabledTip": "O microfone não pode ser alternado durante a gravação", "disableWebcam": "Desativar sobreposição da webcam", "enableWebcam": "Ativar sobreposição da webcam", "countdownDelay": "Atraso da contagem regressiva", @@ -20,6 +21,8 @@ "closeApp": "Fechar app", "screens": "Telas", "windows": "Janelas", + "screen": "Tela", + "window": "Janela", "noSourcesFound": "Nenhuma fonte encontrada", "microphone": "Microfone", "turnOffMicrophone": "Desligar microfone", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index 17be557f9..164c02afe 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "启用系统音频", "disableMicrophone": "禁用麦克风", "enableMicrophone": "启用麦克风", + "micToggleDisabledTip": "录制过程中无法切换麦克风", "disableWebcam": "禁用摄像头叠加", "enableWebcam": "启用摄像头叠加", "countdownDelay": "倒计时延迟", @@ -20,6 +21,8 @@ "closeApp": "关闭应用", "screens": "屏幕", "windows": "窗口", + "screen": "屏幕", + "window": "窗口", "noSourcesFound": "未找到源", "microphone": "麦克风", "turnOffMicrophone": "关闭麦克风", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index eb44476e8..12ec494d0 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -4,6 +4,7 @@ "enableSystemAudio": "啟用系統音訊", "disableMicrophone": "停用麥克風", "enableMicrophone": "啟用麥克風", + "micToggleDisabledTip": "錄製過程中無法切換麥克風", "disableWebcam": "停用網路攝影機疊加", "enableWebcam": "啟用網路攝影機疊加", "countdownDelay": "倒數延遲", @@ -20,6 +21,8 @@ "closeApp": "關閉應用程式", "screens": "螢幕", "windows": "視窗", + "screen": "螢幕", + "window": "視窗", "noSourcesFound": "找不到可錄製的來源", "microphone": "麥克風", "turnOffMicrophone": "關閉麥克風", @@ -74,4 +77,4 @@ "failedToStart": "無法開始錄製:{{error}}", "failedToStartGeneric": "無法開始錄製" } -} \ No newline at end of file +} diff --git a/src/index.css b/src/index.css index 765a6239a..a5ae2368b 100644 --- a/src/index.css +++ b/src/index.css @@ -22,6 +22,12 @@ margin: 0; } + html.hud-overlay-window, + body.hud-overlay-window, + #root.hud-overlay-window { + overflow: hidden; + } + :root { --app-font-sans: "SF Pro Display", "SF Pro Text", Helvetica, sans-serif; --brand-accent: #2563eb; @@ -262,3 +268,6 @@ transform: translateX(270%); } } + + +