feat(call): Discord-style screen-source picker with thumbnails

Rust side:
- New src-tauri/src/screen_sources.rs with an enumerate_screen_sources
  command. Uses the xcap crate for cross-platform screen + window
  enumeration and capture; PNG thumbnails are letterbox-scaled to fit
  320x180 and returned as base64.
- Source ids emit Chromium's internal desktopCapturer format
  ("screen:<id>:0", "window:<hwnd>:0") so JS can try passing them
  straight into chromeMediaSourceId.
- Registered in both invoke_handler branches in lib.rs.

Frontend:
- New lib/screenSources.ts — Tauri command wrapper + thumbnailDataUrl
  helper for the picker UI.
- New components/ScreenSourcePicker.tsx — Discord-style grid: sources
  grouped under "Bildschirme" / "Fenster", large thumbnail cards with
  selection state, quality preset + system-audio toggle in the footer.
  "Teilen" button is enabled either way; without a selection it says
  "Ohne Auswahl weiter" and falls through to the OS picker.
- Replaces the old form-style ScreenShareDialog entirely (removed).

CallContext wiring:
- startScreenShare now accepts an optional sourceId. When set, it
  captures that exact source via getUserMedia's legacy
  chromeMediaSourceId constraint and publishes the resulting tracks
  manually (video as ScreenShare, audio as ScreenShareAudio). Falls
  back to setScreenShareEnabled if WebView2 rejects the constraint,
  so users always get a working share even if the direct path fails.
- Track 'ended' listeners unpublish the pub when the OS revokes
  capture (close of shared window, OS "stop sharing" banner).

InCallPanel:
- Left-click on the share button now opens the picker instead of
  starting with last-saved settings; right-click opens it too. The
  picker itself is the 1-click UX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 20:55:52 +02:00
