05870ef8fa
ScreenSharePickerModal now exposes Auflösung (Auto · 720p · 1080p · 1440p · 4K) and FPS (30 · 60) as separate pill rows instead of bundled quality presets — users can pick "1440p · 30 fps" or "4K · 30 fps" which the old preset list didn't surface. The underlying screenShareSettings framerateOverride slot already existed; the modal just stopped resetting it to null on every start and now plumbs the chosen FPS through to startScreenShare. Cinema-mode fullscreen on Windows had two defects: 1. Maximized → fullscreen left the taskbar drawn on top of the window because DWM kept the maximized work-area constraints. We now unmaximize first so DWM recomposes cleanly and setFullScreen actually covers the whole monitor including the taskbar strip. 2. Esc out of cinema came back as a small floating window even when the user had been maximized before clicking the Vollbild button — the unmaximize from (1) was never undone. We now memo the pre-fullscreen maximized flag per window-id and call win.maximize() once the leave-full-screen event has fired. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
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<TabId>('screen');
|
|
const [sources, setSources] = useState<ScreenSource[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
|
|
const [res, setRes] = useState<ResChoice>(() => {
|
|
const s = getScreenShareSettings();
|
|
return decomposePreset(s.preset, s.framerateOverride).res;
|
|
});
|
|
const [fps, setFps] = useState<FpsChoice>(() => {
|
|
const s = getScreenShareSettings();
|
|
return decomposePreset(s.preset, s.framerateOverride).fps;
|
|
});
|
|
const [audio, setAudio] = useState<boolean>(
|
|
() => getScreenShareSettings().includeSystemAudio,
|
|
);
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<typeof setTimeout> | 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:<HWND>: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(
|
|
<div
|
|
className="fixed inset-0 z-[120] flex items-center justify-center bg-black/70 p-6 backdrop-blur-sm motion-safe:animate-fade-in"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) handleClose();
|
|
}}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-label="Bildschirmfreigabe"
|
|
className="relative flex max-h-[88vh] w-full max-w-[680px] motion-safe:animate-slide-up flex-col overflow-hidden rounded-xl border border-line bg-surface-2 shadow-xl"
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-line px-5 py-3">
|
|
<div className="flex items-center gap-2.5">
|
|
<MonitorShareIcon className="h-5 w-5 text-fg-muted" />
|
|
<h2 className="text-base font-semibold text-fg">Bildschirmfreigabe</h2>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-line px-3">
|
|
{TABS.map((t) => {
|
|
const active = tab === t.id;
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
type="button"
|
|
onClick={() => {
|
|
setTab(t.id);
|
|
setSelectedId(null);
|
|
}}
|
|
className={
|
|
'relative cursor-pointer px-4 py-2.5 text-sm transition focus:outline-none ' +
|
|
(active
|
|
? 'font-semibold text-fg'
|
|
: 'text-fg-muted hover:text-fg')
|
|
}
|
|
>
|
|
{t.label}
|
|
{active && (
|
|
<span className="absolute inset-x-3 -bottom-px h-0.5 rounded-full bg-accent" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Source grid */}
|
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
{loading && visibleSources.length === 0 ? (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="aspect-video animate-pulse rounded-lg border border-line bg-surface-3"
|
|
/>
|
|
))}
|
|
</div>
|
|
) : visibleSources.length === 0 ? (
|
|
<div className="rounded-lg border border-dashed border-line bg-surface-3/30 px-4 py-10 text-center text-xs text-fg-muted">
|
|
{tab === 'screen' ? 'Keine Bildschirme gefunden.' : 'Keine offenen Anwendungen.'}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{visibleSources.map((src) => {
|
|
const active = selectedId === src.id;
|
|
return (
|
|
<button
|
|
key={src.id}
|
|
type="button"
|
|
onClick={() => setSelectedId(src.id)}
|
|
className={
|
|
'group flex flex-col overflow-hidden rounded-lg border text-left transition focus:outline-none ' +
|
|
(active
|
|
? 'border-accent ring-2 ring-accent/40 bg-accent/5'
|
|
: 'border-line bg-surface-3 hover:border-accent/50')
|
|
}
|
|
>
|
|
<div className="relative aspect-video w-full overflow-hidden bg-black/40">
|
|
{src.thumbnailDataUrl ? (
|
|
<img
|
|
src={src.thumbnailDataUrl}
|
|
alt=""
|
|
className="h-full w-full object-contain"
|
|
draggable={false}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-fg-muted">
|
|
—
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 px-2.5 py-2">
|
|
{src.iconDataUrl && (
|
|
<img
|
|
src={src.iconDataUrl}
|
|
alt=""
|
|
className="h-4 w-4 flex-none"
|
|
draggable={false}
|
|
/>
|
|
)}
|
|
<span
|
|
className="truncate text-xs font-medium text-fg"
|
|
title={src.name}
|
|
>
|
|
{src.name}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer config */}
|
|
<div className="border-t border-line bg-surface-3/30 px-5 py-3">
|
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
|
Auflösung
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{RES_PILLS.map((q) => {
|
|
const active = res === q.id;
|
|
return (
|
|
<button
|
|
key={q.id}
|
|
type="button"
|
|
onClick={() => setRes(q.id)}
|
|
className={
|
|
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
|
(active
|
|
? 'border-accent bg-accent/10 text-fg'
|
|
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
|
}
|
|
>
|
|
{q.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] font-semibold uppercase tracking-wide text-fg-muted">
|
|
FPS
|
|
</span>
|
|
<div className="flex gap-1">
|
|
{FPS_PILLS.map((q) => {
|
|
const active = fps === q.id;
|
|
return (
|
|
<button
|
|
key={q.id}
|
|
type="button"
|
|
onClick={() => setFps(q.id)}
|
|
className={
|
|
'cursor-pointer rounded-md border px-2.5 py-1 text-[11px] font-medium transition focus:outline-none ' +
|
|
(active
|
|
? 'border-accent bg-accent/10 text-fg'
|
|
: 'border-line bg-surface-3 text-fg-muted hover:text-fg')
|
|
}
|
|
>
|
|
{q.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<label className="ml-auto flex cursor-pointer items-center gap-2 text-xs text-fg">
|
|
<input
|
|
type="checkbox"
|
|
checked={audio}
|
|
onChange={(e) => setAudio(e.target.checked)}
|
|
className="h-3.5 w-3.5 cursor-pointer accent-accent"
|
|
/>
|
|
<span>Sound mitstreamen</span>
|
|
</label>
|
|
</div>
|
|
|
|
|
|
{error && (
|
|
<p className="mt-2 rounded border border-rose-500/30 bg-rose-500/10 px-2.5 py-1.5 text-[11px] text-rose-600 dark:text-rose-300">
|
|
{error}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
|
|
<button
|
|
type="button"
|
|
disabled={busy}
|
|
onClick={handleClose}
|
|
className="cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium text-fg-muted transition hover:text-fg disabled:opacity-50 focus:outline-none"
|
|
>
|
|
Abbrechen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={busy || !selectedId}
|
|
onClick={() => void handleStart()}
|
|
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-emerald-600 px-4 py-1.5 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
|
>
|
|
<MonitorShareIcon className="h-4 w-4" />
|
|
{busy ? 'Starte …' : 'Live gehen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|