Files
ChatApp/apps/desktop/src/components/ScreenSharePickerModal.tsx
T
byGalax 825160ee46 feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)
Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 23:35:01 +02:00

342 lines
12 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'];
const QUALITY_PILLS: { id: ScreenSharePreset; label: string }[] = [
{ id: 'auto', label: 'Auto' },
{ id: '720p60', label: '720p · 60' },
{ id: '1080p60', label: '1080p · 60' },
{ id: '1440p60', label: '1440p · 60' },
];
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 [preset, setPreset] = useState<ScreenSharePreset>(
() => getScreenShareSettings().preset,
);
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 {
// Persist quality + audio toggle. Also force-clear any stale duck
// setting users may have inherited from earlier builds — the
// native loopback addon excludes the app's own audio at OS level
// now, so JS-side ducking (which muted the user's incoming peer
// audio) is no longer needed and was causing "I can't hear anyone".
updateScreenShareSettings({
preset,
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: null,
// 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">
Qualität
</span>
<div className="flex gap-1">
{QUALITY_PILLS.map((q) => {
const active = preset === q.id;
return (
<button
key={q.id}
type="button"
onClick={() => setPreset(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,
);
}