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
+95
View File
@@ -1130,6 +1130,11 @@ export function CallProvider({ children }: { children: ReactNode }) {
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
/** Chromium-format source id from our custom picker. When set, we
* try to capture that exact source via `chromeMediaSourceId`
* instead of the OS-level getDisplayMedia picker. Falls back to
* getDisplayMedia if WebView2 rejects the constraint. */
sourceId: string | null;
}>,
) => {
const r = roomRef.current;
@@ -1153,6 +1158,96 @@ export function CallProvider({ children }: { children: ReactNode }) {
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
const sourceId = overrides?.sourceId ?? null;
// Direct-publish path when our custom picker supplied a Chromium-
// format source id. Bypasses the OS picker so the user shares exactly
// the window/monitor they clicked in the grid. getUserMedia with the
// legacy chromeMediaSourceId constraint is not in the MediaStream
// spec but is honoured by Chromium / WebView2. If it throws we fall
// through to setScreenShareEnabled and let the OS picker run.
if (sourceId) {
try {
const { Track: LkTrack } = await import('livekit-client');
const maxWidth = ssParams.dims?.width ?? 3840;
const maxHeight = ssParams.dims?.height ?? 2160;
// Cast chains: browsers expose the legacy constraint via
// `MediaTrackConstraints.mandatory` which isn't in lib.dom.
const videoConstraints = {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
maxWidth,
maxHeight,
maxFrameRate: fps,
},
} as unknown as MediaTrackConstraints;
const audioConstraints: MediaTrackConstraints | false = settings.includeSystemAudio
? ({
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sourceId,
},
} as unknown as MediaTrackConstraints)
: false;
const stream = await navigator.mediaDevices.getUserMedia({
audio: audioConstraints,
video: videoConstraints,
});
const videoMst = stream.getVideoTracks()[0];
const audioMst = stream.getAudioTracks()[0];
if (!videoMst) {
stream.getTracks().forEach((t) => t.stop());
throw new Error('no video track from chromeMediaSource');
}
// Pass raw MediaStreamTracks — `publishTrack` wraps them in the
// right Local*Track internally and the publishDefaults on the
// Room handle VP9 codec + screenShareEncoding caps. Passing the
// raw tracks also sidesteps a type incompatibility between
// livekit-client's Local*Track and our exactOptionalPropertyTypes
// setting.
const videoPub = await lp.publishTrack(videoMst, {
source: LkTrack.Source.ScreenShare,
videoCodec: 'vp9',
});
// Stop the publish when the OS revokes capture (user hit the
// OS "Stop sharing" banner, or closed the window we were sharing).
videoMst.addEventListener('ended', () => {
void (async () => {
try {
if (videoPub.track) await lp.unpublishTrack(videoPub.track);
} catch {
/* already unpublished */
}
setIsScreenSharing(false);
})();
});
if (audioMst) {
const audioPub = await lp.publishTrack(audioMst, {
source: LkTrack.Source.ScreenShareAudio,
});
audioMst.addEventListener('ended', () => {
void (async () => {
try {
if (audioPub.track) await lp.unpublishTrack(audioPub.track);
} catch {
/* ignore */
}
})();
});
}
setIsScreenSharing(true);
return;
} catch (err: unknown) {
// WebView2 / browser rejected the legacy constraint. Fall through
// to the normal OS picker path below so the user still gets a
// working share instead of a hard error.
console.warn(
'direct screen-share via chromeMediaSourceId failed; falling back to getDisplayMedia',
err,
);
}
}
try {
await lp.setScreenShareEnabled(true, {