mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user