fix(call): volume>100% crash, picker UI freeze, native-path diagnostics

Volume crash:
- setParticipantVolume / setScreenShareVolume propagated values up to 2.0
(200%) to the per-track GainNode, but also called applyToAttachedElements
which set the raw HTMLAudioElement.volume — that property is hard-clamped
to [0, 1] and throws IndexSizeError above 1. Clip the element-path apply
at 1.0. WebAudio GainNode keeps doing the actual amplification.

Picker freeze:
- Firing ~20 captureScreenSourceThumbnail invokes in parallel caused
perceptible input freezes while each ~100KB base64 result arrived and
triggered a setState. Bounded the worker pool to 4 concurrent captures
with a queue — overall wall-clock is nearly identical and the grid stays
scrollable / clickable throughout the load.

Native-path diagnostics:
- Previous logs only fired on non-NativeCaptureUnavailable errors, so
users couldn't tell whether the native path was skipped (audio toggle
on, no sourceId) or attempted-and-failed. Added explicit info logs for
each skip reason plus an always-on warn with the underlying error when
the try block throws. Makes the next debug pass on screenshare much
quicker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 21:50:20 +02:00
parent c3e0c47d32
commit a5e930ac17
4 changed files with 53 additions and 24 deletions
@@ -49,10 +49,11 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
const [error, setError] = useState<string | null>(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,18 +62,18 @@ 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;
if (cancelled) return;
if (png !== null) {
setSources((prev) => {
if (!prev) return prev;
const idx = prev.findIndex((s) => s.id === src.id);
@@ -81,7 +82,14 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
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 () => {
+15 -3
View File
@@ -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,
);
}
}
+7 -1
View File
@@ -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<HTMLAudioElement>(
'audio[data-participant="' + cssEscape(userId) + '"]',
);
nodes.forEach((el) => {
el.volume = volume;
el.volume = elVolume;
});
}
+5 -2
View File
@@ -60,15 +60,18 @@ export function clearScreenShareVolumes(): void {
// elements are tagged by attachTrack in CallContext with
// `data-participant="<identity>"` + `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<HTMLAudioElement>(
'audio[data-participant="' +
cssEscape(userId) +
'"][data-track-source="screenshare"]',
);
nodes.forEach((el) => {
el.volume = volume;
el.volume = elVolume;
});
}