From 7618187a494931bf255a4ecbdad7e1ea8459f85b Mon Sep 17 00:00:00 2001 From: Alan Trebugeais Date: Wed, 6 May 2026 19:52:21 +0200 Subject: [PATCH] add: SourceSelector part 1. not refactoring (next commit!) --- src/components/launch/LaunchWindow.tsx | 96 +---- src/components/launch/SourceSelector.tsx | 504 +++++++++-------------- 2 files changed, 205 insertions(+), 395 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0fe66d61..5adc58bc 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,5 +1,4 @@ import { - AppWindow, CaretUp as ChevronUp, Eye, EyeSlash as EyeOff, @@ -46,6 +45,7 @@ import { } from "./hudMousePassthrough"; import styles from "./LaunchWindow.module.css"; import { RecordingControls } from "./RecordingControls"; +import { SourceSelector } from "./SourceSelector"; interface DesktopSource { id: string; @@ -1146,82 +1146,24 @@ export function LaunchWindow() { /> ) : null} - {activeDropdown !== "none" && ( -
- {activeDropdown === "sources" && ( - <> - {sourcesLoading ? ( -
-
-
- ) : ( - <> - {screenSources.length > 0 && ( - <> -
- {t("recording.screens")} -
- {screenSources.map((source) => ( - } - selected={ - selectedSource === source.name - } - onClick={() => - handleSourceSelect(source) - } - > - {source.name} - - ))} - - )} - {windowSources.length > 0 && ( - <> -
0 - ? { - marginTop: 4, - } - : undefined - } - > - {t("recording.windows")} -
- {windowSources.map((source) => ( - } - selected={ - selectedSource === source.name - } - onClick={() => - handleSourceSelect(source) - } - > - {source.appName && - source.appName !== source.name - ? `${source.appName} — ${source.name}` - : source.name} - - ))} - - )} - {screenSources.length === 0 && - windowSources.length === 0 && ( -
- {t("recording.noSourcesFound")} -
- )} - - )} - - )} - - {activeDropdown === "mic" && ( + {activeDropdown !== "none" && ( +
+ {activeDropdown === "sources" && ( +
+ setActiveDropdown(open ? "sources" : "none")} + /> +
+ )} + + {activeDropdown === "mic" && ( <>
{t("recording.microphone")} diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 291a85a9..2c62f2b3 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,10 +1,10 @@ -import { type KeyboardEvent as ReactKeyboardEvent, useEffect, useState } from "react"; -import { MdCheck } from "react-icons/md"; -import { useScopedT } from "../../contexts/I18nContext"; -import { Button } from "../ui/button"; -import { Card } from "../ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; -import styles from "./SourceSelector.module.css"; +import * as React from "react"; +import { Monitor, AppWindow } from "@phosphor-icons/react"; +import { useMemo, useCallback, useEffect } from "react"; +import { useScopedT } from "@/contexts/I18nContext"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; interface DesktopSource { id: string; @@ -12,334 +12,202 @@ interface DesktopSource { thumbnail: string | null; display_id: string; appIcon: string | null; - originalName: string; - sourceType: "screen" | "window"; + sourceType?: "screen" | "window"; appName?: string; windowTitle?: string; } -function parseSourceMetadata(source: ProcessedDesktopSource) { - if (source.sourceType === "window" && (source.appName || source.windowTitle)) { - return { - sourceType: "window" as const, - appName: source.appName, - windowTitle: source.windowTitle ?? source.name, - displayName: source.windowTitle ?? source.name, - }; - } - - const sourceType: "screen" | "window" = source.id.startsWith("window:") ? "window" : "screen"; - if (sourceType === "window") { - const [appNamePart, ...windowTitleParts] = source.name.split(" — "); - const appName = appNamePart?.trim() || undefined; - const windowTitle = windowTitleParts.join(" — ").trim() || source.name.trim(); - - return { - sourceType, - appName, - windowTitle, - displayName: windowTitle, - }; - } - - return { - sourceType, - appName: undefined, - windowTitle: undefined, - displayName: source.name, - }; +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; + /** Whether the popover is open */ + open?: boolean; + /** Callback when open state changes */ + onOpenChange?: (open: boolean) => void; } -export function SourceSelector() { +/** + * SourceSelector - A rich source selection component with thumbnails + * Uses Radix UI Popover for positioning and accessibility + */ +export const SourceSelector = React.memo(function SourceSelector({ + screenSources = [], + windowSources = [], + selectedSource = "Screen", + loading = false, + onSourceSelect = () => {}, + onFetchSources = async () => {}, + open = false, + onOpenChange = () => {}, +}: SourceSelectorProps) { const t = useScopedT("launch"); - const [sources, setSources] = useState([]); - const [selectedSource, setSelectedSource] = useState(null); - const [activeTab, setActiveTab] = useState<"screens" | "windows">("screens"); - const [loading, setLoading] = useState(true); + // Fetch sources when popover opens useEffect(() => { - let isMounted = true; - async function fetchSources() { - setLoading(true); - try { - // Step 1: Fetch metadata quickly without thumbnails - const metadataSources = await window.electronAPI.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 160, height: 90 }, - fetchWindowIcons: true, - }); - - if (!isMounted) return; - - const initialSources = metadataSources.map((source) => { - const metadata = parseSourceMetadata(source); - return { - id: source.id, - name: metadata.displayName, - thumbnail: null, - display_id: source.display_id, - appIcon: source.appIcon, - originalName: source.name, - sourceType: metadata.sourceType, - appName: metadata.appName, - windowTitle: metadata.windowTitle, - }; - }); - - setSources(initialSources); - setLoading(false); - - // Step 2: Fetch actual thumbnails in the background - const fullSources = await window.electronAPI.getSources({ - types: ["screen", "window"], - thumbnailSize: { width: 240, height: 135 }, - fetchWindowIcons: true, - }); - - if (!isMounted) return; - - setSources((prev) => - prev.map((s) => { - const full = fullSources.find((f) => f.id === s.id); - return full ? { ...s, thumbnail: full.thumbnail } : s; - }), - ); - } catch (error) { - console.error("Error loading sources:", error); - if (isMounted) setLoading(false); - } + if (open) { + void onFetchSources(); } - fetchSources(); - return () => { - isMounted = false; - }; - }, []); + }, [open, onFetchSources]); - const screenSources = sources.filter((s) => s.id.startsWith("screen:")); - const windowSources = sources.filter((s) => s.id.startsWith("window:")); - - useEffect(() => { - if (loading) { - return; - } - - if (screenSources.length === 0 && windowSources.length > 0) { - setActiveTab("windows"); - return; - } - - if (windowSources.length === 0 && screenSources.length > 0) { - setActiveTab("screens"); - } - }, [loading, screenSources.length, windowSources.length]); - - const handleSourceSelect = (source: DesktopSource) => setSelectedSource(source); - const handleSourceKeyDown = ( - event: ReactKeyboardEvent, - source: DesktopSource, - ) => { - if (event.key !== "Enter" && event.key !== " ") { - return; - } - - event.preventDefault(); - handleSourceSelect(source); - }; - - const handleShare = async () => { - if (selectedSource) await window.electronAPI.selectSource(selectedSource); - }; - - if (loading) { - return ( -
( + + ), + [onSourceSelect, t], + ); + + // Memoized section for screens + const screenSection = useMemo(() => { + if (screenSources.length === 0) return null; + + return ( +
+
+ {t("recording.screens")} +
+
+ {screenSources.map((source) => ( + + ))}
); - } + }, [screenSources, selectedSource, SourceItem, t]); - return ( -
-
- setActiveTab(value as "screens" | "windows")} - > - - - {t("sourceSelector.screens")} ({screenSources.length}) - - - {t("sourceSelector.windows")} ({windowSources.length}) - - -
- -
- {screenSources.length === 0 && ( -
- {t("sourceSelector.noScreensAvailable")} -
- )} - {screenSources.map((source) => { - const isSelected = selectedSource?.id === source.id; + // Memoized section for windows + const windowSection = useMemo(() => { + if (windowSources.length === 0) return null; - return ( - handleSourceSelect(source)} - onKeyDown={(event) => - handleSourceKeyDown(event, source) - } - role="button" - tabIndex={0} - aria-pressed={isSelected} - > -
-
- {source.name} - {isSelected && ( -
-
- -
-
- )} -
-
- {source.name} -
-
-
- ); - })} -
-
- -

