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-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-0RmLu8Be.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BxUpjkzh.css">
<script type="module" crossorigin src="/assets/index-BRHN50zb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DoC35k7C.css">
</head>
<body>
<div id="root"></div>
+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>
+11 -4
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="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>}
+44 -25
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>
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>