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:
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user