Merge pull request #5 from legop3/webuiopt

Webuiopt
This commit is contained in:
legop3
2026-05-03 22:07:43 -04:00
committed by GitHub
40 changed files with 470 additions and 361 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -10,8 +10,8 @@
<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="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BhTaDZYU.js"></script> <script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css"> <link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
</head> </head>
<body> <body>
+13 -9
View File
@@ -93,30 +93,34 @@ function MobileFeatureTabs({
roomPanelId, roomPanelId,
showTelemetry = true, showTelemetry = true,
}) { }) {
const session = useSessionSelector((state) => state.session); const [activeTab, setActiveTab] = useState('chat');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
return roverId ? state.session?.audioForward?.[roverId] || null : null;
});
const { state: controlState } = useControlSystem(); const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' }); const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400'; const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const pttActive = Boolean(controlState?.mic?.pttActive); const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled); const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live'; const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean( const vipMicActive = Boolean(
ownRoverId && ownRoverId &&
session?.isVerified && isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)), (pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
); );
const vipClipPlaying = Boolean( const vipClipPlaying = Boolean(
ownRoverId && ownRoverId &&
session?.isVerified && isVerified &&
pttMode === 'clip' && pttMode === 'clip' &&
ownAudioForward?.source === 'upload' && ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing', ownAudioForward?.state === 'playing',
); );
return ( return (
<section className="panel text-base"> <section className="panel text-base">
<Tabs defaultTab="chat"> <Tabs defaultTab="chat" currentTab={activeTab} onTabChange={setActiveTab}>
<TabList> <TabList>
<Tab id="chat">Chat</Tab> <Tab id="chat">Chat</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}> <Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
@@ -125,7 +129,7 @@ function MobileFeatureTabs({
<span <span
className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`} className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`}
aria-hidden="true" aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'} title={isVerified ? 'Verified' : 'Not verified'}
/> />
</span> </span>
</Tab> </Tab>
@@ -141,7 +145,7 @@ function MobileFeatureTabs({
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="vip" keepMounted> <TabPanel id="vip" keepMounted>
<VipPanel /> <VipPanel isActive={activeTab === 'vip'} />
</TabPanel> </TabPanel>
<TabPanel id="roomcontrols"> <TabPanel id="roomcontrols">
<div className="space-y-0.5"> <div className="space-y-0.5">
+3 -3
View File
@@ -20,7 +20,7 @@ function buildKey(alert) {
export default function AlertFeed({ scale = 1 }) { export default function AlertFeed({ scale = 1 }) {
const alerts = useSessionSelector((state) => state.alerts); const alerts = useSessionSelector((state) => state.alerts);
const session = useSessionSelector((state) => state.session); const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const { pushAlert } = useSessionActions(); const { pushAlert } = useSessionActions();
const socket = useSocket(); const socket = useSocket();
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -29,7 +29,7 @@ export default function AlertFeed({ scale = 1 }) {
function onButtonIncrement(payload = {}) { function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId); const buttonId = Number(payload.buttonId);
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return; if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
const buttons = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : []; const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {}; const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0; const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
const goal = Number.isFinite(button.goal) ? button.goal : 0; const goal = Number.isFinite(button.goal) ? button.goal : 0;
@@ -49,7 +49,7 @@ export default function AlertFeed({ scale = 1 }) {
return () => { return () => {
socket.off('buttonBox:increment', onButtonIncrement); socket.off('buttonBox:increment', onButtonIncrement);
}; };
}, [pushAlert, session?.buttonBox?.buttons, socket]); }, [pushAlert, buttonBoxButtons, socket]);
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]); const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
+4 -1
View File
@@ -1,6 +1,7 @@
// Battery Bar // Battery Bar
// Purpose: Defines the Battery Bar module and the local helpers/components used in this file. // Purpose: Defines the Battery Bar module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react';
import { WARN_DISPLAY_PERCENT } from '../../lib/battery.js'; import { WARN_DISPLAY_PERCENT } from '../../lib/battery.js';
const WARN_FLASH_MS = 1600; const WARN_FLASH_MS = 1600;
@@ -10,7 +11,7 @@ function classNames(...values) {
return values.filter(Boolean).join(' '); return values.filter(Boolean).join(' ');
} }
export default function BatteryBar({ function BatteryBar({
visual, visual,
orientation = 'horizontal', orientation = 'horizontal',
variant = 'inline', variant = 'inline',
@@ -155,3 +156,5 @@ export default function BatteryBar({
</div> </div>
); );
} }
export default React.memo(BatteryBar);
@@ -2,7 +2,7 @@
// Purpose: Defines the Button Box Panel module and the local helpers/components used in this file. // Purpose: Defines the Button Box Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSocket } from '../../context/SocketContext.jsx'; import { useSocket } from '../../context/SocketContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js'; import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
@@ -18,7 +18,7 @@ const BUTTON_TONES = {
}; };
export default function ButtonBoxPanel() { export default function ButtonBoxPanel() {
const { session } = useSession(); const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const socket = useSocket(); const socket = useSocket();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume) const masterVolume = Number.isFinite(audioSettings?.masterVolume)
@@ -29,7 +29,7 @@ export default function ButtonBoxPanel() {
: AUDIO_SETTINGS_DEFAULTS.alertVolume; : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume)); const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
const buttons = useMemo(() => { const buttons = useMemo(() => {
const list = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : []; const list = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
if (list.length === 4) return list; if (list.length === 4) return list;
return [1, 2, 3, 4].map((id) => list.find((entry) => Number(entry?.id) === id) || { return [1, 2, 3, 4].map((id) => list.find((entry) => Number(entry?.id) === id) || {
id, id,
@@ -40,7 +40,7 @@ export default function ButtonBoxPanel() {
rewardNumber: null, rewardNumber: null,
lastRewardAt: null, lastRewardAt: null,
}); });
}, [session?.buttonBox?.buttons]); }, [buttonBoxButtons]);
const [incFlash, setIncFlash] = useState({}); const [incFlash, setIncFlash] = useState({});
const [rewardFlash, setRewardFlash] = useState({}); const [rewardFlash, setRewardFlash] = useState({});
+6 -5
View File
@@ -17,7 +17,9 @@ export default function ChatPanel({
fillHeight = false, fillHeight = false,
allowSpectatorInput = false, allowSpectatorInput = false,
}) { }) {
const session = useSessionSelector((state) => state.session); const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const { const {
messages, messages,
typing, typing,
@@ -38,13 +40,12 @@ export default function ChatPanel({
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite'); const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms'); const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50)); const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
const canChat = session?.role !== 'spectator' || allowSpectatorInput; const canChat = role !== 'spectator' || allowSpectatorInput;
const listRef = useRef(null); const listRef = useRef(null);
const currentRoverId = session?.assignment?.roverId || null;
const rover = useMemo( const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null, () => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster], [currentRoverId, roster],
); );
const ttsSupported = Boolean(rover?.audio?.ttsEnabled); const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
+18 -15
View File
@@ -10,7 +10,12 @@ import { useControlSystem } from '../../controls/index.js';
import VideoTile from '../VideoTile/index.jsx'; import VideoTile from '../VideoTile/index.jsx';
export default function DriverVideoPanel({layoutFormat = 'desktop'}) { export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const session = useSessionSelector((state) => state.session); const mode = useSessionSelector((state) => state.session?.mode || null);
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const { const {
state: { song, lastControlIntentAt }, state: { song, lastControlIntentAt },
overcurrentLimiter, overcurrentLimiter,
@@ -20,19 +25,17 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const [turnCueStartAt, setTurnCueStartAt] = useState(null); const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const lastTurnRef = useRef({ active: false, roverId: null }); const lastTurnRef = useRef({ active: false, roverId: null });
useEffect(() => { useEffect(() => {
if (session?.mode !== 'turns') { if (mode !== 'turns') {
return undefined; return undefined;
} }
const timer = setInterval(() => setNow(Date.now()), 250); const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [session?.mode]); }, [mode]);
const roverId = session?.assignment?.roverId;
const rosterEntry = const rosterEntry =
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null; roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl); const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
const turnInfo = roverId ? session?.turnQueues?.[roverId] : null; const turnInfo = roverId ? turnQueues?.[roverId] : null;
const socketId = session?.socketId || null; const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId); const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => { const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || []; const queue = turnInfo?.queue || [];
@@ -49,8 +52,8 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const msUntilTurn = deadline ? deadline - now : null; const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null; const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isPreSwitchWindow = const isPreSwitchWindow =
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0; mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow; const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
const turnSeconds = const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null; msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds = const idleSkipSeconds =
@@ -75,20 +78,20 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null; const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], { const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
enabled: Boolean(roverId), enabled: Boolean(roverId),
version: session?.mode, version: mode,
}); });
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null; const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
const frame = useTelemetryFrame(roverId); const frame = useTelemetryFrame(roverId);
const batteryRecord = const batteryRecord =
roverId && session?.roster roverId && roster
? session.roster.find((item) => String(item.id) === String(roverId)) ? roster.find((item) => String(item.id) === String(roverId))
: null; : null;
const batteryConfig = batteryRecord?.battery ?? null; const batteryConfig = batteryRecord?.battery ?? null;
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : ''); const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
useEffect(() => { useEffect(() => {
if (session?.mode !== 'turns') { if (mode !== 'turns') {
setTurnCueVisible(false); setTurnCueVisible(false);
setTurnCueStartAt(null); setTurnCueStartAt(null);
lastTurnRef.current = { active: false, roverId: null }; lastTurnRef.current = { active: false, roverId: null };
@@ -105,7 +108,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
setTurnCueStartAt(null); setTurnCueStartAt(null);
} }
lastTurnRef.current = { active: isActiveDriver, roverId }; lastTurnRef.current = { active: isActiveDriver, roverId };
}, [isActiveDriver, roverId, session?.mode]); }, [isActiveDriver, roverId, mode]);
useEffect(() => { useEffect(() => {
if (!turnCueVisible || !turnCueStartAt) return; if (!turnCueVisible || !turnCueStartAt) return;
@@ -2,15 +2,17 @@
// Purpose: Defines the Global Objective Banner module and the local helpers/components used in this file. // Purpose: Defines the Global Objective Banner module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
const MOBILE_DISMISS_MS = 10000; const MOBILE_DISMISS_MS = 10000;
const MAX_FONT_PX = 28; const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14; const MIN_FONT_PX = 14;
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) { export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
const { session } = useSession(); const goalText = useSessionSelector((state) => {
const goalText = session?.globalObjective?.text ? String(session.globalObjective.text).trim() : ''; const text = state.session?.globalObjective?.text;
return text ? String(text).trim() : '';
});
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile'; const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX); const [fontSize, setFontSize] = useState(MAX_FONT_PX);
@@ -243,10 +243,9 @@ export default function HomeAssistantControls() {
const { const {
state: { keymap }, state: { keymap },
} = useControlSystem(); } = useControlSystem();
const session = useSessionSelector((state) => state.session); const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } = const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions(); useSessionActions();
const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]); const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const lightPolicy = ha?.lightPolicy || null; const lightPolicy = ha?.lightPolicy || null;
const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn); const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
+3 -4
View File
@@ -2,9 +2,11 @@
// Purpose: Defines the Log Panel module and the local helpers/components used in this file. // Purpose: Defines the Log Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useMemo } from 'react';
export default function LogPanel() { export default function LogPanel() {
const logs = useSessionSelector((state) => state.logs); const logs = useSessionSelector((state) => state.logs);
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
return ( return (
<div className="panel-section space-y-0.5 text-base"> <div className="panel-section space-y-0.5 text-base">
<div className="flex items-center justify-between text-sm text-slate-400"> <div className="flex items-center justify-between text-sm text-slate-400">
@@ -15,10 +17,7 @@ export default function LogPanel() {
{logs.length === 0 ? ( {logs.length === 0 ? (
<p>No logs yet.</p> <p>No logs yet.</p>
) : ( ) : (
logs rendered.map((entry) => (
.slice()
.reverse()
.map((entry) => (
<div key={entry.id} className="surface"> <div key={entry.id} className="surface">
<span className="text-amber-400">{entry.timestamp}</span>{' '} <span className="text-amber-400">{entry.timestamp}</span>{' '}
<span className="text-lime-400">[{entry.level}]</span>{' '} <span className="text-lime-400">[{entry.level}]</span>{' '}
+11 -11
View File
@@ -28,19 +28,19 @@ function getModeDetails(mode = 'admin') {
} }
export default function ModeGateOverlay() { export default function ModeGateOverlay() {
const session = useSessionSelector((state) => state.session); const mode = useSessionSelector((state) => state.session?.mode || null);
const mode = session?.mode; const role = useSessionSelector((state) => state.session?.role || null);
const role = session?.role; const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
const reason = session?.adminReason?.text || ''; const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
const reasonUpdatedAt = session?.adminReason?.updatedAt || null; const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const timezone = session?.timezone || 'UTC'; const discordUrl = useSessionSelector((state) => {
const discordUrl = const socials = state.session?.socials || [];
session?.socials?.find((entry) => { const fromSocials = socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase(); const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord'; return key === 'discord';
})?.url || })?.url;
session?.discord?.invite || return fromSocials || state.session?.discord?.invite || null;
null; });
const restricted = RESTRICTED_MODES.has(mode); const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role); const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
const [now, setNow] = useState(() => new Date()); const [now, setNow] = useState(() => new Date());
+4 -3
View File
@@ -2,16 +2,17 @@
// Purpose: Defines the Nickname Form module and the local helpers/components used in this file. // Purpose: Defines the Nickname Form module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
export default function NicknameForm({ compact = false }) { export default function NicknameForm({ compact = false }) {
const { session, setNickname } = useSession(); const role = useSessionSelector((state) => state.session?.role || null);
const { setNickname } = useSessionActions();
const { value, save } = useSettingsNamespace('profile', { nickname: '' }); const { value, save } = useSettingsNamespace('profile', { nickname: '' });
const [nicknameInput, setNicknameInput] = useState(value.nickname || ''); const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const canSetNickname = session?.role !== 'spectator'; const canSetNickname = role !== 'spectator';
useEffect(() => { useEffect(() => {
setNicknameInput(value.nickname || ''); setNicknameInput(value.nickname || '');
@@ -2,7 +2,7 @@
// Purpose: Defines the Raw User Pile Panel module and the local helpers/components used in this file. // Purpose: Defines the Raw User Pile Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx'; import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
@@ -35,10 +35,10 @@ export default function RawUserPilePanel({
fillHeight = false, fillHeight = false,
compact = false, compact = false,
}) { }) {
const { session } = useSession(); const role = useSessionSelector((state) => state.session?.role || null);
const canSetNickname = session?.role !== 'spectator'; const users = useSessionSelector((state) => state.session?.users ?? []);
const users = session?.users ?? []; const selfId = useSessionSelector((state) => state.session?.socketId || null);
const selfId = session?.socketId || null; const canSetNickname = role !== 'spectator';
const sorted = useMemo( const sorted = useMemo(
() => () =>
@@ -2,7 +2,7 @@
// Purpose: Defines the Replay Sources Panel module and the local helpers/components used in this file. // Purpose: Defines the Replay Sources Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { roverNameChromeStyle } from '../../lib/roverColor.js'; import { roverNameChromeStyle } from '../../lib/roverColor.js';
@@ -22,8 +22,15 @@ function normalizeSources(list = []) {
} }
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) { export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
const { session, triggerReplay } = useSession(); const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
const sources = normalizeSources(session?.replaySources || []); const mode = useSessionSelector((state) => state.session?.mode || null);
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const replayState = useSessionSelector((state) => state.session?.replay || null);
const { triggerReplay } = useSessionActions();
const sources = normalizeSources(replaySources || []);
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {}); const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -32,29 +39,28 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [titleDirty, setTitleDirty] = useState(false); const [titleDirty, setTitleDirty] = useState(false);
const [includeSidebar, setIncludeSidebar] = useState(true); const [includeSidebar, setIncludeSidebar] = useState(true);
const replayState = session?.replay || null;
const [remainingMs, setRemainingMs] = useState(0); const [remainingMs, setRemainingMs] = useState(0);
const defaults = useMemo(() => { const defaults = useMemo(() => {
const roverId = session?.assignment?.roverId; const roverId = assignmentRoverId;
if (roverId) { if (roverId) {
return [`rover:${roverId}`]; return [`rover:${roverId}`];
} }
return []; return [];
}, [session?.assignment?.roverId]); }, [assignmentRoverId]);
const defaultTitle = useMemo(() => { const defaultTitle = useMemo(() => {
const self = Array.isArray(session?.users) const self = Array.isArray(users)
? session.users.find((entry) => entry?.socketId === session?.socketId) ? users.find((entry) => entry?.socketId === selfSocketId)
: null; : null;
const nickname = (self?.nickname || 'Someone').trim() || 'Someone'; const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
const roverId = session?.assignment?.roverId || null; const roverId = assignmentRoverId || null;
const roverName = const roverName =
roverId && Array.isArray(session?.roster) roverId && Array.isArray(roster)
? session.roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId ? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
: 'a rover'; : 'a rover';
return `${nickname} driving ${roverName}`; return `${nickname} driving ${roverName}`;
}, [session?.users, session?.socketId, session?.assignment?.roverId, session?.roster]); }, [users, selfSocketId, assignmentRoverId, roster]);
useEffect(() => { useEffect(() => {
const saved = settings?.[panelId]; const saved = settings?.[panelId];
@@ -106,7 +112,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
return () => clearInterval(interval); return () => clearInterval(interval);
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]); }, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length; const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
const toggleKey = (key) => { const toggleKey = (key) => {
setSelected((prev) => { setSelected((prev) => {
+14 -9
View File
@@ -23,6 +23,7 @@ import VipPanel from '../VipPanel/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx'; import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
import { useState } from 'react';
function TopDownMapPanel() { function TopDownMapPanel() {
const { const {
@@ -115,30 +116,34 @@ function DriveDockPanel() {
} }
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) { export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const session = useSessionSelector((state) => state.session); const [activeTab, setActiveTab] = useState('telemetry');
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
const ownAudioForward = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
return roverId ? state.session?.audioForward?.[roverId] || null : null;
});
const { state: controlState } = useControlSystem(); const { state: controlState } = useControlSystem();
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' }); const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600'; const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-red-600';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const pttActive = Boolean(controlState?.mic?.pttActive); const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled); const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live'; const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean( const vipMicActive = Boolean(
ownRoverId && ownRoverId &&
session?.isVerified && isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)), (pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
); );
const vipClipPlaying = Boolean( const vipClipPlaying = Boolean(
ownRoverId && ownRoverId &&
session?.isVerified && isVerified &&
pttMode === 'clip' && pttMode === 'clip' &&
ownAudioForward?.source === 'upload' && ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing', ownAudioForward?.state === 'playing',
); );
return ( return (
<section className="panel text-base"> <section className="panel text-base">
<Tabs defaultTab="telemetry"> <Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={setActiveTab}>
<TabList> <TabList>
<Tab id="telemetry">Controls</Tab> <Tab id="telemetry">Controls</Tab>
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}> <Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
@@ -147,7 +152,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<span <span
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`} className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
aria-hidden="true" aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'} title={isVerified ? 'Verified' : 'Not verified'}
/> />
</span> </span>
</Tab> </Tab>
@@ -179,7 +184,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="vip" keepMounted> <TabPanel id="vip" keepMounted>
<VipPanel /> <VipPanel isActive={activeTab === 'vip'} />
</TabPanel> </TabPanel>
<TabPanel id="help"> <TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} /> <HelpPanel layout={layout} onOpenOverlay={onOpenHelpOverlay} />
@@ -2,7 +2,7 @@
// Purpose: Defines the Room Camera Panel module and the local helpers/components used in this file. // Purpose: Defines the Room Camera Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js'; import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
import RoomCameraFeed from '../RoomCameraFeed/index.jsx'; import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
@@ -32,8 +32,7 @@ export default function RoomCameraPanel({
hideHeader = false, hideHeader = false,
panelId = null, panelId = null,
}) { }) {
const { session } = useSession(); const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
const cameras = session?.roomCameras || [];
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id }))); const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {}); const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() => const [orientation, setOrientation] = useState(() =>
@@ -45,23 +45,20 @@ function formatLabel(user, selfId) {
} }
export default function RoverQueuesPanel({ title = 'Rovers' }) { export default function RoverQueuesPanel({ title = 'Rovers' }) {
const session = useSessionSelector((state) => state.session); const role = useSessionSelector((state) => state.session?.role || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { requestControl } = useSessionActions(); const { requestControl } = useSessionActions();
const [pending, setPending] = useState({}); const [pending, setPending] = useState({});
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]); const canRequest = useMemo(() => role && role !== 'spectator', [role]);
const adminCapable = useMemo( const adminCapable = useMemo(
() => () => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
session?.role === 'admin' || [role],
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin',
[session?.role],
); );
const roster = session?.roster ?? [];
const turnQueues = session?.turnQueues ?? {};
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const hasDeadlines = useMemo( const hasDeadlines = useMemo(
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline), () => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues], [turnQueues],
@@ -1,20 +1,20 @@
// Social Buttons Grid // Social Buttons Grid
// Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file. // Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import SocialButton from '../SocialButton/index.jsx'; import SocialButton from '../SocialButton/index.jsx';
function normalizeSocials(session) { function normalizeSocials({ socials, discordInvite, kofiLink }) {
const configured = Array.isArray(session?.socials) ? session.socials : null; const configured = Array.isArray(socials) ? socials : null;
if (configured && configured.length) { if (configured && configured.length) {
return configured; return configured;
} }
const fallback = []; const fallback = [];
if (session?.discord?.invite) { if (discordInvite) {
fallback.push({ id: 'discord', label: 'Discord', url: session.discord.invite }); fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
} }
if (session?.kofi?.link) { if (kofiLink) {
fallback.push({ id: 'kofi', label: 'Ko-fi', url: session.kofi.link }); fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
} }
return fallback; return fallback;
} }
@@ -28,8 +28,12 @@ function normalizeEntry(entry) {
} }
export default function SocialButtonsGrid({ className = '' }) { export default function SocialButtonsGrid({ className = '' }) {
const { session } = useSession(); const socialsInput = useSessionSelector((state) => ({
const socials = normalizeSocials(session) socials: state.session?.socials ?? [],
discordInvite: state.session?.discord?.invite || null,
kofiLink: state.session?.kofi?.link || null,
}));
const socials = normalizeSocials(socialsInput)
.map(normalizeEntry) .map(normalizeEntry)
.filter(Boolean) .filter(Boolean)
.slice(0, 4); .slice(0, 4);
+10 -6
View File
@@ -13,8 +13,13 @@ function formatMetric(value, fallback = '--') {
export default function TelemetryPanel() { export default function TelemetryPanel() {
const connected = useSessionSelector((state) => state.connected); const connected = useSessionSelector((state) => state.connected);
const session = useSessionSelector((state) => state.session); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roverId = session?.assignment?.roverId; const activeDriverId = useSessionSelector((state) => {
const id = state.session?.assignment?.roverId;
return id ? state.session?.activeDrivers?.[id] || null : null;
});
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const frame = useTelemetryFrame(roverId); const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {}; const sensors = frame?.sensors || {};
const dockIr = useDockIr(sensors); const dockIr = useDockIr(sensors);
@@ -25,14 +30,13 @@ export default function TelemetryPanel() {
const capacity = sensors.batteryCapacityMah; const capacity = sensors.batteryCapacityMah;
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null; const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
const rawSnippet = frame?.raw ? frame.raw : null; const rawSnippet = frame?.raw ? frame.raw : null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const driverLabel = useMemo(() => { const driverLabel = useMemo(() => {
if (!roverId) return 'n/a'; if (!roverId) return 'n/a';
if (!activeDriverId) return 'Available'; if (!activeDriverId) return 'Available';
if (activeDriverId === session?.socketId) return 'You'; if (activeDriverId === selfSocketId) return 'You';
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId); const user = users.find((entry) => entry.socketId === activeDriverId);
return user?.nickname || activeDriverId.slice(0, 6); return user?.nickname || activeDriverId.slice(0, 6);
}, [activeDriverId, roverId, session?.socketId, session?.users]); }, [activeDriverId, roverId, selfSocketId, users]);
return ( return (
<section className="panel-section space-y-0.5 text-base text-slate-100"> <section className="panel-section space-y-0.5 text-base text-slate-100">
@@ -13,7 +13,7 @@ import {
cliffColor, cliffColor,
} from './visuals.jsx'; } from './visuals.jsx';
export default function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize, overlay = false }) { function TopDownMapContent({ sensors = {}, variant = 'full', size: overrideSize, overlay = false }) {
const size = overrideSize || (variant === 'mini' ? 190 : 260); const size = overrideSize || (variant === 'mini' ? 190 : 260);
const center = size / 2; const center = size / 2;
const offsetY = size * 0.07; const offsetY = size * 0.07;
@@ -147,3 +147,5 @@ export default function TopDownMapContent({ sensors = {}, variant = 'full', size
</div> </div>
); );
} }
export default React.memo(TopDownMapContent);
@@ -1,7 +1,7 @@
// Turn Alert Listener // Turn Alert Listener
// Purpose: Defines the Turn Alert Listener module and the local helpers/components used in this file. // Purpose: Defines the Turn Alert Listener module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useRef } from 'react';
import turnSound from '../../assets/turn_alert.mp3'; import turnSound from '../../assets/turn_alert.mp3';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
@@ -24,7 +24,9 @@ function useAudio(src, volume = 1) {
} }
export default function TurnAlertListener() { export default function TurnAlertListener() {
const session = useSessionSelector((state) => state.session); const socketId = useSessionSelector((state) => state.session?.socketId || null);
const assignments = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const { pushAlert } = useSessionActions(); const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS); const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume; const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
@@ -33,9 +35,6 @@ export default function TurnAlertListener() {
const playSound = useAudio(turnSound, effectiveAlertVolume); const playSound = useAudio(turnSound, effectiveAlertVolume);
const seenRoversRef = useRef(new Set()); const seenRoversRef = useRef(new Set());
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]);
const socketId = session?.socketId || null;
useEffect(() => { useEffect(() => {
if (!socketId) return; if (!socketId) return;
const newlyMine = []; const newlyMine = [];
@@ -53,7 +52,7 @@ export default function TurnAlertListener() {
}); });
if (newlyMine.length === 0) return; if (newlyMine.length === 0) return;
newlyMine.forEach((roverId) => { newlyMine.forEach((roverId) => {
const roverName = session?.roster?.find((r) => String(r.id) === String(roverId))?.name || roverId; const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
pushAlert({ pushAlert({
title: 'Your turn!', title: 'Your turn!',
message: `You now control ${roverName}.`, message: `You now control ${roverName}.`,
@@ -61,7 +60,7 @@ export default function TurnAlertListener() {
}); });
}); });
playSound(); playSound();
}, [assignments, playSound, pushAlert, session?.roster, socketId]); }, [assignments, playSound, pushAlert, roster, socketId]);
return null; return null;
} }
+9 -8
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the User List Panel module and the local helpers/components used in this file. // Purpose: Defines the User List Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useMemo, useState, useCallback } from 'react'; import { useEffect, useMemo, useState, useCallback } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import NicknameForm from '../NicknameForm/index.jsx'; import NicknameForm from '../NicknameForm/index.jsx';
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx'; import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js'; import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
@@ -55,13 +55,14 @@ export default function UserListPanel({
compact = false, compact = false,
showBothTurnsAndUsers = false, showBothTurnsAndUsers = false,
}) { }) {
const { session } = useSession(); const role = useSessionSelector((state) => state.session?.role || null);
const canSetNickname = session?.role !== 'spectator'; const users = useSessionSelector((state) => state.session?.users ?? []);
const users = session?.users ?? []; const selfId = useSessionSelector((state) => state.session?.socketId || null);
const selfId = session?.socketId || null; const mode = useSessionSelector((state) => state.session?.mode || null);
const isTurnsMode = session?.mode === 'turns'; const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
const turnQueues = session?.turnQueues || {}; const roster = useSessionSelector((state) => state.session?.roster || []);
const roster = session?.roster || []; const canSetNickname = role !== 'spectator';
const isTurnsMode = mode === 'turns';
const [turnView, setTurnView] = useState('queues'); const [turnView, setTurnView] = useState('queues');
useEffect(() => { useEffect(() => {
@@ -1,23 +1,24 @@
// Hud Chat Input // Hud Chat Input
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file. // Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { useChat } from '../../context/ChatContext.jsx'; import { useChat } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx'; import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
export default function HudChatInput({ compact = false }) { function HudChatInput({ compact = false }) {
const session = useSessionSelector((state) => state.session); const role = useSessionSelector((state) => state.session?.role || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat(); const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 }); const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
const [draft, setDraft] = useState(''); const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const canChat = session?.role !== 'spectator'; const canChat = role !== 'spectator';
const hideHudChat = session?.role === 'spectator'; const hideHudChat = role === 'spectator';
const currentRoverId = session?.assignment?.roverId || null;
const rover = useMemo( const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null, () => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster], [currentRoverId, roverRoster],
); );
const ttsSupported = Boolean(rover?.audio?.ttsEnabled); const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
const ttsPayload = useMemo(() => { const ttsPayload = useMemo(() => {
@@ -98,3 +99,5 @@ export default function HudChatInput({ compact = false }) {
</form> </form>
); );
} }
export default memo(HudChatInput);
@@ -5,7 +5,7 @@ import React from 'react';
import TopDownMap from '../TopDownMap/index.jsx'; import TopDownMap from '../TopDownMap/index.jsx';
import { roverNameChromeStyle } from '../../lib/roverColor.js'; import { roverNameChromeStyle } from '../../lib/roverColor.js';
export default function HudOverlay({ function HudOverlay({
sensors, sensors,
label, label,
roverColor = null, roverColor = null,
@@ -174,3 +174,5 @@ export default function HudOverlay({
</div> </div>
); );
} }
export default React.memo(HudOverlay);
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react'; import React from 'react';
export default function LightBumpBars({ sensors }) { function LightBumpBars({ sensors }) {
const values = [ const values = [
sensors?.lightBumpLeftSignal, sensors?.lightBumpLeftSignal,
sensors?.lightBumpFrontLeftSignal, sensors?.lightBumpFrontLeftSignal,
@@ -47,3 +47,5 @@ export default function LightBumpBars({ sensors }) {
</div> </div>
); );
} }
export default React.memo(LightBumpBars);
@@ -3,7 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import React from 'react'; import React from 'react';
export default function LowBatteryOverlay({ battery, compact = false }) { function LowBatteryOverlay({ battery, compact = false }) {
if (!battery?.available) return null; if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null; if (!battery.warnActive && !battery.urgentActive) return null;
@@ -24,3 +24,5 @@ export default function LowBatteryOverlay({ battery, compact = false }) {
</div> </div>
); );
} }
export default React.memo(LowBatteryOverlay);
@@ -4,7 +4,7 @@
import React from 'react'; import React from 'react';
import { OVERCURRENT_LABELS } from './constants.js'; import { OVERCURRENT_LABELS } from './constants.js';
export default function OvercurrentOverlay({ motors, fill = 0, compact = false }) { function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
if (!motors?.length) return null; if (!motors?.length) return null;
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name); const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]'; const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
@@ -29,3 +29,5 @@ export default function OvercurrentOverlay({ motors, fill = 0, compact = false }
</div> </div>
); );
} }
export default React.memo(OvercurrentOverlay);
+47 -28
View File
@@ -48,7 +48,15 @@ export default function VideoTile({
isActiveDriver = false, isActiveDriver = false,
idleSkipSeconds = null, idleSkipSeconds = null,
}) { }) {
const session = useSessionSelector((state) => state.session); const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const socialUrl =
socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url || null;
return socialUrl || state.session?.discord?.invite || null;
});
const videoRef = useRef(null); const videoRef = useRef(null);
const audioRef = useRef(null); const audioRef = useRef(null);
const restartTimer = useRef(null); const restartTimer = useRef(null);
@@ -92,18 +100,29 @@ export default function VideoTile({
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop; const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig }); const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
const wheelOvercurrents = sensors?.wheelOvercurrents || null; const wheelOvercurrents = sensors?.wheelOvercurrents || null;
const overcurrentMotors = const overcurrentMotors = useMemo(
wheelOvercurrents == null () =>
? [] wheelOvercurrents == null
: Object.entries(wheelOvercurrents) ? []
.filter(([, active]) => Boolean(active)) : Object.entries(wheelOvercurrents)
.map(([key]) => key); .filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
);
const limiterCaps = overcurrentLimiter?.caps || null; const limiterCaps = overcurrentLimiter?.caps || null;
const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null; const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null;
const debugAudio = const debugFlags = useMemo(() => {
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugAudio'); if (typeof window === 'undefined') {
const debugHud = return { debugAudio: false, debugHud: false };
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugHud'); }
const params = new URLSearchParams(window.location.search);
return {
debugAudio: params.has('debugAudio'),
debugHud: params.has('debugHud'),
};
}, []);
const debugAudio = debugFlags.debugAudio;
const debugHud = debugFlags.debugHud;
const limiterFill = useMemo(() => { const limiterFill = useMemo(() => {
if (!limiterCaps) return null; if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1; const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
@@ -111,9 +130,15 @@ export default function VideoTile({
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap))); return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]); }, [limiterCaps]);
const limiterActive = Boolean(overcurrentLimiter?.isActive); const limiterActive = Boolean(overcurrentLimiter?.isActive);
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []; const overlayState = useMemo(() => {
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0); const motors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
const overlayVisible = Boolean(overlayMotors.length); const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
return {
motors,
fill,
visible: Boolean(motors.length),
};
}, [overcurrentMotors, limiterActive, limiterFill]);
const mainBrushActive = Boolean( const mainBrushActive = Boolean(
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA || (Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
sensors?.wheelOvercurrents?.mainBrush, sensors?.wheelOvercurrents?.mainBrush,
@@ -188,26 +213,18 @@ export default function VideoTile({
}, },
[debugAudio, label], [debugAudio, label],
); );
const discordUrl =
session?.socials?.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url ||
session?.discord?.invite ||
null;
useEffect(() => { useEffect(() => {
if (!debugHud) return; if (!debugHud) return;
console.log('[OvercurrentHUD]', { console.log('[OvercurrentHUD]', {
overlayVisible, overlayVisible: overlayState.visible,
overlayMotors, overlayMotors: overlayState.motors,
overlayFill, overlayFill: overlayState.fill,
limiterActive, limiterActive,
limiterCaps, limiterCaps,
limiterGroups, limiterGroups,
wheelOvercurrents, wheelOvercurrents,
}); });
}, [debugHud, overlayFill, overlayMotors, overlayVisible, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]); }, [debugHud, overlayState, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
useEffect(() => { useEffect(() => {
logAudio('settings/update'); logAudio('settings/update');
@@ -623,10 +640,12 @@ export default function VideoTile({
{!noHud ? <HudChatInput compact={mobileHud} /> : null} {!noHud ? <HudChatInput compact={mobileHud} /> : null}
{!noHud && debugHud ? ( {!noHud && debugHud ? (
<div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200"> <div className="pointer-events-none absolute left-1 top-1 z-40 rounded bg-black/80 px-1 py-0.5 text-[0.6rem] text-lime-200">
{`OC vis:${overlayVisible ? 1 : 0} motors:${overlayMotors.length} fill:${Math.round(overlayFill * 100)}%`} {`OC vis:${overlayState.visible ? 1 : 0} motors:${overlayState.motors.length} fill:${Math.round(
overlayState.fill * 100,
)}%`}
</div> </div>
) : null} ) : null}
{!noHud ? <OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} /> : null} {!noHud ? <OvercurrentOverlay motors={overlayState.motors} fill={overlayState.fill} compact={mobileHud} /> : null}
{!noHud ? <LowBatteryOverlay battery={batteryVisual} compact={mobileHud} /> : null} {!noHud ? <LowBatteryOverlay battery={batteryVisual} compact={mobileHud} /> : null}
{!noHud && showVerticalBattery && batteryVisual.available ? ( {!noHud && showVerticalBattery && batteryVisual.available ? (
<div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1"> <div className="pointer-events-none absolute right-1 top-1/2 flex h-[70%] -translate-y-1/2 flex-col items-center justify-center rounded bg-black/60 px-0.5 pb-1 pt-1">
+6 -5
View File
@@ -2,7 +2,7 @@
// Purpose: Defines the Vip Panel module and the local helpers/components used in this file. // Purpose: Defines the Vip Panel module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx'; import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js'; import { useSettingsNamespace } from '../../settings/index.js';
import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js'; import { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js';
import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx'; import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
@@ -12,10 +12,10 @@ import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
import VipNeatoCard from '../vip/VipNeatoCard.jsx'; import VipNeatoCard from '../vip/VipNeatoCard.jsx';
import VipLiftCard from '../vip/VipLiftCard.jsx'; import VipLiftCard from '../vip/VipLiftCard.jsx';
export default function VipPanel() { export default function VipPanel({ isActive = true }) {
const session = useSessionSelector((state) => state.session);
const neatoLidar = useSessionSelector((state) => state.neatoLidar);
const { const {
session,
neatoLidar,
identifySession, identifySession,
requestVerification, requestVerification,
requestPrivateRoverAccess, requestPrivateRoverAccess,
@@ -31,7 +31,7 @@ export default function VipPanel() {
neatoPowerCycle, neatoPowerCycle,
liftUp, liftUp,
liftDown, liftDown,
} = useSession(); } = useSessionActions();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' }); const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' }); const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -80,6 +80,7 @@ export default function VipPanel() {
<VipNeatoCard <VipNeatoCard
neato={session?.neato || null} neato={session?.neato || null}
lidar={neatoLidar} lidar={neatoLidar}
lidarActive={isActive}
onStart={neatoStart} onStart={neatoStart}
onSendHome={neatoSendHome} onSendHome={neatoSendHome}
onLocate={neatoLocate} onLocate={neatoLocate}
+8 -3
View File
@@ -1,7 +1,7 @@
// Vip Neato Card // Vip Neato Card
// Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file. // Purpose: Defines the Vip Neato Card module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit. // Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
function normalizeState(value) { function normalizeState(value) {
return String(value || '').trim(); return String(value || '').trim();
@@ -114,6 +114,7 @@ function buildLidarRenderPoints(points = []) {
export default function VipNeatoCard({ export default function VipNeatoCard({
neato, neato,
lidar, lidar,
lidarActive = true,
onStart, onStart,
onSendHome, onSendHome,
onLocate, onLocate,
@@ -140,7 +141,10 @@ export default function VipNeatoCard({
const robotError = normalizeState(neato?.telemetry?.robotError) || '--'; const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--'; const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : []; const lidarPoints = Array.isArray(lidar?.points) ? lidar.points : [];
const lidarRenderPoints = buildLidarRenderPoints(lidarPoints); const lidarRenderPoints = useMemo(
() => (lidarActive ? buildLidarRenderPoints(lidarPoints) : []),
[lidarActive, lidarPoints],
);
const lidarStatus = normalizeState(lidar?.status) || '--'; const lidarStatus = normalizeState(lidar?.status) || '--';
const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null; const lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
const lidarReason = normalizeState(lidarDebug?.reason) || '--'; const lidarReason = normalizeState(lidarDebug?.reason) || '--';
@@ -180,13 +184,14 @@ export default function VipNeatoCard({
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock'; const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
useEffect(() => { useEffect(() => {
if (!lidarActive) return undefined;
if (!lidar || !Array.isArray(lidar.points)) return undefined; if (!lidar || !Array.isArray(lidar.points)) return undefined;
setLidarFlash(true); setLidarFlash(true);
const timer = setTimeout(() => { const timer = setTimeout(() => {
setLidarFlash(false); setLidarFlash(false);
}, 120); }, 120);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [lidar]); }, [lidar, lidarActive]);
return ( return (
<section className={`surface text-sm text-slate-200 ${wrapClass}`}> <section className={`surface text-sm text-slate-200 ${wrapClass}`}>
+69 -12
View File
@@ -2,27 +2,75 @@
// Purpose: Maintains shared telemetry snapshots and rover status streams for UI consumers. Scope: Subscribes to telemetry events and exposes normalized read APIs to components. // Purpose: Maintains shared telemetry snapshots and rover status streams for UI consumers. Scope: Subscribes to telemetry events and exposes normalized read APIs to components.
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useMemo, useState, useEffect } from 'react'; import { createContext, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import { useSocket } from './SocketContext.jsx'; import { useSocket } from './SocketContext.jsx';
const TelemetryContext = createContext({ frames: {} }); const EMPTY_FRAMES = Object.freeze({});
const EMPTY_FRAME = null;
const TelemetryContext = createContext(null);
export function TelemetryProvider({ children }) { export function TelemetryProvider({ children }) {
const socket = useSocket(); const socket = useSocket();
const [frames, setFrames] = useState({}); const framesRef = useRef({});
const roverSubscribersRef = useRef(new Map());
const allSubscribersRef = useRef(new Set());
const notifyRover = (roverId) => {
const listeners = roverSubscribersRef.current.get(roverId);
if (listeners) {
listeners.forEach((listener) => listener());
}
allSubscribersRef.current.forEach((listener) => listener());
};
const store = useMemo(
() => ({
getFrames: () => framesRef.current,
getFrame: (roverId) => {
if (!roverId) return EMPTY_FRAME;
return framesRef.current[roverId] ?? EMPTY_FRAME;
},
subscribeAll: (listener) => {
allSubscribersRef.current.add(listener);
return () => {
allSubscribersRef.current.delete(listener);
};
},
subscribeRover: (roverId, listener) => {
if (!roverId) return () => {};
let listeners = roverSubscribersRef.current.get(roverId);
if (!listeners) {
listeners = new Set();
roverSubscribersRef.current.set(roverId, listeners);
}
listeners.add(listener);
return () => {
const current = roverSubscribersRef.current.get(roverId);
if (!current) return;
current.delete(listener);
if (!current.size) {
roverSubscribersRef.current.delete(roverId);
}
};
},
}),
[],
);
useEffect(() => { useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) { function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return; if (!roverId) return;
setFrames((prev) => ({ framesRef.current = {
...prev, ...framesRef.current,
[roverId]: { [roverId]: {
roverId, roverId,
sensors, sensors,
raw: frame?.data || null, raw: frame?.data || null,
receivedAt: Date.now(), receivedAt: Date.now(),
}, },
})); };
notifyRover(roverId);
} }
socket.on('sensorFrame', handleSensorFrame); socket.on('sensorFrame', handleSensorFrame);
@@ -31,16 +79,25 @@ export function TelemetryProvider({ children }) {
}; };
}, [socket]); }, [socket]);
const value = useMemo(() => ({ frames }), [frames]); return <TelemetryContext.Provider value={store}>{children}</TelemetryContext.Provider>;
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
} }
export function useTelemetryFrames() { export function useTelemetryFrames() {
return useContext(TelemetryContext).frames; const store = useContext(TelemetryContext);
if (!store) {
throw new Error('useTelemetryFrames must be used within TelemetryProvider');
}
return useSyncExternalStore(store.subscribeAll, store.getFrames, () => EMPTY_FRAMES);
} }
export function useTelemetryFrame(roverId) { export function useTelemetryFrame(roverId) {
const frames = useTelemetryFrames(); const store = useContext(TelemetryContext);
if (!roverId) return null; if (!store) {
return frames[roverId] ?? null; throw new Error('useTelemetryFrame must be used within TelemetryProvider');
}
return useSyncExternalStore(
(listener) => store.subscribeRover(roverId, listener),
() => store.getFrame(roverId),
() => EMPTY_FRAME,
);
} }
+10 -10
View File
@@ -73,9 +73,9 @@ export function ControlSystemProvider({ children }) {
typeof pageSettings?.driveMacroBackoffEnabled === 'boolean' typeof pageSettings?.driveMacroBackoffEnabled === 'boolean'
? pageSettings.driveMacroBackoffEnabled ? pageSettings.driveMacroBackoffEnabled
: true; : true;
const session = useSessionSelector((state) => state.session); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const homeAssistantEntities = useSessionSelector((state) => state.session?.homeAssistant?.entities ?? []);
const { homeAssistantSetState } = useSessionActions(); const { homeAssistantSetState } = useSessionActions();
const roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId); const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback( const driveTransform = useCallback(
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune), (speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
@@ -88,7 +88,7 @@ export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline({ driveTransform, auxTransform }); const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const turnOnAllLights = useCallback(() => { const turnOnAllLights = useCallback(() => {
const entities = session?.homeAssistant?.entities || []; const entities = homeAssistantEntities || [];
const targets = entities.filter( const targets = entities.filter(
(ent) => (ent) =>
(ent.type === 'light' || ent.type === 'switch') && (ent.type === 'light' || ent.type === 'switch') &&
@@ -105,7 +105,7 @@ export function ControlSystemProvider({ children }) {
}); });
// Give the loop a chance; clear pending after issuing commands. // Give the loop a chance; clear pending after issuing commands.
pendingLightsRef.current = false; pendingLightsRef.current = false;
}, [session?.homeAssistant?.entities, homeAssistantSetState]); }, [homeAssistantEntities, homeAssistantSetState]);
useEffect(() => { useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId }); dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
@@ -114,18 +114,18 @@ export function ControlSystemProvider({ children }) {
useEffect(() => { useEffect(() => {
const prevMode = prevModeRef.current; const prevMode = prevModeRef.current;
prevModeRef.current = state.mode; prevModeRef.current = state.mode;
if (prevMode !== 'drive' && state.mode === 'drive' && session?.homeAssistant?.entities) { if (prevMode !== 'drive' && state.mode === 'drive' && homeAssistantEntities?.length) {
turnOnAllLights(); turnOnAllLights();
} else if (prevMode !== 'drive' && state.mode === 'drive') { } else if (prevMode !== 'drive' && state.mode === 'drive') {
pendingLightsRef.current = true; pendingLightsRef.current = true;
} }
}, [state.mode, session?.homeAssistant?.entities, turnOnAllLights]); }, [state.mode, homeAssistantEntities, turnOnAllLights]);
useEffect(() => { useEffect(() => {
if (pendingLightsRef.current && session?.homeAssistant?.entities) { if (pendingLightsRef.current && homeAssistantEntities?.length) {
turnOnAllLights(); turnOnAllLights();
} }
}, [session?.homeAssistant?.entities, turnOnAllLights]); }, [homeAssistantEntities, turnOnAllLights]);
useEffect(() => { useEffect(() => {
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) }; const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
@@ -308,7 +308,7 @@ export function ControlSystemProvider({ children }) {
let macroToRun = macro; let macroToRun = macro;
if (macroId === 'drive-sequence') { if (macroId === 'drive-sequence') {
turnOnAllLights(); turnOnAllLights();
if (!session?.homeAssistant?.entities) { if (!homeAssistantEntities?.length) {
pendingLightsRef.current = true; pendingLightsRef.current = true;
} }
recordControlIntent(); recordControlIntent();
@@ -327,7 +327,7 @@ export function ControlSystemProvider({ children }) {
driveMacroBackoffEnabled, driveMacroBackoffEnabled,
pipeline, pipeline,
recordControlIntent, recordControlIntent,
session?.homeAssistant?.entities, homeAssistantEntities,
state.macros, state.macros,
turnOnAllLights, turnOnAllLights,
], ],
+5 -5
View File
@@ -16,13 +16,13 @@ import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline(options = {}) { export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options; const { driveTransform, auxTransform } = options;
const socket = useSocket(); const socket = useSocket();
const session = useSessionSelector((state) => state.session); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roverId = session?.assignment?.roverId; const roster = useSessionSelector((state) => state.session?.roster ?? []);
const rosterEntry = useMemo(() => { const rosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(session?.roster)) return null; if (!roverId || !Array.isArray(roster)) return null;
return session.roster.find((entry) => String(entry.id) === String(roverId)) || null; return roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, session?.roster]); }, [roverId, roster]);
const servoConfig = useMemo(() => { const servoConfig = useMemo(() => {
if (!rosterEntry?.cameraServo || !rosterEntry.cameraServo.enabled) return null; if (!rosterEntry?.cameraServo || !rosterEntry.cameraServo.enabled) return null;
@@ -136,7 +136,7 @@ export default function KeyboardInputManager() {
sendSong, sendSong,
}, },
} = useControlSystem(); } = useControlSystem();
const session = useSessionSelector((state) => state.session); const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantSetState } = useSessionActions(); const { homeAssistantSetState } = useSessionActions();
const { focusChat, blurChat, isChatFocused } = useChat(); const { focusChat, blurChat, isChatFocused } = useChat();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS); const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
@@ -318,7 +318,7 @@ export default function KeyboardInputManager() {
const triggerHomeAssistantCycle = useCallback( const triggerHomeAssistantCycle = useCallback(
(targetState) => { (targetState) => {
const ha = session?.homeAssistant; const ha = homeAssistant;
if (!ha?.enabled || !ha?.connected) return; if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return; if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return;
const entities = ha.entities || []; const entities = ha.entities || [];
@@ -346,7 +346,7 @@ export default function KeyboardInputManager() {
} }
} }
}, },
[homeAssistantSetState, session?.homeAssistant], [homeAssistantSetState, homeAssistant],
); );
useEffect(() => { useEffect(() => {
+2 -5
View File
@@ -29,7 +29,7 @@ function clampUnit(value) {
} }
export function useOvercurrentLimiter(roverId, options = {}) { export function useOvercurrentLimiter(roverId, options = {}) {
const session = useSessionSelector((state) => state.session); const role = useSessionSelector((state) => state.session?.role || null);
const frame = useTelemetryFrame(roverId); const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {}; const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {}; const overcurrentFlags = sensors?.wheelOvercurrents || {};
@@ -131,10 +131,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
return { motors, groups }; return { motors, groups };
}, [overcurrentFlags]); }, [overcurrentFlags]);
const adminImmune = const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
return useMemo( return useMemo(
() => ({ () => ({
-14
View File
@@ -53,20 +53,6 @@ export function useDockIr(sensors, options = {}) {
})); }));
}, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]); }, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]);
useEffect(() => {
// Debug: log when new codes appear
const { left, right, omni } = state;
const haveAny = left?.ts || right?.ts || omni?.ts;
if (!haveAny) return;
const stamp = new Date().toISOString();
// eslint-disable-next-line no-console
console.debug('[DockIR]', stamp, {
left: left?.code ?? 0,
omni: omni?.code ?? 0,
right: right?.code ?? 0,
});
}, [state.left?.code, state.right?.code, state.omni?.code]);
return useMemo(() => { return useMemo(() => {
const now = Date.now(); const now = Date.now();
const withWindow = (entry) => { const withWindow = (entry) => {
+4 -2
View File
@@ -16,6 +16,8 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
const idsRef = useRef([]); const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0); const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map()); const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => { useEffect(() => {
if (!socket) return undefined; if (!socket) return undefined;
@@ -61,7 +63,7 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
totalBytes: prevStats.totalBytes + sizeBytes, totalBytes: prevStats.totalBytes + sizeBytes,
lastLogAt: prevStats.lastLogAt, lastLogAt: prevStats.lastLogAt,
}; };
if (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000) { if (debugSnapshots && (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000)) {
const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0; const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0;
console.log( console.log(
'[roomCamera]', '[roomCamera]',
@@ -120,7 +122,7 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear(); objectUrls.current.clear();
}; };
}, [socket, idsKey, enabled, connectionNonce]); }, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feeds; return feeds;
} }
+4 -2
View File
@@ -19,6 +19,8 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
const idsRef = useRef([]); const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0); const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map()); const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => { useEffect(() => {
if (!socket) return undefined; if (!socket) return undefined;
@@ -64,7 +66,7 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
totalBytes: prevStats.totalBytes + sizeBytes, totalBytes: prevStats.totalBytes + sizeBytes,
lastLogAt: prevStats.lastLogAt, lastLogAt: prevStats.lastLogAt,
}; };
if (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000) { if (debugSnapshots && (!nextStats.lastLogAt || now - nextStats.lastLogAt >= 10000)) {
const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0; const avgBytes = nextStats.count ? nextStats.totalBytes / nextStats.count : 0;
console.log( console.log(
'[roverSnapshot]', '[roverSnapshot]',
@@ -123,7 +125,7 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear(); objectUrls.current.clear();
}; };
}, [socket, idsKey, enabled, connectionNonce]); }, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feeds; return feeds;
} }