mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
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:
@@ -18,6 +18,44 @@ const MODES = [
|
||||
{ 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) {
|
||||
const safety = rover?.private?.safety || {};
|
||||
return {
|
||||
@@ -72,6 +110,7 @@ export default function AdminPanelContent() {
|
||||
updateAllRovers,
|
||||
rebootServer,
|
||||
setAudioLevels,
|
||||
setUserAudioGainCaps,
|
||||
setPrivateSafety,
|
||||
llmControl,
|
||||
overseerControl,
|
||||
@@ -94,11 +133,13 @@ export default function AdminPanelContent() {
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||
const currentAudioLevels = session?.audioLevels || {};
|
||||
const [audioLevelDraft, setAudioLevelDraft] = useState({
|
||||
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
||||
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||
});
|
||||
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
|
||||
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
||||
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
||||
);
|
||||
const [userGainCapDraft, setUserGainCapDraft] = useState(
|
||||
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
|
||||
);
|
||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = 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 () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('buttonBox:rewardRunLocalTest', {
|
||||
@@ -320,13 +374,15 @@ export default function AdminPanelContent() {
|
||||
}, [currentReason]);
|
||||
|
||||
useEffect(() => {
|
||||
setAudioLevelDraft({
|
||||
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
||||
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||
});
|
||||
setAudioLevelDraft(normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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(() => {
|
||||
setPrivateSafetyDrafts((currentDrafts) => {
|
||||
@@ -420,57 +476,48 @@ export default function AdminPanelContent() {
|
||||
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Horn gain</span>
|
||||
<span>{audioLevelDraft.hornGain.toFixed(2)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="4"
|
||||
step="0.01"
|
||||
value={audioLevelDraft.hornGain}
|
||||
onChange={handleAudioLevelDraft('hornGain')}
|
||||
className="w-full accent-emerald-500"
|
||||
{GAIN_FIELDS.map(({ key, label }) => (
|
||||
<GainSlider
|
||||
key={key}
|
||||
label={label}
|
||||
value={audioLevelDraft[key]}
|
||||
onChange={handleAudioLevelDraft(key)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>TTS gain</span>
|
||||
<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>
|
||||
))}
|
||||
<p className="text-xs text-slate-500">
|
||||
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||
Apply audio levels
|
||||
</button>
|
||||
</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 <vip></code>. A boost never lowers
|
||||
someone'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="flex items-center justify-between text-xs text-slate-400">
|
||||
<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 SocketLogPanel from '../SocketLogPanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import VolumeSettingsCard from '../VolumeSettingsCard/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useHudMapSetting } from '../../hooks/useHudMapSetting.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.
|
||||
</SettingHelp>
|
||||
</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">
|
||||
<SettingRow>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user