mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
/mini new (again)
This commit is contained in:
@@ -392,6 +392,7 @@ export default function VideoTile({
|
||||
? `${audioStatus} (${audioDetail})`
|
||||
: audioStatus;
|
||||
const showVerticalBattery = hudVariant === 'spectator';
|
||||
const noHud = hudVariant === 'none';
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||
@@ -422,7 +423,7 @@ export default function VideoTile({
|
||||
/>
|
||||
)}
|
||||
<audio ref={audioRef} autoPlay hidden />
|
||||
{showTurnCue ? (
|
||||
{!noHud && showTurnCue ? (
|
||||
<TurnCueOverlay
|
||||
mobileHud={mobileHud}
|
||||
turnSeconds={turnSeconds}
|
||||
@@ -430,35 +431,37 @@ export default function VideoTile({
|
||||
idleSkipSeconds={idleSkipSeconds}
|
||||
/>
|
||||
) : null}
|
||||
<HudOverlay
|
||||
frame={telemetryFrame}
|
||||
sensors={sensors}
|
||||
label={label}
|
||||
status={renderedStatus}
|
||||
audioStatus={renderedAudioStatus}
|
||||
desktopLayout={desktopLayout}
|
||||
layoutFormat={layoutFormat}
|
||||
variant={hudVariant}
|
||||
driverLabel={driverLabel}
|
||||
battery={batteryVisual}
|
||||
showTopDown={showHudMap}
|
||||
mobileHud={mobileHud}
|
||||
mapPosition={hudMapPosition}
|
||||
turnTimerText={turnTimerText}
|
||||
labelScale={hudLabelScale}
|
||||
/>
|
||||
<HudChatInput compact={mobileHud} />
|
||||
{debugHud ? (
|
||||
{!noHud ? (
|
||||
<HudOverlay
|
||||
frame={telemetryFrame}
|
||||
sensors={sensors}
|
||||
label={label}
|
||||
status={renderedStatus}
|
||||
audioStatus={renderedAudioStatus}
|
||||
desktopLayout={desktopLayout}
|
||||
layoutFormat={layoutFormat}
|
||||
variant={hudVariant}
|
||||
driverLabel={driverLabel}
|
||||
battery={batteryVisual}
|
||||
showTopDown={showHudMap}
|
||||
mobileHud={mobileHud}
|
||||
mapPosition={hudMapPosition}
|
||||
turnTimerText={turnTimerText}
|
||||
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:${overlayVisible ? 1 : 0} motors:${overlayMotors.length} fill:${Math.round(overlayFill * 100)}%`}
|
||||
</div>
|
||||
) : null}
|
||||
<OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} />
|
||||
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} />
|
||||
{showVerticalBattery && batteryVisual.available ? (
|
||||
{!noHud ? <OvercurrentOverlay motors={overlayMotors} fill={overlayFill} compact={mobileHud} /> : null}
|
||||
{!noHud ? <LowBatteryOverlay charge={batteryCharge} config={batteryConfig} compact={mobileHud} /> : null}
|
||||
{!noHud && showVerticalBattery && batteryVisual.available ? (
|
||||
<BatteryBarVertical visual={batteryVisual} />
|
||||
) : null}
|
||||
{qualityNotice ? (
|
||||
{!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 ${
|
||||
@@ -473,7 +476,7 @@ export default function VideoTile({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!showVerticalBattery && (
|
||||
{!noHud && !showVerticalBattery && (
|
||||
<div className="space-y-0.5">
|
||||
<LightBumpBars sensors={sensors} />
|
||||
<BatteryBar visual={batteryVisual} />
|
||||
@@ -626,6 +629,10 @@ function HudOverlay({
|
||||
: { right: '0.25rem', top: '0.25rem' }),
|
||||
};
|
||||
|
||||
if (variant === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (variant === 'spectator') {
|
||||
const telemetryEntries = [
|
||||
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SettingsProvider } from '../settings/index.js';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
@@ -21,37 +22,259 @@ function formatDriverLabel({ roverId, session }) {
|
||||
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
|
||||
}
|
||||
|
||||
function getBatteryVisual({ rover, frame }) {
|
||||
function formatDuration(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '0:00';
|
||||
const totalSeconds = Math.ceil(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getBatteryPercent({ rover, frame }) {
|
||||
const percentDisplay = rover?.batteryState?.percentDisplay;
|
||||
const urgentActive = rover?.batteryState?.urgentActive ?? false;
|
||||
const warnActive = rover?.batteryState?.warnActive ?? false;
|
||||
if (percentDisplay != null && Number.isFinite(percentDisplay)) {
|
||||
return Math.round(percentDisplay);
|
||||
}
|
||||
const config = rover?.battery ?? null;
|
||||
const charge = frame?.sensors?.batteryChargeMah ?? null;
|
||||
const full = config?.Full ?? null;
|
||||
const warn = config?.Warn ?? null;
|
||||
const urgent = config?.Urgent ?? null;
|
||||
|
||||
if (percentDisplay != null && Number.isFinite(percentDisplay)) {
|
||||
const percent = Math.max(0, Math.min(1, percentDisplay / 100));
|
||||
const barClass = urgentActive ? 'bg-red-500/40' : warnActive ? 'bg-amber-400/40' : 'bg-emerald-400/40';
|
||||
return { percent, barClass };
|
||||
}
|
||||
|
||||
if (charge == null || full == null || warn == null) {
|
||||
return { percent: 0, barClass: 'bg-slate-700/40' };
|
||||
}
|
||||
|
||||
if (charge == null || full == null || warn == null) return null;
|
||||
const span = full - warn;
|
||||
if (span <= 0) {
|
||||
return { percent: 0, barClass: 'bg-slate-700/40' };
|
||||
}
|
||||
|
||||
if (span <= 0) return null;
|
||||
const normalized = (charge - warn) / span;
|
||||
const percent = Math.max(0, Math.min(1, normalized));
|
||||
const depleted = normalized <= 0;
|
||||
const warnTriggered = urgent != null && charge <= urgent;
|
||||
const barClass = depleted ? 'bg-red-500/40' : warnTriggered ? 'bg-amber-400/40' : 'bg-emerald-400/40';
|
||||
return { percent, barClass };
|
||||
return Math.round(Math.max(0, Math.min(1, normalized)) * 100);
|
||||
}
|
||||
|
||||
function getDockChargeStatus(frame) {
|
||||
const docked = Boolean(frame?.sensors?.chargingSources?.homeBase);
|
||||
const chargingLabel = frame?.sensors?.chargingState?.label || '';
|
||||
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
||||
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/80 text-slate-200';
|
||||
const chargeTone = charging
|
||||
? 'bg-emerald-500/80 text-emerald-50'
|
||||
: docked
|
||||
? 'bg-amber-400/80 text-amber-950'
|
||||
: 'bg-slate-700/80 text-slate-200';
|
||||
return {
|
||||
docked,
|
||||
charging,
|
||||
dockLabel: docked ? 'Docked' : 'Undocked',
|
||||
chargeLabel: charging ? 'Charging' : 'Not charging',
|
||||
dockTone,
|
||||
chargeTone,
|
||||
};
|
||||
}
|
||||
|
||||
function getBatteryFillClass({ rover }) {
|
||||
const urgentActive = rover?.batteryState?.urgentActive ?? false;
|
||||
const warnActive = rover?.batteryState?.warnActive ?? false;
|
||||
if (urgentActive) return 'bg-red-500/40';
|
||||
if (warnActive) return 'bg-amber-400/40';
|
||||
return 'bg-emerald-400/40';
|
||||
}
|
||||
|
||||
function InfoColumn({
|
||||
rover,
|
||||
frame,
|
||||
driverLabel,
|
||||
snapshotFeed,
|
||||
showCountdown = false,
|
||||
countdownText = '—',
|
||||
withDivider = false,
|
||||
showPreview = true,
|
||||
variant = 'stacked',
|
||||
}) {
|
||||
const batteryPercent = getBatteryPercent({ rover, frame });
|
||||
const batteryFillClass = getBatteryFillClass({ rover });
|
||||
const dockStatus = getDockChargeStatus(frame);
|
||||
const isActiveView = variant === 'active';
|
||||
const mapWrapRef = useRef(null);
|
||||
const [mapScale, setMapScale] = useState(1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveView) return undefined;
|
||||
const el = mapWrapRef.current;
|
||||
if (!el) return undefined;
|
||||
const baseSize = 240;
|
||||
let raf = null;
|
||||
|
||||
const fit = () => {
|
||||
const width = el.clientWidth;
|
||||
if (!width) {
|
||||
raf = requestAnimationFrame(fit);
|
||||
return;
|
||||
}
|
||||
setMapScale(width / baseSize);
|
||||
};
|
||||
|
||||
const scheduleFit = () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
raf = requestAnimationFrame(fit);
|
||||
};
|
||||
|
||||
scheduleFit();
|
||||
const ro = new ResizeObserver(scheduleFit);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [isActiveView]);
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 flex-1 flex-col gap-4 px-0 py-0 ${withDivider ? 'border-r border-slate-700/60' : ''}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-3 text-center">
|
||||
<div className="min-w-0 rounded-lg border border-slate-800/80 bg-slate-900/70 px-3 py-2">
|
||||
<AutoFitText className="font-semibold leading-tight text-white" maxSize={64} minSize={18}>
|
||||
{rover.name || rover.id}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
{driverLabel ? (
|
||||
<div className="min-w-0 rounded-lg border border-slate-800/80 bg-slate-900/70 px-3 py-2">
|
||||
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={52} minSize={16}>
|
||||
{driverLabel}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative min-w-0 overflow-hidden rounded-lg border border-slate-800/80 bg-slate-900/70 px-3 py-2">
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${batteryFillClass}`}
|
||||
style={{ width: `${batteryPercent == null ? 0 : batteryPercent}%` }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={48} minSize={16}>
|
||||
{batteryPercent == null ? 'Battery --%' : `Battery ${batteryPercent}%`}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-slate-800/80">
|
||||
<div className="grid grid-cols-2 text-center text-[clamp(0.85rem,2.4vw,2rem)] font-semibold uppercase tracking-wide">
|
||||
<div className={`${dockStatus.dockTone} px-2 py-2`}>{dockStatus.dockLabel}</div>
|
||||
<div className={`${dockStatus.chargeTone} px-2 py-2`}>{dockStatus.chargeLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
{isActiveView ? (
|
||||
<div className="relative mt-auto w-full min-w-0">
|
||||
<div ref={mapWrapRef} className="flex w-full items-end justify-center">
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{
|
||||
width: '240px',
|
||||
height: '240px',
|
||||
transformOrigin: 'bottom center',
|
||||
transform: `scale(${mapScale})`,
|
||||
}}
|
||||
>
|
||||
<TopDownMap sensors={frame?.sensors} size={240} overlay />
|
||||
</div>
|
||||
</div>
|
||||
{showCountdown ? (
|
||||
<div className="absolute bottom-0 right-0 pb-1 pr-1 text-right">
|
||||
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={72} minSize={18}>
|
||||
{countdownText}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full min-w-0 flex-col items-center gap-4">
|
||||
{showPreview ? (
|
||||
<div className="w-full">
|
||||
<div className="w-full aspect-[4/3]">
|
||||
<VideoTile
|
||||
sessionInfo={null}
|
||||
videoMode="snapshot"
|
||||
snapshotFeed={snapshotFeed}
|
||||
audioSessionInfo={null}
|
||||
label={rover.name || rover.id}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={rover.battery}
|
||||
layoutFormat="mobile"
|
||||
hudVariant="none"
|
||||
fitParent
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div style={{ width: '240px', height: '240px', transform: 'scale(0.78)', transformOrigin: 'center' }}>
|
||||
<TopDownMap sensors={frame?.sensors} size={240} overlay />
|
||||
</div>
|
||||
</div>
|
||||
{showCountdown ? (
|
||||
<div className="flex min-w-0 items-center justify-center text-center">
|
||||
<AutoFitText className="font-semibold leading-tight text-slate-200" maxSize={72} minSize={18}>
|
||||
{countdownText}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AutoFitText({ children, className = '', maxSize = 64, minSize = 14 }) {
|
||||
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` }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSummaryContent() {
|
||||
@@ -62,14 +285,17 @@ function MiniSummaryContent() {
|
||||
const frames = useTelemetryFrames();
|
||||
const roster = session?.roster ?? [];
|
||||
const [index, setIndex] = useState(0);
|
||||
const [rotationBase, setRotationBase] = useState(Date.now());
|
||||
const [nowTick, setNowTick] = useState(Date.now());
|
||||
const activeDrivers = session?.activeDrivers || {};
|
||||
const driverRoster = useMemo(
|
||||
() => roster.filter((rover) => activeDrivers[rover.id]),
|
||||
[roster, activeDrivers],
|
||||
);
|
||||
const snapshotRoster = driverRoster.length ? driverRoster : roster;
|
||||
|
||||
const snapshotFeeds = useRoverSnapshots(
|
||||
driverRoster.map((rover) => rover.id),
|
||||
snapshotRoster.map((rover) => rover.id),
|
||||
{ enabled: !inLockdown, version: session?.mode },
|
||||
);
|
||||
const audioEntries = useMemo(
|
||||
@@ -103,16 +329,25 @@ function MiniSummaryContent() {
|
||||
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
setRotationBase(Date.now());
|
||||
}, [rotationKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rotationPool.length) return undefined;
|
||||
const timer = setInterval(() => {
|
||||
setRotationBase(Date.now());
|
||||
setIndex((prev) => (prev + 1) % rotationPool.length);
|
||||
}, ROTATE_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [rotationPool.length, rotationKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setNowTick(Date.now());
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
||||
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
||||
|
||||
@@ -120,6 +355,9 @@ function MiniSummaryContent() {
|
||||
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;
|
||||
const rotationRemainingMs =
|
||||
rotationPool.length > 1 ? Math.max(0, rotationBase + ROTATE_MS - nowTick) : null;
|
||||
const rotationCountdown = rotationRemainingMs != null ? formatDuration(rotationRemainingMs) : '—';
|
||||
|
||||
if (inLockdown) {
|
||||
return (
|
||||
@@ -132,6 +370,33 @@ function MiniSummaryContent() {
|
||||
);
|
||||
}
|
||||
|
||||
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-1">
|
||||
{roster.length ? (
|
||||
roster.map((rover, idx) => (
|
||||
<InfoColumn
|
||||
key={rover.id}
|
||||
rover={rover}
|
||||
frame={frames[rover.id] || null}
|
||||
driverLabel={null}
|
||||
snapshotFeed={snapshotFeeds[rover.id] || null}
|
||||
showCountdown={false}
|
||||
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
|
||||
@@ -153,11 +418,7 @@ function MiniSummaryContent() {
|
||||
telemetryFrame={activeFrame}
|
||||
batteryConfig={activeRover.battery}
|
||||
layoutFormat="mobile"
|
||||
hudVariant="spectator"
|
||||
driverLabel={driverLabel}
|
||||
hudLabelScale={7}
|
||||
hudForceMap
|
||||
hudMapPosition="bottom-left"
|
||||
hudVariant="none"
|
||||
fitParent
|
||||
/>
|
||||
</FitViewportFrame>
|
||||
@@ -167,38 +428,21 @@ function MiniSummaryContent() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-slate-950/90">
|
||||
{roster.length ? (
|
||||
roster.map((rover) => {
|
||||
const frame = frames[rover.id] || null;
|
||||
const driverText = formatDriverLabel({ roverId: rover.id, session });
|
||||
const batteryVisual = getBatteryVisual({ rover, frame });
|
||||
const isActive = activeRover?.id === rover.id;
|
||||
return (
|
||||
<div
|
||||
key={rover.id}
|
||||
className={`relative flex min-h-0 flex-1 flex-col justify-center gap-2 overflow-hidden px-6 py-4 ${
|
||||
isActive ? 'border-4 border-emerald-300/90 bg-emerald-300/10' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 ${batteryVisual.barClass}`}
|
||||
style={{ width: `${batteryVisual.percent * 100}%` }}
|
||||
/>
|
||||
<div className="relative flex flex-col gap-1">
|
||||
<div className={`text-6xl font-semibold tracking-wide ${isActive ? 'text-white' : 'text-slate-200'}`}>
|
||||
{rover.name || rover.id}
|
||||
</div>
|
||||
<div className={`text-5xl font-semibold ${isActive ? 'text-white' : 'text-slate-300'}`}>
|
||||
{driverText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
<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}
|
||||
snapshotFeed={snapshotFeeds[activeRover.id] || null}
|
||||
showCountdown={true}
|
||||
countdownText={rotationCountdown}
|
||||
showPreview={false}
|
||||
variant="active"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
||||
No rovers available.
|
||||
No active rover.
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user