- {t("sourceSelector.windowsNote")} -

-
- {windowSources.length === 0 && ( -
- {t("sourceSelector.noWindowsAvailable")} -
- )} - {windowSources.map((source) => { - const isSelected = selectedSource?.id === source.id; - - return ( - handleSourceSelect(source)} - onKeyDown={(event) => - handleSourceKeyDown(event, source) - } - role="button" - tabIndex={0} - aria-pressed={isSelected} - > -
-
- {source.thumbnail ? ( - {source.name} - ) : ( -
- {source.appIcon ? ( - App icon - ) : ( -
- )} -
- {t( - "sourceSelector.windowPlaceholder", - )} -
-
- )} - {isSelected && ( -
-
- -
-
- )} -
-
- {source.appIcon && ( - App icon - )} -
- {source.name} -
-
-
- - ); - })} -
- -
- -
-
-
- - + return ( +
+
+ {t("recording.windows")} +
+
+ {windowSources.map((source) => ( + + ))}
-
+ ); + }, [windowSources, selectedSource, SourceItem, t]); + + return ( + + + + + + {loading ? ( +
+
+
+ ) : ( +
+ {screenSection || windowSection ? ( + <> + {screenSection} + {windowSection} + + ) : ( +
+ {t("recording.noSourcesFound")} +
+ )} +
+ )} + + ); -} +}); + +SourceSelector.displayName = "SourceSelector";