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
+2 -2
View File
@@ -87,7 +87,7 @@ function MobilePortraitLayout() {
<TelemetryPanel />
<AuthPanel />
<AdminPanel />
<RoomCameraPanel />
<RoomCameraPanel panelId="mobile-portrait-room" />
<HomeAssistantControls />
<LogPanel />
</div>
@@ -117,7 +117,7 @@ function MobileLandscapeLayout() {
<ChatPanel />
</div>
</section>
<RoomCameraPanel />
<RoomCameraPanel panelId="mobile-landscape-room" />
<HomeAssistantControls />
{/* <DrivePanel /> */}
</div>
+2 -2
View File
@@ -21,14 +21,14 @@ export default function RightPaneTabs({ layout }) {
<TabPanel id="telemetry">
<div className="space-y-0.5">
<DrivePanel />
<RoomCameraPanel defaultOrientation="horizontal" />
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
<TelemetryPanel />
<CameraServoPanel />
</div>
</TabPanel>
<TabPanel id="room">
<div className="space-y-0.5">
<RoomCameraPanel defaultOrientation="vertical" />
<RoomCameraPanel defaultOrientation="vertical" panelId="rightpane-room" />
<HomeAssistantControls />
</div>
</TabPanel>
+14 -7
View File
@@ -126,14 +126,21 @@ export default function RoomCameraFeed({ sessionInfo, label }) {
: status;
return (
<div className="space-y-0.5">
<div className="relative aspect-video w-full overflow-hidden bg-black">
<video 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">
{label}
</div>
<div className="relative aspect-video w-full overflow-hidden rounded bg-black">
<video
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">
{label}
</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>
<p className="text-xs text-slate-500">{renderedStatus}</p>
</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 { useVideoRequests } from '../hooks/useVideoRequests.js';
import { useSettingsNamespace } from '../settings/index.js';
import RoomCameraFeed from './RoomCameraFeed.jsx';
function EmptyState() {
@@ -26,20 +27,39 @@ export default function RoomCameraPanel({
orientation: forcedOrientation,
hideLayoutToggle = false,
hideHeader = false,
panelId = null,
}) {
const { session } = useSession();
const cameras = session?.roomCameras || [];
const sourceDescriptors = cameras.map((camera) => ({ type: 'room', id: camera.id, key: `room:${camera.id}` }));
const videoSources = useVideoRequests(sourceDescriptors);
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
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
? normalizeOrientation(forcedOrientation, 'horizontal')
: orientation;
const containerClass =
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
const applyOrientation = (next) => {
setOrientation(next);
if (panelId) {
saveOrientationSettings((current) => ({ ...(current || {}), [panelId]: next }));
}
};
if (cameras.length === 0) {
return <EmptyState />;
@@ -62,7 +82,7 @@ export default function RoomCameraPanel({
key={option}
type="button"
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'}
</button>
@@ -77,7 +97,7 @@ export default function RoomCameraPanel({
const key = `room:${camera.id}`;
const sessionInfo = videoSources[key];
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">
<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>}
+45 -26
View File
@@ -91,7 +91,37 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
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 (
<section
@@ -113,7 +143,7 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
</span>
</div>
)}
<div className={`surface space-y-0.25 ${listClass}`}>
<div className={`surface space-y-0.25 ${isTurnsMode ? turnsListClass : baseListClass}`}>
{isTurnsMode ? (
Object.keys(turnQueues || {}).length === 0 ? (
<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) => {
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>
);
})
renderUserList()
)}
</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 className={`surface space-y-0.25 ${usersListClass}`}>
{renderUserList()}
</div>
</div>
) : null}
</div>
</section>
);
+126 -19
View File
@@ -4,6 +4,32 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
const RESTART_DELAY_MS = 2000;
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({
sessionInfo,
audioSessionInfo,
@@ -12,6 +38,8 @@ export default function VideoTile({
telemetryFrame,
batteryConfig,
layoutFormat = 'desktop',
hudVariant = 'default',
driverLabel = null,
}) {
const videoRef = useRef(null);
const audioRef = useRef(null);
@@ -28,6 +56,7 @@ export default function VideoTile({
const sensors = telemetryFrame?.sensors;
const batteryCharge = sensors?.batteryChargeMah ?? null;
const desktopLayout = layoutFormat === 'desktop';
const batteryVisual = buildBatteryVisual(batteryCharge, batteryConfig);
// console.log('[BatteryBarDebug]', {
// frameSensors: sensors,
// batteryCharge,
@@ -204,6 +233,16 @@ export default function VideoTile({
: detail
? `${status} (${detail})`
: 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 (
<div className="flex flex-col gap-0.5">
@@ -217,40 +256,41 @@ export default function VideoTile({
className="h-full w-full object-contain"
/>
<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} />
<LowBatteryOverlay charge={batteryCharge} config={batteryConfig} />
{showVerticalBattery && batteryVisual.available ? (
<BatteryBarVertical visual={batteryVisual} />
) : null}
</div>
<BatteryBar charge={batteryCharge} config={batteryConfig} />
{!showVerticalBattery && <BatteryBar visual={batteryVisual} />}
</div>
);
}
function BatteryBar({ charge, config }) {
const full = config?.Full;
const warn = config?.Warn;
const urgent = config?.Urgent ?? null;
if (charge == null || full == null || warn == null) {
function BatteryBar({ visual }) {
if (!visual?.available) {
return (
<div className="panel-section space-y-0.5 text-sm">
<p className="text-xs text-slate-500">Battery telemetry unavailable</p>
</div>
);
}
const span = full - warn;
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';
const percentText = `${visual.percentDisplay}%`;
const barClass = visual.barClass;
return (
<div className="panel-section space-y-0.5 text-sm">
<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">
Battery {percentText}
</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 bumps = sensors?.bumpsAndWheelDrops || {};
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;
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 (
<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">
<span>Status: {status}</span>
{audioStatus ? <div>Audio: {audioStatus}</div> : null}
</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">
<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 ChatPanel from '../components/ChatPanel.jsx';
import LogPanel from '../components/LogPanel.jsx';
import RoverRoster from '../components/RoverRoster.jsx';
function TelemetrySummary({ frame }) {
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 }) {
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];
const driverText = mode === 'turns' && turnInfo?.current ? `Driver: ${label} (turns)` : `Driver: ${label}`;
const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
return (
<div className="surface-muted text-xs text-slate-300">
{driverText}
</div>
);
return driverText;
}
function RoverSpectatorCard({ rover, frame, videoInfo, audioInfo, session }) {
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
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]">
<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>
<article className="min-h-[16rem] rounded bg-zinc-900 p-0.5 sm:min-h-[18rem]">
<div className="min-h-0 overflow-hidden rounded bg-black/20">
<VideoTile
sessionInfo={videoInfo}
@@ -68,9 +32,10 @@ function RoverSpectatorCard({ rover, frame, videoInfo, audioInfo, session }) {
label={rover.name}
telemetryFrame={frame}
batteryConfig={rover.battery}
hudVariant="spectator"
driverLabel={driverLabel}
/>
</div>
<TelemetrySummary frame={frame} />
</article>
);
}
@@ -99,15 +64,20 @@ function SecondaryRow() {
return (
<section className="min-h-0">
<div className="surface min-h-[14rem] overflow-hidden">
<RoomCameraPanel defaultOrientation="horizontal" hideLayoutToggle hideHeader />
<RoomCameraPanel
defaultOrientation="horizontal"
hideLayoutToggle
hideHeader
panelId="spectator-secondary"
/>
</div>
</section>
);
}
function LogsRow() {
function LogsRow({ className = '' }) {
return (
<div className="panel">
<div className={`panel ${className}`}>
<LogPanel />
</div>
);
@@ -135,15 +105,18 @@ export default function SpectatorApp() {
<RoverRow roster={roster} frames={frames} videoSources={videoSources} session={session} />
<SecondaryRow />
</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)]">
<div className="min-h-0 min-w-0 overflow-hidden">
<section className="flex min-h-0 min-w-0 flex-col gap-0.5 md:h-full">
<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" />
</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 />
</div>
<div className="min-h-0 min-w-0 overflow-hidden">
<LogsRow />
<div className="min-h-0 min-w-0">
<LogsRow className="h-40 overflow-hidden" />
</div>
</section>
</main>