import { memo, startTransition, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { type DisplaySurfaceHint, getPresetParams, getScreenShareSettings, PRESET_ORDER, type ScreenSharePreset, updateScreenShareSettings, } from '../lib/screenShareSettings'; import { captureScreenSourceThumbnailBytes, listScreenSources, type ScreenSource, } from '../lib/screenSources'; import { MonitorShareIcon, SpinnerIcon, XIcon } from './icons'; interface Props { open: boolean; onClose: () => void; /** Parent handles the actual share start. `sourceId` is null when the user * clicks "Teilen" without picking a specific source — fallback to the * OS-level getDisplayMedia picker. */ onStart: (opts: { sourceId: string | null; preset: ScreenSharePreset; displaySurface: DisplaySurfaceHint; framerate: number | null; includeAudio: boolean; }) => Promise; } // Discord-style picker. Replaces the old form-field dialog with a thumbnail // grid sourced from the Rust `enumerate_screen_sources` command. Clicking a // thumbnail stashes its Chromium-format id; the parent then attempts a // `chromeMediaSourceId`-constrained getUserMedia call. If WebView2 ignores // the constraint (it may), the fallback OS picker still runs — but at least // the user already saw + chose from a real preview first. export function ScreenSourcePicker({ open, onClose, onStart }: Props) { const { t } = useTranslation(['app']); const initial = getScreenShareSettings(); const [sources, setSources] = useState(null); // Thumbnails are kept in a separate state from the source list so an // arriving thumbnail never creates a new `ScreenSource` object for // unrelated cards — memo compares `thumbnailUrl` by string identity, // so only the one card whose URL changes rerenders. const [thumbnailUrls, setThumbnailUrls] = useState>({}); // All blob URLs we've handed out this session. Revoked on picker close // so the native buffers they point at don't leak across opens. const blobUrlsRef = useRef([]); const [selectedId, setSelectedId] = useState(null); const [preset, setPreset] = useState(initial.preset); const [includeAudio, setIncludeAudio] = useState(initial.includeSystemAudio); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Two-phase load: (1) fast list returns names + placeholders so the grid // paints instantly, (2) capture thumbnails in a bounded worker-pool // using the binary-IPC variant. Arriving bytes are wrapped in a Blob // and exposed via URL.createObjectURL — no base64 on either side, // which is the single biggest main-thread win compared to the old // JSON-of-base64 flow. Combined with rAF-batched state updates, the // picker stays responsive even on 20+ source enumerations. useEffect(() => { if (!open) { // Revoke blob URLs created during the last session so native // buffers don't linger after close. for (const url of blobUrlsRef.current) URL.revokeObjectURL(url); blobUrlsRef.current = []; setSources(null); setThumbnailUrls({}); setSelectedId(null); setError(null); return; } let cancelled = false; // Coalesce thumbnail arrivals within a single animation frame into // one setState — cuts re-renders from O(N) to O(frames) during the // initial fan-in and prevents consecutive 10-30 ms long tasks from // stacking in one frame. // // Previously `batch` was aliased to `pendingUrls` and then we cleared // pendingUrls via `delete` — which emptied batch too (same reference) // and every flush ended up spreading nothing into the state. Clone // first, then clear, so the batch keeps its entries. let pendingUrls: Record = {}; let rafScheduled = false; const flush = () => { rafScheduled = false; const batch = pendingUrls; if (Object.keys(batch).length === 0) return; pendingUrls = {}; startTransition(() => { setThumbnailUrls((prev) => ({ ...prev, ...batch })); }); }; const queueUrl = (id: string, url: string) => { pendingUrls[id] = url; if (!rafScheduled) { rafScheduled = true; requestAnimationFrame(flush); } }; // Concurrency 2: Windows GDI BitBlt / PrintWindow contends for the // desktop compositor, so 4+ parallel captures stutter the whole Tauri // window. 2 in parallel keeps the compositor breathing. const CONCURRENCY = 2; void (async () => { const list = await listScreenSources(); if (cancelled) return; setSources(list); const queue = [...list]; const pickOne = (src: typeof list[number]) => { void (async () => { const blob = await captureScreenSourceThumbnailBytes(src.id); if (cancelled) { // Edge case: picker closed while this request was in flight. // blob may still exist; nothing holds a URL to it, so it GCs. return; } if (blob) { const url = URL.createObjectURL(blob); blobUrlsRef.current.push(url); queueUrl(src.id, url); } const nextSrc = queue.shift(); if (nextSrc) pickOne(nextSrc); })(); }; for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) { const s = queue.shift(); if (s) pickOne(s); } })(); return () => { cancelled = true; }; }, [open]); if (!open) return null; const screens = sources?.filter((s) => s.kind === 'screen') ?? []; const windows = sources?.filter((s) => s.kind === 'window') ?? []; const hasAny = (sources?.length ?? 0) > 0; async function handleStart(): Promise { setBusy(true); setError(null); try { // Persist user's audio + preset choice so subsequent shares start with // the same prefs when they skip the picker. The picker itself stays // as the entry for future starts (right-click on share button also // opens it — see InCallPanel wiring). updateScreenShareSettings({ preset, includeSystemAudio: includeAudio }); // Infer a displaySurface hint from the selection so the fallback OS // picker jumps to the right tab when our direct-publish path is // rejected by WebView2. const selected = sources?.find((s) => s.id === selectedId) ?? null; const hint: DisplaySurfaceHint = selected?.kind === 'screen' ? 'monitor' : selected?.kind === 'window' ? 'window' : null; await onStart({ sourceId: selected?.id ?? null, preset, displaySurface: hint, framerate: null, includeAudio, }); onClose(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'screenshare failed'); } finally { setBusy(false); } } return (
e.stopPropagation()} className="flex max-h-[88vh] w-full max-w-[860px] flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl" >

{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}

{sources === null ? (
{t('app:call.share_loading', { defaultValue: 'Lade Quellen…' })}
) : !hasAny ? (
{t('app:call.share_no_sources', { defaultValue: 'Keine Quellen-Previews verfügbar. Klick auf „Teilen" öffnet den System-Picker.', })}
) : (
{screens.length > 0 && ( )} {windows.length > 0 && ( )}
)}
{error && (

{error}

)}
); } function SourceSection({ title, sources, thumbnailUrls, selectedId, onSelect, }: { title: string; sources: ScreenSource[]; thumbnailUrls: Record; selectedId: string | null; onSelect: (id: string) => void; }) { return (

{title}

{sources.map((src) => ( ))}
); } // Memoized so a thumbnail arriving for card B doesn't re-render card A. // Keeps re-render work proportional to the number of updates instead of // "whole grid on every update" — which was the main reason scrolling felt // frozen during the initial thumbnail fan-in. // // The parent passes `onSelect(id)` rather than an inline `onClick`-arrow // so the callback reference stays stable across renders; otherwise // React.memo would always see a fresh function prop and re-render every // card on every parent update. const SourceCard = memo(function SourceCard({ source, thumbnailUrl, selected, onSelect, }: { source: ScreenSource; thumbnailUrl: string | null; selected: boolean; onSelect: (id: string) => void; }) { return ( ); });