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 (
- (
+