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:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user