// 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; 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)) { 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(); 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( '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, '\\"'); }