This commit is contained in:
legop3
2026-05-13 19:55:55 -04:00
parent b38a81d002
commit 36a6ee012c
32 changed files with 898 additions and 933 deletions
+4 -4
View File
@@ -16,7 +16,7 @@ import {
} from './controls/index.js';
import RoomCameraPanel from './components/RoomCameraPanel/index.jsx';
import LogPanel from './components/LogPanel/index.jsx';
import DriverVideoPanel from './components/DriverVideoPanel/index.jsx';
import DriverVideo from './components/DriverVideo/index.jsx';
import RightPaneTabs from './components/RightPaneTabs/index.jsx';
import ModeGateOverlay from './components/ModeGateOverlay/index.jsx';
import HomeAssistantControls from './components/HomeAssistantControls/index.jsx';
@@ -75,7 +75,7 @@ function DesktopLayout({ layout, onOpenHelpOverlay }) {
return (
<div className="flex h-full gap-0.5 overflow-hidden">
<div className="flex min-w-0 flex-[1.22] flex-col gap-0.5 overflow-y-auto pr-0">
<DriverVideoPanel />
<DriverVideo />
<TelemetryPanel />
<LogPanel />
</div>
@@ -174,7 +174,7 @@ function MobileFeatureTabs({
function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = false }) {
return (
<div className="flex flex-col gap-0.5">
<DriverVideoPanel layoutFormat="mobile-portrait" />
<DriverVideo layoutFormat="mobile-portrait" />
<MobileControls swapColumns={swapMobileControlColumns} />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
@@ -203,7 +203,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
{firstColumn}
<div>
<DriverVideoPanel layoutFormat="mobile-landscape" />
<DriverVideo layoutFormat="mobile-landscape" />
<div className="grid gap-0.5 grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)]">
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
<RoverQueuesPanel />
@@ -0,0 +1,36 @@
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
export default function DriverVideo({ layoutFormat = 'desktop' }) {
const mobileHud = layoutFormat !== 'desktop';
return (
<section className="panel">
<div className="flex flex-col gap-0.5">
<div className="relative w-full overflow-hidden bg-black aspect-[4/3]">
<RoverMediaPlayer />
<TurnsOverlay mobileHud={mobileHud} />
<RoverDescriptionOverlay
variant="default"
mobileHud={mobileHud}
/>
<HudOverlay
layoutFormat={layoutFormat}
variant="default"
mobileHud={mobileHud}
labelScale={1}
/>
<HudChatInput compact={mobileHud} />
<OvercurrentOverlay compact={mobileHud} />
<LowBatteryOverlay compact={mobileHud} />
</div>
<DriverBottomStrip mobileHud={mobileHud} />
</div>
</section>
);
}
@@ -1,218 +0,0 @@
// Driver Video Panel
// Purpose: Defines the Driver Video 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 { useSessionSelector } from '../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useControlSystem } from '../../controls/index.js';
import VideoTile from '../VideoTile/index.jsx';
function countEligibleDrivers(users = []) {
const unique = new Set();
users.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const roverId = String(entry?.roverId || '').trim();
const socketId = String(entry?.socketId || '').trim();
if (!roverId || !socketId) return;
unique.add(socketId);
});
return unique.size;
}
export default function DriverVideoPanel({ layoutFormat = 'desktop' }) {
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 users = useSessionSelector((state) => state.session?.users ?? []);
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,
} = useControlSystem();
const [now, setNow] = useState(() => Date.now());
const [turnCueVisible, setTurnCueVisible] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const [notTurnFlashAt, setNotTurnFlashAt] = useState(0);
const lastTurnRef = useRef({ initialized: false, roverId: null, activeDriverId: null });
const lastIntentRef = useRef(lastControlIntentAt || 0);
useEffect(() => {
if (mode !== 'turns') {
return undefined;
}
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [mode]);
const rosterEntry =
roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
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 || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) {
return queue[0] || null;
}
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo?.queue, turnInfo?.current]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isTurnsMode = mode === 'turns';
const totalRovers = roster.length;
const totalDrivers = useMemo(() => countEligibleDrivers(users), [users]);
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow = isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const isNotYourTurn = isTurnsMode && !isActiveDriver;
const shouldUsePreview = isNotYourTurn && !isPreSwitchWindow && shouldUsePreviewByLoad;
const shouldShowVideo = !shouldUsePreview;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
: null;
const turnTimerText = useMemo(() => {
if (!isTurnsMode || !isActiveDriver) return null;
return turnSeconds != null ? `${turnSeconds}s left` : 'Your turn';
}, [isTurnsMode, isActiveDriver, turnSeconds]);
const notTurnCountdownText = useMemo(() => {
if (!isNotYourTurn || !isNextDriver || turnSeconds == null) return null;
return `${turnSeconds} seconds until your turn.`;
}, [isNotYourTurn, isNextDriver, turnSeconds]);
const entries = roverId
? [
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
...(hasAudio ? [{ type: 'rover', id: `${roverId}-audio`, key: `${roverId}-audio` }] : []),
]
: [];
const sources = useVideoRequests(entries);
const info = roverId && shouldShowVideo ? sources[roverId] : null;
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
enabled: Boolean(roverId),
version: mode,
});
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
const frame = useTelemetryFrame(roverId);
const batteryRecord =
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 (mode !== 'turns') {
setTurnCueVisible(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
const lastTurn = lastTurnRef.current;
const nextActiveDriverId = activeDriverId || null;
if (!socketId || !roverId) {
setTurnCueVisible(false);
setTurnCueStartAt(null);
lastTurnRef.current = {
initialized: false,
roverId: null,
activeDriverId: null,
};
return;
}
if (!lastTurn.initialized || lastTurn.roverId !== roverId) {
lastTurnRef.current = {
initialized: true,
roverId,
activeDriverId: nextActiveDriverId,
};
return;
}
const becameActive =
Boolean(lastTurn.activeDriverId) &&
lastTurn.activeDriverId !== socketId &&
nextActiveDriverId === socketId;
if (becameActive) {
setTurnCueVisible(true);
setTurnCueStartAt(Date.now());
} else if (nextActiveDriverId !== socketId && turnCueVisible) {
setTurnCueVisible(false);
setTurnCueStartAt(null);
}
lastTurnRef.current = {
initialized: true,
roverId,
activeDriverId: nextActiveDriverId,
};
}, [activeDriverId, mode, roverId, socketId, turnCueVisible]);
useEffect(() => {
if (!turnCueVisible || !turnCueStartAt) return;
if (lastControlIntentAt > turnCueStartAt) {
setTurnCueVisible(false);
}
}, [lastControlIntentAt, turnCueStartAt, turnCueVisible]);
useEffect(() => {
const lastIntent = Number(lastIntentRef.current) || 0;
const nextIntent = Number(lastControlIntentAt) || 0;
const advanced = nextIntent > lastIntent;
if (advanced && isNotYourTurn) {
setNotTurnFlashAt(Date.now());
}
lastIntentRef.current = nextIntent;
}, [isNotYourTurn, lastControlIntentAt]);
return (
<section className="panel">
{roverId ? (
<VideoTile
sessionInfo={info}
videoMode={shouldShowVideo ? 'whep' : 'snapshot'}
snapshotFeed={snapshotFeed}
audioSessionInfo={audioInfo}
label={roverLabel}
roverDescription={batteryRecord?.description}
roverColor={batteryRecord?.color || null}
telemetryFrame={frame}
batteryConfig={batteryConfig}
layoutFormat={layoutFormat}
overcurrentLimiter={overcurrentLimiter}
songNote={song?.note}
qualityNotice={null}
showTurnCue={turnCueVisible}
turnTimerText={turnTimerText}
turnSeconds={turnSeconds}
isActiveDriver={isActiveDriver}
idleSkipSeconds={idleSkipSeconds}
showNotTurnNotice={isNotYourTurn}
notTurnCountdownText={notTurnCountdownText}
showPreviewReason={shouldUsePreview}
notTurnFlashAt={notTurnFlashAt}
controlIntentAt={lastControlIntentAt}
/>
) : (
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
<p>You are not assigned to a rover.</p>
{/* colored button to visit the spectator page */}
<p className="mt-0">
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
Click here to visit the spectator page.
</a>
</p>
</div>
)}
</section>
);
}
@@ -0,0 +1,31 @@
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import LightBumpBars from '../LightBumpBars/index.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
export default function DriverBottomStrip({ roverId = null, mobileHud = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const sensors = frame?.sensors ?? null;
const batteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.battery ?? null;
});
const batteryVisual = buildBatteryVisual({
charge: sensors?.batteryChargeMah ?? null,
config: batteryConfig,
});
return (
<div className="space-y-0.5">
<LightBumpBars roverId={effectiveRoverId} />
<div className="panel-section space-y-0.5 text-sm">
<BatteryBar visual={batteryVisual} compact={mobileHud} />
</div>
</div>
);
}
@@ -2,9 +2,9 @@
// 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 { memo, useMemo, useState } from 'react';
import { useChat } from '../../context/ChatContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/index.js';
import { useChat } from '../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../../settings/index.js';
function HudChatInput({ compact = false }) {
const role = useSessionSelector((state) => state.session?.role || null);
@@ -2,35 +2,64 @@
// Purpose: Defines the Hud Overlay 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 TopDownMap from '../TopDownMap/index.jsx';
import { roverNameChromeStyle } from '../../lib/roverColor.js';
import { useHudMapSetting } from '../../../hooks/useHudMapSetting.js';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import TopDownMap from '../../TopDownMap/index.jsx';
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
function HudOverlay({
roverId = null,
sensors,
label,
roverColor = null,
status,
audioStatus,
levelStatus,
layoutFormat = 'desktop',
variant = 'default',
driverLabel = null,
showTopDown = false,
showTopDown = undefined,
mobileHud = false,
mapPosition = 'top-center',
turnTimerText = null,
turnTimerFlashActive = false,
mapPosition = null,
labelScale = 1,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const rosterInfo = useSessionSelector((state) => {
if (!effectiveRoverId) return { label: null, roverColor: null };
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return {
label: rover?.name || null,
roverColor: rover?.color || null,
};
});
const derivedDriverLabel = useSessionSelector((state) => {
if (!effectiveRoverId || variant !== 'spectator') return null;
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
const users = state.session?.users || [];
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;
const resolvedRoverColor = roverColor ?? rosterInfo.roverColor ?? null;
const resolvedDriverLabel = driverLabel ?? derivedDriverLabel;
const isMobile = mobileHud;
const [showHudMapDesktop] = useHudMapSetting();
const resolvedShowTopDown =
typeof showTopDown === 'boolean'
? showTopDown
: variant === 'spectator'
? true
: isMobile
? true
: showHudMapDesktop;
const resolvedMapPosition =
mapPosition || (variant === 'spectator' ? 'top-center' : isMobile ? 'top-right' : 'top-center');
const portraitMobile = layoutFormat === 'mobile-portrait';
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
const timerTextClass = isMobile ? 'text-[0.5rem]' : 'text-[0.7rem]';
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
const labelWrapperStyle = {
@@ -44,12 +73,16 @@ function HudOverlay({
width: mapSize,
height: mapSize,
opacity: mapOpacity,
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
transform: resolvedMapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
transformOrigin:
mapPosition === 'bottom-left' ? 'bottom left' : mapPosition === 'top-center' ? 'top center' : 'top right',
...(mapPosition === 'bottom-left'
resolvedMapPosition === 'bottom-left'
? 'bottom left'
: resolvedMapPosition === 'top-center'
? 'top center'
: 'top right',
...(resolvedMapPosition === 'bottom-left'
? { left: '0.25rem', bottom: '0.25rem' }
: mapPosition === 'top-center'
: resolvedMapPosition === 'top-center'
? { left: '50%', top: '0.25rem' }
: { right: '0.25rem', top: '0.25rem' }),
};
@@ -60,15 +93,15 @@ function HudOverlay({
if (variant === 'spectator') {
const telemetryEntries = [
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
['OI', sensors?.oiMode?.label || '--'],
['Voltage', resolvedSensors?.voltageMv != null ? `${(resolvedSensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', resolvedSensors?.currentMa != null ? `${resolvedSensors.currentMa} mA` : '--'],
['Charge', resolvedSensors?.batteryChargeMah != null ? `${resolvedSensors.batteryChargeMah}` : '--'],
['OI', resolvedSensors?.oiMode?.label || '--'],
];
const docked = Boolean(sensors?.chargingSources?.homeBase);
const chargingLabel = sensors?.chargingState?.label || '';
const docked = Boolean(resolvedSensors?.chargingSources?.homeBase);
const chargingLabel = resolvedSensors?.chargingState?.label || '';
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
const oiLabel = sensors?.oiMode?.label || 'Unknown';
const oiLabel = resolvedSensors?.oiMode?.label || 'Unknown';
const oiNormalized = oiLabel.toLowerCase();
const oiTone =
oiNormalized === 'full'
@@ -87,15 +120,8 @@ function HudOverlay({
return (
<>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div>
</div>
<div
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]'} ${statusPadClass}`}
>
<div className="space-y-0.5 leading-tight">
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
@@ -120,17 +146,17 @@ function HudOverlay({
>
<span
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(roverColor, 0.18)}
style={roverNameChromeStyle(resolvedRoverColor, 0.18)}
>
{label || 'Unnamed Rover'}
{resolvedLabel || 'Unnamed Rover'}
</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
{resolvedDriverLabel ? <span className="text-slate-300"> {resolvedDriverLabel}</span> : null}
</div>
</div>
</div>
{showTopDown ? (
{resolvedShowTopDown ? (
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
<TopDownMap sensors={sensors} size={240} overlay />
<TopDownMap sensors={resolvedSensors} size={240} overlay />
</div>
) : null}
</>
@@ -139,41 +165,23 @@ function HudOverlay({
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div>
</div>
{turnTimerText ? (
<div
className={`absolute bottom-1 left-1 rounded border ${
turnTimerFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${timerPadClass} ${timerTextClass}`}
>
{turnTimerText}
</div>
) : null}
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
<span>
Rover:{' '}
<span
className="rounded px-1 py-[1px] border border-transparent"
style={roverNameChromeStyle(roverColor, 0.18)}
style={roverNameChromeStyle(resolvedRoverColor, 0.18)}
>
"{label || 'Unnamed Rover'}"
"{resolvedLabel || 'Unnamed Rover'}"
</span>
</span>
</div>
</div>
{showTopDown && variant !== 'spectator' ? (
{resolvedShowTopDown && variant !== 'spectator' ? (
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
<TopDownMap sensors={sensors} size={240} overlay />
<TopDownMap sensors={resolvedSensors} size={240} overlay />
</div>
) : null}
</div>
@@ -2,15 +2,21 @@
// Purpose: Defines the Light Bump Bars 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 { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
function LightBumpBars({ sensors }) {
function LightBumpBars({ roverId = null, sensors }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const values = [
sensors?.lightBumpLeftSignal,
sensors?.lightBumpFrontLeftSignal,
sensors?.lightBumpCenterLeftSignal,
sensors?.lightBumpCenterRightSignal,
sensors?.lightBumpFrontRightSignal,
sensors?.lightBumpRightSignal,
resolvedSensors?.lightBumpLeftSignal,
resolvedSensors?.lightBumpFrontLeftSignal,
resolvedSensors?.lightBumpCenterLeftSignal,
resolvedSensors?.lightBumpCenterRightSignal,
resolvedSensors?.lightBumpFrontRightSignal,
resolvedSensors?.lightBumpRightSignal,
];
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
@@ -0,0 +1,46 @@
// Low Battery Overlay
// Purpose: Defines the Low Battery Overlay 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 { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
function LowBatteryOverlay({ roverId = null, sensors, batteryConfig, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const battery = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
const message = battery.urgentActive
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
: 'Battery low! please dock and charge the rover soon.';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div>
</div>
</div>
);
}
export default React.memo(LowBatteryOverlay);
@@ -0,0 +1,7 @@
export const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel',
rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
limiter: 'Overcurrent limit',
};
@@ -0,0 +1,65 @@
// Overcurrent Overlay
// Purpose: Defines the Overcurrent Overlay 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 { useMemo } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { OVERCURRENT_LABELS } from './constants.js';
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedOvercurrentLimiter = overcurrentLimiter ?? null;
const wheelOvercurrents = resolvedSensors?.wheelOvercurrents || null;
const overcurrentMotors = useMemo(
() =>
wheelOvercurrents == null
? []
: Object.entries(wheelOvercurrents)
.filter(([, active]) => Boolean(active))
.map(([key]) => key),
[wheelOvercurrents],
);
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
const limiterFill = useMemo(() => {
if (!limiterCaps) return null;
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
const motors = useMemo(
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
[overcurrentMotors, limiterActive],
);
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
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]';
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
const safeFill = Math.max(0, Math.min(1, fill));
const fillWidth = `${Math.round(safeFill * 100)}%`;
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-hidden">
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
</div>
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
<div>OVERCURRENT</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div>
</div>
</div>
);
}
export default React.memo(OvercurrentOverlay);
@@ -1,15 +1,30 @@
import { useEffect, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
const DISMISS_AFTER_INPUT_MS = 5000;
const LARGE_FADE_MS = 700;
export default function RoverDescriptionOverlay({
roverId = null,
description,
variant = 'default',
mobileHud = false,
displayKey = '',
controlIntentAt = 0,
controlIntentAt,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const rosterDescription = useSessionSelector((state) => {
if (!effectiveRoverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(effectiveRoverId));
return rover?.description || null;
});
const resolvedDescription = description ?? rosterDescription;
const resolvedControlIntentAt =
typeof controlIntentAt === 'number' ? controlIntentAt : 0;
const resolvedDisplayKey =
displayKey || `${effectiveRoverId || ''}::${resolvedDescription || ''}`;
const [largeVisible, setLargeVisible] = useState(false);
const [largeFading, setLargeFading] = useState(false);
const baselineIntentRef = useRef(0);
@@ -25,7 +40,7 @@ export default function RoverDescriptionOverlay({
);
useEffect(() => {
if (variant !== 'default' || !description) {
if (variant !== 'default' || !resolvedDescription) {
setLargeVisible(false);
setLargeFading(false);
clearTimeout(fadeTimerRef.current);
@@ -36,13 +51,13 @@ export default function RoverDescriptionOverlay({
clearTimeout(hideTimerRef.current);
setLargeVisible(true);
setLargeFading(false);
baselineIntentRef.current = Number(controlIntentAt) || 0;
baselineIntentRef.current = Number(resolvedControlIntentAt) || 0;
return undefined;
}, [description, displayKey, variant]);
}, [resolvedDescription, resolvedDisplayKey, resolvedControlIntentAt, variant]);
useEffect(() => {
if (variant !== 'default' || !description || !largeVisible || largeFading) return;
const nextIntent = Number(controlIntentAt) || 0;
if (variant !== 'default' || !resolvedDescription || !largeVisible || largeFading) return;
const nextIntent = Number(resolvedControlIntentAt) || 0;
if (nextIntent <= baselineIntentRef.current) return;
if (fadeTimerRef.current || hideTimerRef.current) return;
fadeTimerRef.current = setTimeout(() => {
@@ -53,15 +68,15 @@ export default function RoverDescriptionOverlay({
setLargeVisible(false);
hideTimerRef.current = null;
}, DISMISS_AFTER_INPUT_MS + LARGE_FADE_MS);
}, [controlIntentAt, description, largeFading, largeVisible, variant]);
}, [resolvedControlIntentAt, resolvedDescription, largeFading, largeVisible, variant]);
if (!description) return null;
if (!resolvedDescription) return null;
if (variant === 'spectator') {
return (
<div className="pointer-events-none absolute inset-x-0 top-1 z-50 flex justify-center">
<div className="surface max-w-[92%] border border-slate-600/80 px-1 py-0.5 text-center text-[0.62rem] leading-tight text-slate-100 shadow-lg">
{description}
{resolvedDescription}
</div>
</div>
);
@@ -72,12 +87,12 @@ export default function RoverDescriptionOverlay({
return (
<div className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center">
<div
className={`surface max-w-[94%] border border-slate-400/70 bg-neutral-900/45 px-2 py-1 text-center font-semibold text-slate-100 shadow-xl transition-opacity duration-700 ${
className={`surface max-w-[94%] border border-slate-400 bg-neutral-900 px-2 py-1 text-center font-semibold text-slate-100 shadow-xl transition-opacity duration-700 ${
largeFading ? 'opacity-0' : 'opacity-100'
} ${mobileHud ? 'text-[1rem] leading-tight' : 'text-[1.5rem] leading-tight'}`}
>
<p>Just so you know, this rover</p>
<p>{description}</p>
<p>{resolvedDescription}</p>
<p className={`${mobileHud ? 'text-[0.58rem]' : 'text-[0.72rem]'} mt-0.5 font-normal text-slate-300`}>
This fades 5 seconds after your first control input.
</p>
@@ -0,0 +1,230 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useControlSystem } from '../../../controls/index.js';
import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({
roverId = null,
mobileHud = false,
discordUrl: discordUrlProp = null,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
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 {
state: { lastControlIntentAt },
} = useControlSystem();
const effectiveDiscordUrl = discordUrlProp || discordUrl;
const [now, setNow] = useState(() => Date.now());
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
const [notTurnFlashAt, setNotTurnFlashAt] = useState(0);
const lastTurnRef = useRef({ initialized: false, roverId: null, activeDriverId: null });
const lastIntentRef = useRef(lastControlIntentAt || 0);
const timerTextClass = mobileHud ? 'text-[0.5rem]' : 'text-[0.7rem]';
const timerPadClass = mobileHud ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo?.queue, turnInfo?.current]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isTurnsMode = mode === 'turns';
const totalRovers = roster.length;
const totalDrivers = useMemo(() => {
const unique = new Set();
users.forEach((entry) => {
const role = String(entry?.role || '');
if (role === 'spectator') return;
const turnRoverId = String(entry?.roverId || '').trim();
const turnSocketId = String(entry?.socketId || '').trim();
if (!turnRoverId || !turnSocketId) return;
unique.add(turnSocketId);
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
: null;
const turnTimerText = useMemo(() => {
if (!isTurnsMode || !isActiveDriver) return null;
return turnSeconds != null ? `${turnSeconds}s left` : 'Your turn';
}, [isTurnsMode, isActiveDriver, turnSeconds]);
const notTurnCountdownText = useMemo(() => {
if (!showNotTurnNotice || !isNextDriver || turnSeconds == null) return null;
return `${turnSeconds} seconds until your turn.`;
}, [showNotTurnNotice, isNextDriver, turnSeconds]);
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
const turnTimerFlashActive = noticeFlashActive;
useEffect(() => {
if (mode !== 'turns') return undefined;
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [mode]);
useEffect(() => {
if (mode !== 'turns') {
setShowTurnCue(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
const lastTurn = lastTurnRef.current;
const nextActiveDriverId = activeDriverId || null;
if (!socketId || !effectiveRoverId) {
setShowTurnCue(false);
setTurnCueStartAt(null);
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
return;
}
if (!lastTurn.initialized || lastTurn.roverId !== effectiveRoverId) {
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
return;
}
const becameActive =
Boolean(lastTurn.activeDriverId) &&
lastTurn.activeDriverId !== socketId &&
nextActiveDriverId === socketId;
if (becameActive) {
setShowTurnCue(true);
setTurnCueStartAt(Date.now());
} else if (nextActiveDriverId !== socketId && showTurnCue) {
setShowTurnCue(false);
setTurnCueStartAt(null);
}
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
}, [activeDriverId, mode, effectiveRoverId, socketId, showTurnCue]);
useEffect(() => {
if (!showTurnCue || !turnCueStartAt) return;
if (lastControlIntentAt > turnCueStartAt) {
setShowTurnCue(false);
}
}, [lastControlIntentAt, showTurnCue, turnCueStartAt]);
useEffect(() => {
const lastIntent = Number(lastIntentRef.current) || 0;
const nextIntent = Number(lastControlIntentAt) || 0;
if (nextIntent > lastIntent && showNotTurnNotice) {
setNotTurnFlashAt(Date.now());
}
lastIntentRef.current = nextIntent;
}, [lastControlIntentAt, showNotTurnNotice]);
useEffect(() => {
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
setNoticeFlashActive(true);
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
return () => clearTimeout(timer);
}, [showNotTurnNotice, notTurnFlashAt]);
return (
<>
{showTurnCue ? (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div
className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${cuePadClass}`}
>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
{showCountdown ? (
<div className={`text-red-100/90 ${cueTimerClass}`}>
Idle skip in {idleSkipSeconds}s
</div>
) : null}
</div>
</div>
) : null}
{turnTimerText ? (
<div
className={`pointer-events-none absolute bottom-1 left-1 rounded border ${
turnTimerFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${timerPadClass} ${timerTextClass}`}
>
{turnTimerText}
</div>
) : null}
{showNotTurnNotice ? (
<div className="pointer-events-none absolute bottom-1 left-1 z-40">
<div
className={`w-fit rounded border ${
noticeFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'}`}
>
<div
className={
noticeFlashActive
? 'text-[0.82rem] font-semibold text-red-50'
: 'text-[0.82rem] font-semibold text-white'
}
>
Not your turn to drive!
</div>
{notTurnCountdownText ? (
<div className={noticeFlashActive ? 'text-red-100/95' : 'text-amber-100'}>
{notTurnCountdownText}
</div>
) : null}
{showPreviewReason ? (
<div className={noticeFlashActive ? 'text-red-100/90' : 'text-amber-200/85'}>
Video switched to preview mode to save bandwidth.
</div>
) : null}
<div className="pointer-events-auto mt-0.5">
<SocialButton
id="discord"
label="Join our Discord while you wait!"
url={effectiveDiscordUrl}
/>
</div>
</div>
</div>
) : null}
</>
);
}
export default React.memo(TurnsOverlay);
@@ -0,0 +1,36 @@
import React from 'react';
import { useSessionSelector } from '../../../context/SessionContext.jsx';
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
import { buildBatteryVisual } from '../../../lib/battery.js';
import BatteryBar from '../../BatteryBar/index.jsx';
function VerticalBatteryOverlay({ show = false, roverId = null, sensors, batteryConfig, mobileHud = false }) {
const frame = useTelemetryFrame(roverId);
const rosterBatteryConfig = useSessionSelector((state) => {
if (!roverId) return null;
const roster = state.session?.roster || [];
const rover = roster.find((entry) => String(entry.id) === String(roverId));
return rover?.battery ?? null;
});
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const resolvedBatteryConfig = batteryConfig ?? rosterBatteryConfig;
const batteryVisual = buildBatteryVisual({
charge: resolvedSensors?.batteryChargeMah ?? null,
config: resolvedBatteryConfig,
});
if (!show || !batteryVisual?.available) return null;
return (
<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">
<BatteryBar
visual={batteryVisual}
orientation="vertical"
variant="inline"
compact={mobileHud}
className="h-full w-4"
/>
</div>
);
}
export default React.memo(VerticalBatteryOverlay);
@@ -6,11 +6,3 @@ export const UNMUTE_RETRY_MS = 3000;
export const AUDIO_RETRY_MS = 3000;
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
export const DUCK_RELEASE_FADE_MS = 1000;
export const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel',
rightWheel: 'Right wheel',
mainBrush: 'Main brush',
sideBrush: 'Side brush',
limiter: 'Overcurrent limit',
};
@@ -1,22 +1,11 @@
// Video Tile
// Purpose: Defines the Video Tile 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
import { useTelemetryFrame } from '../../context/TelemetryContext.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import SocialButton from '../SocialButton/index.jsx';
import BatteryBar from '../BatteryBar/index.jsx';
import { buildBatteryVisual } from '../../lib/battery.js';
import TurnCueOverlay from './TurnCueOverlay.jsx';
import HudOverlay from './HudOverlay.jsx';
import RoverDescriptionOverlay from './RoverDescriptionOverlay.jsx';
import OvercurrentOverlay from './OvercurrentOverlay.jsx';
import LowBatteryOverlay from './LowBatteryOverlay.jsx';
import LightBumpBars from './LightBumpBars.jsx';
import HudChatInput from './HudChatInput.jsx';
import {
RESTART_DELAY_MS,
UNMUTE_RETRY_MS,
@@ -25,45 +14,55 @@ import {
DUCK_RELEASE_FADE_MS,
} from './constants.js';
export default function VideoTile({
sessionInfo,
audioSessionInfo,
videoMode = 'whep',
export default function RoverMediaPlayer({
roverId = null,
sessionInfo = null,
audioSessionInfo = null,
videoMode = null,
snapshotFeed = null,
qualityNotice = null,
label,
roverDescription = null,
roverColor = null,
forceMute = false,
telemetryFrame,
batteryConfig,
layoutFormat = 'desktop',
hudVariant = 'default',
driverLabel = null,
hudForceMap = false,
hudMapPosition = 'top-center',
hudLabelScale = 1,
fitParent = false,
overcurrentLimiter = null,
showTurnCue = false,
turnTimerText = null,
isActiveDriver = false,
idleSkipSeconds = null,
showNotTurnNotice = false,
notTurnCountdownText = null,
showPreviewReason = false,
notTurnFlashAt = 0,
controlIntentAt = 0,
sensors,
}) {
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 assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const isLocalNetwork = useSessionSelector((state) => Boolean(state.session?.isLocalNetwork));
const rosterEntry = useSessionSelector((state) =>
effectiveRoverId && Array.isArray(state.session?.roster)
? state.session.roster.find((item) => String(item.id) === String(effectiveRoverId)) || null
: null,
);
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
const autoVideoEnabled = videoMode ? videoMode === 'whep' : isLocalNetwork;
const autoEntries = useMemo(() => {
if (!effectiveRoverId || !autoVideoEnabled) return [];
return [
{ type: 'rover', id: effectiveRoverId, key: effectiveRoverId },
...(hasAudio
? [{ type: 'rover', id: `${effectiveRoverId}-audio`, key: `${effectiveRoverId}-audio` }]
: []),
];
}, [effectiveRoverId, autoVideoEnabled, hasAudio]);
const autoSources = useVideoRequests(autoEntries, {
enabled: Boolean(effectiveRoverId && autoVideoEnabled),
version: mode,
});
const resolvedSessionInfo =
sessionInfo ?? (effectiveRoverId ? autoSources[effectiveRoverId] || null : null);
const resolvedAudioSessionInfo =
audioSessionInfo ??
(effectiveRoverId && hasAudio ? autoSources[`${effectiveRoverId}-audio`] || null : null);
const autoSnapshots = useRoverSnapshots(effectiveRoverId ? [effectiveRoverId] : [], {
enabled: Boolean(effectiveRoverId && !resolvedSessionInfo?.url),
version: mode,
});
const resolvedSnapshotFeed =
snapshotFeed ?? (effectiveRoverId ? autoSnapshots[effectiveRoverId] || null : null);
const resolvedLabel =
label || rosterEntry?.name || (effectiveRoverId ? `Rover ${effectiveRoverId}` : 'Rover');
const frame = useTelemetryFrame(effectiveRoverId);
const resolvedSensors = sensors ?? frame?.sensors ?? null;
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
@@ -79,10 +78,8 @@ export default function VideoTile({
const [restartToken, setRestartToken] = useState(0);
const [audioRestartToken, setAudioRestartToken] = useState(0);
const [muted, setMuted] = useState(true);
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
const hasDedicatedAudio = Boolean(audioSessionInfo?.url);
const usingSnapshot = videoMode === 'snapshot';
const sensors = telemetryFrame?.sensors;
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
? audioSettings.masterVolume
@@ -100,63 +97,21 @@ export default function VideoTile({
? Math.max(0, Math.min(1, audioSettings.mainBrushDuckAmount))
: AUDIO_SETTINGS_DEFAULTS.mainBrushDuckAmount;
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
const batteryCharge = sensors?.batteryChargeMah ?? null;
const desktopLayout = layoutFormat === 'desktop';
const mobileHud = !desktopLayout;
const effectiveHudMapPosition = mobileHud ? 'top-right' : hudMapPosition;
const [showHudMapDesktop] = useHudMapSetting();
const showHudMap = hudForceMap ? true : mobileHud ? true : showHudMapDesktop;
const batteryVisual = buildBatteryVisual({ charge: batteryCharge, config: batteryConfig });
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
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 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;
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
}, [limiterCaps]);
const limiterActive = Boolean(overcurrentLimiter?.isActive);
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,
(Number(resolvedSensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
resolvedSensors?.wheelOvercurrents?.mainBrush,
);
const duckGain = mainBrushDuckEnabled && mainBrushActive ? 1 - mainBrushDuckAmount : 1;
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
const levelIndicator =
mainBrushDuckEnabled && mainBrushActive && mainBrushDuckAmount > 0
? `Volume decreased ${Math.round(mainBrushDuckAmount * 1000) / 10}%`
: null;
const debugAudio = useMemo(() => {
if (typeof window === 'undefined') {
return false;
}
const params = new URLSearchParams(window.location.search);
return params.has('debugAudio');
}, []);
const audioDebugStateRef = useRef({
hasDedicatedAudio: false,
audioUrl: null,
@@ -169,7 +124,7 @@ export default function VideoTile({
useEffect(() => {
audioDebugStateRef.current = {
hasDedicatedAudio,
audioUrl: audioSessionInfo?.url || null,
audioUrl: resolvedAudioSessionInfo?.url || null,
mainBrushDuckEnabled,
mainBrushDuckAmount,
mainBrushActive,
@@ -178,13 +133,14 @@ export default function VideoTile({
};
}, [
hasDedicatedAudio,
audioSessionInfo?.url,
resolvedAudioSessionInfo?.url,
mainBrushDuckEnabled,
mainBrushDuckAmount,
mainBrushActive,
baseRoverGain,
effectiveRoverGain,
]);
const logAudio = useCallback(
(event, meta = {}) => {
if (!debugAudio) return;
@@ -193,7 +149,7 @@ export default function VideoTile({
const payload = {
event,
ts: Date.now(),
roverLabel: label || null,
roverLabel: resolvedLabel || null,
hasDedicatedAudio: state.hasDedicatedAudio,
audioUrl: state.audioUrl,
mainBrushDuckEnabled: state.mainBrushDuckEnabled,
@@ -219,20 +175,8 @@ export default function VideoTile({
console.log('[AudioDebug]', event, payload);
}
},
[debugAudio, label],
[debugAudio, resolvedLabel],
);
useEffect(() => {
if (!debugHud) return;
console.log('[OvercurrentHUD]', {
overlayVisible: overlayState.visible,
overlayMotors: overlayState.motors,
overlayFill: overlayState.fill,
limiterActive,
limiterCaps,
limiterGroups,
wheelOvercurrents,
});
}, [debugHud, overlayState, limiterActive, limiterCaps, limiterGroups, wheelOvercurrents]);
useEffect(() => {
logAudio('settings/update');
@@ -306,13 +250,6 @@ export default function VideoTile({
[],
);
useEffect(() => {
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
setNoticeFlashActive(true);
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
return () => clearTimeout(timer);
}, [showNotTurnNotice, notTurnFlashAt]);
useEffect(() => {
if (status === 'playing') {
attemptUnmute(0);
@@ -327,16 +264,16 @@ export default function VideoTile({
}, [usingSnapshot]);
useEffect(() => {
if (usingSnapshot || !sessionInfo?.url || !videoRef.current) {
if (usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
return undefined;
}
let active = true;
let player;
const resetMuteId = setTimeout(() => setMuted(true), 0);
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
setDetail(info || null);
if (nextStatus === 'playing') {
ensurePlayback();
@@ -347,8 +284,8 @@ export default function VideoTile({
};
player = new WhepPlayer({
url: sessionInfo.url,
token: sessionInfo.token,
url: resolvedSessionInfo.url,
token: resolvedSessionInfo.token,
video: videoRef.current,
receiveAudio: !hasDedicatedAudio,
onStatus: handleStatus,
@@ -366,17 +303,26 @@ export default function VideoTile({
clearTimeout(resetMuteId);
player?.stop();
};
}, [usingSnapshot, sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback, hasDedicatedAudio, logAudio]);
}, [
usingSnapshot,
resolvedSessionInfo?.url,
resolvedSessionInfo?.token,
restartToken,
scheduleRestart,
ensurePlayback,
hasDedicatedAudio,
logAudio,
]);
useEffect(() => {
if (status === 'stopped' && sessionInfo?.url) {
if (status === 'stopped' && resolvedSessionInfo?.url) {
scheduleRestart();
}
}, [status, sessionInfo?.url, scheduleRestart]);
}, [status, resolvedSessionInfo?.url, scheduleRestart]);
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !audioSessionInfo?.url) {
if (!audioEl || !resolvedAudioSessionInfo?.url) {
logAudio('route/no-audio-url');
appliedVolumeRef.current = null;
return;
@@ -417,7 +363,7 @@ export default function VideoTile({
duckAmount: mainBrushDuckAmount,
});
}, [
audioSessionInfo?.url,
resolvedAudioSessionInfo?.url,
effectiveRoverGain,
mainBrushDuckEnabled,
mainBrushActive,
@@ -425,9 +371,8 @@ export default function VideoTile({
logAudio,
]);
// Audio-only WHEP (no pausing/muting; keeps trying to play)
useEffect(() => {
if (!audioSessionInfo?.url || !audioRef.current) {
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
return undefined;
}
let active = true;
@@ -451,8 +396,8 @@ export default function VideoTile({
};
player = new WhepPlayer({
url: audioSessionInfo.url,
token: audioSessionInfo.token,
url: resolvedAudioSessionInfo.url,
token: resolvedAudioSessionInfo.token,
video: audioRef.current,
audioOnly: true,
onStatus: handleStatus,
@@ -470,17 +415,16 @@ export default function VideoTile({
player?.stop();
};
}, [
audioSessionInfo?.url,
audioSessionInfo?.token,
resolvedAudioSessionInfo?.url,
resolvedAudioSessionInfo?.token,
audioRestartToken,
scheduleAudioRestart,
logAudio,
]);
// Keep nudging the audio element to play in case autoplay was blocked.
useEffect(() => {
const audioEl = audioRef.current;
if (!audioSessionInfo?.url || !audioEl) {
if (!resolvedAudioSessionInfo?.url || !audioEl) {
clearInterval(audioPlayInterval.current);
return undefined;
}
@@ -513,13 +457,8 @@ export default function VideoTile({
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
return () => clearInterval(audioPlayInterval.current);
}, [
audioSessionInfo?.url,
audioStatus,
logAudio,
]);
}, [resolvedAudioSessionInfo?.url, audioStatus, logAudio]);
// Reflect audio element events back into status/detail so the HUD stays accurate.
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl) return undefined;
@@ -570,185 +509,64 @@ export default function VideoTile({
audioEl.removeEventListener('canplay', handleCanPlay);
audioEl.removeEventListener('stalled', handleStalled);
};
}, [audioSessionInfo?.url, logAudio]);
}, [resolvedAudioSessionInfo?.url, logAudio]);
const snapshotStatus = snapshotFeed?.error
? `Error: ${snapshotFeed.error}`
: snapshotFeed?.objectUrl
const snapshotStatus = resolvedSnapshotFeed?.error
? `Error: ${resolvedSnapshotFeed.error}`
: resolvedSnapshotFeed?.objectUrl
? 'snapshot'
: snapshotFeed?.status || 'waiting';
: resolvedSnapshotFeed?.status || 'waiting';
const renderedStatus = usingSnapshot
? snapshotStatus
: !sessionInfo?.url
: !resolvedSessionInfo?.url
? 'waiting'
: status === 'error'
? `Error: ${detail || 'unknown'}`
: detail
? `${status} (${detail})`
: status;
const renderedAudioStatus = audioSessionInfo?.error
? `Error: ${audioSessionInfo.error}`
: !audioSessionInfo?.url
const renderedAudioStatus = resolvedAudioSessionInfo?.error
? `Error: ${resolvedAudioSessionInfo.error}`
: !resolvedAudioSessionInfo?.url
? null
: audioStatus === 'error'
? `Error: ${audioDetail || 'unknown'}`
: audioDetail
? `${audioStatus} (${audioDetail})`
: audioStatus;
const showVerticalBattery = hudVariant === 'spectator';
const noHud = hudVariant === 'none';
const descriptionDisplayKey = `${label || ''}::${roverDescription || ''}`;
return (
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
<div
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}
>
{usingSnapshot ? (
snapshotFeed?.objectUrl ? (
<img
src={snapshotFeed.objectUrl}
alt={label}
className="h-full w-full object-contain"
draggable={false}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
Waiting for frame
</div>
)
) : (
<video
ref={videoRef}
muted={forceMute || muted || hasDedicatedAudio}
playsInline
autoPlay
controls={false}
<>
{usingSnapshot ? (
resolvedSnapshotFeed?.objectUrl ? (
<img
src={resolvedSnapshotFeed.objectUrl}
alt={resolvedLabel}
className="h-full w-full object-contain"
draggable={false}
/>
)}
<audio ref={audioRef} autoPlay hidden />
{!noHud && showTurnCue ? (
<TurnCueOverlay
mobileHud={mobileHud}
isActiveDriver={isActiveDriver}
idleSkipSeconds={idleSkipSeconds}
/>
) : null}
{!noHud ? (
<RoverDescriptionOverlay
description={roverDescription}
variant={hudVariant}
mobileHud={mobileHud}
displayKey={descriptionDisplayKey}
controlIntentAt={controlIntentAt}
/>
) : null}
{!noHud ? (
<HudOverlay
sensors={sensors}
label={label}
roverColor={roverColor}
status={renderedStatus}
audioStatus={renderedAudioStatus}
levelStatus={levelIndicator}
layoutFormat={layoutFormat}
variant={hudVariant}
driverLabel={driverLabel}
showTopDown={showHudMap}
mobileHud={mobileHud}
mapPosition={effectiveHudMapPosition}
turnTimerText={turnTimerText}
turnTimerFlashActive={noticeFlashActive}
labelScale={hudLabelScale}
/>
) : null}
{!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:${overlayState.visible ? 1 : 0} motors:${overlayState.motors.length} fill:${Math.round(
overlayState.fill * 100,
)}%`}
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-300">
Waiting for frame
</div>
) : 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">
<BatteryBar
visual={batteryVisual}
orientation="vertical"
variant="inline"
compact={mobileHud}
className="h-full w-4"
/>
</div>
) : null}
{!noHud && qualityNotice ? (
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
<div
className={`mx-auto w-fit rounded border border-amber-300/80 bg-black/75 text-amber-200 ${
mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
}`}
>
<div className="text-center">{qualityNotice}</div>
<div className="pointer-events-auto mt-0">
<SocialButton
id="discord"
label="Join our Discord server while you wait!"
url={discordUrl}
/>
</div>
</div>
</div>
) : null}
{!noHud && showNotTurnNotice ? (
<div className="pointer-events-none absolute bottom-1 left-1 z-40">
<div
className={`w-fit rounded border ${
noticeFlashActive
? 'border-red-300/90 bg-red-900/80 text-red-100'
: 'border-amber-300/80 bg-black/75 text-amber-200'
} ${mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'}`}
>
<div
className={
noticeFlashActive
? 'text-[0.82rem] font-semibold text-red-50'
: 'text-[0.82rem] font-semibold text-white'
}
>
Not your turn to drive!
</div>
{notTurnCountdownText ? (
<div className={noticeFlashActive ? 'text-red-100/95' : 'text-amber-100'}>
{notTurnCountdownText}
</div>
) : null}
{showPreviewReason ? (
<div className={noticeFlashActive ? 'text-red-100/90' : 'text-amber-200/85'}>
Video switched to preview mode to save bandwidth.
</div>
) : null}
<div className="pointer-events-auto mt-0.5">
<SocialButton
id="discord"
label="Join our Discord while you wait!"
url={discordUrl}
/>
</div>
</div>
</div>
) : null}
</div>
{!noHud && !showVerticalBattery && (
<div className="space-y-0.5">
<LightBumpBars sensors={sensors} />
<div className="panel-section space-y-0.5 text-sm">
<BatteryBar visual={batteryVisual} compact={mobileHud} />
</div>
</div>
)
) : (
<video
ref={videoRef}
muted={forceMute || muted || hasDedicatedAudio}
playsInline
autoPlay
controls={false}
className="h-full w-full object-contain"
/>
)}
</div>
<audio ref={audioRef} autoPlay hidden />
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {renderedStatus}</span>
{renderedAudioStatus ? <span>Audio: {renderedAudioStatus}</span> : null}
</div>
</div>
</>
);
}
@@ -0,0 +1,39 @@
import RoverMediaPlayer from '../RoverMediaPlayer/index.jsx';
import HudOverlay from '../HudOverlays/HudOverlay/index.jsx';
import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/index.jsx';
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
export default function SpectateVideo({
roverId = null,
label,
fitParent = false,
layoutFormat = 'desktop',
}) {
return (
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
<RoverMediaPlayer
roverId={roverId}
label={label}
/>
<RoverDescriptionOverlay
roverId={roverId}
variant="spectator"
mobileHud={false}
/>
<HudOverlay
roverId={roverId}
layoutFormat={layoutFormat}
variant="spectator"
mobileHud={false}
labelScale={1}
/>
<OvercurrentOverlay roverId={roverId} compact={false} />
<LowBatteryOverlay roverId={roverId} compact={false} />
<VerticalBatteryOverlay show roverId={roverId} mobileHud={false} />
</div>
</div>
);
}
@@ -1,28 +0,0 @@
// Low Battery Overlay
// Purpose: Defines the Low Battery Overlay 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';
function LowBatteryOverlay({ battery, compact = false }) {
if (!battery?.available) return null;
if (!battery.warnActive && !battery.urgentActive) return null;
const message = battery.urgentActive
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
: 'Battery low! please dock and charge the rover soon.';
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
const textClass = compact ? 'text-sm' : 'text-2xl';
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
>
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
<div>{message}</div>
</div>
</div>
);
}
export default React.memo(LowBatteryOverlay);
@@ -1,33 +0,0 @@
// Overcurrent Overlay
// Purpose: Defines the Overcurrent Overlay 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 { OVERCURRENT_LABELS } from './constants.js';
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]';
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
const textClass = compact ? 'text-lg' : 'text-4xl';
const subTextClass = compact ? 'text-xs' : 'text-xl';
const safeFill = Math.max(0, Math.min(1, fill));
const fillWidth = `${Math.round(safeFill * 100)}%`;
return (
<div
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
>
<div className="relative h-full w-full">
<div className="absolute inset-0 overflow-hidden">
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
</div>
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
<div>OVERCURRENT</div>
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
</div>
</div>
</div>
);
}
export default React.memo(OvercurrentOverlay);
@@ -1,29 +0,0 @@
// Turn Cue Overlay
// Purpose: Defines the Turn Cue Overlay 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';
export default function TurnCueOverlay({
mobileHud = false,
isActiveDriver = false,
idleSkipSeconds = null,
}) {
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const timerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const padClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
{showCountdown ? (
<div className={`text-red-100/90 ${timerClass}`}>
Idle skip in {idleSkipSeconds}s
</div>
) : null}
</div>
</div>
);
}
@@ -8,7 +8,7 @@ import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import VideoTile from '../../components/VideoTile/index.jsx';
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.js';
@@ -163,37 +163,27 @@ export default function MiniSummaryContent() {
key={rover.id}
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<VideoTile
<RoverMediaPlayer
sessionInfo={videoSources[rover.id] || null}
videoMode="whep"
snapshotFeed={null}
audioSessionInfo={isActive ? activeAudio : null}
forceMute={!isActive}
label={rover.name || rover.id}
roverColor={rover.color || null}
telemetryFrame={frames[rover.id] || null}
batteryConfig={rover.battery}
layoutFormat="mobile"
hudVariant="none"
fitParent
sensors={frames[rover.id]?.sensors || null}
/>
</div>
);
})}
</div>
) : (
<VideoTile
<RoverMediaPlayer
sessionInfo={null}
videoMode="snapshot"
snapshotFeed={activeSnapshot}
audioSessionInfo={activeAudio}
label={activeRover.name || activeRover.id}
roverColor={activeRover.color || null}
telemetryFrame={activeFrame}
batteryConfig={activeRover.battery}
layoutFormat="mobile"
hudVariant="none"
fitParent
sensors={activeFrame?.sensors || null}
/>
)}
</FitViewportFrame>
@@ -1,7 +1,7 @@
// Info Column
// Purpose: Defines the Info Column 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 VideoTile from '../../../components/VideoTile/index.jsx';
import RoverMediaPlayer from '../../../components/RoverMediaPlayer/index.jsx';
import BatteryBar from '../../../components/BatteryBar/index.jsx';
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
import { getBatteryVisual } from '../utils.js';
@@ -95,18 +95,13 @@ export default function InfoColumn({
{showPreview ? (
<div className="mt-auto w-full">
<div className="w-full aspect-[4/3]">
<VideoTile
<RoverMediaPlayer
sessionInfo={sessionInfo}
videoMode={videoMode}
snapshotFeed={snapshotFeed}
audioSessionInfo={null}
label={rover.name || rover.id}
roverColor={rover.color || null}
telemetryFrame={frame}
batteryConfig={rover.battery}
layoutFormat="mobile"
hudVariant="none"
fitParent
sensors={frame?.sensors || null}
/>
</div>
</div>
@@ -2,9 +2,6 @@
// Purpose: Defines the Spectator Content 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 { useTelemetryFrames } from '../../context/TelemetryContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import ChatPanel from '../../components/ChatPanel/index.jsx';
@@ -22,29 +19,10 @@ import LogsRow from './components/LogsRow.jsx';
export default function SpectatorContent() {
const { session } = useSession();
const inLockdown = session?.mode === 'lockdown';
const canSpectateVideo = Boolean(session?.isLocalNetwork);
useDefaultNickname();
useSpectatorMode();
const isPortraitLayout = usePortraitLayout();
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const snapshotFeeds = useRoverSnapshots(
roster.map((rover) => rover.id),
{ enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
);
const videoEntries = canSpectateVideo
? roster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
: [];
const videoSources = useVideoRequests(videoEntries, {
enabled: !inLockdown && canSpectateVideo,
version: session?.mode,
});
const audioEntries = roster.flatMap((rover) =>
rover.media?.audioPublishUrl
? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
: [],
);
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
if (inLockdown) {
return (
@@ -105,15 +83,7 @@ export default function SpectatorContent() {
</div>
</section>
<section className={contentClass}>
<RoverRow
roster={roster}
frames={frames}
videoSources={videoSources}
snapshotFeeds={snapshotFeeds}
audioSources={audioSources}
session={session}
canSpectateVideo={canSpectateVideo}
/>
<RoverRow roster={roster} />
<SecondaryRow />
</section>
</main>
@@ -3,25 +3,14 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
export default function RoverRow({ roster, frames, videoSources, snapshotFeeds, audioSources, session, canSpectateVideo }) {
export default function RoverRow({ roster }) {
if (roster.length === 0) {
return <p className="col-span-full text-slate-400">No rovers registered.</p>;
}
return (
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
{roster.map((rover) => (
<RoverSpectatorCard
key={rover.id}
rover={rover}
frame={frames[rover.id]}
sessionInfo={canSpectateVideo ? videoSources[rover.id] || null : null}
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[rover.id]}
audioInfo={audioSources[`${rover.id}-audio`]}
session={session}
showHudMap
hudMapPosition="bottom-left"
/>
<RoverSpectatorCard key={rover.id} rover={rover} />
))}
</section>
);
@@ -1,28 +1,15 @@
// Rover Spectator Card
// Purpose: Defines the Rover Spectator 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 VideoTile from '../../../components/VideoTile/index.jsx';
import { formatDriverLabel } from '../utils.js';
import SpectateVideo from '../../../components/SpectateVideo/index.jsx';
export default function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed, audioInfo, session }) {
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
export default function RoverSpectatorCard({ rover }) {
return (
<article className="min-h-[16rem] rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
<div className="min-h-0 overflow-hidden rounded bg-black/20">
<VideoTile
sessionInfo={sessionInfo}
videoMode={videoMode}
snapshotFeed={snapshotFeed}
audioSessionInfo={audioInfo}
<SpectateVideo
roverId={rover.id}
label={rover.name}
roverDescription={rover.description}
roverColor={rover.color || null}
telemetryFrame={frame}
batteryConfig={rover.battery}
hudVariant="spectator"
driverLabel={driverLabel}
hudForceMap
hudMapPosition="top-center"
/>
</div>
</article>