it begins

This commit is contained in:
legop3
2026-04-28 14:02:14 -04:00
parent 9b59718a37
commit 13e9ebc909
11 changed files with 536 additions and 501 deletions
+63 -50
View File
@@ -1,67 +1,80 @@
# REFACTOR RULES
- do NOT change ANY functionality!!! all changes should be purely internal!!
- this refactor is for simplification purposes, to make it easier to work on. remove any unused files and code.
- Do NOT change ANY functionality. All changes must be purely internal refactors.
- Refactor for simplification and maintainability.
- Remove unused files/code only when verified unused.
- No backwards compatability is needed anywhere. Clients and the server are both always up to date.
- Keep behavior/API contracts unchanged.
## server backend
- there are a lot of services and some have gotten huge, like way too large to manage manually
- all services should be converted into folders, with their files inside the folders
- large functions of the service should be split off into more, smaller files within the service's folder
- keep files relatively small and very clear and concise in their function. with titles commented at the top.
## Required safety checks for every change
- Preserve imports/exports and call signatures unless internal-only and non-observable.
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
- Make small, reviewable commits per service/component area.
## webui frontend
- similar situation to the server's service file bloat, but with large jsx component files
- split up large jsx components and backing js files into folders containing smaller files
- use same clear concise functional format, with title comments
- the web UI probably has a lot of leftovers inside still, old files that arent used, backwards compatability that doesnt need to exist, etc. simplify it all.
## Server backend
- Convert every service into a folder-based structure.
- Split very large service files into smaller focused modules.
- Keep files concise and single-purpose.
- Add clear title comments at top of split files.
## WebUI frontend
- Split large JSX/components and large backing JS files into folderized modules.
- Keep modules clear and focused, with title comments.
- Remove stale compatibility/leftover code only after usage verification.
# REFACTOR TRACKING
## server backend
## Current phase
- [x] Phase 1: Inventory + usage mapping (server + webui)
- [ ] Phase 2: Refactor highest-impact offenders first
- [ ] Phase 3: Sweep remaining services/components
- [ ] Phase 4: Dead code/file removal pass
- [ ] Phase 5: Final regression validation
## Server backend
### BIGGEST OFFENDERS
- audio forward service
- button box service
- chat service
- discord bot service
- home assistant service
- llm commentary service
- private rover access request service
- replay services, already split up kinda. still go through them and reformat into folders.
- room camera services, also split up already. go through and reformat
- rover manager service
- session service
- turn service
- verification service
- video auth service
- despite this list, ALL SERVICES should be reorganized into folders and split up where reasonable
- [ ] audio forward service
- [ ] button box service
- [ ] chat service
- [ ] discord bot service
- [ ] home assistant service
- [ ] llm commentary service
- [ ] private rover access request service
- [ ] replay services (already partly split; reformat consistently)
- [ ] room camera services (already partly split; reformat consistently)
- [ ] rover manager service
- [ ] session service
- [ ] turn service
- [ ] verification service
- [ ] video auth service
- [ ] All remaining services: reorganize to folder structure where needed
### COMPLETED SERVICES
-
- None yet.
### LARGE CHANGES
-
- None yet.
## webui frontend
## WebUI frontend
### BIGGEST OFFENDERS
- mini summary app
- spectator app
- vip audio upload card
- admin panel
- drive dock action
- gamepad mapping settings
- mobile controls
- top down map
- videotile
- a lot of the code in the webui folder may be unused or unneeded. when going through the files, check if that function needs to exist, or if the file is used
- [x] mini summary app
- [ ] spectator app
- [ ] vip audio upload card
- [ ] admin panel
- [ ] drive dock action
- [ ] gamepad mapping settings
- [ ] mobile controls
- [ ] top down map
- [ ] video tile
- [ ] Sweep `webui` for unused or unneeded files/code with verification
### COMPLETED COMPONENTS
-
- mini summary app
### LARGE CHANGES
-
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
## Done criteria (per item)
- [ ] Folderized structure created.
- [ ] Large functions extracted into focused files.
- [ ] Imports/exports updated with no external behavior change.
- [ ] Verified references/usages still resolve.
- [ ] Passed targeted checks/tests for touched area.
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-bndWXjZU.js"></script>
<script type="module" crossorigin src="/assets/index-BK4DZOjR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
</head>
<body>
+2 -449
View File
@@ -1,450 +1,3 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
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 VideoTile from '../components/VideoTile.jsx';
import TopDownMap from '../components/TopDownMap.jsx';
import AlertFeed from '../components/AlertFeed.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import BatteryBar from '../components/BatteryBar.jsx';
import { buildBatteryVisual } from '../lib/battery.js';
import { roverNameChromeStyle } from '../lib/roverColor.js';
import MiniSummaryAppRoot from './MiniSummaryApp/MiniSummaryAppRoot.jsx';
const ROTATE_MS = 20000;
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
const mode = session?.mode;
const turnInfo = session?.turnQueues?.[roverId];
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
}
function getBatteryVisual({ rover, frame }) {
const charge = frame?.sensors?.batteryChargeMah ?? null;
const config = rover?.battery ?? null;
return buildBatteryVisual({ batteryState: rover?.batteryState ?? null, charge, config });
}
function InfoColumn({
rover,
frame,
driverLabel,
sessionInfo,
videoMode = 'snapshot',
snapshotFeed,
withDivider = false,
showPreview = true,
variant = 'stacked',
}) {
const batteryVisual = getBatteryVisual({ rover, frame });
const batteryPercent = batteryVisual?.available ? batteryVisual.percentDisplay : null;
const isActiveView = variant === 'active';
return (
<div
className={`relative flex min-w-0 flex-1 flex-col gap-4 overflow-hidden bg-black px-0 py-0 ${
withDivider ? 'border-r border-slate-700/60' : ''
}`}
>
<BatteryBar
visual={batteryVisual}
orientation={isActiveView ? 'vertical' : 'horizontal'}
variant="background"
/>
{isActiveView ? (
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText
className="font-semibold leading-none text-white rounded border border-transparent px-1 py-[1px]"
maxSize={1000}
minSize={18}
style={roverNameChromeStyle(rover.color, 0.18)}
>
{rover.name || rover.id}
</AutoFitText>
</div>
{driverLabel ? (
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={16}>
{driverLabel}
</AutoFitText>
</div>
) : (
<div />
)}
<div className="relative min-w-0 overflow-hidden bg-transparent px-0 py-0 leading-none">
<div className="relative">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={16}>
{batteryPercent == null ? '--%' : `${batteryPercent}%`}
</AutoFitText>
</div>
</div>
</div>
) : (
<>
<div className="relative z-10 flex min-w-0 flex-col gap-1 text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText
className="font-semibold leading-none text-white rounded border border-transparent px-1 py-[1px]"
maxSize={1000}
minSize={18}
style={roverNameChromeStyle(rover.color, 0.18)}
>
{rover.name || rover.id}
</AutoFitText>
</div>
{driverLabel ? (
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={16}>
{driverLabel}
</AutoFitText>
</div>
) : null}
<div className="relative min-w-0 overflow-hidden bg-transparent px-0 py-0 leading-none">
<div className="relative">
<AutoFitText className="font-semibold leading-none text-white" maxSize={80} minSize={16}>
{batteryPercent == null ? '--%' : `${batteryPercent}%`}
</AutoFitText>
</div>
</div>
</div>
<div className="relative z-10 flex min-w-0 flex-1 flex-col gap-4">
<div className="flex w-full min-w-0 flex-1 flex-col items-center gap-4">
{showPreview ? (
<div className="mt-auto w-full">
<div className="w-full aspect-[4/3]">
<VideoTile
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
/>
</div>
</div>
) : null}
</div>
</div>
</>
)}
</div>
);
}
function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let raf = null;
const fit = () => {
const width = container.clientWidth;
if (!width) {
scheduleFit();
return;
}
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
textEl.style.fontSize = `${mid}px`;
const fits = textEl.scrollWidth <= width;
if (fits) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(fit);
};
scheduleFit();
const ro = new ResizeObserver(scheduleFit);
ro.observe(container);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, [children, maxSize, minSize]);
return (
<div ref={containerRef} className="w-full min-w-0">
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
function MiniSummaryContent() {
const { session } = useSession();
const spectatorReady = useSpectatorMode();
useDefaultNickname();
const inLockdown = session?.mode === 'lockdown';
const canSpectateVideo = Boolean(session?.isLocalNetwork);
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const [index, setIndex] = useState(0);
const activeDrivers = session?.activeDrivers || {};
const driverRoster = useMemo(
() => roster.filter((rover) => activeDrivers[rover.id]),
[roster, activeDrivers],
);
const snapshotRoster = driverRoster.length ? driverRoster : roster;
const snapshotFeeds = useRoverSnapshots(
snapshotRoster.map((rover) => rover.id),
{ enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
);
const videoEntries = useMemo(
() =>
canSpectateVideo
? snapshotRoster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
: [],
[canSpectateVideo, snapshotRoster],
);
const videoSources = useVideoRequests(videoEntries, { enabled: !inLockdown && canSpectateVideo, version: session?.mode });
const audioEntries = useMemo(
() =>
driverRoster.flatMap((rover) => {
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
const id = String(rover.id);
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
}),
[driverRoster],
);
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
const roverPool = useMemo(() => {
if (!driverRoster.length) return [];
if (!canSpectateVideo) {
const withSnapshot = driverRoster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
return withSnapshot.length ? withSnapshot : driverRoster;
}
const withVideo = driverRoster.filter((rover) => {
const sessionInfo = videoSources[rover.id];
return sessionInfo?.url && !sessionInfo?.error;
});
return withVideo.length ? withVideo : driverRoster;
}, [driverRoster, snapshotFeeds, videoSources, canSpectateVideo]);
const rotationPool = useMemo(() => {
return roverPool.map((rover) => ({ type: 'rover', rover }));
}, [roverPool]);
const rotationKey = useMemo(
() =>
rotationPool
.map((entry) => `r:${entry.rover.id}`)
.join('|'),
[rotationPool],
);
useEffect(() => {
setIndex(0);
}, [rotationKey]);
useEffect(() => {
if (!rotationPool.length) return undefined;
const timer = setInterval(() => {
setIndex((prev) => (prev + 1) % rotationPool.length);
}, ROTATE_MS);
return () => clearInterval(timer);
}, [rotationPool.length, rotationKey]);
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
const activeSnapshot = !canSpectateVideo && activeRover ? snapshotFeeds[activeRover.id] || null : null;
const activeVideo = canSpectateVideo && activeRover ? videoSources[activeRover.id] || null : null;
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
if (inLockdown) {
return (
<div className="relative flex h-screen w-screen items-center justify-center bg-black text-slate-200">
<div className="surface max-w-sm space-y-0.5 p-1 text-center text-sm">
<p className="text-lg font-semibold text-white">Mini spectator is disabled in lockdown.</p>
<p className="text-slate-300">It will automatically resume once lockdown ends.</p>
</div>
</div>
);
}
if (!driverRoster.length) {
return (
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section className="flex h-full w-full gap-x-3 bg-slate-900 px-0">
{roster.length ? (
roster.map((rover, idx) => (
<InfoColumn
key={rover.id}
rover={rover}
frame={frames[rover.id] || null}
driverLabel={null}
sessionInfo={canSpectateVideo ? videoSources[rover.id] || null : null}
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[rover.id] || null}
showPreview={true}
withDivider={false}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
No rovers available.
</div>
)}
</section>
</div>
);
}
return (
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section
className="relative flex h-full shrink-0 items-center justify-start overflow-hidden bg-black"
style={{ width: 'min(100vw, calc(100vh * 4 / 3))' }}
>
{!spectatorReady ? (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
Switching to spectator
</div>
) : activeRover ? (
<FitViewportFrame>
{canSpectateVideo ? (
<div className="relative h-full w-full">
{rotationPool.map((entry) => {
const rover = entry.rover;
const isActive = activeRover?.id === rover.id;
return (
<div
key={rover.id}
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<VideoTile
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
/>
</div>
);
})}
</div>
) : (
<VideoTile
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
/>
)}
</FitViewportFrame>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
{driverRoster.length ? 'No sources available.' : 'No active drivers.'}
</div>
)}
</section>
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-black">
{activeRover ? (
<InfoColumn
rover={activeRover}
frame={activeFrame}
driverLabel={driverLabel}
sessionInfo={canSpectateVideo ? activeVideo : null}
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[activeRover.id] || null}
showPreview={false}
variant="active"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
No active rover.
</div>
)}
</aside>
</div>
);
}
export default function MiniSummaryApp() {
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const timer = setTimeout(() => {
const url = new URL(window.location.href);
url.searchParams.set('refresh', Date.now().toString());
window.location.replace(url.toString());
}, HARD_REFRESH_MS);
return () => clearTimeout(timer);
}, []);
return (
<>
<MiniSummaryContent />
<AlertFeed scale={3} />
</>
);
}
function FitViewportFrame({ children }) {
return (
<div className="flex h-full w-full items-center justify-start overflow-hidden bg-black">
<div
className="relative flex items-center justify-center overflow-hidden bg-black"
style={{
width: 'min(100%, calc(100vh * 4 / 3))',
height: 'min(100%, calc(100vw * 3 / 4))',
maxWidth: '100%',
maxHeight: '100%',
aspectRatio: '4 / 3',
}}
>
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
</div>
</div>
);
}
export default MiniSummaryAppRoot;
@@ -0,0 +1,24 @@
// Top-level mini summary app shell with hard-refresh timer and alerts.
import { useEffect } from 'react';
import AlertFeed from '../../components/AlertFeed.jsx';
import MiniSummaryContent from './MiniSummaryContent.jsx';
import { HARD_REFRESH_MS } from './constants.js';
export default function MiniSummaryAppRoot() {
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const timer = setTimeout(() => {
const url = new URL(window.location.href);
url.searchParams.set('refresh', Date.now().toString());
window.location.replace(url.toString());
}, HARD_REFRESH_MS);
return () => clearTimeout(timer);
}, []);
return (
<>
<MiniSummaryContent />
<AlertFeed scale={3} />
</>
);
}
@@ -0,0 +1,224 @@
// Main mini summary screen composition and rotation logic.
import { useEffect, useMemo, useState } from 'react';
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 VideoTile from '../../components/VideoTile.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx';
import { ROTATE_MS } from './constants.js';
import { formatDriverLabel } from './utils.js';
export default function MiniSummaryContent() {
const { session } = useSession();
const spectatorReady = useSpectatorMode();
useDefaultNickname();
const inLockdown = session?.mode === 'lockdown';
const canSpectateVideo = Boolean(session?.isLocalNetwork);
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const [index, setIndex] = useState(0);
const activeDrivers = session?.activeDrivers || {};
const driverRoster = useMemo(
() => roster.filter((rover) => activeDrivers[rover.id]),
[roster, activeDrivers],
);
const snapshotRoster = driverRoster.length ? driverRoster : roster;
const snapshotFeeds = useRoverSnapshots(
snapshotRoster.map((rover) => rover.id),
{ enabled: !inLockdown && !canSpectateVideo, version: session?.mode },
);
const videoEntries = useMemo(
() =>
canSpectateVideo
? snapshotRoster.map((rover) => ({ type: 'rover', id: rover.id, key: rover.id }))
: [],
[canSpectateVideo, snapshotRoster],
);
const videoSources = useVideoRequests(videoEntries, {
enabled: !inLockdown && canSpectateVideo,
version: session?.mode,
});
const audioEntries = useMemo(
() =>
driverRoster.flatMap((rover) => {
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
const id = String(rover.id);
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
}),
[driverRoster],
);
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
const roverPool = useMemo(() => {
if (!driverRoster.length) return [];
if (!canSpectateVideo) {
const withSnapshot = driverRoster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
return withSnapshot.length ? withSnapshot : driverRoster;
}
const withVideo = driverRoster.filter((rover) => {
const sessionInfo = videoSources[rover.id];
return sessionInfo?.url && !sessionInfo?.error;
});
return withVideo.length ? withVideo : driverRoster;
}, [driverRoster, snapshotFeeds, videoSources, canSpectateVideo]);
const rotationPool = useMemo(() => {
return roverPool.map((rover) => ({ type: 'rover', rover }));
}, [roverPool]);
const rotationKey = useMemo(
() =>
rotationPool
.map((entry) => `r:${entry.rover.id}`)
.join('|'),
[rotationPool],
);
useEffect(() => {
setIndex(0);
}, [rotationKey]);
useEffect(() => {
if (!rotationPool.length) return undefined;
const timer = setInterval(() => {
setIndex((prev) => (prev + 1) % rotationPool.length);
}, ROTATE_MS);
return () => clearInterval(timer);
}, [rotationPool.length, rotationKey]);
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
const activeSnapshot = !canSpectateVideo && activeRover ? snapshotFeeds[activeRover.id] || null : null;
const activeVideo = canSpectateVideo && activeRover ? videoSources[activeRover.id] || null : null;
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
if (inLockdown) {
return (
<div className="relative flex h-screen w-screen items-center justify-center bg-black text-slate-200">
<div className="surface max-w-sm space-y-0.5 p-1 text-center text-sm">
<p className="text-lg font-semibold text-white">Mini spectator is disabled in lockdown.</p>
<p className="text-slate-300">It will automatically resume once lockdown ends.</p>
</div>
</div>
);
}
if (!driverRoster.length) {
return (
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section className="flex h-full w-full gap-x-3 bg-slate-900 px-0">
{roster.length ? (
roster.map((rover) => (
<InfoColumn
key={rover.id}
rover={rover}
frame={frames[rover.id] || null}
driverLabel={null}
sessionInfo={canSpectateVideo ? videoSources[rover.id] || null : null}
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[rover.id] || null}
showPreview={true}
withDivider={false}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
No rovers available.
</div>
)}
</section>
</div>
);
}
return (
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section
className="relative flex h-full shrink-0 items-center justify-start overflow-hidden bg-black"
style={{ width: 'min(100vw, calc(100vh * 4 / 3))' }}
>
{!spectatorReady ? (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
Switching to spectator
</div>
) : activeRover ? (
<FitViewportFrame>
{canSpectateVideo ? (
<div className="relative h-full w-full">
{rotationPool.map((entry) => {
const rover = entry.rover;
const isActive = activeRover?.id === rover.id;
return (
<div
key={rover.id}
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<VideoTile
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
/>
</div>
);
})}
</div>
) : (
<VideoTile
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
/>
)}
</FitViewportFrame>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
{driverRoster.length ? 'No sources available.' : 'No active drivers.'}
</div>
)}
</section>
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-black">
{activeRover ? (
<InfoColumn
rover={activeRover}
frame={activeFrame}
driverLabel={driverLabel}
sessionInfo={canSpectateVideo ? activeVideo : null}
videoMode={canSpectateVideo ? 'whep' : 'snapshot'}
snapshotFeed={canSpectateVideo ? null : snapshotFeeds[activeRover.id] || null}
showPreview={false}
variant="active"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
No active rover.
</div>
)}
</aside>
</div>
);
}
@@ -0,0 +1,63 @@
// Auto-fitting single-line text component for mini summary layout.
import { useLayoutEffect, useRef, useState } from 'react';
export default function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let raf = null;
const fit = () => {
const width = container.clientWidth;
if (!width) {
scheduleFit();
return;
}
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
textEl.style.fontSize = `${mid}px`;
const fits = textEl.scrollWidth <= width;
if (fits) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(fit);
};
scheduleFit();
const ro = new ResizeObserver(scheduleFit);
ro.observe(container);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, [children, maxSize, minSize]);
return (
<div ref={containerRef} className="w-full min-w-0">
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
>
{children}
</div>
</div>
);
}
@@ -0,0 +1,19 @@
// 4:3 viewport fitting frame for active mini-summary video.
export default function FitViewportFrame({ children }) {
return (
<div className="flex h-full w-full items-center justify-start overflow-hidden bg-black">
<div
className="relative flex items-center justify-center overflow-hidden bg-black"
style={{
width: 'min(100%, calc(100vh * 4 / 3))',
height: 'min(100%, calc(100vw * 3 / 4))',
maxWidth: '100%',
maxHeight: '100%',
aspectRatio: '4 / 3',
}}
>
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,118 @@
// Rover information and optional preview column for mini summary UI.
import VideoTile from '../../../components/VideoTile.jsx';
import BatteryBar from '../../../components/BatteryBar.jsx';
import { roverNameChromeStyle } from '../../../lib/roverColor.js';
import { getBatteryVisual } from '../utils.js';
import AutoFitText from './AutoFitText.jsx';
export default function InfoColumn({
rover,
frame,
driverLabel,
sessionInfo,
videoMode = 'snapshot',
snapshotFeed,
withDivider = false,
showPreview = true,
variant = 'stacked',
}) {
const batteryVisual = getBatteryVisual({ rover, frame });
const batteryPercent = batteryVisual?.available ? batteryVisual.percentDisplay : null;
const isActiveView = variant === 'active';
return (
<div
className={`relative flex min-w-0 flex-1 flex-col gap-4 overflow-hidden bg-black px-0 py-0 ${
withDivider ? 'border-r border-slate-700/60' : ''
}`}
>
<BatteryBar
visual={batteryVisual}
orientation={isActiveView ? 'vertical' : 'horizontal'}
variant="background"
/>
{isActiveView ? (
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText
className="font-semibold leading-none text-white rounded border border-transparent px-1 py-[1px]"
maxSize={1000}
minSize={18}
style={roverNameChromeStyle(rover.color, 0.18)}
>
{rover.name || rover.id}
</AutoFitText>
</div>
{driverLabel ? (
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={16}>
{driverLabel}
</AutoFitText>
</div>
) : (
<div />
)}
<div className="relative min-w-0 overflow-hidden bg-transparent px-0 py-0 leading-none">
<div className="relative">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={16}>
{batteryPercent == null ? '--%' : `${batteryPercent}%`}
</AutoFitText>
</div>
</div>
</div>
) : (
<>
<div className="relative z-10 flex min-w-0 flex-col gap-1 text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText
className="font-semibold leading-none text-white rounded border border-transparent px-1 py-[1px]"
maxSize={1000}
minSize={18}
style={roverNameChromeStyle(rover.color, 0.18)}
>
{rover.name || rover.id}
</AutoFitText>
</div>
{driverLabel ? (
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={16}>
{driverLabel}
</AutoFitText>
</div>
) : null}
<div className="relative min-w-0 overflow-hidden bg-transparent px-0 py-0 leading-none">
<div className="relative">
<AutoFitText className="font-semibold leading-none text-white" maxSize={80} minSize={16}>
{batteryPercent == null ? '--%' : `${batteryPercent}%`}
</AutoFitText>
</div>
</div>
</div>
<div className="relative z-10 flex min-w-0 flex-1 flex-col gap-4">
<div className="flex w-full min-w-0 flex-1 flex-col items-center gap-4">
{showPreview ? (
<div className="mt-auto w-full">
<div className="w-full aspect-[4/3]">
<VideoTile
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
/>
</div>
</div>
) : null}
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,3 @@
// Mini summary app timing constants.
export const ROTATE_MS = 20000;
export const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
+17
View File
@@ -0,0 +1,17 @@
// Mini summary app session/telemetry formatting utilities.
import { buildBatteryVisual } from '../../lib/battery.js';
export function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
const mode = session?.mode;
const turnInfo = session?.turnQueues?.[roverId];
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
}
export function getBatteryVisual({ rover, frame }) {
const charge = frame?.sensors?.batteryChargeMah ?? null;
const config = rover?.battery ?? null;
return buildBatteryVisual({ batteryState: rover?.batteryState ?? null, charge, config });
}