visceral...

This commit is contained in:
legop3
2025-11-30 03:15:27 -05:00
parent 757684f383
commit c29d076f26
12 changed files with 251 additions and 125 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<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-0RmLu8Be.js"></script> <script type="module" crossorigin src="/assets/index-BRHN50zb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxUpjkzh.css"> <link rel="stylesheet" crossorigin href="/assets/index-DoC35k7C.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -87,7 +87,7 @@ function MobilePortraitLayout() {
<TelemetryPanel /> <TelemetryPanel />
<AuthPanel /> <AuthPanel />
<AdminPanel /> <AdminPanel />
<RoomCameraPanel /> <RoomCameraPanel panelId="mobile-portrait-room" />
<HomeAssistantControls /> <HomeAssistantControls />
<LogPanel /> <LogPanel />
</div> </div>
@@ -117,7 +117,7 @@ function MobileLandscapeLayout() {
<ChatPanel /> <ChatPanel />
</div> </div>
</section> </section>
<RoomCameraPanel /> <RoomCameraPanel panelId="mobile-landscape-room" />
<HomeAssistantControls /> <HomeAssistantControls />
{/* <DrivePanel /> */} {/* <DrivePanel /> */}
</div> </div>
+2 -2
View File
@@ -21,14 +21,14 @@ export default function RightPaneTabs({ layout }) {
<TabPanel id="telemetry"> <TabPanel id="telemetry">
<div className="space-y-0.5"> <div className="space-y-0.5">
<DrivePanel /> <DrivePanel />
<RoomCameraPanel defaultOrientation="horizontal" /> <RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
<TelemetryPanel /> <TelemetryPanel />
<CameraServoPanel /> <CameraServoPanel />
</div> </div>
</TabPanel> </TabPanel>
<TabPanel id="room"> <TabPanel id="room">
<div className="space-y-0.5"> <div className="space-y-0.5">
<RoomCameraPanel defaultOrientation="vertical" /> <RoomCameraPanel defaultOrientation="vertical" panelId="rightpane-room" />
<HomeAssistantControls /> <HomeAssistantControls />
</div> </div>
</TabPanel> </TabPanel>
+11 -4
View File
@@ -126,14 +126,21 @@ export default function RoomCameraFeed({ sessionInfo, label }) {
: status; : status;
return ( return (
<div className="space-y-0.5"> <div className="relative aspect-video w-full overflow-hidden rounded bg-black">
<div className="relative aspect-video w-full overflow-hidden bg-black"> <video
<video ref={videoRef} muted={muted} playsInline autoPlay controls={false} className="h-full w-full object-cover" /> ref={videoRef}
muted={muted}
playsInline
autoPlay
controls={false}
className="h-full w-full object-cover"
/>
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white"> <div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-0.5 py-0.5 text-xs font-semibold text-white">
{label} {label}
</div> </div>
<div className="pointer-events-none absolute bottom-0 left-0 m-0.5 rounded bg-black/70 px-0.5 py-0.25 text-[0.7rem] text-slate-100">
{renderedStatus}
</div> </div>
<p className="text-xs text-slate-500">{renderedStatus}</p>
</div> </div>
); );
} }
+24 -4
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js'; import { useVideoRequests } from '../hooks/useVideoRequests.js';
import { useSettingsNamespace } from '../settings/index.js';
import RoomCameraFeed from './RoomCameraFeed.jsx'; import RoomCameraFeed from './RoomCameraFeed.jsx';
function EmptyState() { function EmptyState() {
@@ -26,20 +27,39 @@ export default function RoomCameraPanel({
orientation: forcedOrientation, orientation: forcedOrientation,
hideLayoutToggle = false, hideLayoutToggle = false,
hideHeader = false, hideHeader = false,
panelId = null,
}) { }) {
const { session } = useSession(); const { session } = useSession();
const cameras = session?.roomCameras || []; const cameras = session?.roomCameras || [];
const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` })); const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` }));
const videoSources = useVideoRequests(sourceDescriptors); const videoSources = useVideoRequests(sourceDescriptors);
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() => const [orientation, setOrientation] = useState(() =>
normalizeOrientation(defaultOrientation, 'horizontal'), normalizeOrientation(
panelId ? orientationSettings?.[panelId] : defaultOrientation,
'horizontal',
),
); );
useEffect(() => {
if (!panelId) return;
const stored = orientationSettings?.[panelId];
if (!stored) return;
setOrientation(normalizeOrientation(stored, 'horizontal'));
// only respond to changes for this panel id
}, [panelId, orientationSettings?.[panelId]]);
const effectiveOrientation = forcedOrientation const effectiveOrientation = forcedOrientation
? normalizeOrientation(forcedOrientation, 'horizontal') ? normalizeOrientation(forcedOrientation, 'horizontal')
: orientation; : orientation;
const containerClass = const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2'; effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0; const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
const applyOrientation = (next) => {
setOrientation(next);
if (panelId) {
saveOrientationSettings((current) => ({ ...(current || {}), [panelId]: next }));
}
};
if (cameras.length === 0) { if (cameras.length === 0) {
return <EmptyState />; return <EmptyState />;
@@ -62,7 +82,7 @@ export default function RoomCameraPanel({
key={option} key={option}
type="button" type="button"
className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`} className={`px-1 py-0.5 ${effectiveOrientation === option ? 'bg-slate-600 text-white' : 'bg-transparent text-slate-400 hover:text-white'}`}
onClick={() => setOrientation(option)} onClick={() => applyOrientation(option)}
> >
{option === 'vertical' ? 'Vertical' : 'Grid'} {option === 'vertical' ? 'Vertical' : 'Grid'}
</button> </button>
@@ -77,7 +97,7 @@ export default function RoomCameraPanel({
const key = `room:${camera.id}`; const key = `room:${camera.id}`;
const sessionInfo = videoSources[key]; const sessionInfo = videoSources[key];
return ( return (
<article key={camera.id} className="w-full space-y-0.5 rounded border border-slate-800 bg-zinc-950 p-0.5"> <article key={camera.id} className="w-full space-y-0.5 rounded bg-zinc-950 p-0.5 shadow-inner shadow-black/40">
{/* <header className="space-y-0.5"> {/* <header className="space-y-0.5">
<p className="text-lg font-semibold text-white">{camera.name || camera.id}</p> <p className="text-lg font-semibold text-white">{camera.name || camera.id}</p>
{camera.description && <p className="text-xs text-slate-500">{camera.description}</p>} {camera.description && <p className="text-xs text-slate-500">{camera.description}</p>}
+44 -25
View File
@@ -91,7 +91,37 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
return Math.ceil(ms / 1000); return Math.ceil(ms / 1000);
}, []); }, []);
const listClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto'; const baseListClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : 'h-48 overflow-y-auto';
const turnsListClass = isTurnsMode && fillHeight ? 'max-h-40 overflow-y-auto' : baseListClass;
const usersListClass = isTurnsMode && fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : baseListClass;
const renderUserList = () =>
sorted.length === 0 ? (
<p className="text-sm text-slate-500">Waiting for users</p>
) : (
sorted.map((user) => {
const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
return (
<div
key={user.socketId}
className="surface-muted flex items-center gap-1 text-sm"
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId ? (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {user.roverId}</span>
) : (
<span className="text-[0.7rem] text-slate-500">no rover</span>
)}
{isAdmin && (
<span className="rounded bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
Admin
</span>
)}
</div>
);
})
);
return ( return (
<section <section
@@ -113,7 +143,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
</span> </span>
</div> </div>
)} )}
<div className={`surface space-y-0.25 ${listClass}`}> <div className={`surface space-y-0.25 ${isTurnsMode ? turnsListClass : baseListClass}`}>
{isTurnsMode ? ( {isTurnsMode ? (
Object.keys(turnQueues || {}).length === 0 ? ( Object.keys(turnQueues || {}).length === 0 ? (
<p className="text-sm text-slate-500">No turn queues yet.</p> <p className="text-sm text-slate-500">No turn queues yet.</p>
@@ -162,33 +192,22 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
); );
}) })
) )
) : sorted.length === 0 ? (
<p className="text-sm text-slate-500">Waiting for users</p>
) : ( ) : (
sorted.map((user) => { renderUserList()
const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
return (
<div
key={user.socketId}
className="surface-muted flex items-center gap-1 text-sm"
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId ? (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {user.roverId}</span>
) : (
<span className="text-[0.7rem] text-slate-500">no rover</span>
)}
{isAdmin && (
<span className="rounded bg-amber-500/30 px-1 text-[0.7rem] text-amber-200">
Admin
</span>
)} )}
</div> </div>
);
}) {isTurnsMode ? (
)} <div className={`space-y-0.25 ${fillHeight ? 'flex min-h-0 flex-1 flex-col' : ''}`}>
<div className="flex items-center justify-between text-xs text-slate-400">
<span>Users</span>
<span className="text-[0.7rem] text-slate-500">{sorted.length}</span>
</div> </div>
<div className={`surface space-y-0.25 ${usersListClass}`}>
{renderUserList()}
</div>
</div>
) : null}
</div> </div>
</section> </section>
); );
+126 -19
View File
@@ -4,6 +4,32 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
const RESTART_DELAY_MS = 2000; const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000; const UNMUTE_RETRY_MS = 3000;
function buildBatteryVisual(charge, config) {
const full = config?.Full;
const warn = config?.Warn;
const urgent = config?.Urgent ?? null;
if (charge == null || full == null || warn == null) {
return { available: false };
}
const span = full - warn;
if (span <= 0) return { available: false };
const normalized = (charge - warn) / span;
const percent = Math.min(1, Math.max(0, normalized));
const percentDisplay = Math.round(percent * 100);
const depleted = normalized <= 0;
const warnTriggered = urgent != null && charge <= urgent;
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
return {
available: true,
percentDisplay,
depleted,
warnTriggered,
barClass,
};
}
export default function VideoTile({ export default function VideoTile({
sessionInfo, sessionInfo,
audioSessionInfo, audioSessionInfo,
@@ -12,6 +38,8 @@ export default function VideoTile({
telemetryFrame, telemetryFrame,
batteryConfig, batteryConfig,
layoutFormat = 'desktop', layoutFormat = 'desktop',
hudVariant = 'default',
driverLabel = null,
}) { }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const audioRef = useRef(null); const audioRef = useRef(null);
@@ -28,6 +56,7 @@ export default function VideoTile({
const sensors = telemetryFrame?.sensors; const sensors = telemetryFrame?.sensors;
const batteryCharge = sensors?.batteryChargeMah ?? null; const batteryCharge = sensors?.batteryChargeMah ?? null;
const desktopLayout = layoutFormat === 'desktop'; const desktopLayout = layoutFormat === 'desktop';
const batteryVisual = buildBatteryVisual(batteryCharge, batteryConfig);
// console.log('[BatteryBarDebug]', { // console.log('[BatteryBarDebug]', {
// frameSensors: sensors, // frameSensors: sensors,
// batteryCharge, // batteryCharge,
@@ -204,6 +233,16 @@ export default function VideoTile({
: detail : detail
? `${status} (${detail})` ? `${status} (${detail})`
: status; : status;
const renderedAudioStatus = audioSessionInfo?.error
? `Error: ${audioSessionInfo.error}`
: !audioSessionInfo?.url
? null
: audioStatus === 'error'
? `Error: ${audioDetail || 'unknown'}`
: audioDetail
? `${audioStatus} (${audioDetail})`
: audioStatus;
const showVerticalBattery = hudVariant === 'spectator';
return ( return (
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
@@ -217,40 +256,41 @@ export default function VideoTile({
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
<audio ref={audioRef} autoPlay hidden /> <audio ref={audioRef} autoPlay hidden />
<HudOverlay frame={telemetryFrame} label={label} status={renderedStatus} desktopLayout={desktopLayout}/> <HudOverlay
frame={telemetryFrame}
label={label}
status={renderedStatus}
audioStatus={renderedAudioStatus}
desktopLayout={desktopLayout}
variant={hudVariant}
driverLabel={driverLabel}
battery={batteryVisual}
/>
<OvercurrentOverlay motors={overcurrentMotors} /> <OvercurrentOverlay motors={overcurrentMotors} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} /> <LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
{showVerticalBattery && batteryVisual.available ? (
<BatteryBarVertical visual={batteryVisual} />
) : null}
</div> </div>
<BatteryBar charge={batteryCharge} config={batteryConfig} /> {!showVerticalBattery && <BatteryBar visual={batteryVisual} />}
</div> </div>
); );
} }
function BatteryBar({ charge, config }) { function BatteryBar({ visual }) {
const full = config?.Full; if (!visual?.available) {
const warn = config?.Warn;
const urgent = config?.Urgent ?? null;
if (charge == null || full == null || warn == null) {
return ( return (
<div className="panel-section space-y-0.5 text-sm"> <div className="panel-section space-y-0.5 text-sm">
<p className="text-xs text-slate-500">Battery telemetry unavailable</p> <p className="text-xs text-slate-500">Battery telemetry unavailable</p>
</div> </div>
); );
} }
const percentText = `${visual.percentDisplay}%`;
const span = full - warn; const barClass = visual.barClass;
if (span <= 0) return null;
const normalized = (charge - warn) / span;
const percent = Math.min(1, Math.max(0, normalized));
const percentDisplay = Math.round(percent * 100);
const percentText = `${percentDisplay}%`;
const depleted = normalized <= 0;
const warnTriggered = urgent != null && charge <= urgent;
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
return ( return (
<div className="panel-section space-y-0.5 text-sm"> <div className="panel-section space-y-0.5 text-sm">
<div className="relative h-4 w-full bg-zinc-900 flex"> <div className="relative h-4 w-full bg-zinc-900 flex">
<div className={`h-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }}> <div className={`h-full transition-[width] ${barClass}`} style={{ width: `${visual.percentDisplay}%` }}>
<span className="inset-0 flex items-center justify-center text-xs font-semibold text-black/80"> <span className="inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
Battery {percentText} Battery {percentText}
</span> </span>
@@ -260,7 +300,32 @@ function BatteryBar({ charge, config }) {
); );
} }
function HudOverlay({ frame, label, status, desktopLayout = true }) { function BatteryBarVertical({ visual }) {
if (!visual?.available) return null;
const percentText = `${visual.percentDisplay}%`;
return (
<div className="pointer-events-none absolute right-1 top-1 flex h-[70%] flex-col items-center justify-end rounded bg-black/60 px-0.5 pb-1 pt-1">
<div className="flex h-full w-4 items-end overflow-hidden rounded bg-zinc-900">
<div
className={`${visual.barClass} w-full transition-[height]`}
style={{ height: `${visual.percentDisplay}%` }}
/>
</div>
<span className="mt-0.5 text-[0.65rem] font-semibold text-slate-100">{percentText}</span>
</div>
);
}
function HudOverlay({
frame,
label,
status,
audioStatus,
desktopLayout = true,
variant = 'default',
driverLabel = null,
battery,
}) {
const sensors = frame?.sensors; const sensors = frame?.sensors;
const bumps = sensors?.bumpsAndWheelDrops || {}; const bumps = sensors?.bumpsAndWheelDrops || {};
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -272,10 +337,52 @@ function HudOverlay({ frame, label, status, desktopLayout = true }) {
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false; const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
if (variant === 'spectator') {
const telemetryEntries = [
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
['OI', sensors?.oiMode?.label || '--'],
];
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="absolute left-1 top-1 flex flex-col gap-0.25 bg-black/70 px-1 py-0.5 text-[0.65rem] font-medium text-slate-100">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
</div>
<div className="absolute bottom-1 left-1 flex flex-col gap-0.25 bg-black/70 px-1 py-0.5 text-[0.65rem] text-slate-100">
<span className="text-[0.6rem] uppercase tracking-wide text-slate-400">Telemetry</span>
<div className="grid grid-cols-2 gap-x-1 gap-y-0.25">
{telemetryEntries.map(([labelText, value]) => (
<span key={labelText} className="flex items-center justify-between gap-0.5">
<span className="text-slate-400">{labelText}</span>
<span className="font-semibold text-white">{value}</span>
</span>
))}
</div>
</div>
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 items-center gap-1 bg-black/80 px-1 py-0.5 text-[0.8rem] text-slate-100">
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
</div>
<div className="absolute top-0.5 left-1/2 flex -translate-x-1/2 gap-1 bg-black/70 text-[0.6rem] font-medium text-slate-200">
<div className={`${desktopLayout ? 'px-1 py-0.5' : 'px-0.5 py-0 text-nowrap'} ${bumps.bumpLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left bump</div>
<div className={`${desktopLayout ? 'px-1 py-0.5' : 'px-0.5 py-0 text-nowrap'} ${bumps.wheelDropLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left wheel drop</div>
<div className={`${desktopLayout ? 'px-1 py-0.5' : 'px-0.5 py-0 text-nowrap'} ${bumps.wheelDropRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right wheel drop</div>
<div className={`${desktopLayout ? 'px-1 py-0.5' : 'px-0.5 py-0 text-nowrap'} ${bumps.bumpRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right bump</div>
</div>
</div>
);
}
return ( return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="absolute left-1 top-1 bg-black/70 px-1 py-0.5 text-[0.65rem] font-medium text-slate-100"> <div className="absolute left-1 top-1 bg-black/70 px-1 py-0.5 text-[0.65rem] font-medium text-slate-100">
<span>Status: {status}</span> <span>Status: {status}</span>
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
</div> </div>
<div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100"> <div className="absolute bottom-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 px-0.5 py-0.5 text-slate-100">
<span>Rover: "{label || 'Unnamed Rover'}"</span> <span>Rover: "{label || 'Unnamed Rover'}"</span>
+24 -51
View File
@@ -8,59 +8,23 @@ import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
import UserListPanel from '../components/UserListPanel.jsx'; import UserListPanel from '../components/UserListPanel.jsx';
import ChatPanel from '../components/ChatPanel.jsx'; import ChatPanel from '../components/ChatPanel.jsx';
import LogPanel from '../components/LogPanel.jsx'; import LogPanel from '../components/LogPanel.jsx';
import RoverRoster from '../components/RoverRoster.jsx';
function TelemetrySummary({ frame }) { function formatDriverLabel({ roverId, session }) {
const sensors = frame?.sensors || {};
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
const entries = [
['Voltage', sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
['Current', sensors.currentMa != null ? `${sensors.currentMa} mA` : '--'],
['Charge', sensors.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
['OI', sensors.oiMode?.label || '--'],
];
return (
<div className="surface-muted flex flex-wrap items-center gap-0.5 rounded px-0.5 py-0.25 text-[0.75rem] text-slate-200">
<span className="text-[0.7rem] uppercase tracking-wide text-slate-500">
{updated ? `Updated ${updated}` : 'Telemetry'}
</span>
{entries.map(([label, value]) => (
<span key={label} className="flex items-center gap-0.25 rounded bg-slate-900/50 px-0.5 py-0.25">
<span className="text-slate-400">{label}</span>
<span className="font-semibold text-white">{value}</span>
</span>
))}
</div>
);
}
function CurrentDriverBadge({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null; const activeDriverId = session?.activeDrivers?.[roverId] || null;
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId); const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver'); const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
const mode = session?.mode; const mode = session?.mode;
const turnInfo = session?.turnQueues?.[roverId]; const turnInfo = session?.turnQueues?.[roverId];
const driverText = mode === 'turns' && turnInfo?.current ? `Driver: ${label} (turns)` : `Driver: ${label}`; const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
return ( return driverText;
<div className="surface-muted text-xs text-slate-300">
{driverText}
</div>
);
} }
function RoverSpectatorCard({ rover, frame, videoInfo, audioInfo, session }) { function RoverSpectatorCard({ rover, frame, videoInfo, audioInfo, session }) {
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
return ( return (
<article className="grid min-h-[16rem] grid-rows-[auto_minmax(0,1fr)_auto] gap-0.5 rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]"> <article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]">
<header className="flex items-center justify-between gap-0.5">
<div className="flex flex-col leading-tight">
<h3 className="text-xl font-semibold text-white">{rover.name}</h3>
<CurrentDriverBadge roverId={rover.id} session={session} />
</div>
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-300">
Rover {rover.id}
</span>
</header>
<div className="min-h-0 overflow-hidden rounded bg-black/20"> <div className="min-h-0 overflow-hidden rounded bg-black/20">
<VideoTile <VideoTile
sessionInfo={videoInfo} sessionInfo={videoInfo}
@@ -68,9 +32,10 @@ function RoverSpectatorCard({ rover, frame, videoInfo, audioInfo, session }) {
label={rover.name} label={rover.name}
telemetryFrame={frame} telemetryFrame={frame}
batteryConfig={rover.battery} batteryConfig={rover.battery}
hudVariant="spectator"
driverLabel={driverLabel}
/> />
</div> </div>
<TelemetrySummary frame={frame} />
</article> </article>
); );
} }
@@ -99,15 +64,20 @@ function SecondaryRow() {
return ( return (
<section className="min-h-0"> <section className="min-h-0">
<div className="surface min-h-[14rem] overflow-hidden"> <div className="surface min-h-[14rem] overflow-hidden">
<RoomCameraPanel defaultOrientation="horizontal" hideLayoutToggle hideHeader /> <RoomCameraPanel
defaultOrientation="horizontal"
hideLayoutToggle
hideHeader
panelId="spectator-secondary"
/>
</div> </div>
</section> </section>
); );
} }
function LogsRow() { function LogsRow({ className = '' }) {
return ( return (
<div className="panel"> <div className={`panel ${className}`}>
<LogPanel /> <LogPanel />
</div> </div>
); );
@@ -135,15 +105,18 @@ export default function SpectatorApp() {
<RoverRow roster={roster} frames={frames} videoSources={videoSources} session={session} /> <RoverRow roster={roster} frames={frames} videoSources={videoSources} session={session} />
<SecondaryRow /> <SecondaryRow />
</section> </section>
<section className="grid min-h-0 min-w-0 gap-0.5 md:h-full grid-rows-[minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,1fr)]"> <section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
<div className="min-h-0 min-w-0 overflow-hidden"> <div className="panel">
<RoverRoster roster={roster} title="Rovers" emptyText="No rovers registered." />
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
<UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" /> <UserListPanel hideNicknameForm hideHeader fillHeight className="h-full" />
</div> </div>
<div className="min-h-0 min-w-0 overflow-hidden"> <div className="min-h-0 min-w-0 flex-[1.1] overflow-hidden">
<ChatPanel hideInput hideSpectatorNotice fillHeight /> <ChatPanel hideInput hideSpectatorNotice fillHeight />
</div> </div>
<div className="min-h-0 min-w-0 overflow-hidden"> <div className="min-h-0 min-w-0">
<LogsRow /> <LogsRow className="h-40 overflow-hidden" />
</div> </div>
</section> </section>
</main> </main>