mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -10,8 +10,8 @@
|
||||
<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-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BhTaDZYU.js"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+13
-9
@@ -93,30 +93,34 @@ function MobileFeatureTabs({
|
||||
roomPanelId,
|
||||
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 { 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',
|
||||
);
|
||||
return (
|
||||
<section className="panel text-base">
|
||||
<Tabs defaultTab="chat">
|
||||
<Tabs defaultTab="chat" currentTab={activeTab} onTabChange={setActiveTab}>
|
||||
<TabList>
|
||||
<Tab id="chat">Chat</Tab>
|
||||
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
|
||||
@@ -125,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>
|
||||
@@ -141,7 +145,7 @@ function MobileFeatureTabs({
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="vip" keepMounted>
|
||||
<VipPanel />
|
||||
<VipPanel isActive={activeTab === 'vip'} />
|
||||
</TabPanel>
|
||||
<TabPanel id="roomcontrols">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Battery Bar
|
||||
// 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.
|
||||
import React from 'react';
|
||||
import { WARN_DISPLAY_PERCENT } from '../../lib/battery.js';
|
||||
|
||||
const WARN_FLASH_MS = 1600;
|
||||
@@ -10,7 +11,7 @@ function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function BatteryBar({
|
||||
function BatteryBar({
|
||||
visual,
|
||||
orientation = 'horizontal',
|
||||
variant = 'inline',
|
||||
@@ -155,3 +156,5 @@ export default function BatteryBar({
|
||||
</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.
|
||||
// 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({});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,15 +2,17 @@
|
||||
// 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.
|
||||
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 MAX_FONT_PX = 28;
|
||||
const MIN_FONT_PX = 14;
|
||||
|
||||
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
||||
const { session } = useSession();
|
||||
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);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// 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.
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export default function LogPanel() {
|
||||
const logs = useSessionSelector((state) => state.logs);
|
||||
const rendered = useMemo(() => logs.slice().reverse(), [logs]);
|
||||
return (
|
||||
<div className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
@@ -15,10 +17,7 @@ export default function LogPanel() {
|
||||
{logs.length === 0 ? (
|
||||
<p>No logs yet.</p>
|
||||
) : (
|
||||
logs
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
rendered.map((entry) => (
|
||||
<div key={entry.id} className="surface">
|
||||
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 || '');
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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.
|
||||
import { useMemo } 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';
|
||||
|
||||
@@ -35,10 +35,10 @@ export default function RawUserPilePanel({
|
||||
fillHeight = false,
|
||||
compact = false,
|
||||
}) {
|
||||
const { session } = useSession();
|
||||
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) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import VipPanel from '../VipPanel/index.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
@@ -115,30 +116,34 @@ function DriveDockPanel() {
|
||||
}
|
||||
|
||||
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 { 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',
|
||||
);
|
||||
return (
|
||||
<section className="panel text-base">
|
||||
<Tabs defaultTab="telemetry">
|
||||
<Tabs defaultTab="telemetry" currentTab={activeTab} onTabChange={setActiveTab}>
|
||||
<TabList>
|
||||
<Tab id="telemetry">Controls</Tab>
|
||||
<Tab id="vip" highlight={vipClipPlaying ? 'green' : vipMicActive ? 'pink' : 'none'}>
|
||||
@@ -147,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>
|
||||
@@ -179,7 +184,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="vip" keepMounted>
|
||||
<VipPanel />
|
||||
<VipPanel isActive={activeTab === 'vip'} />
|
||||
</TabPanel>
|
||||
<TabPanel id="help">
|
||||
<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.
|
||||
// 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 { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
||||
@@ -32,8 +32,7 @@ export default function RoomCameraPanel({
|
||||
hideHeader = false,
|
||||
panelId = null,
|
||||
}) {
|
||||
const { session } = useSession();
|
||||
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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
cliffColor,
|
||||
} 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 center = size / 2;
|
||||
const offsetY = size * 0.07;
|
||||
@@ -147,3 +147,5 @@ export default function TopDownMapContent({ sensors = {}, variant = 'full', size
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TopDownMapContent);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
// Hud Chat Input
|
||||
// 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.
|
||||
import { useMemo, useState } from 'react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { useChat } from '../../context/ChatContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
export default function HudChatInput({ compact = false }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
function HudChatInput({ compact = false }) {
|
||||
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 { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const canChat = session?.role !== 'spectator';
|
||||
const hideHudChat = session?.role === 'spectator';
|
||||
const currentRoverId = session?.assignment?.roverId || null;
|
||||
const canChat = role !== 'spectator';
|
||||
const hideHudChat = role === 'spectator';
|
||||
const rover = useMemo(
|
||||
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, session?.roster],
|
||||
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, roverRoster],
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||
const ttsPayload = useMemo(() => {
|
||||
@@ -98,3 +99,5 @@ export default function HudChatInput({ compact = false }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(HudChatInput);
|
||||
|
||||
@@ -5,7 +5,7 @@ import React from 'react';
|
||||
import TopDownMap from '../TopDownMap/index.jsx';
|
||||
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||
|
||||
export default function HudOverlay({
|
||||
function HudOverlay({
|
||||
sensors,
|
||||
label,
|
||||
roverColor = null,
|
||||
@@ -174,3 +174,5 @@ export default function HudOverlay({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(HudOverlay);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
|
||||
export default function LightBumpBars({ sensors }) {
|
||||
function LightBumpBars({ sensors }) {
|
||||
const values = [
|
||||
sensors?.lightBumpLeftSignal,
|
||||
sensors?.lightBumpFrontLeftSignal,
|
||||
@@ -47,3 +47,5 @@ export default function LightBumpBars({ sensors }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LightBumpBars);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React from 'react';
|
||||
|
||||
export default function LowBatteryOverlay({ battery, compact = false }) {
|
||||
function LowBatteryOverlay({ battery, compact = false }) {
|
||||
if (!battery?.available) return null;
|
||||
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||
|
||||
@@ -24,3 +24,5 @@ export default function LowBatteryOverlay({ battery, compact = false }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(LowBatteryOverlay);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import React from 'react';
|
||||
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;
|
||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(OvercurrentOverlay);
|
||||
|
||||
@@ -48,7 +48,15 @@ export default function VideoTile({
|
||||
isActiveDriver = false,
|
||||
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 audioRef = useRef(null);
|
||||
const restartTimer = useRef(null);
|
||||
@@ -92,18 +100,29 @@ export default function VideoTile({
|
||||
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
|
||||
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
|
||||
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
|
||||
const overcurrentMotors =
|
||||
wheelOvercurrents == null
|
||||
? []
|
||||
: Object.entries(wheelOvercurrents)
|
||||
.filter(([, active]) => Boolean(active))
|
||||
.map(([key]) => key);
|
||||
const overcurrentMotors = useMemo(
|
||||
() =>
|
||||
wheelOvercurrents == null
|
||||
? []
|
||||
: Object.entries(wheelOvercurrents)
|
||||
.filter(([, active]) => Boolean(active))
|
||||
.map(([key]) => key),
|
||||
[wheelOvercurrents],
|
||||
);
|
||||
const limiterCaps = overcurrentLimiter?.caps || null;
|
||||
const limiterGroups = overcurrentLimiter?.overcurrent?.groups || null;
|
||||
const debugAudio =
|
||||
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugAudio');
|
||||
const debugHud =
|
||||
typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debugHud');
|
||||
const debugFlags = useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { debugAudio: false, debugHud: false };
|
||||
}
|
||||
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(() => {
|
||||
if (!limiterCaps) return null;
|
||||
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)));
|
||||
}, [limiterCaps]);
|
||||
const limiterActive = Boolean(overcurrentLimiter?.isActive);
|
||||
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
|
||||
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||
const overlayVisible = Boolean(overlayMotors.length);
|
||||
const overlayState = useMemo(() => {
|
||||
const motors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
|
||||
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
||||
return {
|
||||
motors,
|
||||
fill,
|
||||
visible: Boolean(motors.length),
|
||||
};
|
||||
}, [overcurrentMotors, limiterActive, limiterFill]);
|
||||
const mainBrushActive = Boolean(
|
||||
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
|
||||
sensors?.wheelOvercurrents?.mainBrush,
|
||||
@@ -188,26 +213,18 @@ export default function VideoTile({
|
||||
},
|
||||
[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(() => {
|
||||
if (!debugHud) return;
|
||||
console.log('[OvercurrentHUD]', {
|
||||
overlayVisible,
|
||||
overlayMotors,
|
||||
overlayFill,
|
||||
overlayVisible: overlayState.visible,
|
||||
overlayMotors: overlayState.motors,
|
||||
overlayFill: overlayState.fill,
|
||||
limiterActive,
|
||||
limiterCaps,
|
||||
limiterGroups,
|
||||
wheelOvercurrents,
|
||||
});
|
||||
}, [debugHud, overlayFill, overlayMotors, overlayVisible, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
|
||||
}, [debugHud, overlayState, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
|
||||
|
||||
useEffect(() => {
|
||||
logAudio('settings/update');
|
||||
@@ -623,10 +640,12 @@ export default function VideoTile({
|
||||
{!noHud ? <HudChatInput compact={mobileHud} /> : null}
|
||||
{!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">
|
||||
{`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>
|
||||
) : 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 && 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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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.
|
||||
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 { COOKIE_KEY_REGEX, flowWrapClass } from '../vip/constants.js';
|
||||
import VipAudioUploadCard from '../vip/VipAudioUploadCard/index.jsx';
|
||||
@@ -12,10 +12,10 @@ import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||
import VipNeatoCard from '../vip/VipNeatoCard.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 {
|
||||
session,
|
||||
neatoLidar,
|
||||
identifySession,
|
||||
requestVerification,
|
||||
requestPrivateRoverAccess,
|
||||
@@ -31,7 +31,7 @@ export default function VipPanel() {
|
||||
neatoPowerCycle,
|
||||
liftUp,
|
||||
liftDown,
|
||||
} = useSession();
|
||||
} = useSessionActions();
|
||||
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||
|
||||
@@ -80,6 +80,7 @@ export default function VipPanel() {
|
||||
<VipNeatoCard
|
||||
neato={session?.neato || null}
|
||||
lidar={neatoLidar}
|
||||
lidarActive={isActive}
|
||||
onStart={neatoStart}
|
||||
onSendHome={neatoSendHome}
|
||||
onLocate={neatoLocate}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Vip Neato Card
|
||||
// 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.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
function normalizeState(value) {
|
||||
return String(value || '').trim();
|
||||
@@ -114,6 +114,7 @@ function buildLidarRenderPoints(points = []) {
|
||||
export default function VipNeatoCard({
|
||||
neato,
|
||||
lidar,
|
||||
lidarActive = true,
|
||||
onStart,
|
||||
onSendHome,
|
||||
onLocate,
|
||||
@@ -140,7 +141,10 @@ export default function VipNeatoCard({
|
||||
const robotError = normalizeState(neato?.telemetry?.robotError) || '--';
|
||||
const robotAlert = normalizeState(neato?.telemetry?.robotAlert) || '--';
|
||||
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 lidarDebug = lidar?.debug && typeof lidar.debug === 'object' ? lidar.debug : null;
|
||||
const lidarReason = normalizeState(lidarDebug?.reason) || '--';
|
||||
@@ -180,13 +184,14 @@ export default function VipNeatoCard({
|
||||
const primaryState = docked ? 'Docked' : uiStateLabel !== '--' ? uiStateLabel : 'Away from dock';
|
||||
|
||||
useEffect(() => {
|
||||
if (!lidarActive) return undefined;
|
||||
if (!lidar || !Array.isArray(lidar.points)) return undefined;
|
||||
setLidarFlash(true);
|
||||
const timer = setTimeout(() => {
|
||||
setLidarFlash(false);
|
||||
}, 120);
|
||||
return () => clearTimeout(timer);
|
||||
}, [lidar]);
|
||||
}, [lidar, lidarActive]);
|
||||
|
||||
return (
|
||||
<section className={`surface text-sm text-slate-200 ${wrapClass}`}>
|
||||
|
||||
@@ -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.
|
||||
/* 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';
|
||||
|
||||
const TelemetryContext = createContext({ frames: {} });
|
||||
const EMPTY_FRAMES = Object.freeze({});
|
||||
const EMPTY_FRAME = null;
|
||||
|
||||
const TelemetryContext = createContext(null);
|
||||
|
||||
export function TelemetryProvider({ children }) {
|
||||
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(() => {
|
||||
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
|
||||
if (!roverId) return;
|
||||
setFrames((prev) => ({
|
||||
...prev,
|
||||
framesRef.current = {
|
||||
...framesRef.current,
|
||||
[roverId]: {
|
||||
roverId,
|
||||
sensors,
|
||||
raw: frame?.data || null,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
};
|
||||
notifyRover(roverId);
|
||||
}
|
||||
|
||||
socket.on('sensorFrame', handleSensorFrame);
|
||||
@@ -31,16 +79,25 @@ export function TelemetryProvider({ children }) {
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const value = useMemo(() => ({ frames }), [frames]);
|
||||
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
|
||||
return <TelemetryContext.Provider value={store}>{children}</TelemetryContext.Provider>;
|
||||
}
|
||||
|
||||
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) {
|
||||
const frames = useTelemetryFrames();
|
||||
if (!roverId) return null;
|
||||
return frames[roverId] ?? null;
|
||||
const store = useContext(TelemetryContext);
|
||||
if (!store) {
|
||||
throw new Error('useTelemetryFrame must be used within TelemetryProvider');
|
||||
}
|
||||
return useSyncExternalStore(
|
||||
(listener) => store.subscribeRover(roverId, listener),
|
||||
() => store.getFrame(roverId),
|
||||
() => EMPTY_FRAME,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -53,20 +53,6 @@ export function useDockIr(sensors, options = {}) {
|
||||
}));
|
||||
}, [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(() => {
|
||||
const now = Date.now();
|
||||
const withWindow = (entry) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user