From 7f564c90bf7297457cc99b2d8a53ce0414fa2aaf Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Wed, 6 May 2026 23:54:15 +0200 Subject: [PATCH] add: Hooks in order to further reduce launchwindow logic LaunchWindow could still be further reduced, but honestly from 1600 lines to 650 lines is a win --- src/components/launch/LaunchHudPopovers.tsx | 470 ----------- src/components/launch/LaunchWindow.tsx | 769 +++--------------- src/components/launch/hooks/useHudBarDrag.ts | 177 ++++ .../hooks/useLaunchWindowSystemState.ts | 130 +++ .../launch/hooks/useRecordingTimer.ts | 54 ++ .../launch/hooks/useWebcamPreviewOverlay.ts | 242 ++++++ .../launch/popovers/CountdownPopover.tsx | 53 ++ .../popovers/LaunchPopoverCoordinator.tsx | 48 ++ src/components/launch/popovers/MicPopover.tsx | 94 +++ .../launch/popovers/MorePopover.tsx | 153 ++++ .../launch/popovers/PopoverScaffold.tsx | 93 +++ .../launch/popovers/SourcePopover.tsx | 98 +++ .../launch/popovers/WebcamPopover.tsx | 129 +++ src/components/launch/popovers/types.ts | 15 + 14 files changed, 1381 insertions(+), 1144 deletions(-) delete mode 100644 src/components/launch/LaunchHudPopovers.tsx create mode 100644 src/components/launch/hooks/useHudBarDrag.ts create mode 100644 src/components/launch/hooks/useLaunchWindowSystemState.ts create mode 100644 src/components/launch/hooks/useRecordingTimer.ts create mode 100644 src/components/launch/hooks/useWebcamPreviewOverlay.ts create mode 100644 src/components/launch/popovers/CountdownPopover.tsx create mode 100644 src/components/launch/popovers/LaunchPopoverCoordinator.tsx create mode 100644 src/components/launch/popovers/MicPopover.tsx create mode 100644 src/components/launch/popovers/MorePopover.tsx create mode 100644 src/components/launch/popovers/PopoverScaffold.tsx create mode 100644 src/components/launch/popovers/SourcePopover.tsx create mode 100644 src/components/launch/popovers/WebcamPopover.tsx create mode 100644 src/components/launch/popovers/types.ts diff --git a/src/components/launch/LaunchHudPopovers.tsx b/src/components/launch/LaunchHudPopovers.tsx deleted file mode 100644 index 007fea2c..00000000 --- a/src/components/launch/LaunchHudPopovers.tsx +++ /dev/null @@ -1,470 +0,0 @@ -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 "./launchTheme.css"; -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.tsx b/src/components/launch/LaunchWindow.tsx index 31137236..f78d6889 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -14,52 +14,46 @@ import { import { AnimatePresence, motion } from "motion/react"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useScopedT } from "../../contexts/I18nContext"; +import { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; +import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; +import { useRecordingTimer } from "./hooks/useRecordingTimer"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; +import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; import ProjectBrowserDialog, { type ProjectLibraryEntry, } from "../video-editor/ProjectBrowserDialog"; 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 { SourcePopover } from "./popovers/SourcePopover"; +import type { DesktopSource } from "./popovers/types"; +import { WebcamPopover } from "./popovers/WebcamPopover"; 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 { useCallback, useEffect, useRef, useState } from "react"; -interface DesktopSource { - id: string; - name: string; - thumbnail: string | null; - display_id: string; - appIcon: string | null; - sourceType?: "screen" | "window"; - appName?: string; - windowTitle?: string; -} - -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 LaunchWindow() { + return ( + + + + ); +} + +function LaunchWindowContent() { const t = useScopedT("launch"); + const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); const { recording, @@ -85,122 +79,62 @@ 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 { elapsed, formatTime } = useRecordingTimer(recording, paused); const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); - const [, setRecordingsDirectory] = useState(null); - 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([]); - 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 [isHudDragging, setIsHudDragging] = useState(false); - const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< - boolean | null - >(null); - const [platform, setPlatform] = useState(null); - const [appVersion, setAppVersion] = useState(null); const hudContentRef = useRef(null); const hudBarRef = useRef(null); - const hudBarTransformRef = useRef(null); - const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET); - 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; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - hudWidth: number; - hudHeight: number; - } - | null - >(null); - const isHudDraggingRef = useRef(false); - const isWebcamPreviewDraggingRef = useRef(false); - const hudDragMoveRafRef = useRef(null); - const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); const anyPopoverOpenRef = useRef(false); const projectBrowserOpenRef = useRef(false); - const anyPopoverOpen = - sourcePopoverOpen || - micPopoverOpen || - webcamPopoverOpen || - countdownPopoverOpen || - morePopoverOpen; + const anyPopoverOpen = openId !== null; useEffect(() => { anyPopoverOpenRef.current = anyPopoverOpen; }, [anyPopoverOpen]); + useEffect(() => { + if (!openId) { + return; + } + setProjectBrowserOpen(false); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + }, [openId]); + useEffect(() => { projectBrowserOpenRef.current = projectBrowserOpen; }, [projectBrowserOpen]); const closeAllPopovers = useCallback(() => { - setSourcePopoverOpen(false); - setMicPopoverOpen(false); - setWebcamPopoverOpen(false); - setCountdownPopoverOpen(false); - setMorePopoverOpen(false); - }, []); + if (openId) { + requestClose(openId); + } + }, [openId, requestClose]); const showWebcamControls = webcamEnabled && !recording; - const showRecordingWebcamPreview = - webcamEnabled && - canShowFloatingWebcamPreview( - showFloatingWebcamPreview, - hudOverlayMousePassthroughSupported, - ); - const shouldStreamWebcamPreview = - webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen)); const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( - microphoneEnabled || micPopoverOpen, + microphoneEnabled || openId === "mic", microphoneDeviceId, ); const { devices: videoDevices, selectedDeviceId: selectedVideoDeviceId, setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled || webcamPopoverOpen); + } = useVideoDevices(webcamEnabled || openId === "webcam"); + + const { + hudOverlayMousePassthroughSupported, + platform, + appVersion, + hideHudFromCapture, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + } = useLaunchWindowSystemState(preparePermissions); const supportsHudCaptureProtection = platform !== "linux"; - useEffect(() => { - // Tell main process to reveal the HUD only after renderer mounted. - window.electronAPI?.hudOverlayRendererReady?.(); - }, []); - useEffect(() => { if (!selectedDeviceId) { return; @@ -215,357 +149,40 @@ export function LaunchWindow() { } }, [selectedVideoDeviceId, setWebcamDeviceId]); - useEffect(() => { - if (!webcamEnabled) { - setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); - webcamPreviewDragStartRef.current = null; - isWebcamPreviewDraggingRef.current = false; - setShowFloatingWebcamPreview(true); - } - }, [webcamEnabled]); + const { + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + showRecordingWebcamPreview, + webcamPreviewOffset, + recordingWebcamPreviewContainerRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + setWebcamPreviewNode, + setRecordingWebcamPreviewNode, + } = useWebcamPreviewOverlay({ + webcamEnabled, + webcamDeviceId, + showWebcamControls, + webcamPopoverOpen: openId === "webcam", + hudOverlayMousePassthroughSupported, + }); - useEffect(() => { - recordingHudOffsetRef.current = recordingHudOffset; - if (!isHudDraggingRef.current && hudBarTransformRef.current) { - hudBarTransformRef.current.style.transform = `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`; - } - }, [recordingHudOffset]); - - 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; - 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, - }; - }; - - const handleHudBarPointerMove = (event: React.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 = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - 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 = (event: React.PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } - - if (hudDragMoveRafRef.current !== null) { - cancelAnimationFrame(hudDragMoveRafRef.current); - hudDragMoveRafRef.current = null; - } - hudDragPendingPointerRef.current = null; - - hudDragStartRef.current = null; - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - setIsHudDragging(false); - setRecordingHudOffset({ ...recordingHudOffsetRef.current }); - 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); - } - }; - - 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. - }); - } - }, []); - - useEffect(() => { - return () => { - if (hudDragMoveRafRef.current !== null) { - cancelAnimationFrame(hudDragMoveRafRef.current); - } - hudDragMoveRafRef.current = null; - hudDragPendingPointerRef.current = null; - hudDragStartRef.current = null; - }; - }, []); - - 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]); - - 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 = (seconds: number) => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, "0"); - const s = (seconds % 60).toString().padStart(2, "0"); - return `${m}:${s}`; - }; + const { + recordingHudOffset, + isHudDragging, + hudBarTransformRef, + isHudDraggingRef, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + } = useHudBarDrag({ + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, + }); useEffect(() => { let mounted = true; @@ -599,132 +216,10 @@ export function LaunchWindow() { }; }, []); - 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; - }; - }, []); - - 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 handleSourceSelect = async (source: DesktopSource) => { await window.electronAPI.selectSource(source); setSelectedSource(source.name); setHasSelectedSource(true); - setSourcePopoverOpen(false); window.electronAPI.showSourceHighlight?.({ ...source, name: source.appName ? `${source.appName} — ${source.name}` : source.name, @@ -733,7 +228,6 @@ export function LaunchWindow() { }; const openVideoFile = async () => { - setMorePopoverOpen(false); const result = await window.electronAPI.openVideoFilePicker(); if (result.canceled) return; if (result.success && result.path) { @@ -778,52 +272,6 @@ export function LaunchWindow() { } }, []); - const chooseRecordingsDirectory = async () => { - setMorePopoverOpen(false); - const result = await window.electronAPI.chooseRecordingsDirectory(); - if (result.canceled) return; - if (result.success && result.path) setRecordingsDirectory(result.path); - }; - - 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 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 handleHudMouseLeave = useCallback((event: React.MouseEvent) => { const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { @@ -843,12 +291,6 @@ export function LaunchWindow() { }); }, []); - const screenSources = sources.filter( - (s) => s.sourceType === "screen" || s.id.startsWith("screen:"), - ); - const windowSources = sources.filter( - (s) => s.sourceType === "window" || s.id.startsWith("window:"), - ); const hudStateTransition = { duration: 0.24, ease: [0.22, 1, 0.36, 1] as const, @@ -874,21 +316,17 @@ export function LaunchWindow() { {platform !== "linux" && ( <> handlePopoverOpenChange("sources", open)} - screenSources={screenSources} - windowSources={windowSources} selectedSource={selectedSource} - loading={sourcesLoading} - onSourceSelect={(source) => { - void handleSourceSelect(source); + onSourceSelect={handleSourceSelect} + onOpen={() => { + setProjectBrowserOpen(false); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); }} - onFetchSources={fetchSources} trigger={ @@ -910,18 +348,11 @@ export function LaunchWindow() { )} { - if (recording) return; - handlePopoverOpenChange("mic", open); - }} + disabled={recording} systemAudioEnabled={systemAudioEnabled} onToggleSystemAudio={() => setSystemAudioEnabled(!systemAudioEnabled)} microphoneEnabled={microphoneEnabled} - onDisableMicrophone={() => { - setMicrophoneEnabled(false); - setMicPopoverOpen(false); - }} + onDisableMicrophone={() => setMicrophoneEnabled(false)} devices={devices} microphoneDeviceId={microphoneDeviceId} selectedDeviceId={selectedDeviceId} @@ -948,16 +379,9 @@ export function LaunchWindow() { /> { - if (recording) return; - handlePopoverOpenChange("webcam", open); - }} + disabled={recording} webcamEnabled={webcamEnabled} - onDisableWebcam={() => { - setWebcamEnabled(false); - setWebcamPopoverOpen(false); - }} + onDisableWebcam={() => setWebcamEnabled(false)} canToggleFloatingPreview={canToggleFloatingWebcamPreview( hudOverlayMousePassthroughSupported, )} @@ -993,13 +417,8 @@ export function LaunchWindow() { /> handlePopoverOpenChange("countdown", open)} countdownDelay={countdownDelay} - onSelectDelay={(delay) => { - setCountdownDelay(delay); - setCountdownPopoverOpen(false); - }} + onSelectDelay={setCountdownDelay} trigger={ + ); +} + +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: ReactNode; + children: ReactNode; + align?: "start" | "center" | "end"; +}) { + return ( + + {trigger} + + {children} + + + ); +} diff --git a/src/components/launch/popovers/SourcePopover.tsx b/src/components/launch/popovers/SourcePopover.tsx new file mode 100644 index 00000000..9db48c80 --- /dev/null +++ b/src/components/launch/popovers/SourcePopover.tsx @@ -0,0 +1,98 @@ +import { useCallback, useMemo, type ReactNode, useState } from "react"; +import { SourceSelector } from "../SourceSelector"; +import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import type { DesktopSource } from "./types"; + +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) => { + 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 { + setLoading(false); + } + }, []); + + const screenSources = useMemo( + () => sources.filter((s) => s.sourceType === "screen" || s.id.startsWith("screen:")), + [sources], + ); + const windowSources = useMemo( + () => sources.filter((s) => s.sourceType === "window" || s.id.startsWith("window:")), + [sources], + ); + + return ( + { + void onSourceSelect(source); + requestClose(POPOVER_ID); + }} + 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 00000000..7d045a18 --- /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 "./types"; +import type { ReactNode } from "react"; + +const POPOVER_ID = "webcam"; + +export function WebcamPopover({ + trigger, + disabled, + webcamEnabled, + onDisableWebcam, + canToggleFloatingPreview, + showFloatingWebcamPreview, + onToggleFloatingPreview, + showWebcamControls, + setWebcamPreviewNode, + videoDevices, + webcamDeviceId, + selectedVideoDeviceId, + onSelectVideoDevice, +}: { + trigger: ReactNode; + 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/types.ts b/src/components/launch/popovers/types.ts new file mode 100644 index 00000000..3690111c --- /dev/null +++ b/src/components/launch/popovers/types.ts @@ -0,0 +1,15 @@ +export interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + sourceType?: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +export interface DeviceOption { + deviceId: string; + label: string; +}