mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-10-02 10:37:47 +00:00
350 lines
10 KiB
TypeScript
350 lines
10 KiB
TypeScript
import { ToggleButton } from "@heroui/react";
|
|
import { AppWindowIcon, CaretUpIcon, MonitorIcon } from "@phosphor-icons/react";
|
|
import * as React from "react";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { useScopedT } from "@/contexts/I18nContext";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
type DesktopSource,
|
|
isScreenSource,
|
|
isWindowSource,
|
|
mapRawSource,
|
|
} from "./popovers/launchPopoverTypes";
|
|
import "./launchTheme.css";
|
|
import "./SourceSelector.css";
|
|
import { useHudInteraction } from "./contexts/HudInteractionContext";
|
|
import { MarqueeText } from "./MarqueeText";
|
|
|
|
interface SourceSelectorProps {
|
|
/** List of available screen sources */
|
|
screenSources?: DesktopSource[];
|
|
/** List of available window sources */
|
|
windowSources?: DesktopSource[];
|
|
/** Currently selected source name */
|
|
selectedSource?: string;
|
|
/** Loading state */
|
|
loading?: boolean;
|
|
/** Callback when a source is selected */
|
|
onSourceSelect?: (source: DesktopSource) => void;
|
|
/** Callback to fetch sources */
|
|
onFetchSources?: () => Promise<void>;
|
|
/** Whether the popover is open */
|
|
open?: boolean;
|
|
/** Callback when open state changes */
|
|
onOpenChange?: (open: boolean) => void;
|
|
/** Optional custom trigger element */
|
|
children?: React.ReactNode;
|
|
}
|
|
|
|
/**
|
|
* SourceSelectorContent - The actual list of sources
|
|
*/
|
|
export const SourceSelectorContent = ({
|
|
screenSources = [],
|
|
windowSources = [],
|
|
selectedSource = "Screen",
|
|
loading = false,
|
|
onSourceSelect = () => undefined,
|
|
}: Pick<
|
|
SourceSelectorProps,
|
|
"screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect"
|
|
>) => {
|
|
const t = useScopedT("launch");
|
|
const renderSourceItem = (source: DesktopSource, index: number) => {
|
|
const isSelected = selectedSource === source.name;
|
|
return (
|
|
<ToggleButton
|
|
variant="ghost"
|
|
isSelected={isSelected}
|
|
key={`${source.id}-${index}`}
|
|
className={cn(
|
|
"source-selector-item group min-h-[46px] w-full px-3 py-2.5 text-left flex items-center justify-start gap-3",
|
|
isSelected && "source-selector-item-selected",
|
|
)}
|
|
onClick={() => onSourceSelect(source)}
|
|
>
|
|
<div className="relative flex-shrink-0">
|
|
{source.thumbnail ? (
|
|
<img
|
|
src={source.thumbnail}
|
|
alt=""
|
|
className="w-12 h-8 rounded-[8px] object-cover bg-black/50"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).style.display = "none";
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="source-selector-thumb-fallback w-12 h-8 rounded-[8px] flex items-center justify-center">
|
|
{source.sourceType === "window" ? (
|
|
<AppWindowIcon className="w-5 h-5 source-selector-muted" />
|
|
) : (
|
|
<MonitorIcon className="w-5 h-5 source-selector-muted" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 flex flex-col items-start text-left">
|
|
<div className="text-sm font-medium source-selector-text w-full">
|
|
<MarqueeText text={source.windowTitle || source.name} />
|
|
</div>
|
|
<div className="text-xs source-selector-subtle truncate w-full text-left">
|
|
{source.sourceType === "screen"
|
|
? t("recording.screen")
|
|
: t("recording.window")}
|
|
</div>
|
|
</div>
|
|
</ToggleButton>
|
|
);
|
|
};
|
|
|
|
const hasAnySources = screenSources.length > 0 || windowSources.length > 0;
|
|
|
|
if (loading && !hasAnySources) {
|
|
return (
|
|
<div className="flex items-center justify-center py-8">
|
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 source-selector-accent-border" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="max-h-[320px] overflow-y-auto overflow-x-hidden p-2 source-selector-scroll">
|
|
{hasAnySources ? (
|
|
<>
|
|
{screenSources.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] source-selector-label flex items-center gap-2">
|
|
{t("recording.screens")}
|
|
<span
|
|
className={cn(
|
|
"normal-case tracking-normal text-[10px] source-selector-muted transition-opacity duration-150",
|
|
loading ? "opacity-100" : "opacity-0",
|
|
)}
|
|
>
|
|
{t("common.loading", "Refreshing...")}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{screenSources.map((source, index) =>
|
|
renderSourceItem(source, index),
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{windowSources.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] source-selector-label">
|
|
{t("recording.windows")}
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{windowSources.map((source, index) =>
|
|
renderSourceItem(source, index),
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<div className="text-center py-8 text-sm source-selector-muted">
|
|
{t("recording.noSourcesFound")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* SourceSelector - A rich source selection component with thumbnails
|
|
* Uses the shared HeroUI popover for positioning and accessibility
|
|
*/
|
|
export const SourceSelector = React.memo(function SourceSelector({
|
|
screenSources: propsScreenSources,
|
|
windowSources: propsWindowSources,
|
|
selectedSource: propsSelectedSource,
|
|
loading: propsLoading,
|
|
onSourceSelect: propsOnSourceSelect,
|
|
onFetchSources: propsOnFetchSources,
|
|
open: propsOpen,
|
|
onOpenChange: propsOnOpenChange,
|
|
children,
|
|
}: SourceSelectorProps) {
|
|
// Internal state for standalone/uncontrolled use
|
|
const [internalOpen, setInternalOpen] = useState(false);
|
|
const [internalSources, setInternalSources] = useState<DesktopSource[]>([]);
|
|
const [internalLoading, setInternalLoading] = useState(false);
|
|
const [internalSelectedSource, setInternalSelectedSource] = useState("Screen");
|
|
|
|
// Determine if we should use internal or external state/logic
|
|
const isAutonomous = propsOpen === undefined;
|
|
const open = propsOpen ?? internalOpen;
|
|
const onOpenChange = propsOnOpenChange ?? setInternalOpen;
|
|
const loading = propsLoading ?? internalLoading;
|
|
const selectedSource = propsSelectedSource ?? internalSelectedSource;
|
|
|
|
// Default fetching logic
|
|
const defaultFetchSources = useCallback(async () => {
|
|
if (!window.electronAPI) return;
|
|
setInternalLoading(true);
|
|
try {
|
|
const rawSources = await window.electronAPI.getSources({
|
|
types: ["screen", "window"],
|
|
thumbnailSize: { width: 160, height: 90 },
|
|
fetchWindowIcons: true,
|
|
});
|
|
setInternalSources(rawSources.map((s) => mapRawSource(s as DesktopSource)));
|
|
} catch (error) {
|
|
console.error("Failed to fetch sources:", error);
|
|
} finally {
|
|
setInternalLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const onFetchSources = propsOnFetchSources ?? defaultFetchSources;
|
|
|
|
// Default selection logic
|
|
const onSourceSelect = useCallback(
|
|
async (source: DesktopSource) => {
|
|
if (propsOnSourceSelect) {
|
|
propsOnSourceSelect(source);
|
|
return;
|
|
}
|
|
if (!window.electronAPI) return;
|
|
try {
|
|
const result = await window.electronAPI.selectSource(source);
|
|
if (result) {
|
|
setInternalSelectedSource(source.name);
|
|
await window.electronAPI.showSourceHighlight?.(source);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to select source:", error);
|
|
}
|
|
},
|
|
[propsOnSourceSelect],
|
|
);
|
|
|
|
// Split sources for internal use
|
|
const internalScreenSources = useMemo(
|
|
() => internalSources.filter(isScreenSource),
|
|
[internalSources],
|
|
);
|
|
const internalWindowSources = useMemo(
|
|
() => internalSources.filter(isWindowSource),
|
|
[internalSources],
|
|
);
|
|
|
|
const screenSources = propsScreenSources ?? internalScreenSources;
|
|
const windowSources = propsWindowSources ?? internalWindowSources;
|
|
|
|
const hasPrefetchedRef = useRef(false);
|
|
const fetchInFlightRef = useRef(false);
|
|
const lastFetchedAtRef = useRef(0);
|
|
|
|
const fetchSourcesOnce = useCallback(
|
|
async (allowRecentSkip: boolean) => {
|
|
if (fetchInFlightRef.current) {
|
|
return;
|
|
}
|
|
if (allowRecentSkip && Date.now() - lastFetchedAtRef.current < 750) {
|
|
return;
|
|
}
|
|
fetchInFlightRef.current = true;
|
|
try {
|
|
await onFetchSources();
|
|
lastFetchedAtRef.current = Date.now();
|
|
} finally {
|
|
fetchInFlightRef.current = false;
|
|
}
|
|
},
|
|
[onFetchSources],
|
|
);
|
|
|
|
const prefetchSources = React.useCallback(() => {
|
|
if (hasPrefetchedRef.current) {
|
|
return;
|
|
}
|
|
hasPrefetchedRef.current = true;
|
|
void fetchSourcesOnce(false);
|
|
}, [fetchSourcesOnce]);
|
|
|
|
// Fetch sources when popover opens
|
|
useEffect(() => {
|
|
if (open) {
|
|
void fetchSourcesOnce(true);
|
|
}
|
|
}, [open, fetchSourcesOnce]);
|
|
|
|
// In autonomous mode, we might want to start open
|
|
useEffect(() => {
|
|
if (isAutonomous) {
|
|
setInternalOpen(true);
|
|
}
|
|
}, [isAutonomous]);
|
|
|
|
const trigger = children ? (
|
|
React.isValidElement(children) ? (
|
|
React.cloneElement(children as React.ReactElement<React.HTMLAttributes<HTMLElement>>, {
|
|
onPointerEnter: prefetchSources,
|
|
onFocusCapture: prefetchSources,
|
|
})
|
|
) : (
|
|
children
|
|
)
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="lg"
|
|
onPointerEnter={prefetchSources}
|
|
onFocusCapture={prefetchSources}
|
|
className={cn(
|
|
"group gap-2 px-3 min-w-0 max-w-[180px] [ -webkit-app-region:no-drag ] shrink-0",
|
|
)}
|
|
title={selectedSource}
|
|
>
|
|
<MonitorIcon size={16} className="shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<MarqueeText text={selectedSource} />
|
|
</div>
|
|
<CaretUpIcon
|
|
size={10}
|
|
className={cn(
|
|
"text-[#6b6b78] ml-0.5 shrink-0 transition-transform duration-200",
|
|
open ? "" : "rotate-180",
|
|
)}
|
|
/>
|
|
</Button>
|
|
);
|
|
|
|
const { onMouseEnter } = useHudInteraction();
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
|
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
|
<PopoverContent
|
|
className="launch-theme w-80 p-0 source-selector-popover"
|
|
unstyled
|
|
align="start"
|
|
sideOffset={8}
|
|
side="top"
|
|
alignOffset={-8}
|
|
avoidCollisions={true}
|
|
collisionPadding={10}
|
|
usePortal={false}
|
|
onMouseEnter={onMouseEnter}
|
|
>
|
|
<SourceSelectorContent
|
|
screenSources={screenSources}
|
|
windowSources={windowSources}
|
|
selectedSource={selectedSource}
|
|
loading={loading}
|
|
onSourceSelect={onSourceSelect}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
});
|
|
|
|
SourceSelector.displayName = "SourceSelector";
|