mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
it begins
This commit is contained in:
@@ -1,67 +1,80 @@
|
|||||||
# REFACTOR RULES
|
# REFACTOR RULES
|
||||||
- do NOT change ANY functionality!!! all changes should be purely internal!!
|
- Do NOT change ANY functionality. All changes must be purely internal refactors.
|
||||||
- this refactor is for simplification purposes, to make it easier to work on. remove any unused files and code.
|
- 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
|
## Required safety checks for every change
|
||||||
- there are a lot of services and some have gotten huge, like way too large to manage manually
|
- Preserve imports/exports and call signatures unless internal-only and non-observable.
|
||||||
- all services should be converted into folders, with their files inside the folders
|
- Validate no runtime behavior changes (manual flow checks + targeted tests when available).
|
||||||
- large functions of the service should be split off into more, smaller files within the service's folder
|
- Make small, reviewable commits per service/component area.
|
||||||
- keep files relatively small and very clear and concise in their function. with titles commented at the top.
|
|
||||||
|
|
||||||
## webui frontend
|
## Server backend
|
||||||
- similar situation to the server's service file bloat, but with large jsx component files
|
- Convert every service into a folder-based structure.
|
||||||
- split up large jsx components and backing js files into folders containing smaller files
|
- Split very large service files into smaller focused modules.
|
||||||
- use same clear concise functional format, with title comments
|
- Keep files concise and single-purpose.
|
||||||
- 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.
|
- 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
|
# 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
|
### BIGGEST OFFENDERS
|
||||||
- audio forward service
|
- [ ] audio forward service
|
||||||
- button box service
|
- [ ] button box service
|
||||||
- chat service
|
- [ ] chat service
|
||||||
- discord bot service
|
- [ ] discord bot service
|
||||||
- home assistant service
|
- [ ] home assistant service
|
||||||
- llm commentary service
|
- [ ] llm commentary service
|
||||||
- private rover access request service
|
- [ ] private rover access request service
|
||||||
- replay services, already split up kinda. still go through them and reformat into folders.
|
- [ ] replay services (already partly split; reformat consistently)
|
||||||
- room camera services, also split up already. go through and reformat
|
- [ ] room camera services (already partly split; reformat consistently)
|
||||||
- rover manager service
|
- [ ] rover manager service
|
||||||
- session service
|
- [ ] session service
|
||||||
- turn service
|
- [ ] turn service
|
||||||
- verification service
|
- [ ] verification service
|
||||||
- video auth service
|
- [ ] video auth service
|
||||||
- despite this list, ALL SERVICES should be reorganized into folders and split up where reasonable
|
- [ ] All remaining services: reorganize to folder structure where needed
|
||||||
|
|
||||||
### COMPLETED SERVICES
|
### COMPLETED SERVICES
|
||||||
-
|
- None yet.
|
||||||
|
|
||||||
### LARGE CHANGES
|
### LARGE CHANGES
|
||||||
-
|
- None yet.
|
||||||
|
|
||||||
|
## WebUI frontend
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## webui frontend
|
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
- mini summary app
|
- [x] mini summary app
|
||||||
- spectator app
|
- [ ] spectator app
|
||||||
- vip audio upload card
|
- [ ] vip audio upload card
|
||||||
- admin panel
|
- [ ] admin panel
|
||||||
- drive dock action
|
- [ ] drive dock action
|
||||||
- gamepad mapping settings
|
- [ ] gamepad mapping settings
|
||||||
- mobile controls
|
- [ ] mobile controls
|
||||||
- top down map
|
- [ ] top down map
|
||||||
- videotile
|
- [ ] video tile
|
||||||
- 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
|
- [ ] Sweep `webui` for unused or unneeded files/code with verification
|
||||||
|
|
||||||
### COMPLETED COMPONENTS
|
### COMPLETED COMPONENTS
|
||||||
-
|
- mini summary app
|
||||||
|
|
||||||
### LARGE CHANGES
|
### 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
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,450 +1,3 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import MiniSummaryAppRoot from './MiniSummaryApp/MiniSummaryAppRoot.jsx';
|
||||||
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';
|
|
||||||
|
|
||||||
const ROTATE_MS = 20000;
|
export default MiniSummaryAppRoot;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user