parent 331b1298f8
commit b44a785d20
9 changed files with 1472 additions and 284 deletions
+10 -10
View File
@@ -18,7 +18,7 @@ import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } f
import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover';
import { ParticipantVolumeMenu } from './ParticipantVolumeMenu';
import { ScreenShareContextMenu } from './ScreenShareContextMenu';
import { ScreenShareDialog } from './ScreenShareDialog';
import { ScreenSourcePicker } from './ScreenSourcePicker';
import { ScreenShareViewer } from './ScreenShareViewer';
import { SoundboardPanel } from './SoundboardPanel';
@@ -84,7 +84,7 @@ export function InCallPanel({ conversation }: Props) {
const { session } = useAuth();
const myId = session?.user.id ?? null;
const activeSpeakers = useActiveSpeakers(room);
const [shareDialogOpen, setShareDialogOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [soundboardOpen, setSoundboardOpen] = useState(false);
const [participantsOpen, setParticipantsOpen] = useState(false);
const [volumeMenu, setVolumeMenu] = useState<
@@ -212,19 +212,19 @@ export function InCallPanel({ conversation }: Props) {
video={isCameraEnabled}
deafened={isDeafened}
onToggleMute={toggleMute}
// 1-click share uses last-saved preset + displaySurface. Right-click
// opens the quality picker for users who want to change settings
// before starting — matches Discord's "Go Live" vs quick-share split.
// Click opens the Discord-style source picker (thumbnails + quality +
// audio). Clicking again while a share is live stops it. Right-click
// also opens the picker in case the user wants to swap sources.
onToggleShare={() => {
if (isScreenSharing) {
void stopScreenShare();
} else {
void startScreenShare();
setPickerOpen(true);
}
}}
onShareContextMenu={(e) => {
e.preventDefault();
if (!isScreenSharing) setShareDialogOpen(true);
if (!isScreenSharing) setPickerOpen(true);
}}
onToggleVideo={() => void toggleCamera()}
onToggleDeafen={toggleDeafen}
@@ -412,9 +412,9 @@ export function InCallPanel({ conversation }: Props) {
<PttHint />
<ScreenShareDialog
open={shareDialogOpen}
onClose={() => setShareDialogOpen(false)}
<ScreenSourcePicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onStart={async (opts) => {
await startScreenShare(opts);
}}
@@ -1,262 +0,0 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
MonitorShareIcon,
SpinnerIcon,
XIcon,
} from './icons';
interface Props {
open: boolean;
onClose: () => void;
onStart: (opts: {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}) => Promise<void>;
}
const FRAMERATE_OPTIONS: ReadonlyArray<{ value: number | null; label: string }> = [
{ value: null, label: 'Preset-Standard' },
{ value: 15, label: '15 fps' },
{ value: 30, label: '30 fps' },
{ value: 60, label: '60 fps' },
];
// Discord-style pre-share dialog. The OS still owns the final source picker
// (browser/OS limitation — only Chrome/Edge plus a native plugin can enumerate
// windows from JS), but we pre-filter with the `displaySurface` hint and lock
// in quality + framerate up-front so the user doesn't have to re-open the
// system picker to adjust them mid-call.
export function ScreenShareDialog({ open, onClose, onStart }: Props) {
const { t } = useTranslation(['app']);
const initial = getScreenShareSettings();
const [surface, setSurface] = useState<DisplaySurfaceHint>(initial.displaySurface);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [framerate, setFramerate] = useState<number | null>(initial.framerateOverride);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) return null;
async function handleStart() {
setBusy(true);
setError(null);
try {
// Persist the audio choice alongside the other picker prefs so the
// upstream startScreenShare picks it up on its settings read.
updateScreenShareSettings({ includeSystemAudio: includeAudio });
await onStart({ preset, displaySurface: surface, framerate });
onClose();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'screenshare failed');
} finally {
setBusy(false);
}
}
const presetParams = getPresetParams(preset);
const effectiveFps = framerate ?? presetParams.framerate;
return (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
className="flex w-full max-w-lg flex-col overflow-hidden rounded-2xl border border-line bg-surface-3 shadow-xl"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="space-y-5 p-5">
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_surface', { defaultValue: 'Quelle' })}
</p>
<div className="grid grid-cols-3 gap-2">
<SurfaceOption
active={surface === null}
onClick={() => setSurface(null)}
label={t('app:call.share_any', { defaultValue: 'Alle anzeigen' })}
sub={t('app:call.share_any_sub', { defaultValue: 'Bildschirm + Fenster' })}
/>
<SurfaceOption
active={surface === 'monitor'}
onClick={() => setSurface('monitor')}
label={t('app:call.share_monitor', { defaultValue: 'Bildschirm' })}
sub={t('app:call.share_monitor_sub', { defaultValue: 'Ganzer Monitor' })}
/>
<SurfaceOption
active={surface === 'window'}
onClick={() => setSurface('window')}
label={t('app:call.share_window', { defaultValue: 'Fenster' })}
sub={t('app:call.share_window_sub', { defaultValue: 'Einzelnes Fenster' })}
/>
</div>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</p>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-2 text-sm text-fg focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</div>
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_fps', { defaultValue: 'Bildrate' })}
</p>
<div className="flex flex-wrap gap-2">
{FRAMERATE_OPTIONS.map((opt) => (
<button
key={String(opt.value)}
type="button"
onClick={() => setFramerate(opt.value)}
className={
'cursor-pointer rounded-lg border px-3 py-1.5 text-xs font-medium transition ' +
(framerate === opt.value
? 'border-accent bg-accent/15 text-accent'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
{opt.label}
</button>
))}
</div>
<p className="mt-2 text-[11px] text-fg-muted">
{t('app:call.share_fps_effective', {
defaultValue: 'Effektiv: {{fps}} fps',
fps: effectiveFps,
})}
</p>
</div>
<div>
<label className="flex cursor-pointer items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs hover:bg-surface">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="mt-0.5 accent-accent"
/>
<span className="min-w-0 flex-1">
<span className="block font-semibold text-fg">
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
<span className="mt-0.5 block text-[11px] text-fg-muted">
{t('app:call.share_system_audio_hint', {
defaultValue:
'"Go Live" — Systemsound wird mitgesendet. Auf macOS braucht das extra Berechtigungen; wird sonst stumm geteilt.',
})}
</span>
</span>
</label>
</div>
{error && (
<p role="alert" className="text-sm text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<p className="text-[11px] text-fg-muted">
{t('app:call.share_hint', {
defaultValue:
'Nach "Teilen starten" öffnet das Betriebssystem den Quellen-Picker. Qualität + Bildrate werden bereits angewendet.',
})}
</p>
</div>
<footer className="flex items-center justify-end gap-2 border-t border-line bg-surface-2 px-5 py-3">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStart()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{t('app:call.share_start', { defaultValue: 'Teilen starten' })}
</span>
</button>
</footer>
</div>
</div>
);
}
function SurfaceOption({
active,
onClick,
label,
sub,
}: {
active: boolean;
onClick: () => void;
label: string;
sub: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={
'flex flex-col items-start gap-0.5 rounded-lg border p-2.5 text-left transition ' +
(active
? 'border-accent bg-accent/10 text-fg'
: 'border-line bg-surface-2 text-fg hover:bg-surface')
}
>
<span className="text-xs font-semibold">{label}</span>
<span className="text-[10px] text-fg-muted">{sub}</span>
</button>
);
}
@@ -0,0 +1,319 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
PRESET_ORDER,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import {
enumerateScreenSources,
type ScreenSource,
thumbnailDataUrl,
} 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<void>;
}
// 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<ScreenSource[] | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [preset, setPreset] = useState<ScreenSharePreset>(initial.preset);
const [includeAudio, setIncludeAudio] = useState<boolean>(initial.includeSystemAudio);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-enumerate every time the picker opens so closed windows + new ones
// stay accurate. A previous stale list would surface sources the user
// can't actually share anymore.
useEffect(() => {
if (!open) {
setSources(null);
setSelectedId(null);
setError(null);
return;
}
let cancelled = false;
void (async () => {
const list = await enumerateScreenSources();
if (cancelled) return;
setSources(list);
})();
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<void> {
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 (
<div
role="dialog"
aria-modal="true"
aria-label={t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
onClick={onClose}
>
<div
onClick={(e) => 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"
>
<header className="flex items-center justify-between border-b border-line px-5 py-3">
<div className="flex items-center gap-2">
<MonitorShareIcon className="h-4 w-4 text-accent" />
<h3 className="font-display text-sm font-semibold text-fg">
{t('app:call.share_dialog_title', { defaultValue: 'Bildschirm teilen' })}
</h3>
</div>
<button
type="button"
onClick={onClose}
aria-label={t('app:common.close', { defaultValue: 'Schließen' })}
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg text-fg-muted transition hover:bg-surface-2 hover:text-fg"
>
<XIcon className="h-4 w-4" />
</button>
</header>
<div className="flex-1 overflow-y-auto">
{sources === null ? (
<div className="flex h-40 items-center justify-center gap-2 text-sm text-fg-muted">
<SpinnerIcon className="h-4 w-4" />
<span>
{t('app:call.share_loading', { defaultValue: 'Lade Quellen…' })}
</span>
</div>
) : !hasAny ? (
<div className="flex flex-col items-center gap-2 px-6 py-10 text-center text-sm text-fg-muted">
<MonitorShareIcon className="h-6 w-6 opacity-60" />
<span>
{t('app:call.share_no_sources', {
defaultValue:
'Keine Quellen-Previews verfügbar. Klick auf „Teilen" öffnet den System-Picker.',
})}
</span>
</div>
) : (
<div className="space-y-5 p-5">
{screens.length > 0 && (
<SourceSection
title={t('app:call.share_screens', { defaultValue: 'Bildschirme' })}
sources={screens}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
{windows.length > 0 && (
<SourceSection
title={t('app:call.share_windows', { defaultValue: 'Fenster' })}
sources={windows}
selectedId={selectedId}
onSelect={setSelectedId}
/>
)}
</div>
)}
</div>
<footer className="flex flex-col gap-3 border-t border-line bg-surface-2 px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
<span className="font-semibold uppercase tracking-wider text-fg-muted">
{t('app:call.share_quality', { defaultValue: 'Qualität' })}
</span>
<select
value={preset}
onChange={(e) => setPreset(e.target.value as ScreenSharePreset)}
className="rounded-md border border-line bg-surface-3 px-2 py-1 text-xs text-fg focus:border-accent focus:outline-none"
>
{PRESET_ORDER.map((p) => (
<option key={p} value={p}>
{getPresetParams(p).label} · {getPresetParams(p).bitrateKbps} kbps
</option>
))}
</select>
</label>
<label className="flex cursor-pointer items-center gap-2 text-xs text-fg">
<input
type="checkbox"
checked={includeAudio}
onChange={(e) => setIncludeAudio(e.target.checked)}
className="accent-accent"
/>
<span>
{t('app:call.share_system_audio', {
defaultValue: 'System-Sound mit übertragen',
})}
</span>
</label>
</div>
{error && (
<p role="alert" className="text-xs text-rose-600 dark:text-rose-300">
{error}
</p>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onClose}
className="cursor-pointer rounded-md border border-line bg-surface-3 px-3 py-2 text-sm text-fg transition hover:bg-surface-2"
>
{t('app:friends.action_cancel', { defaultValue: 'Abbrechen' })}
</button>
<button
type="button"
onClick={() => void handleStart()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-2 text-sm font-semibold text-accent-fg transition hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>
{selectedId
? t('app:call.share_start', { defaultValue: 'Teilen' })
: t('app:call.share_pick_system', {
defaultValue: 'Ohne Auswahl weiter',
})}
</span>
</button>
</div>
</footer>
</div>
</div>
);
}
function SourceSection({
title,
sources,
selectedId,
onSelect,
}: {
title: string;
sources: ScreenSource[];
selectedId: string | null;
onSelect: (id: string) => void;
}) {
return (
<section>
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-fg-muted">
{title}
</h4>
<div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3">
{sources.map((src) => (
<SourceCard
key={src.id}
source={src}
selected={selectedId === src.id}
onClick={() => onSelect(src.id)}
/>
))}
</div>
</section>
);
}
function SourceCard({
source,
selected,
onClick,
}: {
source: ScreenSource;
selected: boolean;
onClick: () => void;
}) {
const thumb = thumbnailDataUrl(source);
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
title={source.name}
className={
'group flex cursor-pointer flex-col overflow-hidden rounded-lg border transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(selected
? 'border-accent ring-2 ring-accent/30'
: 'border-line hover:border-accent/70')
}
>
<div className="relative aspect-video w-full overflow-hidden bg-black">
{thumb ? (
<img
src={thumb}
alt=""
className="h-full w-full object-contain transition group-hover:brightness-110"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-surface-2 to-surface-3 text-fg-muted">
<MonitorShareIcon className="h-6 w-6 opacity-50" />
</div>
)}
</div>
<div className="truncate px-2.5 py-1.5 text-left text-xs font-medium text-fg">
{source.name}
</div>
</button>
);
}