import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useCall } from '../context/CallContext'; import { listScreenSources, type ScreenSource } from '../lib/screenSources'; import { type ScreenSharePreset, getScreenShareSettings, updateScreenShareSettings, } from '../lib/screenShareSettings'; import { MonitorShareIcon } from './icons'; interface Props { onClose: () => void; } // Discord trims the picker to two questions: which source, and a couple of // quality knobs. Anything else lives in Settings → Bildschirmfreigabe (it // already does in this app). So the modal here mirrors that — tabs to switch // between screens and windows, thumbnail grid, and a compact footer with // quality + audio. const TABS = [ { id: 'screen' as const, label: 'Bildschirme' }, { id: 'window' as const, label: 'Anwendungen' }, ]; type TabId = (typeof TABS)[number]['id']; // Resolution and framerate are picked independently. The preset table // (screenShareSettings.ts) still provides the per-tier bitrate/dimension // caps, so we map (res, fps) → existing preset and rely on // `framerateOverride` for the non-default framerate combinations // (e.g. 1440p · 30, 4K · 30). type ResChoice = 'auto' | '720p' | '1080p' | '1440p' | '4k'; type FpsChoice = 30 | 60; const RES_PILLS: { id: ResChoice; label: string }[] = [ { id: 'auto', label: 'Auto' }, { id: '720p', label: '720p' }, { id: '1080p', label: '1080p' }, { id: '1440p', label: '1440p' }, { id: '4k', label: '4K' }, ]; const FPS_PILLS: { id: FpsChoice; label: string }[] = [ { id: 30, label: '30 fps' }, { id: 60, label: '60 fps' }, ]; function presetForResFps(res: ResChoice, fps: FpsChoice): ScreenSharePreset { switch (res) { case 'auto': return 'auto'; case '720p': return fps === 60 ? '720p60' : '720p30'; case '1080p': return fps === 60 ? '1080p60' : '1080p30'; case '1440p': return '1440p60'; case '4k': return '4k60'; } } function decomposePreset( p: ScreenSharePreset, framerateOverride: number | null, ): { res: ResChoice; fps: FpsChoice } { const fallback: FpsChoice = framerateOverride === 30 ? 30 : 60; switch (p) { case 'auto': return { res: 'auto', fps: framerateOverride === 60 ? 60 : 30 }; case '720p30': return { res: '720p', fps: 30 }; case '720p60': return { res: '720p', fps: 60 }; case '1080p30': return { res: '1080p', fps: 30 }; case '1080p60': return { res: '1080p', fps: 60 }; case '1440p60': return { res: '1440p', fps: fallback }; case '4k60': return { res: '4k', fps: fallback }; } } const THUMBNAIL_REFRESH_MS = 3500; export function ScreenSharePickerModal({ onClose }: Props) { const { startScreenShare } = useCall(); const [tab, setTab] = useState('screen'); const [sources, setSources] = useState([]); const [loading, setLoading] = useState(true); const [selectedId, setSelectedId] = useState(null); const [res, setRes] = useState(() => { const s = getScreenShareSettings(); return decomposePreset(s.preset, s.framerateOverride).res; }); const [fps, setFps] = useState(() => { const s = getScreenShareSettings(); return decomposePreset(s.preset, s.framerateOverride).fps; }); const [audio, setAudio] = useState( () => getScreenShareSettings().includeSystemAudio, ); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // Load + refresh thumbnails. Local `cancelled` flag is the single source // of mount-state truth; we deliberately do NOT use a mountedRef pattern // because React 18 strict-mode runs effects twice and a ref set to false // in cleanup never gets re-set on remount, leaving `Lade …` hanging. useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; const tick = async () => { try { const list = await listScreenSources(); if (cancelled) return; setSources(list); setLoading(false); } catch (err) { if (cancelled) return; console.warn('listScreenSources failed', err); setLoading(false); } if (cancelled) return; timer = setTimeout(() => { if (!cancelled && !busy) void tick(); }, THUMBNAIL_REFRESH_MS); }; void tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [busy]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !busy) { e.preventDefault(); onClose(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose, busy]); const visibleSources = sources.filter((s) => s.kind === tab); const handleClose = () => { if (busy) return; void window.electronAPI.setPendingShareSource(null).catch(() => {}); onClose(); }; const handleStart = async () => { if (!selectedId) return; setError(null); setBusy(true); try { const preset = presetForResFps(res, fps); // Persist resolution + fps + audio. The duck flag is force-cleared // because the native loopback addon now excludes the app's own audio // at OS level; JS-side ducking (which also muted incoming peer audio) // was causing "I can't hear anyone" on earlier builds. updateScreenShareSettings({ preset, framerateOverride: fps, includeSystemAudio: audio, duckRemoteAudioWhileSharing: false, }); // Stage the picked source id for main BEFORE getDisplayMedia. Main // reads + clears it on the next display-media request. await window.electronAPI.setPendingShareSource(selectedId); await startScreenShare({ preset, displaySurface: tab === 'screen' ? 'monitor' : 'window', framerate: fps, // Forward the picked source id so the native loopback path can // switch into INCLUDE_TARGET_PROCESS_TREE for window-shares // (parses HWND from `window::0`). For screen-shares this // is just informational — the EXCLUDE-self path stays in play. pickedSourceId: selectedId, }); onClose(); } catch (err: unknown) { try { await window.electronAPI.setPendingShareSource(null); } catch { /* main may already be torn down */ } const msg = err instanceof Error ? err.message : ''; if (/cancel|abort|user/i.test(msg)) { onClose(); return; } setError(msg || 'Bildschirm-Quelle konnte nicht geladen werden'); } finally { setBusy(false); } }; return createPortal(
{ if (e.target === e.currentTarget) handleClose(); }} >
{/* Header */}

Bildschirmfreigabe

{/* Tabs */}
{TABS.map((t) => { const active = tab === t.id; return ( ); })}
{/* Source grid */}
{loading && visibleSources.length === 0 ? (
{Array.from({ length: 4 }).map((_, i) => (
))}
) : visibleSources.length === 0 ? (
{tab === 'screen' ? 'Keine Bildschirme gefunden.' : 'Keine offenen Anwendungen.'}
) : (
{visibleSources.map((src) => { const active = selectedId === src.id; return ( ); })}
)}
{/* Footer config */}
Auflösung
{RES_PILLS.map((q) => { const active = res === q.id; return ( ); })}
FPS
{FPS_PILLS.map((q) => { const active = fps === q.id; return ( ); })}
{error && (

{error}

)}
{/* Actions */}
, document.body, ); }