mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
awa
This commit is contained in:
+4
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -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);
|
||||
+67
-59
@@ -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>
|
||||
+13
-7
@@ -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);
|
||||
+26
-11
@@ -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);
|
||||
-8
@@ -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',
|
||||
};
|
||||
+130
-312
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user