// Volume Settings Card // Purpose: Lets any user set their own horn, TTS, and mic-forward volume. // Scope: Renders the server-resolved ceilings; the server still owns every limit. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import CardFrame from '../CardFrame/index.jsx'; import { useSession } from '../../context/SessionContext.jsx'; import { trackAnalyticsEventThrottled } from '../../analytics/index.js'; /* Sliders are a 0-1 fraction of whichever ceiling the server resolved for this user, so the same three keys describe the fraction, the ceiling, and the gain the rover will actually apply. */ const GAIN_FIELDS = [ { key: 'hornGain', label: 'Horn volume' }, { key: 'ttsGain', label: 'Text-to-speech volume' }, { key: 'forwardGain', label: 'Microphone volume' }, ]; // Dragging a range input fires continuously; persist once the user settles. const COMMIT_DEBOUNCE_MS = 300; function clampFraction(value, fallback = 1) { const num = Number(value); if (!Number.isFinite(num)) return fallback; return Math.max(0, Math.min(1, num)); } function normalizeValues(raw) { const out = {}; GAIN_FIELDS.forEach(({ key }) => { out[key] = clampFraction(raw?.[key], 1); }); return out; } function formatGain(value) { const num = Number(value); return `${Number.isFinite(num) ? num.toFixed(2) : '0.00'}x`; } export default function VolumeSettingsCard() { const { session, setUserAudioGains } = useSession(); const audioGains = session?.audioGains || null; const serverValues = useMemo(() => normalizeValues(audioGains?.values), [audioGains?.values]); const ceilings = audioGains?.ceilings || {}; const boostGranted = Boolean(audioGains?.boostGranted); const [draft, setDraft] = useState(serverValues); const [error, setError] = useState(null); const commitTimerRef = useRef(null); const pendingRef = useRef(null); /* The server is authoritative, so an accepted save or an admin-side change resyncs the sliders. Comparing the serialized values keeps a resync from fighting a drag that is already in flight. */ useEffect(() => { if (pendingRef.current) return; setDraft(serverValues); }, [serverValues]); const commit = useCallback( async (next) => { pendingRef.current = next; try { await setUserAudioGains(next); setError(null); } catch (err) { setError(err?.message || 'Failed to save volume'); setDraft(serverValues); } finally { pendingRef.current = null; } }, [serverValues, setUserAudioGains], ); useEffect(() => () => { if (commitTimerRef.current) clearTimeout(commitTimerRef.current); }, []); const handleChange = (key) => (event) => { const next = clampFraction(event.target.value, 0); const nextDraft = { ...draft, [key]: next }; setDraft(nextDraft); if (commitTimerRef.current) clearTimeout(commitTimerRef.current); commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS); trackAnalyticsEventThrottled( 'settings_change', { setting: key, value: next }, { key: `volume:${key}`, throttleMs: 3 * 1000 }, ); }; // Sliders would be misleading before the first session sync lands. if (!audioGains) return null; return ( {GAIN_FIELDS.map(({ key, label }) => { const ceiling = Number.isFinite(Number(ceilings[key])) ? Number(ceilings[key]) : 0; const fraction = clampFraction(draft[key], 1); const muted = ceiling <= 0; return ( ); })}

{boostGranted ? 'You have a raised volume limit. 100% is the admin-set hard cap for boosted users rather than the normal global gain.' : 'Your limit is the global gain an admin has set. Admins can raise it per user.'}

{error &&

{error}

}
); }