diff --git a/apps/desktop/src/components/ScreenSourcePicker.tsx b/apps/desktop/src/components/ScreenSourcePicker.tsx index a380304..f8b6d09 100644 --- a/apps/desktop/src/components/ScreenSourcePicker.tsx +++ b/apps/desktop/src/components/ScreenSourcePicker.tsx @@ -49,10 +49,11 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) { const [error, setError] = useState(null); // Two-phase load: (1) fast list returns names + placeholders so the grid - // paints instantly, (2) fire one thumbnail capture per source in - // parallel. Thumbnails fill in as each capture completes — Tauri's - // command thread pool runs them concurrently so wall-clock is bounded - // by the slowest source, not the sum. + // paints instantly, (2) capture thumbnails in a bounded worker-pool so + // Tauri IPC returns don't starve the JS main thread. Firing all ~20 + // captures at once caused perceptible input freezes while the base64 + // blobs arrived — limiting concurrency to 4 keeps the grid scrollable + // throughout and doesn't meaningfully slow overall completion. useEffect(() => { if (!open) { setSources(null); @@ -61,27 +62,34 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) { return; } let cancelled = false; + const CONCURRENCY = 4; void (async () => { const list = await listScreenSources(); if (cancelled) return; setSources(list); - // Fan out thumbnail captures. No Promise.all — we want each result - // to render as it lands, not wait for the full batch. The id-based - // setState patch means the slowest source can still be in flight - // while the user already picked one of the fast ones. - for (const src of list) { + + const queue = [...list]; + const pickOne = (src: typeof list[number]) => { void (async () => { const png = await captureScreenSourceThumbnail(src.id); - if (cancelled || png === null) return; - setSources((prev) => { - if (!prev) return prev; - const idx = prev.findIndex((s) => s.id === src.id); - if (idx === -1) return prev; - const next = prev.slice(); - next[idx] = { ...prev[idx]!, thumbnailPng: png }; - return next; - }); + if (cancelled) return; + if (png !== null) { + setSources((prev) => { + if (!prev) return prev; + const idx = prev.findIndex((s) => s.id === src.id); + if (idx === -1) return prev; + const next = prev.slice(); + next[idx] = { ...prev[idx]!, thumbnailPng: png }; + return next; + }); + } + const nextSrc = queue.shift(); + if (nextSrc) pickOne(nextSrc); })(); + }; + for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) { + const s = queue.shift(); + if (s) pickOne(s); } })(); return () => { diff --git a/apps/desktop/src/context/CallContext.tsx b/apps/desktop/src/context/CallContext.tsx index e505609..6b2664b 100644 --- a/apps/desktop/src/context/CallContext.tsx +++ b/apps/desktop/src/context/CallContext.tsx @@ -1196,8 +1196,18 @@ export function CallProvider({ children }: { children: ReactNode }) { // completely skips the OS picker. Video-only (no system audio yet); // if the user asked for audio we fall through to the legacy paths // below so audio still works via getDisplayMedia. + if (!sourceId) { + console.info( + 'screen-share: no sourceId supplied by picker, OS picker will open', + ); + } else if (settings.includeSystemAudio) { + console.info( + 'screen-share: system audio requested — native path unavailable (needs WASAPI/ScreenCaptureKit), OS picker will open', + ); + } if (sourceId && !settings.includeSystemAudio) { try { + console.info('screen-share: trying native capture path', { sourceId, fps }); const { Track: LkTrack } = await import('livekit-client'); const maxWidth = ssParams.dims?.width ?? 1920; const maxHeight = ssParams.dims?.height ?? 1080; @@ -1236,6 +1246,7 @@ export function CallProvider({ children }: { children: ReactNode }) { setIsScreenSharing(false); })(); }); + console.info('screen-share: native capture active'); setIsScreenSharing(true); return; } catch (err: unknown) { @@ -1246,9 +1257,10 @@ export function CallProvider({ children }: { children: ReactNode }) { await nativeCaptureRef.current.stop().catch(() => undefined); nativeCaptureRef.current = null; } - if (!(err instanceof NativeCaptureUnavailable)) { - console.warn('native screen-capture failed, falling back', err); - } + console.warn( + 'screen-share: native path failed, falling back', + err instanceof Error ? err.message : err, + ); } } diff --git a/apps/desktop/src/lib/participantVolumes.ts b/apps/desktop/src/lib/participantVolumes.ts index 2007378..084fba4 100644 --- a/apps/desktop/src/lib/participantVolumes.ts +++ b/apps/desktop/src/lib/participantVolumes.ts @@ -78,12 +78,18 @@ export function subscribeParticipantVolumes(fn: Listener): () => void { // Apply a volume to any audio elements already attached for this user. // Attached elements are tagged with `data-participant` in attachTrack. +// HTMLMediaElement.volume is hard-clamped to [0, 1] — anything above 1 +// throws IndexSizeError. Values above 1 are only meaningful on the +// WebAudio path (remoteAudioPipelines' GainNode handles them); on the +// plain-element fallback path we clip at 1.0 so the user just hears the +// loudest level the element supports rather than an exception. function applyToAttachedElements(userId: string, volume: number): void { + const elVolume = Math.min(1, Math.max(0, volume)); const nodes = document.querySelectorAll( 'audio[data-participant="' + cssEscape(userId) + '"]', ); nodes.forEach((el) => { - el.volume = volume; + el.volume = elVolume; }); } diff --git a/apps/desktop/src/lib/screenShareVolumes.ts b/apps/desktop/src/lib/screenShareVolumes.ts index 00d0c55..838a083 100644 --- a/apps/desktop/src/lib/screenShareVolumes.ts +++ b/apps/desktop/src/lib/screenShareVolumes.ts @@ -60,15 +60,18 @@ export function clearScreenShareVolumes(): void { // elements are tagged by attachTrack in CallContext with // `data-participant=""` + `data-track-source="screenshare"` — the // combined selector makes sure we don't retarget the mic audio for the same -// user (different track-source). +// user (different track-source). HTMLMediaElement.volume caps at 1.0, so +// clip here — the WebAudio GainNode on the live pipeline handles values +// above 1. function applyToAttachedElements(userId: string, volume: number): void { + const elVolume = Math.min(1, Math.max(0, volume)); const nodes = document.querySelectorAll( 'audio[data-participant="' + cssEscape(userId) + '"][data-track-source="screenshare"]', ); nodes.forEach((el) => { - el.volume = volume; + el.volume = elVolume; }); }