feat(webui): Volume settings section and admin boost-cap editor

Adds a server-backed Volume card to page settings with horn, TTS, and
microphone sliders. Each slider is a 0-100% share of the ceiling the
server resolved for that user, and the card shows the resulting
multiplier plus whether the user is on the normal global limit or a
raised VIP limit. A slider whose ceiling is zero renders disabled rather
than pretending to do something.

The admin section gains sliders for the VIP boost hard caps beside the
existing global gains. Both editors now render from one GAIN_FIELDS list
through a shared GainSlider instead of six copied slider blocks.

Includes the rebuilt bundle so the served UI matches the source.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Saul5662
2026-07-29 04:10:26 +01:00
co-authored by Claude
parent 09c1257578
commit 0e1ad8c6a0
11 changed files with 407 additions and 273 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -66
View File
@@ -11,74 +11,9 @@
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<!-- place analytics tags here and they will be injected into <head> of index.html at build time of the web UI. -->
<!-- these tags are loaded PAGE-WIDE, this means /, /spectate, /mini, etc. -->
<script>
/*
Build-time analytics adapter for the rover UI.
React only calls window.roverAnalytics.track/identify. Keeping the Umami
adapter here means analytics can still be removed, replaced, or configured
by changing this injected file instead of rebuilding app logic around a
specific analytics provider.
*/
(function () {
var pendingCalls = [];
var flushTimer = null;
function callUmami(method, args) {
if (!window.umami || typeof window.umami[method] !== 'function') return false;
window.umami[method].apply(window.umami, args);
return true;
}
function flushPendingCalls() {
if (!pendingCalls.length) return;
if (!window.umami) return;
pendingCalls = pendingCalls.filter(function (call) {
return !callUmami(call.method, call.args);
});
if (!pendingCalls.length && flushTimer) {
window.clearInterval(flushTimer);
flushTimer = null;
}
}
function enqueue(method, args) {
if (callUmami(method, args)) return;
pendingCalls.push({ method: method, args: args });
/*
The React app may fire route/session events before Umami's deferred
script has executed. Queueing preserves those early events while still
letting the whole adapter no-op harmlessly if the script is blocked.
*/
if (!flushTimer) {
flushTimer = window.setInterval(flushPendingCalls, 500);
}
}
window.roverAnalytics = {
track: function (name, data) {
enqueue('track', typeof data === 'undefined' ? [name] : [name, data]);
},
identify: function (data) {
enqueue('identify', [data || {}]);
},
};
window.addEventListener('load', flushPendingCalls);
})();
</script>
<!-- otterlytics testing for blocking local -->
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Dsq-cQoD.js"></script> <script type="module" crossorigin src="/assets/index-B8HPO9Qz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3R_zn0v.css"> <link rel="stylesheet" crossorigin href="/assets/index-C3R_zn0v.css">
</head> </head>
<body> <body>
@@ -18,6 +18,44 @@ const MODES = [
{ key: 'lockdown', label: 'Lockdown' }, { key: 'lockdown', label: 'Lockdown' },
]; ];
/*
The same three gain keys drive both the global levels and the VIP boost hard
caps, so both editors render from one list instead of six copied sliders.
*/
const GAIN_FIELDS = [
{ key: 'hornGain', label: 'Horn gain' },
{ key: 'ttsGain', label: 'TTS gain' },
{ key: 'forwardGain', label: 'Forward gain' },
];
function normalizeGainDraft(source, fallback) {
const draft = {};
GAIN_FIELDS.forEach(({ key }) => {
draft[key] = Number.isFinite(source?.[key]) ? source[key] : fallback[key];
});
return draft;
}
function GainSlider({ label, value, onChange }) {
return (
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>{label}</span>
<span>{Number(value).toFixed(2)}x</span>
</div>
<input
type="range"
min="0"
max="4"
step="0.01"
value={value}
onChange={onChange}
className="w-full accent-emerald-500"
/>
</label>
);
}
function buildPrivateSafetyDraft(rover) { function buildPrivateSafetyDraft(rover) {
const safety = rover?.private?.safety || {}; const safety = rover?.private?.safety || {};
return { return {
@@ -72,6 +110,7 @@ export default function AdminPanelContent() {
updateAllRovers, updateAllRovers,
rebootServer, rebootServer,
setAudioLevels, setAudioLevels,
setUserAudioGainCaps,
setPrivateSafety, setPrivateSafety,
llmControl, llmControl,
overseerControl, overseerControl,
@@ -94,11 +133,13 @@ export default function AdminPanelContent() {
const reasonUpdatedAt = session?.adminReason?.updatedAt || null; const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
const [reasonDraft, setReasonDraft] = useState(currentReason); const [reasonDraft, setReasonDraft] = useState(currentReason);
const currentAudioLevels = session?.audioLevels || {}; const currentAudioLevels = session?.audioLevels || {};
const [audioLevelDraft, setAudioLevelDraft] = useState({ const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1, const [audioLevelDraft, setAudioLevelDraft] = useState(
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1, () => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1, );
}); const [userGainCapDraft, setUserGainCapDraft] = useState(
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
);
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({}); const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({}); const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
@@ -299,6 +340,19 @@ export default function AdminPanelContent() {
} }
}; };
const handleUserGainCapDraft = (key) => (event) => {
const next = Number(event.target.value);
setUserGainCapDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 0 }));
};
const handleUserGainCapsSave = async () => {
try {
await setUserAudioGainCaps(userGainCapDraft);
} catch (err) {
alert(err.message);
}
};
const handleTestRewardOverlay = async () => { const handleTestRewardOverlay = async () => {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent('buttonBox:rewardRunLocalTest', { new CustomEvent('buttonBox:rewardRunLocalTest', {
@@ -320,13 +374,15 @@ export default function AdminPanelContent() {
}, [currentReason]); }, [currentReason]);
useEffect(() => { useEffect(() => {
setAudioLevelDraft({ setAudioLevelDraft(normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }));
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1, // eslint-disable-next-line react-hooks/exhaustive-deps
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
});
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]); }, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
useEffect(() => {
setUserGainCapDraft(normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUserGainCaps.forwardGain, currentUserGainCaps.hornGain, currentUserGainCaps.ttsGain]);
useEffect(() => { useEffect(() => {
setPrivateSafetyDrafts((currentDrafts) => { setPrivateSafetyDrafts((currentDrafts) => {
@@ -420,57 +476,48 @@ export default function AdminPanelContent() {
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span> <span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
) : null} ) : null}
</div> </div>
<label className="grid gap-0.5 text-xs text-slate-200"> {GAIN_FIELDS.map(({ key, label }) => (
<div className="flex items-center justify-between gap-0.5"> <GainSlider
<span>Horn gain</span> key={key}
<span>{audioLevelDraft.hornGain.toFixed(2)}x</span> label={label}
</div> value={audioLevelDraft[key]}
<input onChange={handleAudioLevelDraft(key)}
type="range"
min="0"
max="4"
step="0.01"
value={audioLevelDraft.hornGain}
onChange={handleAudioLevelDraft('hornGain')}
className="w-full accent-emerald-500"
/> />
</label> ))}
<label className="grid gap-0.5 text-xs text-slate-200"> <p className="text-xs text-slate-500">
<div className="flex items-center justify-between gap-0.5"> These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
<span>TTS gain</span> </p>
<span>{audioLevelDraft.ttsGain.toFixed(2)}x</span>
</div>
<input
type="range"
min="0"
max="4"
step="0.01"
value={audioLevelDraft.ttsGain}
onChange={handleAudioLevelDraft('ttsGain')}
className="w-full accent-emerald-500"
/>
</label>
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Forward gain</span>
<span>{audioLevelDraft.forwardGain.toFixed(2)}x</span>
</div>
<input
type="range"
min="0"
max="4"
step="0.01"
value={audioLevelDraft.forwardGain}
onChange={handleAudioLevelDraft('forwardGain')}
className="w-full accent-emerald-500"
/>
</label>
<div className="flex gap-0.5 text-xs"> <div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleAudioLevelsSave} className="button-dark"> <button type="button" onClick={handleAudioLevelsSave} className="button-dark">
Apply audio levels Apply audio levels
</button> </button>
</div> </div>
</div> </div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>VIP gain boost hard caps</span>
{session?.audioLevels?.capsUpdatedAt ? (
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
) : null}
</div>
{GAIN_FIELDS.map(({ key, label }) => (
<GainSlider
key={key}
label={label}
value={userGainCapDraft[key]}
onChange={handleUserGainCapDraft(key)}
/>
))}
<p className="text-xs text-slate-500">
Ceilings for VIPs granted the boost with <code>rs gain grant &lt;vip&gt;</code>. A boost never lowers
someone&apos;s limit, so a cap below the global gain above has no effect.
</p>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
Apply boost caps
</button>
</div>
</div>
<div className="space-y-0.5"> <div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>Global objective</span> <span>Global objective</span>
@@ -12,6 +12,7 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
import SessionSnapshot from '../SessionSnapshot/index.jsx'; import SessionSnapshot from '../SessionSnapshot/index.jsx';
import SocketLogPanel from '../SocketLogPanel/index.jsx'; import SocketLogPanel from '../SocketLogPanel/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import VolumeSettingsCard from '../VolumeSettingsCard/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx'; import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useHudMapSetting } from '../../hooks/useHudMapSetting.js'; import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
@@ -468,6 +469,9 @@ export default function SettingsPanel() {
Lowers rover audio only while the main brush is running. Lowers rover audio only while the main brush is running.
</SettingHelp> </SettingHelp>
</CardFrame> </CardFrame>
{/* Volume is server-backed rather than cookie-backed: it changes what the
rover plays for everyone in the room, so the server owns the limits. */}
<VolumeSettingsCard />
<CardFrame title="Connection" bodyClassName="space-y-1 p-1 text-sm"> <CardFrame title="Connection" bodyClassName="space-y-1 p-1 text-sm">
<SettingRow> <SettingRow>
<span className="font-semibold text-white">Transport</span> <span className="font-semibold text-white">Transport</span>
@@ -0,0 +1,141 @@
// 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 (
<CardFrame title="Volume" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
{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 (
<label
key={key}
className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{Math.round(fraction * 100)}% · {formatGain(fraction * ceiling)}
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={fraction}
onChange={handleChange(key)}
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
disabled={muted}
/>
<span className="text-xs text-slate-400">
{muted ? 'Muted by admin gain settings.' : `100% = ${formatGain(ceiling)} (your current limit)`}
</span>
</label>
);
})}
<p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">
{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.'}
</p>
{error && <p className="mx-auto w-full max-w-lg text-xs text-rose-300">{error}</p>}
</CardFrame>
);
}
+7
View File
@@ -422,6 +422,13 @@ export function SessionProvider({ children }) {
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }), readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }), stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels), setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
/*
Personal volume is a 0-1 fraction of whichever ceiling the server has
resolved for this user, so the browser never sends an absolute gain and
cannot widen its own limits.
*/
setUserAudioGains: (gains = {}) => emitWithAck('audioLevels:setUserGains', gains),
setUserAudioGainCaps: (caps = {}) => emitWithAck('audioLevels:setUserCaps', caps),
setPrivateSafety: (roverId, safety = {}) => setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }), emitWithAck('session:privateSafety:set', { roverId, safety }),
ptzClaim: () => emitWithAck('ptzCamera:claim'), ptzClaim: () => emitWithAck('ptzCamera:claim'),