deeper pass

This commit is contained in:
legop3
2026-05-02 17:12:38 -04:00
parent 2f570d92d7
commit 837701eee8
27 changed files with 296 additions and 273 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BCMqnb9w.js"></script>
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
</head>
<body>
+10 -7
View File
@@ -94,23 +94,26 @@ function MobileFeatureTabs({
showTelemetry = true,
}) {
const [activeTab, setActiveTab] = useState('chat');
const session = useSessionSelector((state) => state.session);
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 { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-amber-400';
const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
@@ -126,7 +129,7 @@ function MobileFeatureTabs({
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
+3 -3
View File
@@ -20,7 +20,7 @@ function buildKey(alert) {
export default function AlertFeed({ scale = 1 }) {
const alerts = useSessionSelector((state) => state.alerts);
const session = useSessionSelector((state) => state.session);
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const { pushAlert } = useSessionActions();
const socket = useSocket();
const [now, setNow] = useState(() => Date.now());
@@ -29,7 +29,7 @@ export default function AlertFeed({ scale = 1 }) {
function onButtonIncrement(payload = {}) {
const buttonId = Number(payload.buttonId);
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 count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
const goal = Number.isFinite(button.goal) ? button.goal : 0;
@@ -49,7 +49,7 @@ export default function AlertFeed({ scale = 1 }) {
return () => {
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]);
@@ -2,7 +2,7 @@
// 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.
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 { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
@@ -18,7 +18,7 @@ const BUTTON_TONES = {
};
export default function ButtonBoxPanel() {
const { session } = useSession();
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
const socket = useSocket();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
@@ -29,7 +29,7 @@ export default function ButtonBoxPanel() {
: AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
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;
return [1, 2, 3, 4].map((id) => list.find((entry) => Number(entry?.id) === id) || {
id,
@@ -40,7 +40,7 @@ export default function ButtonBoxPanel() {
rewardNumber: null,
lastRewardAt: null,
});
}, [session?.buttonBox?.buttons]);
}, [buttonBoxButtons]);
const [incFlash, setIncFlash] = useState({});
const [rewardFlash, setRewardFlash] = useState({});
+6 -5
View File
@@ -17,7 +17,9 @@ export default function ChatPanel({
fillHeight = 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 {
messages,
typing,
@@ -38,13 +40,12 @@ export default function ChatPanel({
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
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 currentRoverId = session?.assignment?.roverId || null;
const rover = useMemo(
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, session?.roster],
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
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';
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 {
state: { song, lastControlIntentAt },
overcurrentLimiter,
@@ -20,19 +25,17 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const lastTurnRef = useRef({ active: false, roverId: null });
useEffect(() => {
if (session?.mode !== 'turns') {
if (mode !== 'turns') {
return undefined;
}
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [session?.mode]);
const roverId = session?.assignment?.roverId;
}, [mode]);
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 turnInfo = roverId ? session?.turnQueues?.[roverId] : null;
const socketId = session?.socketId || null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const turnInfo = roverId ? turnQueues?.[roverId] : null;
const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || [];
@@ -49,8 +52,8 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isPreSwitchWindow =
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
@@ -75,20 +78,20 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
enabled: Boolean(roverId),
version: session?.mode,
version: mode,
});
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
const frame = useTelemetryFrame(roverId);
const batteryRecord =
roverId && session?.roster
? session.roster.find((item) => String(item.id) === String(roverId))
roverId && roster
? roster.find((item) => String(item.id) === String(roverId))
: null;
const batteryConfig = batteryRecord?.battery ?? null;
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
useEffect(() => {
if (session?.mode !== 'turns') {
if (mode !== 'turns') {
setTurnCueVisible(false);
setTurnCueStartAt(null);
lastTurnRef.current = { active: false, roverId: null };
@@ -105,7 +108,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
setTurnCueStartAt(null);
}
lastTurnRef.current = { active: isActiveDriver, roverId };
}, [isActiveDriver, roverId, session?.mode]);
}, [isActiveDriver, roverId, mode]);
useEffect(() => {
if (!turnCueVisible || !turnCueStartAt) return;
@@ -9,8 +9,10 @@ const MAX_FONT_PX = 28;
const MIN_FONT_PX = 14;
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
const session = useSessionSelector((state) => state.session);
const goalText = session?.globalObjective?.text ? String(session.globalObjective.text).trim() : '';
const goalText = useSessionSelector((state) => {
const text = state.session?.globalObjective?.text;
return text ? String(text).trim() : '';
});
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
const [visible, setVisible] = useState(false);
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
@@ -243,10 +243,9 @@ export default function HomeAssistantControls() {
const {
state: { keymap },
} = useControlSystem();
const session = useSessionSelector((state) => state.session);
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
useSessionActions();
const ha = session?.homeAssistant;
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
const lightPolicy = ha?.lightPolicy || null;
const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
+11 -11
View File
@@ -28,19 +28,19 @@ function getModeDetails(mode = 'admin') {
}
export default function ModeGateOverlay() {
const session = useSessionSelector((state) => state.session);
const mode = session?.mode;
const role = session?.role;
const reason = session?.adminReason?.text || '';
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
const timezone = session?.timezone || 'UTC';
const discordUrl =
session?.socials?.find((entry) => {
const mode = useSessionSelector((state) => state.session?.mode || null);
const role = useSessionSelector((state) => state.session?.role || null);
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
const discordUrl = useSessionSelector((state) => {
const socials = state.session?.socials || [];
const fromSocials = socials.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
return key === 'discord';
})?.url ||
session?.discord?.invite ||
null;
})?.url;
return fromSocials || state.session?.discord?.invite || null;
});
const restricted = RESTRICTED_MODES.has(mode);
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
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.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useEffect, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
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 [nicknameInput, setNicknameInput] = useState(value.nickname || '');
const [saving, setSaving] = useState(false);
const canSetNickname = session?.role !== 'spectator';
const canSetNickname = role !== 'spectator';
useEffect(() => {
setNicknameInput(value.nickname || '');
@@ -35,10 +35,10 @@ export default function RawUserPilePanel({
fillHeight = false,
compact = false,
}) {
const session = useSessionSelector((state) => state.session);
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const role = useSessionSelector((state) => state.session?.role || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const canSetNickname = role !== 'spectator';
const sorted = useMemo(
() =>
@@ -2,7 +2,7 @@
// 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.
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 { roverNameChromeStyle } from '../../lib/roverColor.js';
@@ -22,8 +22,15 @@ function normalizeSources(list = []) {
}
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
const { session, triggerReplay } = useSession();
const sources = normalizeSources(session?.replaySources || []);
const replaySources = useSessionSelector((state) => state.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 [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
@@ -32,29 +39,28 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const [title, setTitle] = useState('');
const [titleDirty, setTitleDirty] = useState(false);
const [includeSidebar, setIncludeSidebar] = useState(true);
const replayState = session?.replay || null;
const [remainingMs, setRemainingMs] = useState(0);
const defaults = useMemo(() => {
const roverId = session?.assignment?.roverId;
const roverId = assignmentRoverId;
if (roverId) {
return [`rover:${roverId}`];
}
return [];
}, [session?.assignment?.roverId]);
}, [assignmentRoverId]);
const defaultTitle = useMemo(() => {
const self = Array.isArray(session?.users)
? session.users.find((entry) => entry?.socketId === session?.socketId)
const self = Array.isArray(users)
? users.find((entry) => entry?.socketId === selfSocketId)
: null;
const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
const roverId = session?.assignment?.roverId || null;
const roverId = assignmentRoverId || null;
const roverName =
roverId && Array.isArray(session?.roster)
? session.roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
roverId && Array.isArray(roster)
? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
: 'a rover';
return `${nickname} driving ${roverName}`;
}, [session?.users, session?.socketId, session?.assignment?.roverId, session?.roster]);
}, [users, selfSocketId, assignmentRoverId, roster]);
useEffect(() => {
const saved = settings?.[panelId];
@@ -106,7 +112,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
return () => clearInterval(interval);
}, [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) => {
setSelected((prev) => {
+10 -7
View File
@@ -117,23 +117,26 @@ function DriveDockPanel() {
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
const [activeTab, setActiveTab] = useState('telemetry');
const session = useSessionSelector((state) => state.session);
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 { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
const ownRoverId = String(session?.assignment?.roverId || '').trim();
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-red-600';
const pttActive = Boolean(controlState?.mic?.pttActive);
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
const vipMicActive = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
);
const vipClipPlaying = Boolean(
ownRoverId &&
session?.isVerified &&
isVerified &&
pttMode === 'clip' &&
ownAudioForward?.source === 'upload' &&
ownAudioForward?.state === 'playing',
@@ -149,7 +152,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
<span
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
aria-hidden="true"
title={session?.isVerified ? 'Verified' : 'Not verified'}
title={isVerified ? 'Verified' : 'Not verified'}
/>
</span>
</Tab>
@@ -32,8 +32,7 @@ export default function RoomCameraPanel({
hideHeader = false,
panelId = null,
}) {
const session = useSessionSelector((state) => state.session);
const cameras = session?.roomCameras || [];
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() =>
@@ -45,23 +45,20 @@ function formatLabel(user, selfId) {
}
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 [pending, setPending] = useState({});
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(
() =>
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin',
[session?.role],
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
[role],
);
const roster = session?.roster ?? [];
const turnQueues = session?.turnQueues ?? {};
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const hasDeadlines = useMemo(
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues],
@@ -1,20 +1,20 @@
// Social Buttons Grid
// 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.
import { useSession } from '../../context/SessionContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import SocialButton from '../SocialButton/index.jsx';
function normalizeSocials(session) {
const configured = Array.isArray(session?.socials) ? session.socials : null;
function normalizeSocials({ socials, discordInvite, kofiLink }) {
const configured = Array.isArray(socials) ? socials : null;
if (configured && configured.length) {
return configured;
}
const fallback = [];
if (session?.discord?.invite) {
fallback.push({ id: 'discord', label: 'Discord', url: session.discord.invite });
if (discordInvite) {
fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
}
if (session?.kofi?.link) {
fallback.push({ id: 'kofi', label: 'Ko-fi', url: session.kofi.link });
if (kofiLink) {
fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
}
return fallback;
}
@@ -28,8 +28,12 @@ function normalizeEntry(entry) {
}
export default function SocialButtonsGrid({ className = '' }) {
const { session } = useSession();
const socials = normalizeSocials(session)
const socialsInput = useSessionSelector((state) => ({
socials: state.session?.socials ?? [],
discordInvite: state.session?.discord?.invite || null,
kofiLink: state.session?.kofi?.link || null,
}));
const socials = normalizeSocials(socialsInput)
.map(normalizeEntry)
.filter(Boolean)
.slice(0, 4);
+10 -6
View File
@@ -13,8 +13,13 @@ function formatMetric(value, fallback = '--') {
export default function TelemetryPanel() {
const connected = useSessionSelector((state) => state.connected);
const session = useSessionSelector((state) => state.session);
const roverId = session?.assignment?.roverId;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
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 sensors = frame?.sensors || {};
const dockIr = useDockIr(sensors);
@@ -25,14 +30,13 @@ export default function TelemetryPanel() {
const capacity = sensors.batteryCapacityMah;
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
const rawSnippet = frame?.raw ? frame.raw : null;
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
const driverLabel = useMemo(() => {
if (!roverId) return 'n/a';
if (!activeDriverId) return 'Available';
if (activeDriverId === session?.socketId) return 'You';
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
if (activeDriverId === selfSocketId) return 'You';
const user = users.find((entry) => entry.socketId === activeDriverId);
return user?.nickname || activeDriverId.slice(0, 6);
}, [activeDriverId, roverId, session?.socketId, session?.users]);
}, [activeDriverId, roverId, selfSocketId, users]);
return (
<section className="panel-section space-y-0.5 text-base text-slate-100">
@@ -1,7 +1,7 @@
// Turn Alert Listener
// 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.
import { useEffect, useMemo, useRef } from 'react';
import { useEffect, useRef } from 'react';
import turnSound from '../../assets/turn_alert.mp3';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
@@ -24,7 +24,9 @@ function useAudio(src, volume = 1) {
}
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 { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
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 seenRoversRef = useRef(new Set());
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]);
const socketId = session?.socketId || null;
useEffect(() => {
if (!socketId) return;
const newlyMine = [];
@@ -53,7 +52,7 @@ export default function TurnAlertListener() {
});
if (newlyMine.length === 0) return;
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({
title: 'Your turn!',
message: `You now control ${roverName}.`,
@@ -61,7 +60,7 @@ export default function TurnAlertListener() {
});
});
playSound();
}, [assignments, playSound, pushAlert, session?.roster, socketId]);
}, [assignments, playSound, pushAlert, roster, socketId]);
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.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
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 SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
@@ -55,13 +55,14 @@ export default function UserListPanel({
compact = false,
showBothTurnsAndUsers = false,
}) {
const { session } = useSession();
const canSetNickname = session?.role !== 'spectator';
const users = session?.users ?? [];
const selfId = session?.socketId || null;
const isTurnsMode = session?.mode === 'turns';
const turnQueues = session?.turnQueues || {};
const roster = session?.roster || [];
const role = useSessionSelector((state) => state.session?.role || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const canSetNickname = role !== 'spectator';
const isTurnsMode = mode === 'turns';
const [turnView, setTurnView] = useState('queues');
useEffect(() => {
+10 -10
View File
@@ -73,9 +73,9 @@ export function ControlSystemProvider({ children }) {
typeof pageSettings?.driveMacroBackoffEnabled === 'boolean'
? pageSettings.driveMacroBackoffEnabled
: 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 roverId = session?.assignment?.roverId ?? null;
const overcurrentLimiter = useOvercurrentLimiter(roverId);
const driveTransform = useCallback(
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
@@ -88,7 +88,7 @@ export function ControlSystemProvider({ children }) {
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
const turnOnAllLights = useCallback(() => {
const entities = session?.homeAssistant?.entities || [];
const entities = homeAssistantEntities || [];
const targets = entities.filter(
(ent) =>
(ent.type === 'light' || ent.type === 'switch') &&
@@ -105,7 +105,7 @@ export function ControlSystemProvider({ children }) {
});
// Give the loop a chance; clear pending after issuing commands.
pendingLightsRef.current = false;
}, [session?.homeAssistant?.entities, homeAssistantSetState]);
}, [homeAssistantEntities, homeAssistantSetState]);
useEffect(() => {
dispatch({ type: 'control/set-rover', payload: pipeline.roverId });
@@ -114,18 +114,18 @@ export function ControlSystemProvider({ children }) {
useEffect(() => {
const prevMode = prevModeRef.current;
prevModeRef.current = state.mode;
if (prevMode !== 'drive' && state.mode === 'drive' && session?.homeAssistant?.entities) {
if (prevMode !== 'drive' && state.mode === 'drive' && homeAssistantEntities?.length) {
turnOnAllLights();
} else if (prevMode !== 'drive' && state.mode === 'drive') {
pendingLightsRef.current = true;
}
}, [state.mode, session?.homeAssistant?.entities, turnOnAllLights]);
}, [state.mode, homeAssistantEntities, turnOnAllLights]);
useEffect(() => {
if (pendingLightsRef.current && session?.homeAssistant?.entities) {
if (pendingLightsRef.current && homeAssistantEntities?.length) {
turnOnAllLights();
}
}, [session?.homeAssistant?.entities, turnOnAllLights]);
}, [homeAssistantEntities, turnOnAllLights]);
useEffect(() => {
const mergedKeymap = { ...DEFAULT_KEYMAP, ...(controlSettings?.keymap || {}) };
@@ -308,7 +308,7 @@ export function ControlSystemProvider({ children }) {
let macroToRun = macro;
if (macroId === 'drive-sequence') {
turnOnAllLights();
if (!session?.homeAssistant?.entities) {
if (!homeAssistantEntities?.length) {
pendingLightsRef.current = true;
}
recordControlIntent();
@@ -327,7 +327,7 @@ export function ControlSystemProvider({ children }) {
driveMacroBackoffEnabled,
pipeline,
recordControlIntent,
session?.homeAssistant?.entities,
homeAssistantEntities,
state.macros,
turnOnAllLights,
],
+5 -5
View File
@@ -16,13 +16,13 @@ import { bytesToBase64, clampRange, sleep } from './controlMath.js';
export function useCommandPipeline(options = {}) {
const { driveTransform, auxTransform } = options;
const socket = useSocket();
const session = useSessionSelector((state) => state.session);
const roverId = session?.assignment?.roverId;
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const rosterEntry = useMemo(() => {
if (!roverId || !Array.isArray(session?.roster)) return null;
return session.roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, session?.roster]);
if (!roverId || !Array.isArray(roster)) return null;
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
}, [roverId, roster]);
const servoConfig = useMemo(() => {
if (!rosterEntry?.cameraServo || !rosterEntry.cameraServo.enabled) return null;
@@ -136,7 +136,7 @@ export default function KeyboardInputManager() {
sendSong,
},
} = useControlSystem();
const session = useSessionSelector((state) => state.session);
const homeAssistant = useSessionSelector((state) => state.session?.homeAssistant || null);
const { homeAssistantSetState } = useSessionActions();
const { focusChat, blurChat, isChatFocused } = useChat();
const { value: inputSettings } = useSettingsNamespace('inputs', INPUT_SETTINGS_DEFAULTS);
@@ -318,7 +318,7 @@ export default function KeyboardInputManager() {
const triggerHomeAssistantCycle = useCallback(
(targetState) => {
const ha = session?.homeAssistant;
const ha = homeAssistant;
if (!ha?.enabled || !ha?.connected) return;
if (ha?.lightPolicy?.locked || ha?.lightPolicy?.lockedOn) return;
const entities = ha.entities || [];
@@ -346,7 +346,7 @@ export default function KeyboardInputManager() {
}
}
},
[homeAssistantSetState, session?.homeAssistant],
[homeAssistantSetState, homeAssistant],
);
useEffect(() => {
+2 -5
View File
@@ -29,7 +29,7 @@ function clampUnit(value) {
}
export function useOvercurrentLimiter(roverId, options = {}) {
const session = useSessionSelector((state) => state.session);
const role = useSessionSelector((state) => state.session?.role || null);
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const overcurrentFlags = sensors?.wheelOvercurrents || {};
@@ -131,10 +131,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
return { motors, groups };
}, [overcurrentFlags]);
const adminImmune =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
return useMemo(
() => ({
+4 -2
View File
@@ -16,6 +16,8 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => {
if (!socket) return undefined;
@@ -61,7 +63,7 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
totalBytes: prevStats.totalBytes + sizeBytes,
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;
console.log(
'[roomCamera]',
@@ -120,7 +122,7 @@ export function useRoomCameraSnapshots(sourceList = [], options = {}) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
};
}, [socket, idsKey, enabled, connectionNonce]);
}, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feeds;
}
+4 -2
View File
@@ -19,6 +19,8 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
const idsRef = useRef([]);
const [connectionNonce, setConnectionNonce] = useState(0);
const statsRef = useRef(new Map());
const debugSnapshots =
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugSnapshots');
useEffect(() => {
if (!socket) return undefined;
@@ -64,7 +66,7 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
totalBytes: prevStats.totalBytes + sizeBytes,
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;
console.log(
'[roverSnapshot]',
@@ -123,7 +125,7 @@ export function useRoverSnapshots(sourceList = [], options = {}) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
};
}, [socket, idsKey, enabled, connectionNonce]);
}, [socket, idsKey, enabled, connectionNonce, debugSnapshots]);
return feeds;
}