c3e0c47d32
Picker speed (Phase 1+2): - screen_sources.rs split into list_screen_sources (metadata only, returns in ~10ms) + capture_screen_source_thumbnail (single source, by id). ScreenSourcePicker now shows names + placeholders instantly and streams thumbnails in as each capture lands. Total wall-clock is bounded by the slowest source instead of the serial sum. - enumerate_screen_sources kept as a dead_code fallback so any rollout regression can switch the frontend back without code loss. Native capture (Phase 3): - New src-tauri/src/screen_capture.rs. start_screen_capture spawns a Rust thread per share that grabs frames via xcap, downscales to the user's quality preset, JPEG-encodes at Q72, and streams each frame through a Tauri Channel<FramePayload>. stop_screen_capture signals the stop flag and joins the worker. - Worker re-resolves the xcap handle inside the thread because xcap::Window holds a !Send HWND — passing the source id string across the thread boundary sidesteps that. - New lib/screenCapture.ts: decodes each frame into an ImageBitmap, draws to an offscreen canvas, exposes canvas.captureStream() as the MediaStream LiveKit publishes. Latest-wins frame queue drops stale frames when the JS side falls behind the Rust producer. 3s first- frame timeout so a silently-failing source (locked screen, DRM window) surfaces as a clean NativeCaptureUnavailable and we fall back to getDisplayMedia. - CallContext.startScreenShare takes the native path first when the picker provided a sourceId and system audio wasn't requested. The old chromeMediaSourceId attempt and final setScreenShareEnabled fallback stay in place for the audio case + non-Tauri runtimes. - stopScreenShare kills the native handle first, then unpublishes any manually-published ScreenShare/ScreenShareAudio tracks, then falls back to setScreenShareEnabled(false). disconnectRoom also stops the handle so we don't leak Rust threads across calls. Scope note: native path is video-only. System-audio capture needs WASAPI-loopback (Windows) or ScreenCaptureKit-audio (macOS); until those are wired, requesting audio in the picker falls through to the getDisplayMedia path and shows the OS picker for that one case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
185 lines
5.8 KiB
TypeScript
185 lines
5.8 KiB
TypeScript
// Frontend side of the native screen-capture pipeline. Starts a Rust-side
|
|
// capture thread via `start_screen_capture` and streams JPEG frames back
|
|
// through a Tauri Channel. Each frame is decoded into an ImageBitmap,
|
|
// drawn onto an offscreen canvas, and the canvas' captureStream() is
|
|
// returned as a MediaStream that LiveKit can publishTrack() directly —
|
|
// no OS/browser screen picker is involved.
|
|
//
|
|
// Video-only: system audio would require WASAPI / ScreenCaptureKit hooks
|
|
// that xcap doesn't provide. Callers that request shared audio must
|
|
// either fall back to the browser picker or accept video-without-audio.
|
|
|
|
import { isTauriRuntime } from './globalShortcut';
|
|
|
|
export interface NativeCaptureHandle {
|
|
/** Rust-side capture id. Pass to `stopNativeCapture` to tear down. */
|
|
captureId: number;
|
|
/** MediaStream fed by a canvas that's drawing each incoming frame. */
|
|
stream: MediaStream;
|
|
/** Cleanup — stops the Rust thread, closes channels, revokes the canvas
|
|
* stream. Idempotent. */
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
interface FramePayload {
|
|
captureId: number;
|
|
width: number;
|
|
height: number;
|
|
jpegBase64: string;
|
|
}
|
|
|
|
/** Returned when the runtime can't support native capture (no Tauri, no
|
|
* WebAudio, source vanished between enumeration and start, etc.). The
|
|
* caller is expected to fall back to the browser's getDisplayMedia path. */
|
|
export class NativeCaptureUnavailable extends Error {
|
|
constructor(reason: string) {
|
|
super('native capture unavailable: ' + reason);
|
|
this.name = 'NativeCaptureUnavailable';
|
|
}
|
|
}
|
|
|
|
export async function startNativeCapture(opts: {
|
|
sourceId: string;
|
|
maxWidth: number;
|
|
maxHeight: number;
|
|
fps: number;
|
|
}): Promise<NativeCaptureHandle> {
|
|
if (!isTauriRuntime()) {
|
|
throw new NativeCaptureUnavailable('not a tauri runtime');
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = opts.maxWidth;
|
|
canvas.height = opts.maxHeight;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) {
|
|
throw new NativeCaptureUnavailable('canvas 2d context unavailable');
|
|
}
|
|
|
|
// Track whether we got the first frame so we can fail fast if Rust
|
|
// reports "found" but then produces no output (e.g. screen was locked).
|
|
let firstFrameResolved = false;
|
|
let firstFrameResolve!: () => void;
|
|
let firstFrameReject!: (err: Error) => void;
|
|
const firstFramePromise = new Promise<void>((resolve, reject) => {
|
|
firstFrameResolve = resolve;
|
|
firstFrameReject = reject;
|
|
});
|
|
|
|
const { Channel, invoke } = await import('@tauri-apps/api/core');
|
|
const channel = new Channel<FramePayload>();
|
|
|
|
// Latest-wins frame queue: if the JS side falls behind the Rust producer,
|
|
// we drop stale frames rather than queue them. Keeps memory flat and
|
|
// latency sensible for live screenshare.
|
|
let pendingFrame: FramePayload | null = null;
|
|
let decoding = false;
|
|
|
|
const drainQueue = async () => {
|
|
if (decoding) return;
|
|
decoding = true;
|
|
try {
|
|
while (pendingFrame) {
|
|
const frame = pendingFrame;
|
|
pendingFrame = null;
|
|
const bytes = base64ToBytes(frame.jpegBase64);
|
|
// Uint8Array's buffer type is `ArrayBufferLike` (could be a
|
|
// SharedArrayBuffer in theory); Blob wants plain ArrayBuffer.
|
|
// Pass the underlying buffer explicitly so the type narrows.
|
|
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
|
|
let bitmap: ImageBitmap;
|
|
try {
|
|
bitmap = await createImageBitmap(blob);
|
|
} catch (err: unknown) {
|
|
console.warn('createImageBitmap failed', err);
|
|
continue;
|
|
}
|
|
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {
|
|
canvas.width = bitmap.width;
|
|
canvas.height = bitmap.height;
|
|
}
|
|
ctx.drawImage(bitmap, 0, 0);
|
|
bitmap.close();
|
|
if (!firstFrameResolved) {
|
|
firstFrameResolved = true;
|
|
firstFrameResolve();
|
|
}
|
|
}
|
|
} finally {
|
|
decoding = false;
|
|
}
|
|
};
|
|
|
|
channel.onmessage = (frame: FramePayload) => {
|
|
pendingFrame = frame;
|
|
void drainQueue();
|
|
};
|
|
|
|
let captureId: number;
|
|
try {
|
|
captureId = await invoke<number>('start_screen_capture', {
|
|
sourceId: opts.sourceId,
|
|
maxWidth: opts.maxWidth,
|
|
maxHeight: opts.maxHeight,
|
|
fps: opts.fps,
|
|
channel,
|
|
});
|
|
} catch (err: unknown) {
|
|
throw new NativeCaptureUnavailable(
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
}
|
|
|
|
// Bound the wait: the capture thread may fail silently on some sources
|
|
// (locked screens, protected windows). Fall back to getDisplayMedia in
|
|
// that case rather than hang the user.
|
|
const firstFrameTimeout = window.setTimeout(() => {
|
|
firstFrameReject(new Error('first frame timed out (3s)'));
|
|
}, 3000);
|
|
try {
|
|
await firstFramePromise;
|
|
} catch (err: unknown) {
|
|
window.clearTimeout(firstFrameTimeout);
|
|
try {
|
|
await invoke('stop_screen_capture', { captureId });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
throw new NativeCaptureUnavailable(
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
}
|
|
window.clearTimeout(firstFrameTimeout);
|
|
|
|
const stream = canvas.captureStream(opts.fps);
|
|
|
|
let stopped = false;
|
|
const stop = async (): Promise<void> => {
|
|
if (stopped) return;
|
|
stopped = true;
|
|
try {
|
|
await invoke('stop_screen_capture', { captureId });
|
|
} catch (err: unknown) {
|
|
console.warn('stop_screen_capture failed', err);
|
|
}
|
|
for (const track of stream.getTracks()) {
|
|
try {
|
|
track.stop();
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
}
|
|
};
|
|
|
|
return { captureId, stream, stop };
|
|
}
|
|
|
|
function base64ToBytes(b64: string): Uint8Array {
|
|
const bin = atob(b64);
|
|
const bytes = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) {
|
|
bytes[i] = bin.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|