diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 79b099cc..756ff3af 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,28 +1,28 @@ import { - AppWindow, - ArrowUpCircle, - ChevronUp, - CheckCircle2, - Eye, - EyeOff, - FolderOpen, - Languages, - Mic, - MicOff, - Minus, - Monitor, - MoreVertical, - Pause, - Play, - RefreshCw, - Square, - Timer, - Video, - VideoIcon, - VideoOff, - Volume2, - VolumeX, - X, + AppWindow, + ArrowUpCircle, + ChevronUp, + CheckCircle2, + Eye, + EyeOff, + FolderOpen, + Languages, + Mic, + MicOff, + Minus, + Monitor, + MoreVertical, + Pause, + Play, + RefreshCw, + Square, + Timer, + Video, + VideoIcon, + VideoOff, + Volume2, + VolumeX, + X, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import type { ReactNode } from "react"; @@ -38,24 +38,26 @@ 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 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; + 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", - "zh-CN": "中文", + en: "EN", + es: "ES", + "zh-CN": "中文", }; const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; @@ -64,1311 +66,1783 @@ 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, + title, + className = "", + buttonRef, + children, }: { - onClick?: () => void; - title?: string; - className?: string; - buttonRef?: React.Ref; - children: ReactNode; + onClick?: () => void; + title?: string; + className?: string; + buttonRef?: React.Ref; + children: ReactNode; }) { - return ( - - ); + return ( + + ); } function DropdownItem({ - onClick, - selected, - icon, - children, - trailing, + onClick, + selected, + icon, + children, + trailing, }: { - onClick: () => void; - selected?: boolean; - icon: ReactNode; - children: ReactNode; - trailing?: ReactNode; + onClick: () => void; + selected?: boolean; + icon: ReactNode; + children: ReactNode; + trailing?: ReactNode; }) { - return ( - - ); + return ( + + ); } function Separator({ dropdown = false }: { dropdown?: boolean }) { - return
; + return
; } -function MicDeviceRow({ device, selected, onSelect }: { device: { deviceId: string; label: string }; selected: boolean; onSelect: () => void }) { - const { level } = useAudioLevelMeter({ - enabled: true, - deviceId: device.deviceId, - }); +function MicDeviceRow({ + device, + selected, + onSelect, +}: { + device: { deviceId: string; label: string }; + selected: boolean; + onSelect: () => void; +}) { + const { level } = useAudioLevelMeter({ + enabled: true, + deviceId: device.deviceId, + }); - return ( - - ); + 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 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 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 = ( - <> - - - - - - {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} -
- )} - - )} -
- )} -
- -
-
- -
{ - e.preventDefault(); - isHudDraggingRef.current = true; - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - - if (showRecordingWebcamPreview && hudBarRef.current) { - const hudRect = hudBarRef.current.getBoundingClientRect(); - const dragStartX = e.clientX; - const dragStartY = e.clientY; - const originX = recordingHudOffset.x; - const originY = recordingHudOffset.y; - const initialLeft = hudRect.left; - const initialTop = hudRect.top; - const hudWidth = hudRect.width; - const hudHeight = hudRect.height; - - const handleMove = (ev: MouseEvent) => { - const deltaX = ev.clientX - dragStartX; - const deltaY = ev.clientY - dragStartY; - const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0); - const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0); - const unclampedLeft = initialLeft + deltaX; - const unclampedTop = initialTop + deltaY; - const clampedLeft = Math.min(Math.max(0, unclampedLeft), Math.max(0, viewportWidth - hudWidth)); - const clampedTop = Math.min(Math.max(0, unclampedTop), Math.max(0, viewportHeight - hudHeight)); - - setRecordingHudOffset({ - x: originX + (clampedLeft - initialLeft), - y: originY + (clampedTop - initialTop), - }); - }; - - const handleUp = () => { - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - if (wasDragging) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - document.removeEventListener("mousemove", handleMove); - document.removeEventListener("mouseup", handleUp); - }; - - document.addEventListener("mousemove", handleMove); - document.addEventListener("mouseup", handleUp); - return; - } - - window.electronAPI?.hudOverlayDrag?.("start", e.screenX, e.screenY); - const handleMove = (ev: MouseEvent) => { - window.electronAPI?.hudOverlayDrag?.("move", ev.screenX, ev.screenY); - }; - const handleUp = () => { - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - window.electronAPI?.hudOverlayDrag?.("end", 0, 0); - if (wasDragging) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - document.removeEventListener("mousemove", handleMove); - document.removeEventListener("mouseup", handleUp); - }; - document.addEventListener("mousemove", handleMove); - document.addEventListener("mouseup", handleUp); - }} - > - -
- - - -
- - - {recording ? recordingControls : idleControls} - - -
-
-
- {showRecordingWebcamPreview && ( -
-
- )} -
-
-
- ); + 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< + ProjectLibraryEntry[] + >([]); + 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 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 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 = ( + <> + + + + + + {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} +
+ )} + + )} +
+ )} +
+ +
+
+ +
{ + e.preventDefault(); + isHudDraggingRef.current = true; + window.electronAPI?.hudOverlaySetIgnoreMouse?.( + false, + ); + + if ( + showRecordingWebcamPreview && + hudBarRef.current + ) { + const hudRect = + hudBarRef.current.getBoundingClientRect(); + const dragStartX = e.clientX; + const dragStartY = e.clientY; + const originX = recordingHudOffset.x; + const originY = recordingHudOffset.y; + const initialLeft = hudRect.left; + const initialTop = hudRect.top; + const hudWidth = hudRect.width; + const hudHeight = hudRect.height; + + const handleMove = (ev: MouseEvent) => { + const deltaX = + ev.clientX - dragStartX; + const deltaY = + ev.clientY - dragStartY; + const viewportWidth = Math.max( + window.innerWidth, + window.screen?.width ?? 0, + ); + const viewportHeight = Math.max( + window.innerHeight, + window.screen?.height ?? 0, + ); + const unclampedLeft = + initialLeft + deltaX; + const unclampedTop = + initialTop + deltaY; + const clampedLeft = Math.min( + Math.max(0, unclampedLeft), + Math.max( + 0, + viewportWidth - hudWidth, + ), + ); + const clampedTop = Math.min( + Math.max(0, unclampedTop), + Math.max( + 0, + viewportHeight - hudHeight, + ), + ); + + setRecordingHudOffset({ + x: + originX + + (clampedLeft - initialLeft), + y: + originY + + (clampedTop - initialTop), + }); + }; + + const handleUp = () => { + const wasDragging = + isHudDraggingRef.current; + isHudDraggingRef.current = false; + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.( + true, + ); + } + document.removeEventListener( + "mousemove", + handleMove, + ); + document.removeEventListener( + "mouseup", + handleUp, + ); + }; + + document.addEventListener( + "mousemove", + handleMove, + ); + document.addEventListener( + "mouseup", + handleUp, + ); + return; + } + + window.electronAPI?.hudOverlayDrag?.( + "start", + e.screenX, + e.screenY, + ); + const handleMove = (ev: MouseEvent) => { + window.electronAPI?.hudOverlayDrag?.( + "move", + ev.screenX, + ev.screenY, + ); + }; + const handleUp = () => { + const wasDragging = + isHudDraggingRef.current; + isHudDraggingRef.current = false; + window.electronAPI?.hudOverlayDrag?.( + "end", + 0, + 0, + ); + if (wasDragging) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.( + true, + ); + } + document.removeEventListener( + "mousemove", + handleMove, + ); + document.removeEventListener( + "mouseup", + handleUp, + ); + }; + document.addEventListener( + "mousemove", + handleMove, + ); + document.addEventListener( + "mouseup", + handleUp, + ); + }} + > + +
+ + + +
+ + + {recording + ? recordingControls + : idleControls} + + +
+
+
+ {showRecordingWebcamPreview && ( +
+
+ )} +
+
+
+ ); }