add: Hooks in order to further reduce launchwindow logic

LaunchWindow could still be further reduced, but honestly from 1600 lines to 650  lines is a win
This commit is contained in:
Alan Trebugeais
2026-05-06 23:54:15 +02:00
parent ff2759448a
commit 7f564c90bf
14 changed files with 1381 additions and 1144 deletions
-470
View File
@@ -1,470 +0,0 @@
import {
Eye,
EyeSlash as EyeOff,
FolderOpen,
Translate as Languages,
Microphone as Mic,
MicrophoneSlash as MicOff,
Timer,
VideoCamera as Video,
VideoCamera as VideoIcon,
VideoCameraSlash as VideoOff,
SpeakerHigh as Volume2,
SpeakerX as VolumeX,
ArrowClockwise as RefreshCw,
} from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter";
import { useI18n } from "@/contexts/I18nContext";
import type { AppLocale } from "@/i18n/config";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useScopedT } from "../../contexts/I18nContext";
import { AudioLevelMeter } from "../ui/audio-level-meter";
import "./launchTheme.css";
import { SourceSelector } from "./SourceSelector";
import styles from "./LaunchWindow.module.css";
const LOCALE_LABELS: Record<string, string> = {
en: "English",
es: "Español",
fr: "Français",
nl: "Nederlands",
ko: "한국어",
"pt-BR": "Português",
"zh-CN": "簡體中文",
"zh-TW": "繁體中文",
};
const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
sourceType?: "screen" | "window";
appName?: string;
windowTitle?: string;
}
interface DeviceOption {
deviceId: string;
label: string;
}
function DropdownItem({
onClick,
selected,
icon,
children,
trailing,
}: {
onClick: () => void;
selected?: boolean;
icon: ReactNode;
children: ReactNode;
trailing?: ReactNode;
}) {
return (
<button
type="button"
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{children}</span>
{trailing}
</button>
);
}
function MicDeviceRow({
device,
selected,
onSelect,
}: {
device: DeviceOption;
selected: boolean;
onSelect: () => void;
}) {
const { level } = useAudioLevelMeter({
enabled: true,
deviceId: device.deviceId,
});
return (
<button
type="button"
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
onClick={onSelect}
>
<span className="shrink-0">{selected ? <Mic size={16} /> : <MicOff size={16} />}</span>
<span className="truncate flex-1">{device.label}</span>
<AudioLevelMeter level={level} className="w-16 shrink-0" />
</button>
);
}
function HudPopover({
open,
onOpenChange,
trigger,
children,
align = "center",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
children: ReactNode;
align?: "start" | "center" | "end";
}) {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`}
unstyled
side="bottom"
align={align}
sideOffset={8}
avoidCollisions
collisionPadding={10}
usePortal={false}
>
{children}
</PopoverContent>
</Popover>
);
}
export function SourcePopover({
open,
onOpenChange,
trigger,
screenSources,
windowSources,
selectedSource,
loading,
onSourceSelect,
onFetchSources,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
screenSources: DesktopSource[];
windowSources: DesktopSource[];
selectedSource: string;
loading: boolean;
onSourceSelect: (source: DesktopSource) => void;
onFetchSources: () => Promise<void>;
}) {
return (
<SourceSelector
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={loading}
onSourceSelect={onSourceSelect}
onFetchSources={onFetchSources}
open={open}
onOpenChange={onOpenChange}
>
{trigger}
</SourceSelector>
);
}
export function MicPopover({
open,
onOpenChange,
trigger,
systemAudioEnabled,
onToggleSystemAudio,
microphoneEnabled,
onDisableMicrophone,
devices,
microphoneDeviceId,
selectedDeviceId,
onSelectDevice,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
systemAudioEnabled: boolean;
onToggleSystemAudio: () => void;
microphoneEnabled: boolean;
onDisableMicrophone: () => void;
devices: DeviceOption[];
microphoneDeviceId?: string;
selectedDeviceId?: string;
onSelectDevice: (deviceId: string) => void;
}) {
const t = useScopedT("launch");
return (
<HudPopover open={open} onOpenChange={onOpenChange} trigger={trigger} align="start">
<div className={styles.ddLabel}>{t("recording.microphone")}</div>
<DropdownItem
icon={systemAudioEnabled ? <Volume2 size={16} /> : <VolumeX size={16} />}
selected={systemAudioEnabled}
onClick={onToggleSystemAudio}
>
{systemAudioEnabled
? t("recording.disableSystemAudio")
: t("recording.enableSystemAudio")}
</DropdownItem>
{microphoneEnabled && (
<DropdownItem icon={<MicOff size={16} />} onClick={onDisableMicrophone}>
{t("recording.turnOffMicrophone")}
</DropdownItem>
)}
{!microphoneEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
{t("recording.selectMicToEnable")}
</div>
)}
{devices.map((device) => (
<MicDeviceRow
key={device.deviceId}
device={device}
selected={
microphoneEnabled &&
(microphoneDeviceId === device.deviceId || selectedDeviceId === device.deviceId)
}
onSelect={() => onSelectDevice(device.deviceId)}
/>
))}
{devices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noMicrophonesFound")}
</div>
)}
</HudPopover>
);
}
export function WebcamPopover({
open,
onOpenChange,
trigger,
webcamEnabled,
onDisableWebcam,
canToggleFloatingPreview,
showFloatingWebcamPreview,
onToggleFloatingPreview,
showWebcamControls,
setWebcamPreviewNode,
videoDevices,
webcamDeviceId,
selectedVideoDeviceId,
onSelectVideoDevice,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
webcamEnabled: boolean;
onDisableWebcam: () => void;
canToggleFloatingPreview: boolean;
showFloatingWebcamPreview: boolean;
onToggleFloatingPreview: () => void;
showWebcamControls: boolean;
setWebcamPreviewNode: (node: HTMLVideoElement | null) => void;
videoDevices: DeviceOption[];
webcamDeviceId?: string;
selectedVideoDeviceId?: string;
onSelectVideoDevice: (deviceId: string) => void;
}) {
const t = useScopedT("launch");
return (
<HudPopover open={open} onOpenChange={onOpenChange} trigger={trigger} align="center">
<div className={styles.ddLabel}>{t("recording.webcam")}</div>
{webcamEnabled && (
<>
<DropdownItem icon={<VideoOff size={16} />} onClick={onDisableWebcam}>
{t("recording.turnOffWebcam")}
</DropdownItem>
{canToggleFloatingPreview ? (
<DropdownItem
icon={showFloatingWebcamPreview ? <EyeOff size={16} /> : <Eye size={16} />}
selected={showFloatingWebcamPreview}
onClick={onToggleFloatingPreview}
>
{showFloatingWebcamPreview
? t("recording.hideFloatingWebcamPreview")
: t("recording.showFloatingWebcamPreview")}
</DropdownItem>
) : null}
</>
)}
{!webcamEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">
{t("recording.selectWebcamToEnable")}
</div>
)}
{showWebcamControls && (
<div className="flex justify-center px-3 py-2">
<div className="h-24 w-24 overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10">
<video
ref={setWebcamPreviewNode}
className="h-full w-full object-cover"
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
</div>
)}
{videoDevices.map((device) => (
<DropdownItem
key={device.deviceId}
icon={
webcamEnabled &&
(webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? (
<Video size={16} />
) : (
<VideoOff size={16} />
)
}
selected={
webcamEnabled &&
(webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId)
}
onClick={() => onSelectVideoDevice(device.deviceId)}
>
{device.label}
</DropdownItem>
))}
{videoDevices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">
{t("recording.noWebcamsFound")}
</div>
)}
</HudPopover>
);
}
export function CountdownPopover({
open,
onOpenChange,
trigger,
countdownDelay,
onSelectDelay,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
countdownDelay: number;
onSelectDelay: (delay: number) => void;
}) {
const t = useScopedT("launch");
return (
<HudPopover open={open} onOpenChange={onOpenChange} trigger={trigger} align="center">
<div className={styles.ddLabel}>{t("recording.countdownDelay")}</div>
{COUNTDOWN_OPTIONS.map((delay) => (
<DropdownItem
key={delay}
icon={<Timer size={16} />}
selected={countdownDelay === delay}
onClick={() => onSelectDelay(delay)}
>
{delay === 0 ? t("recording.noDelay") : `${delay}s`}
</DropdownItem>
))}
</HudPopover>
);
}
export function MorePopover({
open,
onOpenChange,
trigger,
supportsHudCaptureProtection,
hideHudFromCapture,
onToggleHudCaptureProtection,
onChooseRecordingsDirectory,
onOpenVideoFile,
onOpenProjectBrowser,
showDevUpdatePreview,
onPreviewUpdateUi,
appVersion,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
supportsHudCaptureProtection: boolean;
hideHudFromCapture: boolean;
onToggleHudCaptureProtection: () => void;
onChooseRecordingsDirectory: () => void;
onOpenVideoFile: () => void;
onOpenProjectBrowser: () => void;
showDevUpdatePreview: boolean;
onPreviewUpdateUi: () => void;
appVersion: string | null;
}) {
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
return (
<HudPopover open={open} onOpenChange={onOpenChange} trigger={trigger} align="end">
{supportsHudCaptureProtection && (
<DropdownItem
icon={hideHudFromCapture ? <EyeOff size={16} /> : <Eye size={16} />}
selected={hideHudFromCapture}
onClick={onToggleHudCaptureProtection}
>
{hideHudFromCapture
? t("recording.hideHudFromVideo")
: t("recording.showHudInVideo")}
</DropdownItem>
)}
<DropdownItem icon={<FolderOpen size={16} />} onClick={onChooseRecordingsDirectory}>
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem icon={<VideoIcon size={16} />} onClick={onOpenVideoFile}>
{t("recording.openVideoFile")}
</DropdownItem>
<DropdownItem icon={<FolderOpen size={16} />} onClick={onOpenProjectBrowser}>
{t("recording.openProject")}
</DropdownItem>
{showDevUpdatePreview ? (
<DropdownItem icon={<RefreshCw size={16} />} onClick={onPreviewUpdateUi}>
{t("recording.previewUpdateUi", "Preview Update UI")}
</DropdownItem>
) : null}
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
icon={<Languages size={16} />}
selected={locale === code}
onClick={() => {
setLocale(code as AppLocale);
onOpenChange(false);
}}
>
{LOCALE_LABELS[code] ?? code}
</DropdownItem>
))}
{appVersion && (
<div
style={{
marginTop: 8,
padding: "4px 12px",
fontSize: 11,
color: "#6b6b78",
textAlign: "center",
userSelect: "text",
}}
>
v{appVersion}
</div>
)}
</HudPopover>
);
}
+95 -674
View File
@@ -14,52 +14,46 @@ import {
import { AnimatePresence, motion } from "motion/react";
import { RxDragHandleDots2 } from "react-icons/rx";
import { useScopedT } from "../../contexts/I18nContext";
import { useHudBarDrag } from "./hooks/useHudBarDrag";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState";
import { useRecordingTimer } from "./hooks/useRecordingTimer";
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
import { useVideoDevices } from "../../hooks/useVideoDevices";
import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay";
import ProjectBrowserDialog, {
type ProjectLibraryEntry,
} from "../video-editor/ProjectBrowserDialog";
import {
canShowFloatingWebcamPreview,
canToggleFloatingWebcamPreview,
} from "./floatingWebcamPreview";
import {
mergeHudInteractiveBounds,
shouldRestoreHudMousePassthroughAfterDrag,
} from "./hudMousePassthrough";
import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator";
import { CountdownPopover } from "./popovers/CountdownPopover";
import { MicPopover } from "./popovers/MicPopover";
import { MorePopover } from "./popovers/MorePopover";
import { SourcePopover } from "./popovers/SourcePopover";
import type { DesktopSource } from "./popovers/types";
import { WebcamPopover } from "./popovers/WebcamPopover";
import styles from "./LaunchWindow.module.css";
import {
CountdownPopover,
MicPopover,
MorePopover,
SourcePopover,
WebcamPopover,
} from "./LaunchHudPopovers";
import { Separator } from "@/components/ui/separator";
import { Button } from "../ui/button";
import { RecordingControls } from "./RecordingControls";
import { useCallback, useEffect, useRef, useState } from "react";
interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
sourceType?: "screen" | "window";
appName?: string;
windowTitle?: string;
}
const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 };
const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 };
const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV;
export function LaunchWindow() {
return (
<LaunchPopoverCoordinatorProvider>
<LaunchWindowContent />
</LaunchPopoverCoordinatorProvider>
);
}
function LaunchWindowContent() {
const t = useScopedT("launch");
const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator();
const {
recording,
@@ -85,122 +79,62 @@ export function LaunchWindow() {
preparePermissions,
} = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [pausedAt, setPausedAt] = useState<number | null>(null);
const [pausedTotal, setPausedTotal] = useState(0);
const { elapsed, formatTime } = useRecordingTimer(recording, paused);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [, setRecordingsDirectory] = useState<string | null>(null);
const [sourcePopoverOpen, setSourcePopoverOpen] = useState(false);
const [micPopoverOpen, setMicPopoverOpen] = useState(false);
const [webcamPopoverOpen, setWebcamPopoverOpen] = useState(false);
const [countdownPopoverOpen, setCountdownPopoverOpen] = useState(false);
const [morePopoverOpen, setMorePopoverOpen] = useState(false);
const [projectLibraryEntries, setProjectLibraryEntries] = useState<ProjectLibraryEntry[]>([]);
const [projectBrowserOpen, setProjectBrowserOpen] = useState(false);
const [sources, setSources] = useState<DesktopSource[]>([]);
const [sourcesLoading, setSourcesLoading] = useState(false);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true);
const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET);
const [isHudDragging, setIsHudDragging] = useState(false);
const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState<
boolean | null
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const hudContentRef = useRef<HTMLDivElement>(null);
const hudBarRef = useRef<HTMLDivElement>(null);
const hudBarTransformRef = useRef<HTMLDivElement | null>(null);
const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewContainerRef = useRef<HTMLDivElement | null>(null);
const previewStreamRef = useRef<MediaStream | null>(null);
const webcamPreviewDragStartRef = useRef<{
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
initialLeft: number;
initialTop: number;
previewWidth: number;
previewHeight: number;
dragging: boolean;
} | null>(null);
const hudDragStartRef = useRef<
| {
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
initialLeft: number;
initialTop: number;
hudWidth: number;
hudHeight: number;
}
| null
>(null);
const isHudDraggingRef = useRef(false);
const isWebcamPreviewDraggingRef = useRef(false);
const hudDragMoveRafRef = useRef<number | null>(null);
const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
const anyPopoverOpenRef = useRef(false);
const projectBrowserOpenRef = useRef(false);
const anyPopoverOpen =
sourcePopoverOpen ||
micPopoverOpen ||
webcamPopoverOpen ||
countdownPopoverOpen ||
morePopoverOpen;
const anyPopoverOpen = openId !== null;
useEffect(() => {
anyPopoverOpenRef.current = anyPopoverOpen;
}, [anyPopoverOpen]);
useEffect(() => {
if (!openId) {
return;
}
setProjectBrowserOpen(false);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}, [openId]);
useEffect(() => {
projectBrowserOpenRef.current = projectBrowserOpen;
}, [projectBrowserOpen]);
const closeAllPopovers = useCallback(() => {
setSourcePopoverOpen(false);
setMicPopoverOpen(false);
setWebcamPopoverOpen(false);
setCountdownPopoverOpen(false);
setMorePopoverOpen(false);
}, []);
if (openId) {
requestClose(openId);
}
}, [openId, requestClose]);
const showWebcamControls = webcamEnabled && !recording;
const showRecordingWebcamPreview =
webcamEnabled &&
canShowFloatingWebcamPreview(
showFloatingWebcamPreview,
hudOverlayMousePassthroughSupported,
);
const shouldStreamWebcamPreview =
webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen));
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(
microphoneEnabled || micPopoverOpen,
microphoneEnabled || openId === "mic",
microphoneDeviceId,
);
const {
devices: videoDevices,
selectedDeviceId: selectedVideoDeviceId,
setSelectedDeviceId: setSelectedVideoDeviceId,
} = useVideoDevices(webcamEnabled || webcamPopoverOpen);
} = useVideoDevices(webcamEnabled || openId === "webcam");
const {
hudOverlayMousePassthroughSupported,
platform,
appVersion,
hideHudFromCapture,
chooseRecordingsDirectory,
toggleHudCaptureProtection,
} = useLaunchWindowSystemState(preparePermissions);
const supportsHudCaptureProtection = platform !== "linux";
useEffect(() => {
// Tell main process to reveal the HUD only after renderer mounted.
window.electronAPI?.hudOverlayRendererReady?.();
}, []);
useEffect(() => {
if (!selectedDeviceId) {
return;
@@ -215,357 +149,40 @@ export function LaunchWindow() {
}
}, [selectedVideoDeviceId, setWebcamDeviceId]);
useEffect(() => {
if (!webcamEnabled) {
setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET);
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
setShowFloatingWebcamPreview(true);
}
}, [webcamEnabled]);
const {
showFloatingWebcamPreview,
setShowFloatingWebcamPreview,
showRecordingWebcamPreview,
webcamPreviewOffset,
recordingWebcamPreviewContainerRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
handleWebcamPreviewPointerDown,
handleWebcamPreviewPointerMove,
handleWebcamPreviewPointerUp,
setWebcamPreviewNode,
setRecordingWebcamPreviewNode,
} = useWebcamPreviewOverlay({
webcamEnabled,
webcamDeviceId,
showWebcamControls,
webcamPopoverOpen: openId === "webcam",
hudOverlayMousePassthroughSupported,
});
useEffect(() => {
recordingHudOffsetRef.current = recordingHudOffset;
if (!isHudDraggingRef.current && hudBarTransformRef.current) {
hudBarTransformRef.current.style.transform = `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`;
}
}, [recordingHudOffset]);
const handleWebcamPreviewPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
const dragState = webcamPreviewDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
const wasDragging = dragState.dragging;
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (wasDragging) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
};
const handleHudBarPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
isHudDraggingRef.current = true;
setIsHudDragging(true);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
if (!hudBarRef.current) {
return;
}
const hudRect = hudBarRef.current.getBoundingClientRect();
hudDragStartRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: recordingHudOffsetRef.current.x,
originY: recordingHudOffsetRef.current.y,
initialLeft: hudRect.left,
initialTop: hudRect.top,
hudWidth: hudRect.width,
hudHeight: hudRect.height,
};
};
const handleHudBarPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = hudDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
hudDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY };
if (hudDragMoveRafRef.current !== null) {
return;
}
hudDragMoveRafRef.current = requestAnimationFrame(() => {
hudDragMoveRafRef.current = null;
const latestDragState = hudDragStartRef.current;
const pointer = hudDragPendingPointerRef.current;
if (!latestDragState || !pointer) {
return;
}
const deltaX = pointer.clientX - latestDragState.startX;
const deltaY = pointer.clientY - latestDragState.startY;
const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0);
const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0);
const unclampedLeft = latestDragState.initialLeft + deltaX;
const unclampedTop = latestDragState.initialTop + deltaY;
const clampedLeft = Math.min(
Math.max(0, unclampedLeft),
Math.max(0, viewportWidth - latestDragState.hudWidth),
);
const clampedTop = Math.min(
Math.max(0, unclampedTop),
Math.max(0, viewportHeight - latestDragState.hudHeight),
);
const nextOffset = {
x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft),
y: latestDragState.originY + (clampedTop - latestDragState.initialTop),
};
recordingHudOffsetRef.current = nextOffset;
if (hudBarTransformRef.current) {
hudBarTransformRef.current.style.transform = `translate3d(${nextOffset.x}px, ${nextOffset.y}px, 0)`;
}
});
};
const handleHudBarPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const dragState = hudDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
if (hudDragMoveRafRef.current !== null) {
cancelAnimationFrame(hudDragMoveRafRef.current);
hudDragMoveRafRef.current = null;
}
hudDragPendingPointerRef.current = null;
hudDragStartRef.current = null;
const wasDragging = isHudDraggingRef.current;
isHudDraggingRef.current = false;
setIsHudDragging(false);
setRecordingHudOffset({ ...recordingHudOffsetRef.current });
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const hudBounds = mergeHudInteractiveBounds(
[
hudContentRef.current?.getBoundingClientRect(),
hudBarRef.current?.getBoundingClientRect(),
recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(),
].map((bounds) =>
bounds
? {
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
}
: null,
),
);
if (
wasDragging &&
shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)
) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
};
const attachPreviewStreamToNode = useCallback((videoElement: HTMLVideoElement | null) => {
const previewStream = previewStreamRef.current;
if (!videoElement || !previewStream || videoElement.srcObject === previewStream) {
return;
}
videoElement.srcObject = previewStream;
const playPromise = videoElement.play();
if (playPromise) {
playPromise.catch(() => {
// Ignore autoplay interruptions while the preview element mounts.
});
}
}, []);
useEffect(() => {
return () => {
if (hudDragMoveRafRef.current !== null) {
cancelAnimationFrame(hudDragMoveRafRef.current);
}
hudDragMoveRafRef.current = null;
hudDragPendingPointerRef.current = null;
hudDragStartRef.current = null;
};
}, []);
const setWebcamPreviewNode = useCallback(
(node: HTMLVideoElement | null) => {
webcamPreviewRef.current = node;
attachPreviewStreamToNode(node);
},
[attachPreviewStreamToNode],
);
const setRecordingWebcamPreviewNode = useCallback(
(node: HTMLVideoElement | null) => {
recordingWebcamPreviewRef.current = node;
attachPreviewStreamToNode(node);
},
[attachPreviewStreamToNode],
);
useEffect(() => {
let mounted = true;
const startPreview = async () => {
if (!shouldStreamWebcamPreview) {
return;
}
try {
const previewStream = await navigator.mediaDevices.getUserMedia({
video: webcamDeviceId
? {
deviceId: { exact: webcamDeviceId },
width: { ideal: 320 },
height: { ideal: 320 },
frameRate: { ideal: 24, max: 30 },
}
: {
width: { ideal: 320 },
height: { ideal: 320 },
frameRate: { ideal: 24, max: 30 },
},
audio: false,
});
if (!mounted) {
previewStream.getTracks().forEach((track) => track.stop());
return;
}
previewStreamRef.current = previewStream;
attachPreviewStreamToNode(webcamPreviewRef.current);
attachPreviewStreamToNode(recordingWebcamPreviewRef.current);
} catch (error) {
console.warn("Failed to start live webcam preview:", error);
}
};
void startPreview();
return () => {
mounted = false;
const previewNode = webcamPreviewRef.current;
const recordingPreviewNode = recordingWebcamPreviewRef.current;
const previewStream = previewStreamRef.current;
[previewNode, recordingPreviewNode]
.filter((node): node is HTMLVideoElement => Boolean(node))
.forEach((videoElement) => {
videoElement.pause();
videoElement.srcObject = null;
});
previewStream?.getTracks().forEach((track) => track.stop());
if (previewStreamRef.current === previewStream) {
previewStreamRef.current = null;
}
};
}, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) {
setRecordingStart(Date.now());
setPausedTotal(0);
}
if (paused) {
if (!pausedAt) setPausedAt(Date.now());
if (timer) clearInterval(timer);
} else {
if (pausedAt) {
setPausedTotal((prev) => prev + (Date.now() - pausedAt));
setPausedAt(null);
}
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000));
}
}, 1000);
}
} else {
setRecordingStart(null);
setElapsed(0);
setPausedAt(null);
setPausedTotal(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart, paused, pausedAt, pausedTotal]);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
};
const {
recordingHudOffset,
isHudDragging,
hudBarTransformRef,
isHudDraggingRef,
handleHudBarPointerDown,
handleHudBarPointerMove,
handleHudBarPointerUp,
} = useHudBarDrag({
hudContentRef,
hudBarRef,
recordingWebcamPreviewContainerRef,
});
useEffect(() => {
let mounted = true;
@@ -599,132 +216,10 @@ export function LaunchWindow() {
};
}, []);
useEffect(() => {
const load = async () => {
const result = await window.electronAPI.getRecordingsDirectory();
if (result.success) setRecordingsDirectory(result.path);
};
void load();
}, []);
useEffect(() => {
let cancelled = false;
const loadPlatform = async () => {
try {
const nextPlatform = await window.electronAPI.getPlatform();
if (!cancelled) setPlatform(nextPlatform);
} catch (error) {
console.error("Failed to load platform:", error);
}
};
void loadPlatform();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadHudOverlayMousePassthroughSupport = async () => {
try {
const result = await window.electronAPI.getHudOverlayMousePassthroughSupported();
if (!cancelled && result.success) {
setHudOverlayMousePassthroughSupported(result.supported);
}
} catch (error) {
console.error("Failed to load HUD overlay mouse passthrough support:", error);
}
};
void loadHudOverlayMousePassthroughSupport();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void preparePermissions({ startup: true });
}, [preparePermissions]);
useEffect(() => {
let cancelled = false;
const loadVersion = async () => {
try {
const version = await window.electronAPI.getAppVersion();
if (!cancelled) setAppVersion(version);
} catch (error) {
console.error("Failed to load app version:", error);
}
};
void loadVersion();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadHudCaptureProtection = async () => {
try {
const result = await window.electronAPI.getHudOverlayCaptureProtection();
if (!cancelled && result.success) {
setHideHudFromCapture(result.enabled);
}
} catch (error) {
console.error("Failed to load HUD capture protection state:", error);
}
};
void loadHudCaptureProtection();
return () => {
cancelled = true;
};
}, []);
const fetchSources = useCallback(async () => {
if (!window.electronAPI) return;
setSourcesLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 160, height: 90 },
fetchWindowIcons: true,
});
setSources(
rawSources.map((s) => {
const isWindow = s.id.startsWith("window:");
const type = s.sourceType ?? (isWindow ? "window" : "screen");
let displayName = s.name;
let appName = s.appName;
if (isWindow && !appName && s.name.includes(" — ")) {
const parts = s.name.split(" — ");
appName = parts[0]?.trim();
displayName = parts.slice(1).join(" — ").trim() || s.name;
} else if (isWindow && s.windowTitle) {
displayName = s.windowTitle;
}
return {
id: s.id,
name: displayName,
thumbnail: s.thumbnail,
display_id: s.display_id,
appIcon: s.appIcon,
sourceType: type,
appName,
windowTitle: s.windowTitle ?? displayName,
};
}),
);
} catch (error) {
console.error("Failed to fetch sources:", error);
} finally {
setSourcesLoading(false);
}
}, []);
const handleSourceSelect = async (source: DesktopSource) => {
await window.electronAPI.selectSource(source);
setSelectedSource(source.name);
setHasSelectedSource(true);
setSourcePopoverOpen(false);
window.electronAPI.showSourceHighlight?.({
...source,
name: source.appName ? `${source.appName} — ${source.name}` : source.name,
@@ -733,7 +228,6 @@ export function LaunchWindow() {
};
const openVideoFile = async () => {
setMorePopoverOpen(false);
const result = await window.electronAPI.openVideoFilePicker();
if (result.canceled) return;
if (result.success && result.path) {
@@ -778,52 +272,6 @@ export function LaunchWindow() {
}
}, []);
const chooseRecordingsDirectory = async () => {
setMorePopoverOpen(false);
const result = await window.electronAPI.chooseRecordingsDirectory();
if (result.canceled) return;
if (result.success && result.path) setRecordingsDirectory(result.path);
};
const toggleHudCaptureProtection = async () => {
const nextValue = !hideHudFromCapture;
setHideHudFromCapture(nextValue);
try {
const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue);
if (!result.success) {
setHideHudFromCapture(!nextValue);
return;
}
setHideHudFromCapture(result.enabled);
} catch (error) {
console.error("Failed to update HUD capture protection:", error);
setHideHudFromCapture(!nextValue);
}
};
const handlePopoverOpenChange = useCallback(
(which: "sources" | "mic" | "webcam" | "countdown" | "more", open: boolean) => {
if (!open) {
if (which === "sources") setSourcePopoverOpen(false);
if (which === "mic") setMicPopoverOpen(false);
if (which === "webcam") setWebcamPopoverOpen(false);
if (which === "countdown") setCountdownPopoverOpen(false);
if (which === "more") setMorePopoverOpen(false);
return;
}
setProjectBrowserOpen(false);
closeAllPopovers();
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
if (which === "sources") setSourcePopoverOpen(true);
if (which === "mic") setMicPopoverOpen(true);
if (which === "webcam") setWebcamPopoverOpen(true);
if (which === "countdown") setCountdownPopoverOpen(true);
if (which === "more") setMorePopoverOpen(true);
},
[closeAllPopovers],
);
const handleHudMouseLeave = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
@@ -843,12 +291,6 @@ export function LaunchWindow() {
});
}, []);
const screenSources = sources.filter(
(s) => s.sourceType === "screen" || s.id.startsWith("screen:"),
);
const windowSources = sources.filter(
(s) => s.sourceType === "window" || s.id.startsWith("window:"),
);
const hudStateTransition = {
duration: 0.24,
ease: [0.22, 1, 0.36, 1] as const,
@@ -874,21 +316,17 @@ export function LaunchWindow() {
{platform !== "linux" && (
<>
<SourcePopover
open={sourcePopoverOpen}
onOpenChange={(open) => handlePopoverOpenChange("sources", open)}
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={sourcesLoading}
onSourceSelect={(source) => {
void handleSourceSelect(source);
onSourceSelect={handleSourceSelect}
onOpen={() => {
setProjectBrowserOpen(false);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
}}
onFetchSources={fetchSources}
trigger={
<Button
variant="outline"
size="lg"
className={`${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] shrink-0 border-[#2a2a34] bg-[#1a1a22] text-[#eeeef2] hover:border-[#3e3e4c] hover:bg-[#20202a] transition-all ${sourcePopoverOpen ? "border-[#3e3e4c] bg-[#20202a]" : ""}`}
className={`${styles.electronNoDrag} group gap-2 px-3 min-w-0 max-w-[180px] rounded-[11px] font-medium text-[12px] shrink-0 border-[#2a2a34] bg-[#1a1a22] text-[#eeeef2] hover:border-[#3e3e4c] hover:bg-[#20202a] transition-all ${openId === "sources" ? "border-[#3e3e4c] bg-[#20202a]" : ""}`}
title={selectedSource}
>
<Monitor size={16} className="shrink-0" />
@@ -898,7 +336,7 @@ export function LaunchWindow() {
<ChevronUp
size={10}
className={`text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200 ${
sourcePopoverOpen ? "" : "rotate-180"
openId === "sources" ? "" : "rotate-180"
}`}
/>
</Button>
@@ -910,18 +348,11 @@ export function LaunchWindow() {
)}
<MicPopover
open={micPopoverOpen}
onOpenChange={(open) => {
if (recording) return;
handlePopoverOpenChange("mic", open);
}}
disabled={recording}
systemAudioEnabled={systemAudioEnabled}
onToggleSystemAudio={() => setSystemAudioEnabled(!systemAudioEnabled)}
microphoneEnabled={microphoneEnabled}
onDisableMicrophone={() => {
setMicrophoneEnabled(false);
setMicPopoverOpen(false);
}}
onDisableMicrophone={() => setMicrophoneEnabled(false)}
devices={devices}
microphoneDeviceId={microphoneDeviceId}
selectedDeviceId={selectedDeviceId}
@@ -948,16 +379,9 @@ export function LaunchWindow() {
/>
<WebcamPopover
open={webcamPopoverOpen}
onOpenChange={(open) => {
if (recording) return;
handlePopoverOpenChange("webcam", open);
}}
disabled={recording}
webcamEnabled={webcamEnabled}
onDisableWebcam={() => {
setWebcamEnabled(false);
setWebcamPopoverOpen(false);
}}
onDisableWebcam={() => setWebcamEnabled(false)}
canToggleFloatingPreview={canToggleFloatingWebcamPreview(
hudOverlayMousePassthroughSupported,
)}
@@ -993,13 +417,8 @@ export function LaunchWindow() {
/>
<CountdownPopover
open={countdownPopoverOpen}
onOpenChange={(open) => handlePopoverOpenChange("countdown", open)}
countdownDelay={countdownDelay}
onSelectDelay={(delay) => {
setCountdownDelay(delay);
setCountdownPopoverOpen(false);
}}
onSelectDelay={setCountdownDelay}
trigger={
<Button
variant="ghost"
@@ -1021,7 +440,11 @@ export function LaunchWindow() {
onClick={
hasSelectedSource || platform === "linux"
? toggleRecording
: () => handlePopoverOpenChange("sources", true)
: () => {
setProjectBrowserOpen(false);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
requestOpen("sources");
}
}
disabled={countdownActive}
title={t("recording.record")}
@@ -1032,8 +455,6 @@ export function LaunchWindow() {
<Separator orientation="vertical" className="mx-[5px] h-6" />
<MorePopover
open={morePopoverOpen}
onOpenChange={(open) => handlePopoverOpenChange("more", open)}
supportsHudCaptureProtection={supportsHudCaptureProtection}
hideHudFromCapture={hideHudFromCapture}
onToggleHudCaptureProtection={() => {
@@ -1050,7 +471,7 @@ export function LaunchWindow() {
}}
showDevUpdatePreview={SHOW_DEV_UPDATE_PREVIEW}
onPreviewUpdateUi={() => {
setMorePopoverOpen(false);
closeAllPopovers();
void window.electronAPI.previewUpdateToast().catch((error) => {
console.warn("Failed to preview update toast:", error);
});
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent, type RefObject } from "react";
import { mergeHudInteractiveBounds, shouldRestoreHudMousePassthroughAfterDrag } from "@/components/launch/hudMousePassthrough";
const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 };
export function useHudBarDrag({
hudContentRef,
hudBarRef,
recordingWebcamPreviewContainerRef,
}: {
hudContentRef: RefObject<HTMLDivElement>;
hudBarRef: RefObject<HTMLDivElement>;
recordingWebcamPreviewContainerRef: RefObject<HTMLDivElement>;
}) {
const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET);
const [isHudDragging, setIsHudDragging] = useState(false);
const hudBarTransformRef = useRef<HTMLDivElement | null>(null);
const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET);
const hudDragStartRef = useRef<
| {
pointerId: number;
startX: number;
startY: number;
originX: number;
originY: number;
initialLeft: number;
initialTop: number;
hudWidth: number;
hudHeight: number;
}
| null
>(null);
const isHudDraggingRef = useRef(false);
const hudDragMoveRafRef = useRef<number | null>(null);
const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
useEffect(() => {
recordingHudOffsetRef.current = recordingHudOffset;
if (!isHudDraggingRef.current && hudBarTransformRef.current) {
hudBarTransformRef.current.style.transform = `translate3d(${recordingHudOffset.x}px, ${recordingHudOffset.y}px, 0)`;
}
}, [recordingHudOffset]);
const handleHudBarPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) {
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
isHudDraggingRef.current = true;
setIsHudDragging(true);
window.electronAPI?.hudOverlaySetIgnoreMouse?.(false);
if (!hudBarRef.current) {
return;
}
const hudRect = hudBarRef.current.getBoundingClientRect();
hudDragStartRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: recordingHudOffsetRef.current.x,
originY: recordingHudOffsetRef.current.y,
initialLeft: hudRect.left,
initialTop: hudRect.top,
hudWidth: hudRect.width,
hudHeight: hudRect.height,
};
}, [hudBarRef]);
const handleHudBarPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
const dragState = hudDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
hudDragPendingPointerRef.current = { clientX: event.clientX, clientY: event.clientY };
if (hudDragMoveRafRef.current !== null) {
return;
}
hudDragMoveRafRef.current = requestAnimationFrame(() => {
hudDragMoveRafRef.current = null;
const latestDragState = hudDragStartRef.current;
const pointer = hudDragPendingPointerRef.current;
if (!latestDragState || !pointer) {
return;
}
const deltaX = pointer.clientX - latestDragState.startX;
const deltaY = pointer.clientY - latestDragState.startY;
const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0);
const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0);
const unclampedLeft = latestDragState.initialLeft + deltaX;
const unclampedTop = latestDragState.initialTop + deltaY;
const clampedLeft = Math.min(
Math.max(0, unclampedLeft),
Math.max(0, viewportWidth - latestDragState.hudWidth),
);
const clampedTop = Math.min(
Math.max(0, unclampedTop),
Math.max(0, viewportHeight - latestDragState.hudHeight),
);
const nextOffset = {
x: latestDragState.originX + (clampedLeft - latestDragState.initialLeft),
y: latestDragState.originY + (clampedTop - latestDragState.initialTop),
};
recordingHudOffsetRef.current = nextOffset;
if (hudBarTransformRef.current) {
hudBarTransformRef.current.style.transform = `translate3d(${nextOffset.x}px, ${nextOffset.y}px, 0)`;
}
});
}, []);
const handleHudBarPointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
const dragState = hudDragStartRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
if (hudDragMoveRafRef.current !== null) {
cancelAnimationFrame(hudDragMoveRafRef.current);
hudDragMoveRafRef.current = null;
}
hudDragPendingPointerRef.current = null;
hudDragStartRef.current = null;
const wasDragging = isHudDraggingRef.current;
isHudDraggingRef.current = false;
setIsHudDragging(false);
setRecordingHudOffset({ ...recordingHudOffsetRef.current });
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const hudBounds = mergeHudInteractiveBounds(
[
hudContentRef.current?.getBoundingClientRect(),
hudBarRef.current?.getBoundingClientRect(),
recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(),
].map((bounds) =>
bounds
? {
left: bounds.left,
top: bounds.top,
right: bounds.right,
bottom: bounds.bottom,
}
: null,
),
);
if (wasDragging && shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)) {
window.electronAPI?.hudOverlaySetIgnoreMouse?.(true);
}
}, [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef]);
useEffect(() => {
return () => {
if (hudDragMoveRafRef.current !== null) {
cancelAnimationFrame(hudDragMoveRafRef.current);
}
hudDragMoveRafRef.current = null;
hudDragPendingPointerRef.current = null;
hudDragStartRef.current = null;
};
}, []);
return {
recordingHudOffset,
isHudDragging,
hudBarTransformRef,
isHudDraggingRef,
handleHudBarPointerDown,
handleHudBarPointerMove,
handleHudBarPointerUp,
};
}
@@ -0,0 +1,130 @@
import { useCallback, useEffect, useState } from "react";
export function useLaunchWindowSystemState(
preparePermissions: (args: { startup?: boolean }) => Promise<unknown>,
) {
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState<
boolean | null
>(null);
const [platform, setPlatform] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
useEffect(() => {
window.electronAPI?.hudOverlayRendererReady?.();
}, []);
useEffect(() => {
const load = async () => {
const result = await window.electronAPI.getRecordingsDirectory();
if (result.success) setRecordingsDirectory(result.path);
};
void load();
}, []);
useEffect(() => {
let cancelled = false;
const loadPlatform = async () => {
try {
const nextPlatform = await window.electronAPI.getPlatform();
if (!cancelled) setPlatform(nextPlatform);
} catch (error) {
console.error("Failed to load platform:", error);
}
};
void loadPlatform();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadSupport = async () => {
try {
const result = await window.electronAPI.getHudOverlayMousePassthroughSupported();
if (!cancelled && result.success) {
setHudOverlayMousePassthroughSupported(result.supported);
}
} catch (error) {
console.error("Failed to load HUD overlay mouse passthrough support:", error);
}
};
void loadSupport();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void preparePermissions({ startup: true });
}, [preparePermissions]);
useEffect(() => {
let cancelled = false;
const loadVersion = async () => {
try {
const version = await window.electronAPI.getAppVersion();
if (!cancelled) setAppVersion(version);
} catch (error) {
console.error("Failed to load app version:", error);
}
};
void loadVersion();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const loadCaptureProtection = 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 loadCaptureProtection();
return () => {
cancelled = true;
};
}, []);
const chooseRecordingsDirectory = useCallback(async () => {
const result = await window.electronAPI.chooseRecordingsDirectory();
if (result.canceled) return;
if (result.success && result.path) setRecordingsDirectory(result.path);
}, []);
const toggleHudCaptureProtection = useCallback(async () => {
const nextValue = !hideHudFromCapture;
setHideHudFromCapture(nextValue);
try {
const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue);
if (!result.success) {
setHideHudFromCapture(!nextValue);
return;
}
setHideHudFromCapture(result.enabled);
} catch (error) {
console.error("Failed to update HUD capture protection:", error);
setHideHudFromCapture(!nextValue);
}
}, [hideHudFromCapture]);
return {
recordingsDirectory,
hudOverlayMousePassthroughSupported,
platform,
appVersion,
hideHudFromCapture,
setHideHudFromCapture,
chooseRecordingsDirectory,
toggleHudCaptureProtection,
};
}
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from "react";
export function useRecordingTimer(recording: boolean, paused: boolean) {
const [recordingStart, setRecordingStart] = useState<number | null>(null);
const [elapsed, setElapsed] = useState(0);
const [pausedAt, setPausedAt] = useState<number | null>(null);
const [pausedTotal, setPausedTotal] = useState(0);
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
if (recording) {
if (!recordingStart) {
setRecordingStart(Date.now());
setPausedTotal(0);
}
if (paused) {
if (!pausedAt) setPausedAt(Date.now());
if (timer) clearInterval(timer);
} else {
if (pausedAt) {
setPausedTotal((prev) => prev + (Date.now() - pausedAt));
setPausedAt(null);
}
timer = setInterval(() => {
if (recordingStart) {
setElapsed(Math.floor((Date.now() - recordingStart - pausedTotal) / 1000));
}
}, 1000);
}
} else {
setRecordingStart(null);
setElapsed(0);
setPausedAt(null);
setPausedTotal(0);
if (timer) clearInterval(timer);
}
return () => {
if (timer) clearInterval(timer);
};
}, [recording, recordingStart, paused, pausedAt, pausedTotal]);
const formatTime = useMemo(
() => (seconds: number) => {
const m = Math.floor(seconds / 60)
.toString()
.padStart(2, "0");
const s = (seconds % 60).toString().padStart(2, "0");
return `${m}:${s}`;
},
[],
);
return { elapsed, formatTime };
}
@@ -0,0 +1,242 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { canShowFloatingWebcamPreview } from "@/components/launch/floatingWebcamPreview";
const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 };
export function useWebcamPreviewOverlay({
webcamEnabled,
webcamDeviceId,
showWebcamControls,
webcamPopoverOpen,
hudOverlayMousePassthroughSupported,
}: {
webcamEnabled: boolean;
webcamDeviceId?: string;
showWebcamControls: boolean;
webcamPopoverOpen: boolean;
hudOverlayMousePassthroughSupported: boolean | null;
}) {
const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true);
const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET);
const webcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewRef = useRef<HTMLVideoElement | null>(null);
const recordingWebcamPreviewContainerRef = useRef<HTMLDivElement | null>(null);
const previewStreamRef = useRef<MediaStream | null>(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 isWebcamPreviewDraggingRef = useRef(false);
const showRecordingWebcamPreview =
webcamEnabled &&
canShowFloatingWebcamPreview(
showFloatingWebcamPreview,
hudOverlayMousePassthroughSupported,
);
const shouldStreamWebcamPreview =
webcamEnabled && (showRecordingWebcamPreview || (showWebcamControls && webcamPopoverOpen));
useEffect(() => {
if (!webcamEnabled) {
setWebcamPreviewOffset(DEFAULT_WEBCAM_PREVIEW_OFFSET);
webcamPreviewDragStartRef.current = null;
isWebcamPreviewDraggingRef.current = false;
setShowFloatingWebcamPreview(true);
}
}, [webcamEnabled]);
const handleWebcamPreviewPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
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);
},
[webcamPreviewOffset.x, webcamPreviewOffset.y],
);
const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
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 = useCallback((event: PointerEvent<HTMLDivElement>) => {
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]);
return {
showFloatingWebcamPreview,
setShowFloatingWebcamPreview,
webcamPreviewOffset,
recordingWebcamPreviewContainerRef,
isWebcamPreviewDraggingRef,
webcamPreviewDragStartRef,
handleWebcamPreviewPointerDown,
handleWebcamPreviewPointerMove,
handleWebcamPreviewPointerUp,
setWebcamPreviewNode,
setRecordingWebcamPreviewNode,
showRecordingWebcamPreview,
};
}
@@ -0,0 +1,53 @@
import { Timer } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { useScopedT } from "@/contexts/I18nContext";
import styles from "../LaunchWindow.module.css";
import { DropdownItem, HudPopover } from "./PopoverScaffold";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
const POPOVER_ID = "countdown";
const COUNTDOWN_OPTIONS = [0, 3, 5, 10];
export function CountdownPopover({
trigger,
countdownDelay,
onSelectDelay,
}: {
trigger: ReactNode;
countdownDelay: number;
onSelectDelay: (delay: number) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="center"
>
<div className={styles.ddLabel}>{t("recording.countdownDelay")}</div>
{COUNTDOWN_OPTIONS.map((delay) => (
<DropdownItem
key={delay}
icon={<Timer size={16} />}
selected={countdownDelay === delay}
onClick={() => {
onSelectDelay(delay);
requestClose(POPOVER_ID);
}}
>
{delay === 0 ? t("recording.noDelay") : `${delay}s`}
</DropdownItem>
))}
</HudPopover>
);
}
@@ -0,0 +1,48 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
interface LaunchPopoverCoordinatorValue {
openId: string | null;
requestOpen: (id: string) => void;
requestClose: (id: string) => void;
isOpen: (id: string) => boolean;
}
const LaunchPopoverCoordinatorContext = createContext<LaunchPopoverCoordinatorValue | null>(null);
export function LaunchPopoverCoordinatorProvider({ children }: { children: ReactNode }) {
const [openId, setOpenId] = useState<string | null>(null);
const requestOpen = useCallback((id: string) => {
setOpenId(id);
}, []);
const requestClose = useCallback((id: string) => {
setOpenId((currentId) => (currentId === id ? null : currentId));
}, []);
const isOpen = useCallback((id: string) => openId === id, [openId]);
const value = useMemo(
() => ({
openId,
requestOpen,
requestClose,
isOpen,
}),
[isOpen, openId, requestClose, requestOpen],
);
return (
<LaunchPopoverCoordinatorContext.Provider value={value}>
{children}
</LaunchPopoverCoordinatorContext.Provider>
);
}
export function useLaunchPopoverCoordinator() {
const context = useContext(LaunchPopoverCoordinatorContext);
if (!context) {
throw new Error("useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider");
}
return context;
}
@@ -0,0 +1,94 @@
import { MicrophoneSlash as MicOff, SpeakerHigh as Volume2, SpeakerX as VolumeX } from "@phosphor-icons/react";
import { useScopedT } from "@/contexts/I18nContext";
import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import type { DeviceOption } from "./types";
import type { ReactNode } from "react";
import styles from "../LaunchWindow.module.css";
const POPOVER_ID = "mic";
export function MicPopover({
trigger,
disabled,
systemAudioEnabled,
onToggleSystemAudio,
microphoneEnabled,
onDisableMicrophone,
devices,
microphoneDeviceId,
selectedDeviceId,
onSelectDevice,
}: {
trigger: ReactNode;
disabled?: boolean;
systemAudioEnabled: boolean;
onToggleSystemAudio: () => void;
microphoneEnabled: boolean;
onDisableMicrophone: () => void;
devices: DeviceOption[];
microphoneDeviceId?: string;
selectedDeviceId?: string;
onSelectDevice: (deviceId: string) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
if (disabled) {
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="start"
>
<div className={styles.ddLabel}>{t("recording.microphone")}</div>
<DropdownItem
icon={systemAudioEnabled ? <Volume2 size={16} /> : <VolumeX size={16} />}
selected={systemAudioEnabled}
onClick={onToggleSystemAudio}
>
{systemAudioEnabled
? t("recording.disableSystemAudio")
: t("recording.enableSystemAudio")}
</DropdownItem>
{microphoneEnabled && (
<DropdownItem
icon={<MicOff size={16} />}
onClick={() => {
onDisableMicrophone();
requestClose(POPOVER_ID);
}}
>
{t("recording.turnOffMicrophone")}
</DropdownItem>
)}
{!microphoneEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">{t("recording.selectMicToEnable")}</div>
)}
{devices.map((device) => (
<MicDeviceRow
key={device.deviceId}
device={device}
selected={
microphoneEnabled &&
(microphoneDeviceId === device.deviceId || selectedDeviceId === device.deviceId)
}
onSelect={() => onSelectDevice(device.deviceId)}
/>
))}
{devices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">{t("recording.noMicrophonesFound")}</div>
)}
</HudPopover>
);
}
@@ -0,0 +1,153 @@
import {
Eye,
EyeSlash as EyeOff,
FolderOpen,
Translate as Languages,
VideoCamera as VideoIcon,
ArrowClockwise as RefreshCw,
} from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { useI18n } from "@/contexts/I18nContext";
import { useScopedT } from "@/contexts/I18nContext";
import type { AppLocale } from "@/i18n/config";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import styles from "../LaunchWindow.module.css";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import { DropdownItem, HudPopover } from "./PopoverScaffold";
const POPOVER_ID = "more";
const LOCALE_LABELS: Record<string, string> = {
en: "English",
es: "Español",
fr: "Français",
nl: "Nederlands",
ko: "한국어",
"pt-BR": "Português",
"zh-CN": "簡體中文",
"zh-TW": "繁體中文",
};
export function MorePopover({
trigger,
supportsHudCaptureProtection,
hideHudFromCapture,
onToggleHudCaptureProtection,
onChooseRecordingsDirectory,
onOpenVideoFile,
onOpenProjectBrowser,
showDevUpdatePreview,
onPreviewUpdateUi,
appVersion,
}: {
trigger: ReactNode;
supportsHudCaptureProtection: boolean;
hideHudFromCapture: boolean;
onToggleHudCaptureProtection: () => void;
onChooseRecordingsDirectory: () => void;
onOpenVideoFile: () => void;
onOpenProjectBrowser: () => void;
showDevUpdatePreview: boolean;
onPreviewUpdateUi: () => void;
appVersion: string | null;
}) {
const t = useScopedT("launch");
const { locale, setLocale } = useI18n();
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="end"
>
{supportsHudCaptureProtection && (
<DropdownItem
icon={hideHudFromCapture ? <EyeOff size={16} /> : <Eye size={16} />}
selected={hideHudFromCapture}
onClick={onToggleHudCaptureProtection}
>
{hideHudFromCapture
? t("recording.hideHudFromVideo")
: t("recording.showHudInVideo")}
</DropdownItem>
)}
<DropdownItem
icon={<FolderOpen size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onChooseRecordingsDirectory();
}}
>
{t("recording.recordingsFolder")}
</DropdownItem>
<DropdownItem
icon={<VideoIcon size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onOpenVideoFile();
}}
>
{t("recording.openVideoFile")}
</DropdownItem>
<DropdownItem
icon={<FolderOpen size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onOpenProjectBrowser();
}}
>
{t("recording.openProject")}
</DropdownItem>
{showDevUpdatePreview ? (
<DropdownItem
icon={<RefreshCw size={16} />}
onClick={() => {
requestClose(POPOVER_ID);
onPreviewUpdateUi();
}}
>
{t("recording.previewUpdateUi", "Preview Update UI")}
</DropdownItem>
) : null}
<div className={styles.ddLabel} style={{ marginTop: 4 }}>
{t("recording.language")}
</div>
{SUPPORTED_LOCALES.map((code) => (
<DropdownItem
key={code}
icon={<Languages size={16} />}
selected={locale === code}
onClick={() => {
setLocale(code as AppLocale);
requestClose(POPOVER_ID);
}}
>
{LOCALE_LABELS[code] ?? code}
</DropdownItem>
))}
{appVersion && (
<div
style={{
marginTop: 8,
padding: "4px 12px",
fontSize: 11,
color: "#6b6b78",
textAlign: "center",
userSelect: "text",
}}
>
v{appVersion}
</div>
)}
</HudPopover>
);
}
@@ -0,0 +1,93 @@
import { Microphone as Mic, MicrophoneSlash as MicOff } from "@phosphor-icons/react";
import type { ReactNode } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter";
import { AudioLevelMeter } from "@/components/ui/audio-level-meter";
import styles from "../LaunchWindow.module.css";
import "../launchTheme.css";
import type { DeviceOption } from "./types";
export function DropdownItem({
onClick,
selected,
icon,
children,
trailing,
}: {
onClick: () => void;
selected?: boolean;
icon: ReactNode;
children: ReactNode;
trailing?: ReactNode;
}) {
return (
<button
type="button"
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
onClick={onClick}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{children}</span>
{trailing}
</button>
);
}
export function MicDeviceRow({
device,
selected,
onSelect,
}: {
device: DeviceOption;
selected: boolean;
onSelect: () => void;
}) {
const { level } = useAudioLevelMeter({
enabled: true,
deviceId: device.deviceId,
});
return (
<button
type="button"
className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`}
onClick={onSelect}
>
<span className="shrink-0">{selected ? <Mic size={16} /> : <MicOff size={16} />}</span>
<span className="truncate flex-1">{device.label}</span>
<AudioLevelMeter level={level} className="w-16 shrink-0" />
</button>
);
}
export function HudPopover({
open,
onOpenChange,
trigger,
children,
align = "center",
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
trigger: ReactNode;
children: ReactNode;
align?: "start" | "center" | "end";
}) {
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className={`launch-theme ${styles.menuCard} ${styles.electronNoDrag}`}
unstyled
side="bottom"
align={align}
sideOffset={8}
avoidCollisions
collisionPadding={10}
usePortal={false}
>
{children}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,98 @@
import { useCallback, useMemo, type ReactNode, useState } from "react";
import { SourceSelector } from "../SourceSelector";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import type { DesktopSource } from "./types";
const POPOVER_ID = "sources";
export function SourcePopover({
trigger,
selectedSource,
onSourceSelect,
onOpen,
}: {
trigger: ReactNode;
selectedSource: string;
onSourceSelect: (source: DesktopSource) => Promise<void> | void;
onOpen?: () => void;
}) {
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const [sources, setSources] = useState<DesktopSource[]>([]);
const [loading, setLoading] = useState(false);
const open = isOpen(POPOVER_ID);
const fetchSources = useCallback(async () => {
if (!window.electronAPI) return;
setLoading(true);
try {
const rawSources = await window.electronAPI.getSources({
types: ["screen", "window"],
thumbnailSize: { width: 160, height: 90 },
fetchWindowIcons: true,
});
setSources(
rawSources.map((s) => {
const isWindow = s.id.startsWith("window:");
const type = s.sourceType ?? (isWindow ? "window" : "screen");
let displayName = s.name;
let appName = s.appName;
if (isWindow && !appName && s.name.includes(" — ")) {
const parts = s.name.split(" — ");
appName = parts[0]?.trim();
displayName = parts.slice(1).join(" — ").trim() || s.name;
} else if (isWindow && s.windowTitle) {
displayName = s.windowTitle;
}
return {
id: s.id,
name: displayName,
thumbnail: s.thumbnail,
display_id: s.display_id,
appIcon: s.appIcon,
sourceType: type,
appName,
windowTitle: s.windowTitle ?? displayName,
};
}),
);
} catch (error) {
console.error("Failed to fetch sources:", error);
} finally {
setLoading(false);
}
}, []);
const screenSources = useMemo(
() => sources.filter((s) => s.sourceType === "screen" || s.id.startsWith("screen:")),
[sources],
);
const windowSources = useMemo(
() => sources.filter((s) => s.sourceType === "window" || s.id.startsWith("window:")),
[sources],
);
return (
<SourceSelector
screenSources={screenSources}
windowSources={windowSources}
selectedSource={selectedSource}
loading={loading}
onSourceSelect={(source) => {
void onSourceSelect(source);
requestClose(POPOVER_ID);
}}
onFetchSources={fetchSources}
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
onOpen?.();
requestOpen(POPOVER_ID);
}}
>
{trigger}
</SourceSelector>
);
}
@@ -0,0 +1,129 @@
import {
Eye,
EyeSlash as EyeOff,
VideoCamera as Video,
VideoCameraSlash as VideoOff,
} from "@phosphor-icons/react";
import { useScopedT } from "@/contexts/I18nContext";
import { DropdownItem, HudPopover } from "./PopoverScaffold";
import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator";
import type { DeviceOption } from "./types";
import type { ReactNode } from "react";
const POPOVER_ID = "webcam";
export function WebcamPopover({
trigger,
disabled,
webcamEnabled,
onDisableWebcam,
canToggleFloatingPreview,
showFloatingWebcamPreview,
onToggleFloatingPreview,
showWebcamControls,
setWebcamPreviewNode,
videoDevices,
webcamDeviceId,
selectedVideoDeviceId,
onSelectVideoDevice,
}: {
trigger: ReactNode;
disabled?: boolean;
webcamEnabled: boolean;
onDisableWebcam: () => void;
canToggleFloatingPreview: boolean;
showFloatingWebcamPreview: boolean;
onToggleFloatingPreview: () => void;
showWebcamControls: boolean;
setWebcamPreviewNode: (node: HTMLVideoElement | null) => void;
videoDevices: DeviceOption[];
webcamDeviceId?: string;
selectedVideoDeviceId?: string;
onSelectVideoDevice: (deviceId: string) => void;
}) {
const t = useScopedT("launch");
const { isOpen, requestOpen, requestClose } = useLaunchPopoverCoordinator();
const open = isOpen(POPOVER_ID);
return (
<HudPopover
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
requestClose(POPOVER_ID);
return;
}
if (disabled) {
return;
}
requestOpen(POPOVER_ID);
}}
trigger={trigger}
align="center"
>
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-[#6b6b78]">
{t("recording.webcam")}
</div>
{webcamEnabled && (
<>
<DropdownItem icon={<VideoOff size={16} />} onClick={() => {
onDisableWebcam();
requestClose(POPOVER_ID);
}}>
{t("recording.turnOffWebcam")}
</DropdownItem>
{canToggleFloatingPreview ? (
<DropdownItem
icon={showFloatingWebcamPreview ? <EyeOff size={16} /> : <Eye size={16} />}
selected={showFloatingWebcamPreview}
onClick={onToggleFloatingPreview}
>
{showFloatingWebcamPreview
? t("recording.hideFloatingWebcamPreview")
: t("recording.showFloatingWebcamPreview")}
</DropdownItem>
) : null}
</>
)}
{!webcamEnabled && (
<div className="px-3 py-2 text-xs text-[#6b6b78]">{t("recording.selectWebcamToEnable")}</div>
)}
{showWebcamControls && (
<div className="flex justify-center px-3 py-2">
<div className="h-24 w-24 overflow-hidden rounded-2xl bg-white/5 ring-1 ring-white/10">
<video
ref={setWebcamPreviewNode}
className="h-full w-full object-cover"
muted
playsInline
style={{ transform: "scaleX(-1)" }}
/>
</div>
</div>
)}
{videoDevices.map((device) => (
<DropdownItem
key={device.deviceId}
icon={
webcamEnabled &&
(webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? (
<Video size={16} />
) : (
<VideoOff size={16} />
)
}
selected={
webcamEnabled &&
(webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId)
}
onClick={() => onSelectVideoDevice(device.deviceId)}
>
{device.label}
</DropdownItem>
))}
{videoDevices.length === 0 && (
<div className="text-center text-xs text-[#6b6b78] py-4">{t("recording.noWebcamsFound")}</div>
)}
</HudPopover>
);
}
+15
View File
@@ -0,0 +1,15 @@
export interface DesktopSource {
id: string;
name: string;
thumbnail: string | null;
display_id: string;
appIcon: string | null;
sourceType?: "screen" | "window";
appName?: string;
windowTitle?: string;
}
export interface DeviceOption {
deviceId: string;
label: string;
}