mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
visceral...
This commit is contained in:
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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user