From cf66f3111c8d9caef21ffa10d9d08920c7f8cb71 Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Wed, 6 May 2026 22:26:03 +0200 Subject: [PATCH] fix: popovers are now seperated into different components, still not everything right but it's getting there --- src/components/launch/LaunchHudPopovers.tsx | 468 ++++++++++ src/components/launch/LaunchWindow.module.css | 27 - src/components/launch/LaunchWindow.tsx | 836 ++++++------------ 3 files changed, 752 insertions(+), 579 deletions(-) create mode 100644 src/components/launch/LaunchHudPopovers.tsx diff --git a/src/components/launch/LaunchHudPopovers.tsx b/src/components/launch/LaunchHudPopovers.tsx new file mode 100644 index 00000000..27bf745c --- /dev/null +++ b/src/components/launch/LaunchHudPopovers.tsx @@ -0,0 +1,468 @@ +import { + Eye, + EyeSlash as EyeOff, + FolderOpen, + Translate as Languages, + Microphone as Mic, + MicrophoneSlash as MicOff, + Timer, + VideoCamera as Video, + VideoCamera as VideoIcon, + VideoCameraSlash as VideoOff, + SpeakerHigh as Volume2, + SpeakerX as VolumeX, + ArrowClockwise as RefreshCw, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; +import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; +import { useI18n } from "@/contexts/I18nContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useScopedT } from "../../contexts/I18nContext"; +import { AudioLevelMeter } from "../ui/audio-level-meter"; +import { SourceSelector } from "./SourceSelector"; +import styles from "./LaunchWindow.module.css"; + +const LOCALE_LABELS: Record = { + en: "English", + es: "Español", + fr: "Français", + nl: "Nederlands", + ko: "한국어", + "pt-BR": "Português", + "zh-CN": "簡體中文", + "zh-TW": "繁體中文", +}; + +const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; + +interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +interface DeviceOption { + deviceId: string; + label: string; +} + +function DropdownItem({ + onClick, + selected, + icon, + children, + trailing, +}: { + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; +}) { + return ( + + ); +} + +function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: DeviceOption; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + + ); +} + +function HudPopover({ + open, + onOpenChange, + trigger, + children, + align = "center", +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactNode; + children: ReactNode; + align?: "start" | "center" | "end"; +}) { + return ( + + {trigger} + + {children} + + + ); +} + +export function SourcePopover({ + open, + onOpenChange, + trigger, + screenSources, + windowSources, + selectedSource, + loading, + onSourceSelect, + onFetchSources, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactNode; + screenSources: DesktopSource[]; + windowSources: DesktopSource[]; + selectedSource: string; + loading: boolean; + onSourceSelect: (source: DesktopSource) => void; + onFetchSources: () => Promise; +}) { + return ( + + {trigger} + + ); +} + +export function MicPopover({ + open, + onOpenChange, + trigger, + systemAudioEnabled, + onToggleSystemAudio, + microphoneEnabled, + onDisableMicrophone, + devices, + microphoneDeviceId, + selectedDeviceId, + onSelectDevice, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactNode; + systemAudioEnabled: boolean; + onToggleSystemAudio: () => void; + microphoneEnabled: boolean; + onDisableMicrophone: () => void; + devices: DeviceOption[]; + microphoneDeviceId?: string; + selectedDeviceId?: string; + onSelectDevice: (deviceId: string) => void; +}) { + const t = useScopedT("launch"); + + return ( + +
{t("recording.microphone")}
+ : } + selected={systemAudioEnabled} + onClick={onToggleSystemAudio} + > + {systemAudioEnabled + ? t("recording.disableSystemAudio") + : t("recording.enableSystemAudio")} + + {microphoneEnabled && ( + } onClick={onDisableMicrophone}> + {t("recording.turnOffMicrophone")} + + )} + {!microphoneEnabled && ( +
+ {t("recording.selectMicToEnable")} +
+ )} + {devices.map((device) => ( + onSelectDevice(device.deviceId)} + /> + ))} + {devices.length === 0 && ( +
+ {t("recording.noMicrophonesFound")} +
+ )} +
+ ); +} + +export function WebcamPopover({ + open, + onOpenChange, + trigger, + webcamEnabled, + onDisableWebcam, + canToggleFloatingPreview, + showFloatingWebcamPreview, + onToggleFloatingPreview, + showWebcamControls, + setWebcamPreviewNode, + videoDevices, + webcamDeviceId, + selectedVideoDeviceId, + onSelectVideoDevice, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactNode; + 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"); + + return ( + +
{t("recording.webcam")}
+ {webcamEnabled && ( + <> + } onClick={onDisableWebcam}> + {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")} +
+ )} +
+ ); +} + +export function CountdownPopover({ + open, + onOpenChange, + trigger, + countdownDelay, + onSelectDelay, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactNode; + countdownDelay: number; + onSelectDelay: (delay: number) => void; +}) { + const t = useScopedT("launch"); + + return ( + +
{t("recording.countdownDelay")}
+ {COUNTDOWN_OPTIONS.map((delay) => ( + } + selected={countdownDelay === delay} + onClick={() => onSelectDelay(delay)} + > + {delay === 0 ? t("recording.noDelay") : `${delay}s`} + + ))} +
+ ); +} + +export function MorePopover({ + open, + onOpenChange, + trigger, + supportsHudCaptureProtection, + hideHudFromCapture, + onToggleHudCaptureProtection, + onChooseRecordingsDirectory, + onOpenVideoFile, + onOpenProjectBrowser, + showDevUpdatePreview, + onPreviewUpdateUi, + appVersion, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + trigger: ReactNode; + 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(); + + return ( + + {supportsHudCaptureProtection && ( + : } + selected={hideHudFromCapture} + onClick={onToggleHudCaptureProtection} + > + {hideHudFromCapture + ? t("recording.hideHudFromVideo") + : t("recording.showHudInVideo")} + + )} + } onClick={onChooseRecordingsDirectory}> + {t("recording.recordingsFolder")} + + } onClick={onOpenVideoFile}> + {t("recording.openVideoFile")} + + } onClick={onOpenProjectBrowser}> + {t("recording.openProject")} + + {showDevUpdatePreview ? ( + } onClick={onPreviewUpdateUi}> + {t("recording.previewUpdateUi", "Preview Update UI")} + + ) : null} +
+ {t("recording.language")} +
+ {SUPPORTED_LOCALES.map((code) => ( + } + selected={locale === code} + onClick={() => { + setLocale(code as AppLocale); + onOpenChange(false); + }} + > + {LOCALE_LABELS[code] ?? code} + + ))} + {appVersion && ( +
+ v{appVersion} +
+ )} +
+ ); +} diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index da79e2dd..09b884a5 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -100,16 +100,6 @@ -.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; @@ -127,12 +117,6 @@ animation: menuCardIn 0.18s ease; } -.menuCardTop { - margin-top: 8px; - margin-bottom: auto; - animation: menuCardInTop 0.18s ease; -} - @keyframes menuCardIn { from { opacity: 0; @@ -144,17 +128,6 @@ } } -@keyframes menuCardInTop { - from { - opacity: 0; - transform: translateY(-12px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - .menuCard::-webkit-scrollbar { width: 4px; } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 4b8f8b8b..1a5ae452 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,9 +1,5 @@ import { CaretUp as ChevronUp, - Eye, - EyeSlash as EyeOff, - FolderOpen, - Translate as Languages, Microphone as Mic, MicrophoneSlash as MicOff, Minus, @@ -11,10 +7,7 @@ import { DotsThreeVertical as MoreVertical, Timer, VideoCamera as Video, - VideoCamera as VideoIcon, VideoCameraSlash as VideoOff, - SpeakerHigh as Volume2, - SpeakerX as VolumeX, X, ArrowClockwise as RefreshCw, } from "@phosphor-icons/react"; @@ -22,15 +15,10 @@ import { AnimatePresence, motion } from "motion/react"; import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { RxDragHandleDots2 } from "react-icons/rx"; -import { useI18n } from "@/contexts/I18nContext"; -import type { AppLocale } from "@/i18n/config"; -import { SUPPORTED_LOCALES } from "@/i18n/config"; import { useScopedT } from "../../contexts/I18nContext"; -import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; -import { AudioLevelMeter } from "../ui/audio-level-meter"; import ProjectBrowserDialog, { type ProjectLibraryEntry, } from "../video-editor/ProjectBrowserDialog"; @@ -43,8 +31,17 @@ import { shouldRestoreHudMousePassthroughAfterDrag, } from "./hudMousePassthrough"; import styles from "./LaunchWindow.module.css"; +import { + CountdownPopover, + MicPopover, + MorePopover, + SourcePopover, + WebcamPopover, +} from "./LaunchHudPopovers"; + +import { Separator } from "@/components/ui/separator"; +import { Button } from "../ui/button"; import { RecordingControls } from "./RecordingControls"; -import { SourceSelectorContent } from "./SourceSelector"; interface DesktopSource { id: string; @@ -57,107 +54,12 @@ interface DesktopSource { 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": "繁體中文", -}; - -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; -export function IconButton({ - onClick, - title, - className = "", - buttonRef, - children, -}: { - onClick?: () => void; - title?: string; - className?: string; - buttonRef?: React.Ref; - children: ReactNode; -}) { - return ( - - ); -} - -export function DropdownItem({ - onClick, - selected, - icon, - children, - trailing, -}: { - onClick: () => void; - selected?: boolean; - icon: ReactNode; - children: ReactNode; - trailing?: ReactNode; -}) { - return ( - - ); -} - -import { Separator } from "@/components/ui/separator"; -import { Button } from "../ui/button"; - -function MicDeviceRow({ - device, - selected, - onSelect, -}: { - device: { deviceId: string; label: string }; - selected: boolean; - onSelect: () => void; -}) { - const { level } = useAudioLevelMeter({ - enabled: true, - deviceId: device.deviceId, - }); - - return ( - - ); -} - export function LaunchWindow() { - const { locale, setLocale } = useI18n(); const t = useScopedT("launch"); const { @@ -191,9 +93,11 @@ export function LaunchWindow() { 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 [sourcePopoverOpen, setSourcePopoverOpen] = useState(false); + const [micPopoverOpen, setMicPopoverOpen] = useState(false); + const [webcamPopoverOpen, setWebcamPopoverOpen] = useState(false); + const [countdownPopoverOpen, setCountdownPopoverOpen] = useState(false); + const [morePopoverOpen, setMorePopoverOpen] = useState(false); const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); const [sources, setSources] = useState([]); @@ -207,10 +111,8 @@ export function LaunchWindow() { >(null); const [platform, setPlatform] = useState(null); const [appVersion, setAppVersion] = useState(null); - const dropdownRef = useRef(null); const hudContentRef = useRef(null); const hudBarRef = useRef(null); - const moreButtonRef = useRef(null); const webcamPreviewRef = useRef(null); const recordingWebcamPreviewRef = useRef(null); const recordingWebcamPreviewContainerRef = useRef(null); @@ -248,8 +150,24 @@ export function LaunchWindow() { >(null); const isHudDraggingRef = useRef(false); const isWebcamPreviewDraggingRef = useRef(false); + const hudDragMoveRafRef = useRef(null); + const hudDragPendingScreenPositionRef = useRef<{ x: number; y: number } | null>(null); const [isAtTop, setIsAtTop] = useState(false); + const anyPopoverOpen = + sourcePopoverOpen || + micPopoverOpen || + webcamPopoverOpen || + countdownPopoverOpen || + morePopoverOpen; + + const closeAllPopovers = useCallback(() => { + setSourcePopoverOpen(false); + setMicPopoverOpen(false); + setWebcamPopoverOpen(false); + setCountdownPopoverOpen(false); + setMorePopoverOpen(false); + }, []); useEffect(() => { const checkPosition = () => { @@ -258,13 +176,11 @@ export function LaunchWindow() { setIsAtTop(windowY < screenHeight / 2); }; checkPosition(); - if (activeDropdown !== "none" || projectBrowserOpen) { + if (anyPopoverOpen || projectBrowserOpen) { checkPosition(); } - }, [activeDropdown, projectBrowserOpen]); + }, [anyPopoverOpen, projectBrowserOpen]); - const micDropdownOpen = activeDropdown === "mic"; - const webcamDropdownOpen = activeDropdown === "webcam"; const showWebcamControls = webcamEnabled && !recording; const showRecordingWebcamPreview = webcamEnabled && @@ -273,16 +189,16 @@ export function LaunchWindow() { hudOverlayMousePassthroughSupported, ); const shouldStreamWebcamPreview = - webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamDropdownOpen)); + webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen)); const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( - microphoneEnabled || micDropdownOpen, + microphoneEnabled || micPopoverOpen, microphoneDeviceId, ); const { devices: videoDevices, selectedDeviceId: selectedVideoDeviceId, setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled || webcamDropdownOpen); + } = useVideoDevices(webcamEnabled || webcamPopoverOpen); const supportsHudCaptureProtection = platform !== "linux"; @@ -425,6 +341,7 @@ export function LaunchWindow() { pointerId: event.pointerId, mode: "overlay", }; + hudDragPendingScreenPositionRef.current = null; window.electronAPI?.hudOverlayDrag?.("start", event.screenX, event.screenY); }; @@ -457,7 +374,20 @@ export function LaunchWindow() { return; } - window.electronAPI?.hudOverlayDrag?.("move", event.screenX, event.screenY); + hudDragPendingScreenPositionRef.current = { x: event.screenX, y: event.screenY }; + if (hudDragMoveRafRef.current !== null) { + return; + } + + hudDragMoveRafRef.current = requestAnimationFrame(() => { + hudDragMoveRafRef.current = null; + const position = hudDragPendingScreenPositionRef.current; + if (!position) { + return; + } + + window.electronAPI?.hudOverlayDrag?.("move", position.x, position.y); + }); }; const handleHudBarPointerUp = (event: React.PointerEvent) => { @@ -467,6 +397,15 @@ export function LaunchWindow() { } if (dragState.mode === "overlay") { + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + hudDragMoveRafRef.current = null; + } + const position = hudDragPendingScreenPositionRef.current; + if (position) { + window.electronAPI?.hudOverlayDrag?.("move", position.x, position.y); + } + hudDragPendingScreenPositionRef.current = null; window.electronAPI?.hudOverlayDrag?.("end", 0, 0); } @@ -478,7 +417,7 @@ export function LaunchWindow() { } const hudBounds = mergeHudInteractiveBounds( [ - dropdownRef.current?.getBoundingClientRect(), + hudContentRef.current?.getBoundingClientRect(), hudBarRef.current?.getBoundingClientRect(), recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), ].map((bounds) => @@ -515,6 +454,16 @@ export function LaunchWindow() { } }, []); + useEffect(() => { + return () => { + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + } + hudDragMoveRafRef.current = null; + hudDragPendingScreenPositionRef.current = null; + }; + }, []); + const setWebcamPreviewNode = useCallback( (node: HTMLVideoElement | null) => { webcamPreviewRef.current = node; @@ -745,13 +694,13 @@ export function LaunchWindow() { useEffect(() => { const expanded = - activeDropdown !== "none" || projectBrowserOpen || showRecordingWebcamPreview; + anyPopoverOpen || projectBrowserOpen || showRecordingWebcamPreview; window.electronAPI.setHudOverlayExpanded(expanded); return () => { window.electronAPI.setHudOverlayExpanded(false); }; - }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); + }, [anyPopoverOpen, projectBrowserOpen, showRecordingWebcamPreview]); const reportHudSize = useCallback(() => { const hudContent = hudContentRef.current; @@ -781,9 +730,9 @@ export function LaunchWindow() { window.electronAPI.setHudOverlayCompactWidth(Math.ceil(standardWidth + 24)); window.electronAPI.setHudOverlayMeasuredHeight( Math.ceil(standardHeight + 24), - activeDropdown !== "none" || projectBrowserOpen, + anyPopoverOpen || projectBrowserOpen, ); - }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); + }, [anyPopoverOpen, projectBrowserOpen, showRecordingWebcamPreview]); useEffect(() => { const hudContent = hudContentRef.current; @@ -823,21 +772,6 @@ export function LaunchWindow() { }; }, [reportHudSize]); - useEffect(() => { - const handleClick = (e: MouseEvent) => { - const target = e.target as Node; - const isInsideDropdown = dropdownRef.current?.contains(target); - const isInsideHudBar = hudBarRef.current?.contains(target); - - if (!isInsideDropdown && !isInsideHudBar) { - setActiveDropdown("none"); - setProjectBrowserOpen(false); - } - }; - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, []); - const fetchSources = useCallback(async () => { if (!window.electronAPI) return; setSourcesLoading(true); @@ -879,21 +813,11 @@ export function LaunchWindow() { } }, []); - const toggleDropdown = (which: "sources" | "more" | "mic" | "countdown" | "webcam") => { - setProjectBrowserOpen(false); - const nextActive = activeDropdown === which ? "none" : which; - setActiveDropdown(nextActive); - if (nextActive !== "none") { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - } - if (nextActive !== "none" && which === "sources") fetchSources(); - }; - const handleSourceSelect = async (source: DesktopSource) => { await window.electronAPI.selectSource(source); setSelectedSource(source.name); setHasSelectedSource(true); - setActiveDropdown("none"); + setSourcePopoverOpen(false); window.electronAPI.showSourceHighlight?.({ ...source, name: source.appName ? `${source.appName} — ${source.name}` : source.name, @@ -902,7 +826,7 @@ export function LaunchWindow() { }; const openVideoFile = async () => { - setActiveDropdown("none"); + setMorePopoverOpen(false); const result = await window.electronAPI.openVideoFilePicker(); if (result.canceled) return; if (result.success && result.path) { @@ -928,10 +852,10 @@ export function LaunchWindow() { return; } - setActiveDropdown("none"); + closeAllPopovers(); await refreshProjectLibrary(); setProjectBrowserOpen(true); - }, [projectBrowserOpen, refreshProjectLibrary]); + }, [closeAllPopovers, projectBrowserOpen, refreshProjectLibrary]); const openProjectFromLibrary = useCallback(async (projectPath: string) => { try { @@ -948,17 +872,12 @@ export function LaunchWindow() { }, []); const chooseRecordingsDirectory = async () => { - setActiveDropdown("none"); + setMorePopoverOpen(false); 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); @@ -975,6 +894,29 @@ export function LaunchWindow() { } }; + const handlePopoverOpenChange = useCallback( + (which: "sources" | "mic" | "webcam" | "countdown" | "more", open: boolean) => { + if (!open) { + if (which === "sources") setSourcePopoverOpen(false); + if (which === "mic") setMicPopoverOpen(false); + if (which === "webcam") setWebcamPopoverOpen(false); + if (which === "countdown") setCountdownPopoverOpen(false); + if (which === "more") setMorePopoverOpen(false); + return; + } + + setProjectBrowserOpen(false); + closeAllPopovers(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + if (which === "sources") setSourcePopoverOpen(true); + if (which === "mic") setMicPopoverOpen(true); + if (which === "webcam") setWebcamPopoverOpen(true); + if (which === "countdown") setCountdownPopoverOpen(true); + if (which === "more") setMorePopoverOpen(true); + }, + [closeAllPopovers], + ); + const screenSources = sources.filter((s) => s.sourceType === "screen"); const windowSources = sources.filter((s) => s.sourceType === "window"); const hudStateTransition = { @@ -982,10 +924,6 @@ export function LaunchWindow() { ease: [0.22, 1, 0.36, 1] as const, }; - const toggleWebcam = () => { - if (recording) return; - toggleDropdown("webcam"); - }; const recordingControls = ( {platform !== "linux" && ( <> - + handlePopoverOpenChange("sources", open)} + screenSources={screenSources} + windowSources={windowSources} + selectedSource={selectedSource} + loading={sourcesLoading} + onSourceSelect={(source) => { + void handleSourceSelect(source); + }} + onFetchSources={fetchSources} + trigger={ + + } + /> )} - } - className={microphoneEnabled ? styles.ibActive : ""} - > - {microphoneEnabled ? : } - + /> - + { + if (recording) return; + handlePopoverOpenChange("webcam", open); + }} + webcamEnabled={webcamEnabled} + onDisableWebcam={() => { + setWebcamEnabled(false); + setWebcamPopoverOpen(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={ + + } + /> - + handlePopoverOpenChange("countdown", open)} + countdownDelay={countdownDelay} + onSelectDelay={(delay) => { + setCountdownDelay(delay); + setCountdownPopoverOpen(false); + }} + trigger={ + + } + /> @@ -1073,7 +1091,7 @@ export function LaunchWindow() { onClick={ hasSelectedSource || platform === "linux" ? toggleRecording - : () => toggleDropdown("sources") + : () => handlePopoverOpenChange("sources", true) } disabled={countdownActive} title={t("recording.record")} @@ -1083,16 +1101,42 @@ export function LaunchWindow() { - + handlePopoverOpenChange("more", open)} + supportsHudCaptureProtection={supportsHudCaptureProtection} + hideHudFromCapture={hideHudFromCapture} + onToggleHudCaptureProtection={() => { + void toggleHudCaptureProtection(); + }} + onChooseRecordingsDirectory={() => { + void chooseRecordingsDirectory(); + }} + onOpenVideoFile={() => { + void openVideoFile(); + }} + onOpenProjectBrowser={() => { + void openProjectBrowser(); + }} + showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW} + onPreviewUpdateUi={() => { + setMorePopoverOpen(false); + void window.electronAPI.previewUpdateToast().catch((error) => { + console.warn("Failed to preview update toast:", error); + }); + }} + appVersion={appVersion} + trigger={ + + } + />