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);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Two-phase load: (1) fast list returns names + placeholders so the grid
|
// Two-phase load: (1) fast list returns names + placeholders so the grid
|
||||||
// paints instantly, (2) fire one thumbnail capture per source in
|
// paints instantly, (2) capture thumbnails in a bounded worker-pool so
|
||||||
// parallel. Thumbnails fill in as each capture completes — Tauri's
|
// Tauri IPC returns don't starve the JS main thread. Firing all ~20
|
||||||
// command thread pool runs them concurrently so wall-clock is bounded
|
// captures at once caused perceptible input freezes while the base64
|
||||||
// by the slowest source, not the sum.
|
// blobs arrived — limiting concurrency to 4 keeps the grid scrollable
|
||||||
|
// throughout and doesn't meaningfully slow overall completion.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setSources(null);
|
setSources(null);
|
||||||
@@ -61,27 +62,34 @@ export function ScreenSourcePicker({ open, onClose, onStart }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const CONCURRENCY = 4;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const list = await listScreenSources();
|
const list = await listScreenSources();
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setSources(list);
|
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
|
const queue = [...list];
|
||||||
// setState patch means the slowest source can still be in flight
|
const pickOne = (src: typeof list[number]) => {
|
||||||
// while the user already picked one of the fast ones.
|
|
||||||
for (const src of list) {
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const png = await captureScreenSourceThumbnail(src.id);
|
const png = await captureScreenSourceThumbnail(src.id);
|
||||||
if (cancelled || png === null) return;
|
if (cancelled) return;
|
||||||
setSources((prev) => {
|
if (png !== null) {
|
||||||
if (!prev) return prev;
|
setSources((prev) => {
|
||||||
const idx = prev.findIndex((s) => s.id === src.id);
|
if (!prev) return prev;
|
||||||
if (idx === -1) return prev;
|
const idx = prev.findIndex((s) => s.id === src.id);
|
||||||
const next = prev.slice();
|
if (idx === -1) return prev;
|
||||||
next[idx] = { ...prev[idx]!, thumbnailPng: png };
|
const next = prev.slice();
|
||||||
return next;
|
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 () => {
|
return () => {
|
||||||
|
|||||||
@@ -1196,8 +1196,18 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
// completely skips the OS picker. Video-only (no system audio yet);
|
// completely skips the OS picker. Video-only (no system audio yet);
|
||||||
// if the user asked for audio we fall through to the legacy paths
|
// if the user asked for audio we fall through to the legacy paths
|
||||||
// below so audio still works via getDisplayMedia.
|
// 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) {
|
if (sourceId && !settings.includeSystemAudio) {
|
||||||
try {
|
try {
|
||||||
|
console.info('screen-share: trying native capture path', { sourceId, fps });
|
||||||
const { Track: LkTrack } = await import('livekit-client');
|
const { Track: LkTrack } = await import('livekit-client');
|
||||||
const maxWidth = ssParams.dims?.width ?? 1920;
|
const maxWidth = ssParams.dims?.width ?? 1920;
|
||||||
const maxHeight = ssParams.dims?.height ?? 1080;
|
const maxHeight = ssParams.dims?.height ?? 1080;
|
||||||
@@ -1236,6 +1246,7 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsScreenSharing(false);
|
setIsScreenSharing(false);
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
console.info('screen-share: native capture active');
|
||||||
setIsScreenSharing(true);
|
setIsScreenSharing(true);
|
||||||
return;
|
return;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -1246,9 +1257,10 @@ export function CallProvider({ children }: { children: ReactNode }) {
|
|||||||
await nativeCaptureRef.current.stop().catch(() => undefined);
|
await nativeCaptureRef.current.stop().catch(() => undefined);
|
||||||
nativeCaptureRef.current = null;
|
nativeCaptureRef.current = null;
|
||||||
}
|
}
|
||||||
if (!(err instanceof NativeCaptureUnavailable)) {
|
console.warn(
|
||||||
console.warn('native screen-capture failed, falling back', err);
|
'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.
|
// Apply a volume to any audio elements already attached for this user.
|
||||||
// Attached elements are tagged with `data-participant` in attachTrack.
|
// 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 {
|
function applyToAttachedElements(userId: string, volume: number): void {
|
||||||
|
const elVolume = Math.min(1, Math.max(0, volume));
|
||||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||||
'audio[data-participant="' + cssEscape(userId) + '"]',
|
'audio[data-participant="' + cssEscape(userId) + '"]',
|
||||||
);
|
);
|
||||||
nodes.forEach((el) => {
|
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
|
// elements are tagged by attachTrack in CallContext with
|
||||||
// `data-participant="<identity>"` + `data-track-source="screenshare"` — the
|
// `data-participant="<identity>"` + `data-track-source="screenshare"` — the
|
||||||
// combined selector makes sure we don't retarget the mic audio for the same
|
// 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 {
|
function applyToAttachedElements(userId: string, volume: number): void {
|
||||||
|
const elVolume = Math.min(1, Math.max(0, volume));
|
||||||
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
const nodes = document.querySelectorAll<HTMLAudioElement>(
|
||||||
'audio[data-participant="' +
|
'audio[data-participant="' +
|
||||||
cssEscape(userId) +
|
cssEscape(userId) +
|
||||||
'"][data-track-source="screenshare"]',
|
'"][data-track-source="screenshare"]',
|
||||||
);
|
);
|
||||||
nodes.forEach((el) => {
|
nodes.forEach((el) => {
|
||||||
el.volume = volume;
|
el.volume = elVolume;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user