From a9a019482d59cdaec0ae2b09287ba15e32705254 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Mon, 20 Apr 2026 21:01:00 +1000 Subject: [PATCH 1/9] refactor: split large files (components) --- src/components/launch/LaunchWindow.tsx | 1647 ----- .../launch/LaunchWindow/DropdownContent.tsx | 380 ++ .../launch/LaunchWindow/HudControls.tsx | 302 + .../LaunchWindow/LaunchWindow.module.css | 375 ++ .../launch/LaunchWindow/helperComponents.tsx | 91 + src/components/launch/LaunchWindow/hooks.ts | 372 ++ src/components/launch/LaunchWindow/index.tsx | 385 ++ src/components/launch/LaunchWindow/types.ts | 24 + .../LaunchWindow/useLaunchWindowActions.ts | 208 + .../LaunchWindow/useLaunchWindowSetup.ts | 270 + src/components/launch/SourceSelector.tsx | 26 +- .../video-editor/AnnotationBlurTab.tsx | 122 + .../video-editor/AnnotationFigureTab.tsx | 122 + .../video-editor/AnnotationImageTab.tsx | 78 + .../video-editor/AnnotationSettingsPanel.tsx | 712 +-- .../video-editor/AnnotationTextTab.tsx | 309 + .../video-editor/CursorStylePreview.tsx | 52 + src/components/video-editor/EditorContent.tsx | 346 ++ src/components/video-editor/EditorHeader.tsx | 400 ++ src/components/video-editor/EditorSidebar.tsx | 283 + src/components/video-editor/EditorToolbar.tsx | 260 + .../video-editor/ExtensionManager.tsx | 922 +-- .../video-editor/ExtensionSettingsSection.tsx | 188 + src/components/video-editor/SettingsPanel.tsx | 2731 +------- src/components/video-editor/VideoEditor.tsx | 5493 +---------------- src/components/video-editor/VideoPlayback.tsx | 2474 +------- .../video-editor/annotationSettingsShared.ts | 44 + .../video-editor/captureProjectThumbnail.ts | 141 + .../ExtensionDetailModal.tsx | 203 + .../ExtensionManagerCards.tsx | 285 + .../ExtensionManagerShared.tsx | 28 + .../ExtensionManagerTabs.tsx | 250 + .../video-editor/hooks/editorExportShared.ts | 141 + .../hooks/editorExportWorkflow.ts | 477 ++ .../hooks/useEditorAnnotationAudioRegions.ts | 259 + .../video-editor/hooks/useEditorAudioSync.ts | 274 + .../video-editor/hooks/useEditorCaptions.ts | 245 + .../hooks/useEditorClipRegions.ts | 255 + .../hooks/useEditorCursorTelemetry.ts | 83 + .../video-editor/hooks/useEditorExport.ts | 282 + .../video-editor/hooks/useEditorHistory.ts | 110 + .../video-editor/hooks/useEditorInit.ts | 264 + .../hooks/useEditorPreferences.ts | 381 ++ .../video-editor/hooks/useEditorProject.ts | 228 + .../video-editor/hooks/useEditorRegions.ts | 400 ++ .../hooks/useEditorSideEffects.ts | 291 + .../video-editor/hooks/useEditorWiring.ts | 381 ++ .../video-editor/projectPersistence.ts | 889 +-- .../projectPersistenceNormalization.ts | 265 + .../video-editor/projectPersistencePaths.ts | 110 + .../video-editor/projectPersistenceRegions.ts | 310 + .../video-editor/projectPersistenceShared.ts | 120 + .../settings/BackgroundSection.tsx | 427 ++ .../video-editor/settings/CaptionsSection.tsx | 218 + .../video-editor/settings/ClipSection.tsx | 94 + .../video-editor/settings/CursorSection.tsx | 237 + .../video-editor/settings/FrameSection.tsx | 217 + .../settings/GeneralSettingsSection.tsx | 113 + .../video-editor/settings/WebcamSection.tsx | 199 + .../video-editor/settings/ZoomSection.tsx | 154 + .../video-editor/settingsPanelConstants.tsx | 153 + .../video-editor/settingsPanelUtils.ts | 136 + .../video-editor/timeline/TimelineEditor.tsx | 2145 +------ .../TimelineEditor/TimelineDecorations.tsx | 280 + .../TimelineEditor/TimelineSurface.tsx | 244 + .../TimelineEditor/TimelineToolbar.tsx | 209 + .../timeline/TimelineEditor/index.tsx | 279 + .../timeline/TimelineEditor/shared.ts | 246 + .../TimelineEditor/timelineActionUtils.ts | 82 + .../useTimelineEditorActions.ts | 408 ++ .../TimelineEditor/useTimelineEditorState.ts | 110 + .../useTimelineEditorTimeline.ts | 227 + .../useTimelineKeyboardShortcuts.ts | 183 + .../useTimelineRegionOperations.ts | 273 + src/components/video-editor/types.ts | 7 +- .../video-editor/videoEditorUtils.ts | 398 ++ .../videoPlayback/cursorRenderer.ts | 1318 ---- .../videoPlayback/cursorRenderer/assets.ts | 485 ++ .../cursorRenderer/canvasRenderer.ts | 72 + .../videoPlayback/cursorRenderer/index.ts | 6 + .../cursorRenderer/pixiOverlay.ts | 450 ++ .../videoPlayback/cursorRenderer/shared.ts | 107 + .../cursorRenderer/smoothedState.ts | 74 + .../videoPlayback/cursorRenderer/telemetry.ts | 149 + .../videoPlayback/zoomAnimation.test.ts | 293 - .../VideoPlaybackOverlay.tsx | 282 + .../videoPlaybackComponent/index.tsx | 368 ++ .../videoPlaybackComponent/shared.ts | 270 + .../useCaptionLayout.ts | 102 + .../useCursorOverlayRefresh.ts | 89 + .../videoPlaybackComponent/usePixiApp.ts | 108 + .../usePixiVideoScene.ts | 124 + .../usePlaybackTicker.ts | 365 ++ .../useResolvedWallpaper.ts | 99 + .../useVideoElementLifecycle.ts | 94 + .../useVideoPlaybackLayout.ts | 335 + .../useVideoPlaybackRefs.ts | 171 + .../useVideoPlaybackSync.ts | 401 ++ 98 files changed, 20005 insertions(+), 18084 deletions(-) delete mode 100644 src/components/launch/LaunchWindow.tsx create mode 100644 src/components/launch/LaunchWindow/DropdownContent.tsx create mode 100644 src/components/launch/LaunchWindow/HudControls.tsx create mode 100644 src/components/launch/LaunchWindow/LaunchWindow.module.css create mode 100644 src/components/launch/LaunchWindow/helperComponents.tsx create mode 100644 src/components/launch/LaunchWindow/hooks.ts create mode 100644 src/components/launch/LaunchWindow/index.tsx create mode 100644 src/components/launch/LaunchWindow/types.ts create mode 100644 src/components/launch/LaunchWindow/useLaunchWindowActions.ts create mode 100644 src/components/launch/LaunchWindow/useLaunchWindowSetup.ts create mode 100644 src/components/video-editor/AnnotationBlurTab.tsx create mode 100644 src/components/video-editor/AnnotationFigureTab.tsx create mode 100644 src/components/video-editor/AnnotationImageTab.tsx create mode 100644 src/components/video-editor/AnnotationTextTab.tsx create mode 100644 src/components/video-editor/CursorStylePreview.tsx create mode 100644 src/components/video-editor/EditorContent.tsx create mode 100644 src/components/video-editor/EditorHeader.tsx create mode 100644 src/components/video-editor/EditorSidebar.tsx create mode 100644 src/components/video-editor/EditorToolbar.tsx create mode 100644 src/components/video-editor/ExtensionSettingsSection.tsx create mode 100644 src/components/video-editor/annotationSettingsShared.ts create mode 100644 src/components/video-editor/captureProjectThumbnail.ts create mode 100644 src/components/video-editor/extension-manager/ExtensionDetailModal.tsx create mode 100644 src/components/video-editor/extension-manager/ExtensionManagerCards.tsx create mode 100644 src/components/video-editor/extension-manager/ExtensionManagerShared.tsx create mode 100644 src/components/video-editor/extension-manager/ExtensionManagerTabs.tsx create mode 100644 src/components/video-editor/hooks/editorExportShared.ts create mode 100644 src/components/video-editor/hooks/editorExportWorkflow.ts create mode 100644 src/components/video-editor/hooks/useEditorAnnotationAudioRegions.ts create mode 100644 src/components/video-editor/hooks/useEditorAudioSync.ts create mode 100644 src/components/video-editor/hooks/useEditorCaptions.ts create mode 100644 src/components/video-editor/hooks/useEditorClipRegions.ts create mode 100644 src/components/video-editor/hooks/useEditorCursorTelemetry.ts create mode 100644 src/components/video-editor/hooks/useEditorExport.ts create mode 100644 src/components/video-editor/hooks/useEditorHistory.ts create mode 100644 src/components/video-editor/hooks/useEditorInit.ts create mode 100644 src/components/video-editor/hooks/useEditorPreferences.ts create mode 100644 src/components/video-editor/hooks/useEditorProject.ts create mode 100644 src/components/video-editor/hooks/useEditorRegions.ts create mode 100644 src/components/video-editor/hooks/useEditorSideEffects.ts create mode 100644 src/components/video-editor/hooks/useEditorWiring.ts create mode 100644 src/components/video-editor/projectPersistenceNormalization.ts create mode 100644 src/components/video-editor/projectPersistencePaths.ts create mode 100644 src/components/video-editor/projectPersistenceRegions.ts create mode 100644 src/components/video-editor/projectPersistenceShared.ts create mode 100644 src/components/video-editor/settings/BackgroundSection.tsx create mode 100644 src/components/video-editor/settings/CaptionsSection.tsx create mode 100644 src/components/video-editor/settings/ClipSection.tsx create mode 100644 src/components/video-editor/settings/CursorSection.tsx create mode 100644 src/components/video-editor/settings/FrameSection.tsx create mode 100644 src/components/video-editor/settings/GeneralSettingsSection.tsx create mode 100644 src/components/video-editor/settings/WebcamSection.tsx create mode 100644 src/components/video-editor/settings/ZoomSection.tsx create mode 100644 src/components/video-editor/settingsPanelConstants.tsx create mode 100644 src/components/video-editor/settingsPanelUtils.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/TimelineDecorations.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/TimelineSurface.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/TimelineToolbar.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/index.tsx create mode 100644 src/components/video-editor/timeline/TimelineEditor/shared.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/timelineActionUtils.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineEditorActions.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineEditorState.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineEditorTimeline.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineKeyboardShortcuts.ts create mode 100644 src/components/video-editor/timeline/TimelineEditor/useTimelineRegionOperations.ts create mode 100644 src/components/video-editor/videoEditorUtils.ts delete mode 100644 src/components/video-editor/videoPlayback/cursorRenderer.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/assets.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/canvasRenderer.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/index.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/pixiOverlay.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/shared.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/smoothedState.ts create mode 100644 src/components/video-editor/videoPlayback/cursorRenderer/telemetry.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/VideoPlaybackOverlay.tsx create mode 100644 src/components/video-editor/videoPlaybackComponent/index.tsx create mode 100644 src/components/video-editor/videoPlaybackComponent/shared.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useCaptionLayout.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useCursorOverlayRefresh.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/usePixiApp.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/usePixiVideoScene.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/usePlaybackTicker.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useResolvedWallpaper.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoElementLifecycle.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoPlaybackLayout.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoPlaybackRefs.ts create mode 100644 src/components/video-editor/videoPlaybackComponent/useVideoPlaybackSync.ts diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx deleted file mode 100644 index 2c4e6146..00000000 --- a/src/components/launch/LaunchWindow.tsx +++ /dev/null @@ -1,1647 +0,0 @@ -import { - AppWindow, - ArrowCircleUp as ArrowUpCircle, - ArrowClockwise as RefreshCw, - CaretUp as ChevronUp, - CheckCircle as CheckCircle2, - DotsThreeVertical as MoreVertical, - Eye, - EyeSlash as EyeOff, - FolderOpen, - Microphone as Mic, - MicrophoneSlash as MicOff, - Minus, - Monitor, - Pause, - Play, - SpeakerHigh as Volume2, - SpeakerX as VolumeX, - Stop as Square, - Timer, - Translate as Languages, - VideoCamera as Video, - VideoCamera as VideoIcon, - VideoCameraSlash as VideoOff, - X, -} 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 { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; -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 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: "EN", - es: "ES", - nl: "NL", - "zh-CN": "中文", - ko: "한국어", -}; - -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 }; - -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, - }); - - return ( - - ); -} - -export function LaunchWindow() { - const { locale, setLocale } = useI18n(); - const t = useScopedT("launch"); - - const { - recording, - paused, - countdownActive, - toggleRecording, - pauseRecording, - resumeRecording, - cancelRecording, - microphoneEnabled, - setMicrophoneEnabled, - microphoneDeviceId, - setMicrophoneDeviceId, - systemAudioEnabled, - setSystemAudioEnabled, - webcamEnabled, - setWebcamEnabled, - webcamDeviceId, - setWebcamDeviceId, - countdownDelay, - setCountdownDelay, - 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 [platform, setPlatform] = useState(null); - const [appVersion, setAppVersion] = useState(null); - const [updateStatus, setUpdateStatus] = useState<{ - status: - | "idle" - | "checking" - | "up-to-date" - | "available" - | "downloading" - | "ready" - | "error"; - currentVersion: string; - availableVersion: string | null; - detail?: string; - }>({ - status: "idle", - currentVersion: "", - availableVersion: null, - }); - const [updateActionPending, setUpdateActionPending] = useState(false); - 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); - 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 showWebcamControls = webcamEnabled && !recording; - const showRecordingWebcamPreview = webcamEnabled && showFloatingWebcamPreview; - const shouldStreamWebcamPreview = - webcamEnabled && (showFloatingWebcamPreview || (showWebcamControls && webcamDropdownOpen)); - const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( - microphoneEnabled || micDropdownOpen, - microphoneDeviceId, - ); - const { - devices: videoDevices, - selectedDeviceId: selectedVideoDeviceId, - setSelectedDeviceId: setSelectedVideoDeviceId, - } = useVideoDevices(webcamEnabled || webcamDropdownOpen); - - const supportsHudCaptureProtection = platform !== "linux"; - - useEffect(() => { - if (!selectedDeviceId) { - return; - } - - setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); - }, [selectedDeviceId, setMicrophoneDeviceId]); - - useEffect(() => { - if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { - setWebcamDeviceId(selectedVideoDeviceId); - } - }, [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); - } - 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(() => { - 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}`; - }; - - 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); - }); - - const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { - applySelectedSource(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(() => { - void preparePermissions({ startup: true }); - }, [preparePermissions]); - - useEffect(() => { - let mounted = true; - - const refreshUpdateStatus = async () => { - try { - const summary = await window.electronAPI.getUpdateStatusSummary(); - if (mounted) { - setUpdateStatus(summary); - } - } catch (error) { - console.error("Failed to load update status summary:", error); - } - }; - - void refreshUpdateStatus(); - const pollTimer = window.setInterval(() => { - void refreshUpdateStatus(); - }, 2500); - - return () => { - mounted = false; - window.clearInterval(pollTimer); - }; - }, []); - - 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); - } - }, []); - - 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 updateButtonLabel = - updateStatus.status === "up-to-date" - ? t("recording.update.updated") - : t("recording.update.update"); - const updateButtonTitle = (() => { - switch (updateStatus.status) { - case "up-to-date": - return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", { - version: updateStatus.currentVersion, - }); - case "available": - case "ready": - return updateStatus.availableVersion - ? t("recording.update.availableTitle", "Recordly {{version}} is available.", { - version: updateStatus.availableVersion, - }) - : t("recording.update.availableGenericTitle"); - case "downloading": - return updateStatus.detail ?? t("recording.update.downloadingTitle"); - case "checking": - return t("recording.update.checkingTitle"); - case "error": - return updateStatus.detail ?? t("recording.update.errorTitle"); - default: - return t("recording.update.idleTitle"); - } - })(); - const updateButtonClassName = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`; - const updateButtonIcon = (() => { - switch (updateStatus.status) { - case "up-to-date": - return ; - case "checking": - case "downloading": - return ; - default: - return ; - } - })(); - - const handleUpdateButtonClick = async () => { - if (updateActionPending || updateStatus.status === "downloading") { - return; - } - - setUpdateActionPending(true); - try { - switch (updateStatus.status) { - case "available": - await window.electronAPI.downloadAvailableUpdate(); - break; - case "ready": - await window.electronAPI.installDownloadedUpdate(); - break; - default: - await window.electronAPI.checkForAppUpdates(); - break; - } - - const summary = await window.electronAPI.getUpdateStatusSummary(); - setUpdateStatus(summary); - } catch (error) { - console.error("Failed to handle update button action:", error); - } finally { - setUpdateActionPending(false); - } - }; - - const recordingControls = ( - <> -
-
- - {paused ? t("recording.paused") : t("recording.rec")} - -
- - - {formatTime(elapsed)} - - - - - - {microphoneEnabled ? : } - - - - - - {paused ? ( - - ) : ( - - )} - - - - - - - window.electronAPI?.hudOverlayHide?.()} - title={t("recording.hideHud")} - > - - - - - - - - ); - - const idleControls = ( - <> - {platform !== "linux" && ( - <> - - - - - )} - - - {microphoneEnabled ? : } - - - - {webcamEnabled ? - - toggleDropdown("countdown")} - title={t("recording.countdownDelay")} - className={countdownDelay > 0 ? styles.ibActive : ""} - > - - - - - - - - - - toggleDropdown("more")} - title={t("recording.more")} - > - - - - window.electronAPI?.hudOverlayHide?.()} - title={t("recording.hideHud")} - > - - - - window.electronAPI?.hudOverlayClose?.()} - title={t("recording.closeApp")} - > - - - - ); - - return ( -
-
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)} - onMouseLeave={() => { - if ( - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }} - > - {/* 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")} - - - ) : ( - - ) - } - selected={showFloatingWebcamPreview} - onClick={() => { - setShowFloatingWebcamPreview( - (current) => !current, - ); - }} - > - {showFloatingWebcamPreview - ? t("recording.hideFloatingWebcamPreview") - : t("recording.showFloatingWebcamPreview")} - - - )} - {!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")} - -
- {t("recording.language")} -
- {SUPPORTED_LOCALES.map((code) => ( - } - selected={locale === code} - onClick={() => { - setLocale(code as AppLocale); - setActiveDropdown("none"); - }} - > - {LOCALE_LABELS[code] ?? code} - - ))} - {appVersion && ( -
- v{appVersion} -
- )} - - )} -
- )} -
- -
-
- -
- -
- - - -
- - - {recording ? recordingControls : idleControls} - - -
-
-
- {showRecordingWebcamPreview && ( -
-
- )} -
-
-
- ); -} diff --git a/src/components/launch/LaunchWindow/DropdownContent.tsx b/src/components/launch/LaunchWindow/DropdownContent.tsx new file mode 100644 index 00000000..41671281 --- /dev/null +++ b/src/components/launch/LaunchWindow/DropdownContent.tsx @@ -0,0 +1,380 @@ +import { + AppWindow, + Eye, + EyeSlash as EyeOff, + FolderOpen, + MicrophoneSlash as MicOff, + Monitor, + SpeakerHigh as Volume2, + SpeakerX as VolumeX, + Timer, + Translate as Languages, + VideoCamera as Video, + VideoCamera as VideoIcon, + VideoCameraSlash as VideoOff, +} from "@phosphor-icons/react"; +import type React from "react"; +import { useI18n } from "@/contexts/I18nContext"; +import { useScopedT } from "@/contexts/I18nContext"; +import type { AppLocale } from "@/i18n/config"; +import { SUPPORTED_LOCALES } from "@/i18n/config"; +import { DropdownItem, MicDeviceRow } from "./helperComponents"; +import { COUNTDOWN_OPTIONS, type DesktopSource, LOCALE_LABELS } from "./types"; +import styles from "./LaunchWindow.module.css"; + +interface DropdownContentProps { + activeDropdown: "sources" | "more" | "mic" | "countdown" | "webcam"; + setActiveDropdown: (v: "none" | "sources" | "more" | "mic" | "countdown" | "webcam") => void; + sourcesLoading: boolean; + screenSources: DesktopSource[]; + windowSources: DesktopSource[]; + selectedSource: string; + onSourceSelect: (source: DesktopSource) => void; + systemAudioEnabled: boolean; + setSystemAudioEnabled: (v: boolean) => void; + microphoneEnabled: boolean; + setMicrophoneEnabled: (v: boolean) => void; + microphoneDeviceId: string | undefined; + selectedDeviceId: string | null; + setSelectedDeviceId: (v: string) => void; + setMicrophoneDeviceId: (v: string | undefined) => void; + devices: { deviceId: string; label: string }[]; + webcamEnabled: boolean; + setWebcamEnabled: (v: boolean) => void; + webcamDeviceId: string | undefined; + selectedVideoDeviceId: string | null; + setSelectedVideoDeviceId: (v: string) => void; + setWebcamDeviceId: (v: string) => void; + videoDevices: { deviceId: string; label: string }[]; + showWebcamControls: boolean; + showFloatingWebcamPreview: boolean; + setShowFloatingWebcamPreview: React.Dispatch>; + setWebcamPreviewNode: (node: HTMLVideoElement | null) => void; + countdownDelay: number; + setCountdownDelay: (v: number) => void; + supportsHudCaptureProtection: boolean; + hideHudFromCapture: boolean; + onToggleHudCaptureProtection: () => void; + onChooseRecordingsDirectory: () => void; + onOpenVideoFile: () => void; + onOpenProjectBrowser: () => void; + appVersion: string | null; +} + +export function DropdownContent({ + activeDropdown, + setActiveDropdown, + sourcesLoading, + screenSources, + windowSources, + selectedSource, + onSourceSelect, + systemAudioEnabled, + setSystemAudioEnabled, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + selectedDeviceId, + setSelectedDeviceId, + setMicrophoneDeviceId, + devices, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + selectedVideoDeviceId, + setSelectedVideoDeviceId, + setWebcamDeviceId, + videoDevices, + showWebcamControls, + showFloatingWebcamPreview, + setShowFloatingWebcamPreview, + setWebcamPreviewNode, + countdownDelay, + setCountdownDelay, + supportsHudCaptureProtection, + hideHudFromCapture, + onToggleHudCaptureProtection, + onChooseRecordingsDirectory, + onOpenVideoFile, + onOpenProjectBrowser, + appVersion, +}: DropdownContentProps) { + const { locale, setLocale } = useI18n(); + const t = useScopedT("launch"); + + return ( +
+ {activeDropdown === "sources" && ( + <> + {sourcesLoading ? ( +
+
+
+ ) : ( + <> + {screenSources.length > 0 && ( + <> +
{t("recording.screens")}
+ {screenSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => onSourceSelect(source)} + > + {source.name} + + ))} + + )} + {windowSources.length > 0 && ( + <> +
0 ? { marginTop: 4 } : undefined} + > + {t("recording.windows")} +
+ {windowSources.map((source) => ( + } + selected={selectedSource === source.name} + onClick={() => onSourceSelect(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")} + + + ) : ( + + ) + } + selected={showFloatingWebcamPreview} + onClick={() => setShowFloatingWebcamPreview((current) => !current)} + > + {showFloatingWebcamPreview + ? t("recording.hideFloatingWebcamPreview") + : t("recording.showFloatingWebcamPreview")} + + + )} + {!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 onToggleHudCaptureProtection()} + > + {hideHudFromCapture + ? t("recording.hideHudFromVideo") + : t("recording.showHudInVideo")} + + )} + } + onClick={onChooseRecordingsDirectory} + > + {t("recording.recordingsFolder")} + + } onClick={onOpenVideoFile}> + {t("recording.openVideoFile")} + + } + onClick={() => void onOpenProjectBrowser()} + > + {t("recording.openProject")} + +
+ {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/LaunchWindow/HudControls.tsx b/src/components/launch/LaunchWindow/HudControls.tsx new file mode 100644 index 00000000..bbee287b --- /dev/null +++ b/src/components/launch/LaunchWindow/HudControls.tsx @@ -0,0 +1,302 @@ +import { + ArrowCircleUp as ArrowUpCircle, + ArrowClockwise as RefreshCw, + CaretUp as ChevronUp, + CheckCircle as CheckCircle2, + DotsThreeVertical as MoreVertical, + Microphone as Mic, + MicrophoneSlash as MicOff, + Minus, + Monitor, + Pause, + Play, + Stop as Square, + Timer, + VideoCamera as Video, + VideoCameraSlash as VideoOff, + X, +} from "@phosphor-icons/react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { ContentClamp } from "@/components/ui/content-clamp"; +import { IconButton, Separator } from "./helperComponents"; +import styles from "./LaunchWindow.module.css"; + +interface UpdateBadgeProps { + updateStatus: { + status: "idle" | "checking" | "up-to-date" | "available" | "downloading" | "ready" | "error"; + currentVersion: string; + availableVersion: string | null; + detail?: string; + }; + updateActionPending: boolean; + onUpdateClick: () => void; +} + +export function UpdateBadge({ updateStatus, updateActionPending, onUpdateClick }: UpdateBadgeProps) { + const t = useScopedT("launch"); + + const label = + updateStatus.status === "up-to-date" + ? t("recording.update.updated") + : t("recording.update.update"); + + const title = (() => { + switch (updateStatus.status) { + case "up-to-date": + return t("recording.update.upToDateTitle", "Recordly {{version}} is up to date.", { + version: updateStatus.currentVersion, + }); + case "available": + case "ready": + return updateStatus.availableVersion + ? t("recording.update.availableTitle", "Recordly {{version}} is available.", { + version: updateStatus.availableVersion, + }) + : t("recording.update.availableGenericTitle"); + case "downloading": + return updateStatus.detail ?? t("recording.update.downloadingTitle"); + case "checking": + return t("recording.update.checkingTitle"); + case "error": + return updateStatus.detail ?? t("recording.update.errorTitle"); + default: + return t("recording.update.idleTitle"); + } + })(); + + const className = `${styles.updateBadge} ${updateStatus.status === "up-to-date" ? styles.updateBadgeQuiet : styles.updateBadgeHot} ${styles.electronNoDrag}`; + + const icon = (() => { + switch (updateStatus.status) { + case "up-to-date": + return ; + case "checking": + case "downloading": + return ; + default: + return ; + } + })(); + + return ( + + ); +} + +interface RecordingControlsProps { + paused: boolean; + elapsed: number; + formatTime: (s: number) => string; + microphoneEnabled: boolean; + resumeRecording: () => void; + pauseRecording: () => void; + toggleRecording: () => void; + cancelRecording: () => void; +} + +export function RecordingControls({ + paused, + elapsed, + formatTime, + microphoneEnabled, + resumeRecording, + pauseRecording, + toggleRecording, + cancelRecording, +}: RecordingControlsProps) { + const t = useScopedT("launch"); + + return ( + <> +
+
+ + {paused ? t("recording.paused") : t("recording.rec")} + +
+ + + {formatTime(elapsed)} + + + + + + {microphoneEnabled ? : } + + + + + + {paused ? ( + + ) : ( + + )} + + + + + + + window.electronAPI?.hudOverlayHide?.()} + title={t("recording.hideHud")} + > + + + + + + + + ); +} + +interface IdleControlsProps { + selectedSource: string; + activeDropdown: string; + toggleDropdown: (which: "sources" | "more" | "mic" | "countdown" | "webcam") => void; + hasSelectedSource: boolean; + toggleRecording: () => void; + microphoneEnabled: boolean; + toggleMicrophone: () => void; + webcamEnabled: boolean; + toggleWebcam: () => void; + countdownDelay: number; + countdownActive: boolean; + moreButtonRef: React.RefObject; +} + +export function IdleControls({ + selectedSource, + activeDropdown, + toggleDropdown, + hasSelectedSource, + toggleRecording, + microphoneEnabled, + toggleMicrophone, + webcamEnabled, + toggleWebcam, + countdownDelay, + countdownActive, + moreButtonRef, +}: IdleControlsProps) { + const t = useScopedT("launch"); + + return ( + <> + + + + + + {microphoneEnabled ? : } + + + + {webcamEnabled ? + + toggleDropdown("countdown")} + title={t("recording.countdownDelay")} + className={countdownDelay > 0 ? styles.ibActive : ""} + > + + + + + + + + + + toggleDropdown("more")} + title={t("recording.more")} + > + + + + window.electronAPI?.hudOverlayHide?.()} + title={t("recording.hideHud")} + > + + + + window.electronAPI?.hudOverlayClose?.()} + title={t("recording.closeApp")} + > + + + + ); +} diff --git a/src/components/launch/LaunchWindow/LaunchWindow.module.css b/src/components/launch/LaunchWindow/LaunchWindow.module.css new file mode 100644 index 00000000..230f24b3 --- /dev/null +++ b/src/components/launch/LaunchWindow/LaunchWindow.module.css @@ -0,0 +1,375 @@ +.electronDrag { + -webkit-app-region: drag; +} + +.electronNoDrag { + -webkit-app-region: no-drag; +} + +.bar { + display: flex; + align-items: center; + gap: 10px; + width: max-content; + max-width: 1200px; + background: rgba(18, 18, 24, 0.97); + border: 1px solid rgba(255, 255, 255, 0.07); + 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); + overflow: visible; + position: relative; + transform-origin: center bottom; +} + +.barStateViewport { + position: relative; + display: flex; + align-items: center; + flex: 0 1 auto; + min-height: 46px; + min-width: fit-content; + padding-right: 2px; +} + +.updateBadge { + display: inline-flex; + align-items: center; + gap: 7px; + height: 34px; + padding: 0 12px; + border-radius: 11px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; + transition: all 0.15s ease; + cursor: pointer; + flex-shrink: 0; +} + +.updateBadge:disabled { + opacity: 0.72; + cursor: default; +} + +.updateBadgeQuiet { + color: #a5b4c7; + border-color: rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.035); +} + +.updateBadgeQuiet:hover:not(:disabled) { + color: #d7dee8; + background: rgba(255, 255, 255, 0.06); +} + +.updateBadgeHot { + color: #f8fbff; + border-color: rgba(125, 211, 252, 0.24); + background: linear-gradient(180deg, rgba(125, 211, 252, 0.12), rgba(125, 211, 252, 0.04)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.updateBadgeHot:hover:not(:disabled) { + color: #ffffff; + border-color: rgba(125, 211, 252, 0.36); + background: linear-gradient(180deg, rgba(125, 211, 252, 0.17), rgba(125, 211, 252, 0.07)); + transform: translateY(-1px); +} + +.updateBadgeSpin { + animation: updateBadgeSpin 0.9s linear infinite; +} + +@keyframes updateBadgeSpin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.barState { + display: flex; + align-items: center; + gap: 8px; + flex: 0 1 auto; + white-space: nowrap; + min-width: fit-content; + pointer-events: auto; +} + +.sep { + width: 1px; + height: 26px; + background: #2a2a34; + margin: 0 6px; + flex-shrink: 0; +} + +.ib { + position: relative; + width: 40px; + height: 40px; + border-radius: 11px; + border: none; + background: transparent; + color: #6b6b78; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.ib:hover { + background: rgba(255, 255, 255, 0.07); + color: #eeeef2; +} + +.ibActive { + color: #6360f5; +} + +.ibActive:hover { + color: #7b78ff; +} + +.ibRed { + color: #f43f5e; +} + +.ibRed:hover { + color: #ff5a75; +} + +.ibGreen { + color: #34d399; +} + +.ibGreen:hover { + 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; + 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); + pointer-events: auto; + animation: menuCardIn 0.18s ease; +} + +@keyframes menuCardIn { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.menuCard::-webkit-scrollbar { + width: 4px; +} + +.menuCard::-webkit-scrollbar-track { + background: transparent; +} + +.menuCard::-webkit-scrollbar-thumb { + background: #2a2a34; + border-radius: 2px; +} + +.ddLabel { + font-size: 9px; + font-weight: 600; + color: #6b6b78; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 6px 10px 4px; +} + +.ddItem { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: none; + background: transparent; + font-size: 12px; + color: #6b6b78; + cursor: pointer; + transition: all 0.12s ease; + text-align: left; +} + +.ddItem:hover { + background: rgba(255, 255, 255, 0.06); + color: #eeeef2; +} + +.ddItemSelected { + color: #6360f5; +} + +.recBtn { + position: relative; + width: 46px; + height: 46px; + border-radius: 50%; + border: none; + background: #f43f5e; + color: #fff; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + flex-shrink: 0; + box-shadow: 0 0 0 0 rgba(244, 63, 94, 0.3); +} + +.recBtn:hover { + background: #ff5a75; + box-shadow: 0 0 0 6px rgba(244, 63, 94, 0.15); +} + +.recBtn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.recBtn:disabled:hover { + background: #f43f5e; + box-shadow: none; +} + +.recDot { + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + transition: all 0.2s ease; +} + +.recDotBlink { + animation: blink 1.2s ease-in-out infinite; +} + +.recordingWebcamPreview { + position: fixed; + right: 32px; + bottom: 120px; + height: 288px; + 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); + cursor: grab; + touch-action: none; + user-select: none; + will-change: transform; + z-index: 120; +} + +.recordingWebcamPreview:active { + cursor: grabbing; +} + +.recordingWebcamPreviewVideo { + display: block; + height: 100%; + width: 100%; + object-fit: cover; + border-radius: inherit; + overflow: hidden; + transform: translateZ(0); +} + +@keyframes blink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.2; + } +} + +.micSelect { + color-scheme: dark; +} + +.micSelect option { + background-color: #1a1a22; + color: #eeeef2; +} diff --git a/src/components/launch/LaunchWindow/helperComponents.tsx b/src/components/launch/LaunchWindow/helperComponents.tsx new file mode 100644 index 00000000..8cb1d434 --- /dev/null +++ b/src/components/launch/LaunchWindow/helperComponents.tsx @@ -0,0 +1,91 @@ +import { + Microphone as Mic, + MicrophoneSlash as MicOff, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; +import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; +import styles from "./LaunchWindow.module.css"; + +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 ( + + ); +} + +export function Separator({ dropdown = false }: { dropdown?: boolean }) { + return
; +} + +export function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: { deviceId: string; label: string }; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); + + return ( + + ); +} diff --git a/src/components/launch/LaunchWindow/hooks.ts b/src/components/launch/LaunchWindow/hooks.ts new file mode 100644 index 00000000..fefc8d09 --- /dev/null +++ b/src/components/launch/LaunchWindow/hooks.ts @@ -0,0 +1,372 @@ +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + DEFAULT_RECORDING_HUD_OFFSET, + DEFAULT_WEBCAM_PREVIEW_OFFSET, + WEBCAM_PREVIEW_DRAG_THRESHOLD, +} from "./types"; + +export function useDragHandlers({ + webcamEnabled, + showRecordingWebcamPreview, + hudBarRef, +}: { + webcamEnabled: boolean; + showRecordingWebcamPreview: boolean; + hudBarRef: React.RefObject; +}) { + const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); + const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); + + 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); + + useEffect(() => { + if (!webcamEnabled) { + setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET); + setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); + webcamPreviewDragStartRef.current = null; + isWebcamPreviewDraggingRef.current = false; + } + }, [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); + } + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }; + + return { + webcamPreviewOffset, + recordingHudOffset, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + }; +} + +export function useWebcamPreview({ + shouldStreamWebcamPreview, + webcamDeviceId, +}: { + shouldStreamWebcamPreview: boolean; + webcamDeviceId: string | undefined; +}) { + const webcamPreviewRef = useRef(null); + const recordingWebcamPreviewRef = useRef(null); + const previewStreamRef = useRef(null); + + 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]); + + return { setWebcamPreviewNode, setRecordingWebcamPreviewNode }; +} + +export function useRecordingTimer({ recording, paused }: { 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 = (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/LaunchWindow/index.tsx b/src/components/launch/LaunchWindow/index.tsx new file mode 100644 index 00000000..036a0252 --- /dev/null +++ b/src/components/launch/LaunchWindow/index.tsx @@ -0,0 +1,385 @@ +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import { RxDragHandleDots2 } from "react-icons/rx"; +import { useScopedT } from "@/contexts/I18nContext"; +import { useMicrophoneDevices } from "@/hooks/useMicrophoneDevices"; +import { useScreenRecorder } from "@/hooks/useScreenRecorder"; +import { useVideoDevices } from "@/hooks/useVideoDevices"; +import ProjectBrowserDialog, { + type ProjectLibraryEntry, +} from "@/components/video-editor/ProjectBrowserDialog"; +import { DropdownContent } from "./DropdownContent"; +import { useDragHandlers, useRecordingTimer, useWebcamPreview } from "./hooks"; +import { IdleControls, RecordingControls, UpdateBadge } from "./HudControls"; +import type { DesktopSource } from "./types"; +import { useLaunchWindowActions } from "./useLaunchWindowActions"; +import { useLaunchWindowSetup } from "./useLaunchWindowSetup"; +import styles from "./LaunchWindow.module.css"; + +export function LaunchWindow() { + const t = useScopedT("launch"); + + const { + recording, + paused, + countdownActive, + toggleRecording, + pauseRecording, + resumeRecording, + cancelRecording, + microphoneEnabled, + setMicrophoneEnabled, + microphoneDeviceId, + setMicrophoneDeviceId, + systemAudioEnabled, + setSystemAudioEnabled, + webcamEnabled, + setWebcamEnabled, + webcamDeviceId, + setWebcamDeviceId, + countdownDelay, + setCountdownDelay, + preparePermissions, + } = useScreenRecorder(); + + const [activeDropdown, setActiveDropdown] = useState< + "none" | "sources" | "more" | "mic" | "countdown" | "webcam" + >("none"); + const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); + const [projectLibraryEntries, setProjectLibraryEntries] = useState([]); + const [sources, setSources] = useState([]); + const [sourcesLoading, setSourcesLoading] = useState(false); + const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); + const [, setRecordingsDirectory] = useState(null); + + const dropdownRef = useRef(null); + const hudContentRef = useRef(null); + const hudBarRef = useRef(null); + const moreButtonRef = useRef(null); + const recordingWebcamPreviewContainerRef = useRef(null); + + const micDropdownOpen = activeDropdown === "mic"; + const webcamDropdownOpen = activeDropdown === "webcam"; + const showWebcamControls = webcamEnabled && !recording; + const showRecordingWebcamPreview = webcamEnabled && showFloatingWebcamPreview; + const shouldStreamWebcamPreview = + webcamEnabled && (showFloatingWebcamPreview || (showWebcamControls && webcamDropdownOpen)); + + const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices( + microphoneEnabled || micDropdownOpen, + microphoneDeviceId, + ); + const { + devices: videoDevices, + selectedDeviceId: selectedVideoDeviceId, + setSelectedDeviceId: setSelectedVideoDeviceId, + } = useVideoDevices(webcamEnabled || webcamDropdownOpen); + + const { + webcamPreviewOffset, + recordingHudOffset, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + handleWebcamPreviewPointerDown, + handleWebcamPreviewPointerMove, + handleWebcamPreviewPointerUp, + handleHudBarPointerDown, + handleHudBarPointerMove, + handleHudBarPointerUp, + } = useDragHandlers({ webcamEnabled, showRecordingWebcamPreview, hudBarRef }); + + const { setWebcamPreviewNode, setRecordingWebcamPreviewNode } = useWebcamPreview({ + shouldStreamWebcamPreview, + webcamDeviceId, + }); + + const { elapsed, formatTime } = useRecordingTimer({ recording, paused }); + + const { + selectedSource, + setSelectedSource, + hasSelectedSource, + setHasSelectedSource, + platform, + appVersion, + updateStatus, + updateActionPending, + hideHudFromCapture, + setHideHudFromCapture, + handleUpdateButtonClick, + } = useLaunchWindowSetup({ + preparePermissions, + activeDropdown, + projectBrowserOpen, + showRecordingWebcamPreview, + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, + }); + + const supportsHudCaptureProtection = platform !== "linux"; + + useEffect(() => { + if (!selectedDeviceId) return; + setMicrophoneDeviceId(selectedDeviceId === "default" ? undefined : selectedDeviceId); + }, [selectedDeviceId, setMicrophoneDeviceId]); + + useEffect(() => { + if (selectedVideoDeviceId && selectedVideoDeviceId !== "default") { + setWebcamDeviceId(selectedVideoDeviceId); + } + }, [selectedVideoDeviceId, setWebcamDeviceId]); + + useEffect(() => { + if (!webcamEnabled) setShowFloatingWebcamPreview(true); + }, [webcamEnabled]); + + // Click outside dropdown + 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); + }, []); + + // Recordings directory + useEffect(() => { + const load = async () => { + const result = await window.electronAPI.getRecordingsDirectory(); + if (result.success) setRecordingsDirectory(result.path); + }; + void load(); + }, []); + + const { + toggleDropdown, + handleSourceSelect, + openVideoFile, + openProjectBrowser, + openProjectFromLibrary, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + toggleMicrophone, + toggleWebcam, + } = useLaunchWindowActions({ + activeDropdown, + projectBrowserOpen, + recording, + hideHudFromCapture, + setActiveDropdown, + setSelectedSource, + setHasSelectedSource, + setSources, + setSourcesLoading, + setProjectLibraryEntries, + setProjectBrowserOpen, + setRecordingsDirectory, + setHideHudFromCapture, + fetchSourcesOnOpen: true, + }); + + 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 }; + + return ( +
+
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false)} + onMouseLeave={() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }} + > +
+ {projectBrowserOpen ? ( +
+ { + void openProjectFromLibrary(projectPath); + }} + /> +
+ ) : null} + {activeDropdown !== "none" && ( + + )} +
+ +
+
+ +
+ +
+ + { + void handleUpdateButtonClick(); + }} + /> + +
+ + + {recording ? ( + + ) : ( + + )} + + +
+
+
+ {showRecordingWebcamPreview && ( +
+
+ )} +
+
+
+ ); +} diff --git a/src/components/launch/LaunchWindow/types.ts b/src/components/launch/LaunchWindow/types.ts new file mode 100644 index 00000000..e5f24228 --- /dev/null +++ b/src/components/launch/LaunchWindow/types.ts @@ -0,0 +1,24 @@ +export interface DesktopSource { + id: string; + name: string; + thumbnail: string | null; + display_id: string; + appIcon: string | null; + originalName: string; + sourceType: "screen" | "window"; + appName?: string; + windowTitle?: string; +} + +export const LOCALE_LABELS: Record = { + en: "EN", + es: "ES", + nl: "NL", + "zh-CN": "中文", + ko: "한국어", +}; + +export const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; +export const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; +export const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; +export const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; diff --git a/src/components/launch/LaunchWindow/useLaunchWindowActions.ts b/src/components/launch/LaunchWindow/useLaunchWindowActions.ts new file mode 100644 index 00000000..b4ae6e01 --- /dev/null +++ b/src/components/launch/LaunchWindow/useLaunchWindowActions.ts @@ -0,0 +1,208 @@ +import { useCallback } from "react"; +import type ProjectBrowserDialog from "@/components/video-editor/ProjectBrowserDialog"; +import type { DesktopSource } from "./types"; + +type ProjectLibraryEntry = React.ComponentProps["entries"][number]; + +function toProcessedDesktopSource(source: DesktopSource): ProcessedDesktopSource { + return { + id: source.id, + name: source.originalName, + thumbnail: source.thumbnail, + display_id: source.display_id, + appIcon: source.appIcon, + originalName: source.originalName, + sourceType: source.sourceType, + appName: source.appName, + windowTitle: source.windowTitle, + }; +} + +interface UseLaunchWindowActionsParams { + activeDropdown: "none" | "sources" | "more" | "mic" | "countdown" | "webcam"; + projectBrowserOpen: boolean; + recording: boolean; + hideHudFromCapture: boolean; + setActiveDropdown: (value: "none" | "sources" | "more" | "mic" | "countdown" | "webcam") => void; + setSelectedSource: (value: string) => void; + setHasSelectedSource: (value: boolean) => void; + setSources: (value: DesktopSource[]) => void; + setSourcesLoading: (value: boolean) => void; + setProjectLibraryEntries: (value: ProjectLibraryEntry[]) => void; + setProjectBrowserOpen: (value: boolean) => void; + setRecordingsDirectory: (value: string | null) => void; + setHideHudFromCapture: (value: boolean) => void; + fetchSourcesOnOpen: boolean; +} + +export function useLaunchWindowActions({ + activeDropdown, + projectBrowserOpen, + recording, + hideHudFromCapture, + setActiveDropdown, + setSelectedSource, + setHasSelectedSource, + setSources, + setSourcesLoading, + setProjectLibraryEntries, + setProjectBrowserOpen, + setRecordingsDirectory, + setHideHudFromCapture, + fetchSourcesOnOpen, +}: UseLaunchWindowActionsParams) { + 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((source) => { + const isWindow = source.id.startsWith("window:"); + const type = source.sourceType ?? (isWindow ? "window" : "screen"); + let displayName = source.name; + let appName = source.appName; + if (isWindow && !appName && source.name.includes(" — ")) { + const parts = source.name.split(" — "); + appName = parts[0]?.trim(); + displayName = parts.slice(1).join(" — ").trim() || source.name; + } else if (isWindow && source.windowTitle) { + displayName = source.windowTitle; + } + return { + id: source.id, + name: displayName, + thumbnail: source.thumbnail ?? null, + display_id: source.display_id ?? "", + appIcon: source.appIcon ?? null, + originalName: source.name, + sourceType: type, + appName, + windowTitle: source.windowTitle ?? displayName, + }; + }), + ); + } catch (error) { + console.error("Failed to fetch sources:", error); + } finally { + setSourcesLoading(false); + } + }, [setSources, setSourcesLoading]); + + const toggleDropdown = useCallback( + (which: "sources" | "more" | "mic" | "countdown" | "webcam") => { + setProjectBrowserOpen(false); + setActiveDropdown(activeDropdown === which ? "none" : which); + if (fetchSourcesOnOpen && activeDropdown !== which && which === "sources") { + void fetchSources(); + } + }, + [activeDropdown, fetchSources, fetchSourcesOnOpen, setActiveDropdown, setProjectBrowserOpen], + ); + + const handleSourceSelect = useCallback( + async (source: DesktopSource) => { + const processedSource = toProcessedDesktopSource(source); + await window.electronAPI.selectSource(processedSource); + setSelectedSource(source.name); + setHasSelectedSource(true); + setActiveDropdown("none"); + window.electronAPI.showSourceHighlight?.(processedSource); + }, + [setActiveDropdown, setHasSelectedSource, setSelectedSource], + ); + + const openVideoFile = useCallback(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(); + } + }, [setActiveDropdown]); + + 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); + } + }, [setProjectLibraryEntries]); + + const openProjectBrowser = useCallback(async () => { + if (projectBrowserOpen) { + setProjectBrowserOpen(false); + return; + } + setActiveDropdown("none"); + await refreshProjectLibrary(); + setProjectBrowserOpen(true); + }, [projectBrowserOpen, refreshProjectLibrary, setActiveDropdown, setProjectBrowserOpen]); + + 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); + } + }, + [setProjectBrowserOpen], + ); + + const chooseRecordingsDirectory = useCallback(async () => { + setActiveDropdown("none"); + const result = await window.electronAPI.chooseRecordingsDirectory(); + if (result.canceled) return; + if (result.success && result.path) setRecordingsDirectory(result.path); + }, [setActiveDropdown, setRecordingsDirectory]); + + 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, setHideHudFromCapture]); + + const toggleMicrophone = useCallback(() => { + if (recording) return; + toggleDropdown("mic"); + }, [recording, toggleDropdown]); + + const toggleWebcam = useCallback(() => { + if (recording) return; + toggleDropdown("webcam"); + }, [recording, toggleDropdown]); + + return { + fetchSources, + toggleDropdown, + handleSourceSelect, + openVideoFile, + openProjectBrowser, + openProjectFromLibrary, + chooseRecordingsDirectory, + toggleHudCaptureProtection, + toggleMicrophone, + toggleWebcam, + }; +} \ No newline at end of file diff --git a/src/components/launch/LaunchWindow/useLaunchWindowSetup.ts b/src/components/launch/LaunchWindow/useLaunchWindowSetup.ts new file mode 100644 index 00000000..461f52cd --- /dev/null +++ b/src/components/launch/LaunchWindow/useLaunchWindowSetup.ts @@ -0,0 +1,270 @@ +import type React from "react"; +import { useCallback, useEffect, useState } from "react"; + +interface SetupParams { + preparePermissions: (opts?: { startup?: boolean }) => Promise; + activeDropdown: string; + projectBrowserOpen: boolean; + showRecordingWebcamPreview: boolean; + hudContentRef: React.RefObject; + hudBarRef: React.RefObject; + recordingWebcamPreviewContainerRef: React.RefObject; +} + +export function useLaunchWindowSetup({ + preparePermissions, + activeDropdown, + projectBrowserOpen, + showRecordingWebcamPreview, + hudContentRef, + hudBarRef, + recordingWebcamPreviewContainerRef, +}: SetupParams) { + const [selectedSource, setSelectedSource] = useState("Screen"); + const [hasSelectedSource, setHasSelectedSource] = useState(false); + const [platform, setPlatform] = useState(null); + const [appVersion, setAppVersion] = useState(null); + const [updateStatus, setUpdateStatus] = useState<{ + status: + | "idle" + | "checking" + | "up-to-date" + | "available" + | "downloading" + | "ready" + | "error"; + currentVersion: string; + availableVersion: string | null; + detail?: string; + }>({ + status: "idle", + currentVersion: "", + availableVersion: null, + }); + const [updateActionPending, setUpdateActionPending] = useState(false); + const [hideHudFromCapture, setHideHudFromCapture] = useState(true); + + // Selected source listener + 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); + }); + + const cleanup = window.electronAPI.onSelectedSourceChanged((source) => { + applySelectedSource(source); + }); + + return () => { + mounted = false; + cleanup?.(); + }; + }, []); + + // Platform loading + 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; + }; + }, []); + + // Prepare permissions + useEffect(() => { + void preparePermissions({ startup: true }); + }, [preparePermissions]); + + // Update status polling + useEffect(() => { + let mounted = true; + + const refreshUpdateStatus = async () => { + try { + const summary = await window.electronAPI.getUpdateStatusSummary(); + if (mounted) setUpdateStatus(summary); + } catch (error) { + console.error("Failed to load update status summary:", error); + } + }; + + void refreshUpdateStatus(); + const pollTimer = window.setInterval(() => { + void refreshUpdateStatus(); + }, 2500); + + return () => { + mounted = false; + window.clearInterval(pollTimer); + }; + }, []); + + // App version loading + 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; + }; + }, []); + + // HUD capture protection loading + 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; + }; + }, []); + + // HUD overlay expanded state + useEffect(() => { + const expanded = + activeDropdown !== "none" || projectBrowserOpen || showRecordingWebcamPreview; + window.electronAPI.setHudOverlayExpanded(expanded); + + return () => { + window.electronAPI.setHudOverlayExpanded(false); + }; + }, [activeDropdown, projectBrowserOpen, showRecordingWebcamPreview]); + + // HUD size reporting + 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, hudContentRef, hudBarRef]); + + // HUD resize observer + 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, hudContentRef, hudBarRef, recordingWebcamPreviewContainerRef]); + + // Update button handler + const handleUpdateButtonClick = async () => { + if (updateActionPending || updateStatus.status === "downloading") return; + + setUpdateActionPending(true); + try { + switch (updateStatus.status) { + case "available": + await window.electronAPI.downloadAvailableUpdate(); + break; + case "ready": + await window.electronAPI.installDownloadedUpdate(); + break; + default: + await window.electronAPI.checkForAppUpdates(); + break; + } + const summary = await window.electronAPI.getUpdateStatusSummary(); + setUpdateStatus(summary); + } catch (error) { + console.error("Failed to handle update button action:", error); + } finally { + setUpdateActionPending(false); + } + }; + + return { + selectedSource, + setSelectedSource, + hasSelectedSource, + setHasSelectedSource, + platform, + appVersion, + updateStatus, + updateActionPending, + hideHudFromCapture, + setHideHudFromCapture, + handleUpdateButtonClick, + }; +} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index fec7af9b..895cca68 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -18,6 +18,20 @@ interface DesktopSource { windowTitle?: string; } +function toProcessedDesktopSource(source: DesktopSource): ProcessedDesktopSource { + return { + id: source.id, + name: source.originalName, + thumbnail: source.thumbnail, + display_id: source.display_id, + appIcon: source.appIcon, + originalName: source.originalName, + sourceType: source.sourceType, + appName: source.appName, + windowTitle: source.windowTitle, + }; +} + function parseSourceMetadata(source: ProcessedDesktopSource) { if (source.sourceType === "window" && (source.appName || source.windowTitle)) { return { @@ -73,13 +87,13 @@ export function SourceSelector() { return { id: source.id, name: metadata.displayName, - thumbnail: source.thumbnail, - display_id: source.display_id, - appIcon: source.appIcon, + thumbnail: source.thumbnail ?? null, + display_id: source.display_id ?? "", + appIcon: source.appIcon ?? null, originalName: source.name, sourceType: metadata.sourceType, appName: metadata.appName, - windowTitle: metadata.windowTitle, + windowTitle: metadata.windowTitle ?? source.name, }; }), ); @@ -124,7 +138,9 @@ export function SourceSelector() { }; const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); + if (selectedSource) { + await window.electronAPI.selectSource(toProcessedDesktopSource(selectedSource)); + } }; if (loading) { diff --git a/src/components/video-editor/AnnotationBlurTab.tsx b/src/components/video-editor/AnnotationBlurTab.tsx new file mode 100644 index 00000000..95622d6a --- /dev/null +++ b/src/components/video-editor/AnnotationBlurTab.tsx @@ -0,0 +1,122 @@ +import Block from "@uiw/react-color-block"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Slider } from "@/components/ui/slider"; +import { TabsContent } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { useScopedT } from "../../contexts/I18nContext"; +import { ANNOTATION_COLOR_PALETTE, type AnnotationSettingsPanelProps } from "./annotationSettingsShared"; + +interface AnnotationBlurTabProps extends Pick< + AnnotationSettingsPanelProps, + "annotation" | "onBlurIntensityChange" | "onBlurColorChange" +> {} + +export function AnnotationBlurTab({ + annotation, + onBlurIntensityChange, + onBlurColorChange, +}: AnnotationBlurTabProps) { + const t = useScopedT("editor"); + + return ( + +
+
+
+ + {t("annotations.blurStrength", undefined, { + strength: annotation.blurIntensity ?? 20, + })} + +
+ onBlurIntensityChange?.(value)} + min={1} + max={100} + step={1} + className="w-full" + /> +
+ +
+
+ + {t("annotations.solidColor", "Solid Color (Censorship)")} + +
+
+ + + + + onBlurColorChange?.(color.hex)} + style={{ borderRadius: "8px" }} + /> + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/AnnotationFigureTab.tsx b/src/components/video-editor/AnnotationFigureTab.tsx new file mode 100644 index 00000000..47ba2e9a --- /dev/null +++ b/src/components/video-editor/AnnotationFigureTab.tsx @@ -0,0 +1,122 @@ +import { CaretDown as ChevronDown } from "@phosphor-icons/react"; +import Block from "@uiw/react-color-block"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Slider } from "@/components/ui/slider"; +import { TabsContent } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { useScopedT } from "../../contexts/I18nContext"; +import { getArrowComponent } from "./ArrowSvgs"; +import { ANNOTATION_COLOR_PALETTE, type AnnotationSettingsPanelProps } from "./annotationSettingsShared"; +import type { ArrowDirection, FigureData } from "./types"; + +interface AnnotationFigureTabProps extends Pick {} + +export function AnnotationFigureTab({ annotation, onFigureDataChange }: AnnotationFigureTabProps) { + const t = useScopedT("editor"); + + return ( + +
+ +
+ {([ + "up", + "down", + "left", + "right", + "up-right", + "up-left", + "down-right", + "down-left", + ] as ArrowDirection[]).map((direction) => { + const ArrowComponent = getArrowComponent(direction); + return ( + + ); + })} +
+
+ +
+ + + onFigureDataChange?.({ ...annotation.figureData!, strokeWidth: value }) + } + min={1} + max={6} + step={1} + className="w-full" + /> +
+ +
+ + + + + + + + onFigureDataChange?.({ + ...annotation.figureData!, + color: color.hex, + } as FigureData) + } + style={{ borderRadius: "8px" }} + /> + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/AnnotationImageTab.tsx b/src/components/video-editor/AnnotationImageTab.tsx new file mode 100644 index 00000000..043ef43c --- /dev/null +++ b/src/components/video-editor/AnnotationImageTab.tsx @@ -0,0 +1,78 @@ +import { UploadSimple as Upload } from "@phosphor-icons/react"; +import { useRef } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { TabsContent } from "@/components/ui/tabs"; +import { useScopedT } from "../../contexts/I18nContext"; +import type { AnnotationSettingsPanelProps } from "./annotationSettingsShared"; + +interface AnnotationImageTabProps extends Pick {} + +export function AnnotationImageTab({ annotation, onContentChange }: AnnotationImageTabProps) { + const t = useScopedT("editor"); + const fileInputRef = useRef(null); + + const handleImageUpload = (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + const file = files[0]; + const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; + if (!validTypes.includes(file.type)) { + toast.error(t("annotations.imageUploadError"), { + description: t("annotations.imageUploadErrorDescription"), + }); + event.target.value = ""; + return; + } + + const reader = new FileReader(); + reader.onload = (loadEvent) => { + const dataUrl = loadEvent.target?.result as string; + if (dataUrl) { + onContentChange(dataUrl); + toast.success(t("annotations.imageUploadSuccess")); + } + }; + reader.onerror = () => { + toast.error(t("annotations.imageUploadFailed"), { + description: t("annotations.imageUploadFailedDescription"), + }); + }; + + reader.readAsDataURL(file); + if (event.target) { + event.target.value = ""; + } + }; + + return ( + + + + + {annotation.content && annotation.content.startsWith("data:image") && ( +
+ Uploaded annotation +
+ )} + +

+ {t("annotations.supportedFormats")} +

+
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx index a0370a5d..f443cb97 100644 --- a/src/components/video-editor/AnnotationSettingsPanel.tsx +++ b/src/components/video-editor/AnnotationSettingsPanel.tsx @@ -1,63 +1,19 @@ import { - AlignCenterHorizontal as AlignCenter, - AlignLeft, - AlignRight, - TextB as Bold, - CaretDown as ChevronDown, ImageSquare as ImageIcon, Info, - TextItalic as Italic, BoundingBox as SquareDashed, Trash as Trash2, TextT as Type, - TextUnderline as Underline, - UploadSimple as Upload, } from "@phosphor-icons/react"; -import Block from "@uiw/react-color-block"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Slider } from "@/components/ui/slider"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { type CustomFont, getCustomFonts } from "@/lib/customFonts"; -import { cn } from "@/lib/utils"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useScopedT } from "../../contexts/I18nContext"; -import { AddCustomFontDialog } from "./AddCustomFontDialog"; -import { getArrowComponent } from "./ArrowSvgs"; -import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types"; - -interface AnnotationSettingsPanelProps { - annotation: AnnotationRegion; - onContentChange: (content: string) => void; - onTypeChange: (type: AnnotationType) => void; - onStyleChange: (style: Partial) => void; - onFigureDataChange?: (figureData: FigureData) => void; - onBlurIntensityChange?: (intensity: number) => void; - onBlurColorChange?: (color: string) => void; - onDelete: () => void; -} - -export const FONT_FAMILY_VALUES = [ - { value: "system-ui, -apple-system, sans-serif", labelKey: "fontStyles.classic" }, - { value: "Georgia, serif", labelKey: "fontStyles.editor" }, - { value: "Impact, Arial Black, sans-serif", labelKey: "fontStyles.strong" }, - { value: "Courier New, monospace", labelKey: "fontStyles.typewriter" }, - { value: "Brush Script MT, cursive", labelKey: "fontStyles.deco" }, - { value: "Arial, sans-serif", labelKey: "fontStyles.simple" }, - { value: "Verdana, sans-serif", labelKey: "fontStyles.modern" }, - { value: "Trebuchet MS, sans-serif", labelKey: "fontStyles.clean" }, -]; - -export const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80, 96, 128]; +import { AnnotationBlurTab } from "./AnnotationBlurTab"; +import { AnnotationFigureTab } from "./AnnotationFigureTab"; +import { AnnotationImageTab } from "./AnnotationImageTab"; +import { AnnotationTextTab } from "./AnnotationTextTab"; +import type { AnnotationSettingsPanelProps } from "./annotationSettingsShared"; +import type { AnnotationType } from "./types"; export function AnnotationSettingsPanel({ annotation, @@ -70,73 +26,6 @@ export function AnnotationSettingsPanel({ onDelete, }: AnnotationSettingsPanelProps) { const t = useScopedT("editor"); - const fileInputRef = useRef(null); - const [customFonts, setCustomFonts] = useState([]); - - const fontFamilies = useMemo( - () => FONT_FAMILY_VALUES.map((f) => ({ value: f.value, label: t(f.labelKey) })), - [t], - ); - - // Load custom fonts on mount - useEffect(() => { - setCustomFonts(getCustomFonts()); - }, []); - - const colorPalette = [ - "#FF0000", // Red - "#FFD700", // Yellow/Gold - "#00FF00", // Green - "#FFFFFF", // White - "#0000FF", // Blue - "#FF6B00", // Orange - "#9B59B6", // Purple - "#E91E63", // Pink - "#00BCD4", // Cyan - "#FF5722", // Deep Orange - "#8BC34A", // Light Green - "#FFC107", // Amber - "#2563EB", // Brand Blue - "#000000", // Black - "#607D8B", // Blue Grey - "#795548", // Brown - ]; - - const handleImageUpload = (event: React.ChangeEvent) => { - const files = event.target.files; - if (!files || files.length === 0) return; - - const file = files[0]; - - // Validate file type - const validTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]; - if (!validTypes.includes(file.type)) { - toast.error(t("annotations.imageUploadError"), { - description: t("annotations.imageUploadErrorDescription"), - }); - event.target.value = ""; - return; - } - - const reader = new FileReader(); - - reader.onload = (e) => { - const dataUrl = e.target?.result as string; - if (dataUrl) { - onContentChange(dataUrl); - toast.success(t("annotations.imageUploadSuccess")); - } - }; - - reader.onerror = () => { - toast.error(t("annotations.imageUploadFailed"), { - description: t("annotations.imageUploadFailedDescription"), - }); - }; - - reader.readAsDataURL(file); - event.target.value = ""; - }; return (
@@ -150,7 +39,6 @@ export function AnnotationSettingsPanel({
- {/* Type Selector */} onTypeChange(value as AnnotationType)} @@ -199,577 +87,21 @@ export function AnnotationSettingsPanel({ - {/* Text Content */} - -
- -