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, } from "lucide-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", "zh-CN": "中文", }; const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; 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 [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 isDraggingRef = useRef(false); const micDropdownOpen = activeDropdown === "mic"; const webcamDropdownOpen = activeDropdown === "webcam"; const showWebcamControls = webcamEnabled && !recording; 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(() => { let mounted = true; let previewStream: MediaStream | null = null; const startPreview = async () => { if (!showWebcamControls || !webcamPreviewRef.current) { return; } try { 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 || !webcamPreviewRef.current) { previewStream.getTracks().forEach((track) => track.stop()); return; } webcamPreviewRef.current.srcObject = previewStream; const playPromise = webcamPreviewRef.current.play(); if (playPromise) { playPromise.catch(() => { // Ignore autoplay interruptions while the preview element mounts. }); } } catch (error) { console.warn("Failed to start live webcam preview:", error); } }; void startPreview(); return () => { mounted = false; if (webcamPreviewRef.current) { webcamPreviewRef.current.pause(); webcamPreviewRef.current.srcObject = null; } previewStream?.getTracks().forEach((track) => track.stop()); }; }, [showWebcamControls, 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; window.electronAPI.setHudOverlayExpanded(expanded); return () => { window.electronAPI.setHudOverlayExpanded(false); }; }, [activeDropdown, projectBrowserOpen]); useEffect(() => { const hudContent = hudContentRef.current; const hudBar = hudBarRef.current; if (!hudContent || !hudBar || typeof ResizeObserver === "undefined") { return; } let frameId = 0; const reportHudSize = () => { frameId = 0; const measuredWidth = Math.ceil( Math.max( hudBar.getBoundingClientRect().width, hudBar.scrollWidth, hudContent.getBoundingClientRect().width, hudContent.scrollWidth, ) + 24, ); const measuredHeight = Math.ceil( Math.max(hudContent.getBoundingClientRect().height, hudContent.scrollHeight) + 24, ); window.electronAPI.setHudOverlayCompactWidth(measuredWidth); window.electronAPI.setHudOverlayMeasuredHeight( measuredHeight, activeDropdown !== "none" || projectBrowserOpen, ); }; const scheduleHudSizeReport = () => { if (frameId !== 0) { cancelAnimationFrame(frameId); } frameId = requestAnimationFrame(reportHudSize); }; scheduleHudSizeReport(); const resizeObserver = new ResizeObserver(() => { scheduleHudSizeReport(); }); resizeObserver.observe(hudContent); resizeObserver.observe(hudBar); return () => { resizeObserver.disconnect(); if (frameId !== 0) { cancelAnimationFrame(frameId); } }; }, [activeDropdown, projectBrowserOpen]); 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 (!isDraggingRef.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")} )} {!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(); isDraggingRef.current = true; window.electronAPI?.hudOverlayDrag?.("start", e.screenX, e.screenY); let lastScreenX = e.screenX; let lastScreenY = e.screenY; const handleMove = (ev: MouseEvent) => { if (ev.screenX === lastScreenX && ev.screenY === lastScreenY) { return; } lastScreenX = ev.screenX; lastScreenY = ev.screenY; window.electronAPI?.hudOverlayDrag?.("move", ev.screenX, ev.screenY); }; const handleUp = () => { isDraggingRef.current = false; window.electronAPI?.hudOverlayDrag?.("end", 0, 0); document.removeEventListener("mousemove", handleMove); document.removeEventListener("mouseup", handleUp); }; document.addEventListener("mousemove", handleMove); document.addEventListener("mouseup", handleUp); }} >
{recording ? recordingControls : idleControls}
); }