import { Check, ChevronDown, Languages } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs"; import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { MdCancel, MdMic, MdMicOff, MdMonitor, MdMouse, MdRestartAlt, MdVideocam, MdVideocamOff, MdVideoFile, MdVolumeOff, MdVolumeUp, } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { getAvailableLocales, getLocaleName } from "@/i18n/loader"; import { nativeBridgeClient } from "@/native"; import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter"; import { useCameraDevices } from "../../hooks/useCameraDevices"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; const ICON_SIZE = 20; const ICON_CONFIG = { drag: { icon: RxDragHandleDots2, size: ICON_SIZE }, monitor: { icon: MdMonitor, size: ICON_SIZE }, volumeOn: { icon: MdVolumeUp, size: ICON_SIZE }, volumeOff: { icon: MdVolumeOff, size: ICON_SIZE }, micOn: { icon: MdMic, size: ICON_SIZE }, micOff: { icon: MdMicOff, size: ICON_SIZE }, webcamOn: { icon: MdVideocam, size: ICON_SIZE }, webcamOff: { icon: MdVideocamOff, size: ICON_SIZE }, cursor: { icon: MdMouse, size: ICON_SIZE }, pause: { icon: BsPauseCircle, size: ICON_SIZE }, resume: { icon: BsPlayCircle, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, cancel: { icon: MdCancel, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, minimize: { icon: FiMinus, size: ICON_SIZE }, close: { icon: FiX, size: ICON_SIZE }, } as const; type IconName = keyof typeof ICON_CONFIG; function getIcon(name: IconName, className?: string) { const { icon: Icon, size } = ICON_CONFIG[name]; return ; } const hudGroupClasses = "flex items-center gap-0.5 rounded-xl border border-white/[0.07] bg-white/[0.045] transition-colors duration-150 hover:bg-white/[0.075]"; const hudIconBtnClasses = "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer text-white hover:bg-white/10 active:scale-95"; const hudAuxIconBtnClasses = "flex h-7 w-7 items-center justify-center rounded-lg transition-colors duration-150 text-white/55 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed"; const windowBtnClasses = "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150 cursor-pointer opacity-50 hover:opacity-90 hover:bg-white/[0.08]"; const hudSidebarClasses = "ml-0.5 pl-1.5 border-l border-white/10 flex items-center gap-0.5"; export function LaunchWindow() { const t = useScopedT("launch"); const availableLocales = getAvailableLocales(); const { locale, setLocale, systemLocaleSuggestion, acceptSystemLocaleSuggestion, dismissSystemLocaleSuggestion, resolveSystemLocaleSuggestion, } = useI18n(); const suggestedLanguageName = systemLocaleSuggestion ? getLocaleName(systemLocaleSuggestion) : ""; const activeLanguageLabel = getLocaleName(locale).split(/\s+/)[0] || locale.toUpperCase(); const { recording, paused, elapsedSeconds, toggleRecording, togglePaused, canPauseRecording, restartRecording, cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, setMicrophoneDeviceId, setMicrophoneDeviceName, systemAudioEnabled, setSystemAudioEnabled, webcamEnabled, setWebcamEnabled, webcamDeviceId, setWebcamDeviceId, setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, } = useScreenRecorder(); const showMicControls = microphoneEnabled && !recording; const showWebcamControls = webcamEnabled && !recording; const [isMicHovered, setIsMicHovered] = useState(false); const [isMicFocused, setIsMicFocused] = useState(false); const micExpanded = isMicHovered || isMicFocused; const [isWebcamHovered, setIsWebcamHovered] = useState(false); const [isWebcamFocused, setIsWebcamFocused] = useState(false); const webcamExpanded = isWebcamHovered || isWebcamFocused; const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false); const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false); const languageTriggerRef = useRef(null); const languageMenuPanelRef = useRef(null); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ right: number; top: number; maxHeight: number; }>({ right: 12, top: 12, maxHeight: 240, }); const { devices: micDevices, selectedDeviceId: selectedMicId, setSelectedDeviceId: setSelectedMicId, } = useMicrophoneDevices(microphoneEnabled); const { devices: cameraDevices, selectedDeviceId: selectedCameraId, setSelectedDeviceId: setSelectedCameraId, isLoading: isCameraDevicesLoading, error: cameraDevicesError, } = useCameraDevices(webcamEnabled); const selectedMicLabel = micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label || t("audio.defaultMicrophone"); const selectedCameraDevice = cameraDevices.find( (d) => d.deviceId === (webcamDeviceId || selectedCameraId), ); const selectedCameraLabel = isCameraDevicesLoading ? t("webcam.searching") : cameraDevicesError ? t("webcam.unavailable") : cameraDevices.length === 0 ? t("webcam.noneFound") : selectedCameraDevice?.label || t("webcam.defaultCamera"); const { level } = useAudioLevelMeter({ enabled: showMicControls, deviceId: microphoneDeviceId, }); useEffect(() => { if (selectedMicId && selectedMicId !== "default") { setMicrophoneDeviceId(selectedMicId); setMicrophoneDeviceName(micDevices.find((d) => d.deviceId === selectedMicId)?.label); } }, [selectedMicId, micDevices, setMicrophoneDeviceId, setMicrophoneDeviceName]); useEffect(() => { if (selectedCameraId) { setWebcamDeviceId(selectedCameraId); setWebcamDeviceName(cameraDevices.find((d) => d.deviceId === selectedCameraId)?.label); } }, [selectedCameraId, cameraDevices, setWebcamDeviceId, setWebcamDeviceName]); useEffect(() => { let cancelled = false; nativeBridgeClient.system .getPlatform() .then((platform) => { if (!cancelled) { setSupportsCursorModeToggle(platform === "win32" || platform === "darwin"); } }) .catch(() => { if (!cancelled) { setSupportsCursorModeToggle(false); } }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!import.meta.env.DEV) { return; } void requestCameraAccess().catch((error) => { console.warn("Failed to trigger camera access request during development:", error); }); }, []); useEffect(() => { if (!isLanguageMenuOpen) return; const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node; const clickedTrigger = languageTriggerRef.current?.contains(target); const clickedMenu = languageMenuPanelRef.current?.contains(target); if (!clickedTrigger && !clickedMenu) { setIsLanguageMenuOpen(false); } }; const handleEscape = (event: KeyboardEvent) => { if (event.key === "Escape") { setIsLanguageMenuOpen(false); } }; window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, [isLanguageMenuOpen]); useEffect(() => { if (!isLanguageMenuOpen || !languageTriggerRef.current) return; const updatePosition = () => { if (!languageTriggerRef.current) return; const rect = languageTriggerRef.current.getBoundingClientRect(); const gap = 8; const viewportPadding = 8; const availableHeight = Math.max(80, rect.top - viewportPadding - gap); const top = Math.max(viewportPadding, rect.top - gap - availableHeight); setLanguageMenuStyle({ right: Math.max(viewportPadding, window.innerWidth - rect.right), top, maxHeight: availableHeight, }); }; updatePosition(); window.addEventListener("resize", updatePosition); window.addEventListener("scroll", updatePosition, true); return () => { window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; }, [isLanguageMenuOpen]); useEffect(() => { if (!isLanguageMenuOpen || !languageMenuPanelRef.current) return; const id = requestAnimationFrame(() => { if (languageMenuPanelRef.current) { languageMenuPanelRef.current.scrollTop = 0; } }); return () => cancelAnimationFrame(id); }, [isLanguageMenuOpen]); const hudMouseEventsEnabledRef = useRef(undefined); const setHudMouseEventsEnabled = useCallback((enabled: boolean) => { if (hudMouseEventsEnabledRef.current === enabled) { return; } hudMouseEventsEnabledRef.current = enabled; window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!enabled); }, []); useEffect(() => { setHudMouseEventsEnabled(false); return () => { window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false); }; }, [setHudMouseEventsEnabled]); useEffect(() => { setHudMouseEventsEnabled(isLanguageMenuOpen); }, [isLanguageMenuOpen, setHudMouseEventsEnabled]); const [selectedSource, setSelectedSource] = useState("Screen"); const [hasSelectedSource, setHasSelectedSource] = useState(false); const [, setRecordPointerDownCount] = useState(0); useEffect(() => { const checkSelectedSource = async () => { if (window.electronAPI) { const source = await window.electronAPI.getSelectedSource(); if (source) { setSelectedSource(source.name); setHasSelectedSource(true); } else { setSelectedSource("Screen"); setHasSelectedSource(false); } } }; checkSelectedSource(); const interval = setInterval(checkSelectedSource, 500); return () => clearInterval(interval); }, []); const openSourceSelector = () => { if (window.electronAPI) { window.electronAPI.openSourceSelector(); } }; const openVideoFile = async () => { const result = await window.electronAPI.openVideoFilePicker(); if (result.canceled) { return; } if (result.success && result.path) { const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path); if (!setVideoPathResult.success) { console.error("Failed to set current video path:", setVideoPathResult); return; } await window.electronAPI.switchToEditor(); } }; const openProjectFile = async () => { const result = await nativeBridgeClient.project.loadProjectFile(); if (result.canceled || !result.success) return; await window.electronAPI.switchToEditor(); }; const sendHudOverlayHide = () => { if (window.electronAPI && window.electronAPI.hudOverlayHide) { window.electronAPI.hudOverlayHide(); } }; const sendHudOverlayClose = () => { if (window.electronAPI && window.electronAPI.hudOverlayClose) { window.electronAPI.hudOverlayClose(); } }; const toggleMicrophone = () => { if (!recording) { setMicrophoneEnabled(!microphoneEnabled); } }; const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null); const handleHudDragPointerDown = (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); setHudMouseEventsEnabled(true); event.currentTarget.setPointerCapture(event.pointerId); dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; }; const handleHudDragPointerMove = (event: React.PointerEvent) => { const lastPosition = dragLastPositionRef.current; if (!lastPosition) return; const deltaX = event.screenX - lastPosition.x; const deltaY = event.screenY - lastPosition.y; dragLastPositionRef.current = { x: event.screenX, y: event.screenY }; window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY); }; const handleHudDragPointerEnd = (event: React.PointerEvent) => { dragLastPositionRef.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } setHudMouseEventsEnabled(false); }; return ( // Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh): // 100vw can exceed the inner layout width when scrollbars affect the // viewport (notably on Windows), causing a horizontal scrollbar once the // recording toolbar widened (issue #305).
{ const target = event.target as HTMLElement | null; const shouldCapture = isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']")); setHudMouseEventsEnabled(shouldCapture); }} onPointerLeave={() => { if (!isLanguageMenuOpen) { setHudMouseEventsEnabled(false); } }} > {systemLocaleSuggestion && (
{t("systemLanguagePrompt.title")}
{t("systemLanguagePrompt.description", { language: suggestedLanguageName, })}
)} {/* Device selectors — fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && (
{/* Mic selector */} {showMicControls && (
setIsMicHovered(true)} onMouseLeave={() => setIsMicHovered(false)} onFocus={() => setIsMicFocused(true)} onBlur={() => setIsMicFocused(false)} style={{ width: micExpanded ? "240px" : "140px", transition: "width 300ms ease" }} >
{!micExpanded && (
{selectedMicLabel}
)} {micExpanded && ( )}
)} {/* Webcam selector */} {showWebcamControls && (
setIsWebcamHovered(true)} onMouseLeave={() => setIsWebcamHovered(false)} onFocus={() => setIsWebcamFocused(true)} onBlur={() => setIsWebcamFocused(false)} style={{ width: webcamExpanded ? "240px" : "140px", transition: "width 300ms ease" }} >
{!webcamExpanded && (
{selectedCameraLabel}
)} {webcamExpanded && (isCameraDevicesLoading ? ( {t("webcam.searching")} ) : cameraDevicesError ? ( {t("webcam.unavailable")} ) : cameraDevices.length === 0 ? ( {t("webcam.noneFound")} ) : ( <> ))} {(!webcamExpanded || cameraDevices.length === 0) && ( )}
)}
)} {/* HUD bar — fixed at bottom center, viewport-relative, never moves */}
setHudMouseEventsEnabled(true)} onPointerDown={() => setHudMouseEventsEnabled(true)} onMouseEnter={() => setHudMouseEventsEnabled(true)} onMouseLeave={() => { if (!isLanguageMenuOpen) { setHudMouseEventsEnabled(false); } }} > {/* Drag handle */}
{getIcon("drag", "text-white/30")}
{/* Source selector */} {/* Audio controls group */}
{supportsCursorModeToggle && ( )}
{/* Record/Stop group */} {recording && (
{canPauseRecording && ( )}
)} {!recording && ( <> {/* Open video file */} {/* Open project */} )} {/* Right sidebar controls */}
{isLanguageMenuOpen ? createPortal(
event.stopPropagation()} onPointerEnter={() => setHudMouseEventsEnabled(true)} onPointerMove={() => setHudMouseEventsEnabled(true)} onWheel={(event) => { setHudMouseEventsEnabled(true); event.stopPropagation(); }} > {availableLocales.map((loc) => ( ))}
, document.body, ) : null} {/* Window controls */}
); }