a5e930ac17
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>
103 lines
3.2 KiB
TypeScript
103 lines
3.2 KiB
TypeScript
// Per-participant output volume overrides. Values are 0..1 (HTMLMediaElement
|
|
// scale). Persisted so the user's mixing choices survive reconnects.
|
|
//
|
|
// The store is intentionally tiny — a flat Record keyed by LiveKit identity
|
|
// (our `userId`). `attachTrack` reads from here when a remote audio track
|
|
// first lands; live changes are pushed to any already-attached audio
|
|
// elements via the `data-participant` attribute selector.
|
|
|
|
const STORAGE_KEY = 'call.participantVolumes.v1';
|
|
const DEFAULT_VOLUME = 1;
|
|
|
|
type VolumeMap = Record<string, number>;
|
|
type Listener = (map: VolumeMap) => void;
|
|
|
|
function load(): VolumeMap {
|
|
try {
|
|
const raw = localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== 'object') return {};
|
|
const out: VolumeMap = {};
|
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
out[k] = clamp(v);
|
|
}
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
// Matches Discord's slider range — up to 200% via a WebAudio GainNode in
|
|
// remoteAudioPipelines (HTMLMediaElement.volume caps at 1.0 on its own).
|
|
const MAX_VOLUME = 2;
|
|
|
|
function clamp(v: number): number {
|
|
if (!Number.isFinite(v)) return 0;
|
|
if (v < 0) return 0;
|
|
if (v > MAX_VOLUME) return MAX_VOLUME;
|
|
return v;
|
|
}
|
|
|
|
let current: VolumeMap = load();
|
|
const listeners = new Set<Listener>();
|
|
|
|
function persist(): void {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
|
|
} catch {
|
|
/* ignore quota */
|
|
}
|
|
}
|
|
|
|
function notify(): void {
|
|
for (const fn of listeners) fn(current);
|
|
}
|
|
|
|
export function getParticipantVolume(userId: string): number {
|
|
return current[userId] ?? DEFAULT_VOLUME;
|
|
}
|
|
|
|
export function setParticipantVolume(userId: string, volume: number): void {
|
|
const next = clamp(volume);
|
|
if (next === (current[userId] ?? DEFAULT_VOLUME)) return;
|
|
current = { ...current, [userId]: next };
|
|
persist();
|
|
applyToAttachedElements(userId, next);
|
|
notify();
|
|
}
|
|
|
|
export function subscribeParticipantVolumes(fn: Listener): () => void {
|
|
listeners.add(fn);
|
|
return () => {
|
|
listeners.delete(fn);
|
|
};
|
|
}
|
|
|
|
// 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 = elVolume;
|
|
});
|
|
}
|
|
|
|
function cssEscape(v: string): string {
|
|
if (typeof (globalThis as { CSS?: { escape?: (s: string) => string } }).CSS
|
|
?.escape === 'function') {
|
|
return (globalThis as { CSS: { escape: (s: string) => string } }).CSS.escape(v);
|
|
}
|
|
return v.replace(/"/g, '\\"');
|
|
}
|