mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
coloooors
This commit is contained in:
+12
-12
@@ -49,12 +49,12 @@ function useLayoutMode() {
|
||||
|
||||
function DesktopLayout({ layout }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<section className="grid grid-cols-[minmax(0,1.8fr)_minmax(0,1fr)] gap-0.5">
|
||||
<DriverVideoPanel />
|
||||
<RightPaneTabs layout={layout} />
|
||||
</section>
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
|
||||
{/* <AdminPanel /> */}
|
||||
<LogPanel />
|
||||
<SessionSnapshot />
|
||||
@@ -65,7 +65,7 @@ function DesktopLayout({ layout }) {
|
||||
|
||||
function MobilePortraitLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<DriverVideoPanel />
|
||||
<MobileControls />
|
||||
<DrivePanel />
|
||||
@@ -80,21 +80,21 @@ function MobilePortraitLayout() {
|
||||
|
||||
function MobileLandscapeLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<section className="grid min-h-screen grid-cols-[minmax(0,0.7fr)_minmax(0,2.1fr)_minmax(0,0.7fr)] gap-0.5">
|
||||
<MobileLandscapeAuxColumn />
|
||||
<DriverVideoPanel />
|
||||
<MobileLandscapeControlColumn />
|
||||
</section>
|
||||
<div className="flex flex-col gap-1 pb-2">
|
||||
<div className="flex flex-col gap-0.5 pb-0.5">
|
||||
<RoomCameraPanel />
|
||||
<DrivePanel />
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-0.5">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<LogPanel />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
@@ -114,12 +114,12 @@ function App() {
|
||||
: <MobilePortraitLayout />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-slate-50">
|
||||
<div className="min-h-screen bg-black text-slate-100">
|
||||
<SettingsProvider>
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<main className="flex w-full flex-col gap-1 text-base">{renderedLayout}</main>
|
||||
<main className="flex w-full flex-col gap-0.5 text-base">{renderedLayout}</main>
|
||||
<AlertFeed />
|
||||
<ModeGateOverlay />
|
||||
</ControlSystemProvider>
|
||||
|
||||
@@ -57,14 +57,10 @@ export default function AdminPanel() {
|
||||
if (!isAdmin) return null;
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="flex items-center justify-between gap-1 text-sm">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between gap-0.5 text-sm">
|
||||
<span>Admin controls</span>
|
||||
<select
|
||||
value={currentMode}
|
||||
onChange={handleModeChange}
|
||||
className="rounded-sm bg-black/40 px-1 py-1 text-sm text-slate-100"
|
||||
>
|
||||
<select value={currentMode} onChange={handleModeChange} className="field-input text-sm">
|
||||
{MODES.map((mode) => (
|
||||
<option key={mode.key} value={mode.key}>
|
||||
{mode.label}
|
||||
@@ -73,25 +69,25 @@ export default function AdminPanel() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 space-y-1 text-sm">
|
||||
<div className="space-y-0.5 text-sm">
|
||||
{roster.map((rover) => (
|
||||
<div key={rover.id} className="flex flex-wrap items-center justify-between gap-1 rounded-sm bg-black/30 px-1 py-1">
|
||||
<div key={rover.id} className="surface flex flex-wrap items-center justify-between gap-0.5">
|
||||
<div>
|
||||
<p className="text-slate-100">{rover.name}</p>
|
||||
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-xs">
|
||||
<div className="flex flex-wrap gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLockToggle(rover.id, !lockMap[rover.id])}
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="button-dark"
|
||||
>
|
||||
{lockMap[rover.id] ? 'Unlock' : 'Lock'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleForceControl(rover.id)}
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="button-dark"
|
||||
>
|
||||
Force
|
||||
</button>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function AlertFeed() {
|
||||
if (!visible.length) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-1 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-1">
|
||||
<div className="pointer-events-none fixed top-0.5 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-0.5">
|
||||
{visible.map((toast) => (
|
||||
<AlertToast key={toast.key} alert={toast.alert} />
|
||||
))}
|
||||
@@ -46,8 +46,8 @@ function AlertToast({ alert }) {
|
||||
? 'bg-amber-500/30 text-amber-100'
|
||||
: 'bg-emerald-500/30 text-emerald-100';
|
||||
return (
|
||||
<div className={`pointer-events-auto rounded-sm px-1 py-1 text-[0.75rem] ${colorClasses}`}>
|
||||
<p className="text-[0.65rem] text-slate-300">{alert.title || 'Alert'}</p>
|
||||
<div className={`pointer-events-auto px-0.5 py-0.5 text-[0.85rem] ${colorClasses}`}>
|
||||
<p className="text-xs text-slate-200">{alert.title || 'Alert'}</p>
|
||||
<p className="text-sm text-white">{alert.message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,43 +22,31 @@ export default function AuthPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="panel-section space-y-0.5 text-base">
|
||||
<p className="text-sm text-slate-400">Admin login</p>
|
||||
<form className="mt-1 flex flex-col gap-1" onSubmit={handleLogin}>
|
||||
<form className="flex flex-col gap-0.5" onSubmit={handleLogin}>
|
||||
<input
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="field-input"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="rounded-sm bg-black/50 px-1 py-1 text-slate-100"
|
||||
className="field-input"
|
||||
placeholder="Password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-sm bg-slate-200 px-1 py-1 text-sm font-semibold text-black disabled:opacity-50"
|
||||
>
|
||||
<button type="submit" disabled={loading} className="button-dark w-full disabled:opacity-50">
|
||||
{loading ? 'Logging in…' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-1 flex gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('user')}
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||
>
|
||||
<div className="flex gap-0.5 text-sm">
|
||||
<button type="button" onClick={() => setRole('user')} className="flex-1 button-dark">
|
||||
Driver
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRole('spectator')}
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-slate-200"
|
||||
>
|
||||
<button type="button" onClick={() => setRole('spectator')} className="flex-1 button-dark">
|
||||
Spectator
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -81,12 +81,12 @@ export default function CameraServoPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>Camera Tilt</span>
|
||||
<span className="font-mono text-sm text-slate-100">{formatDegrees(value)}</span>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
className="w-full accent-emerald-400"
|
||||
@@ -99,35 +99,23 @@ export default function CameraServoPanel() {
|
||||
onTouchEnd={commitSlider}
|
||||
onPointerUp={commitSlider}
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-slate-400">
|
||||
<div className="mt-0.5 flex justify-between text-xs text-slate-400">
|
||||
<span>{formatDegrees(min)}</span>
|
||||
<span>{formatDegrees(max)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => handleNudge(-1)}
|
||||
>
|
||||
<div className="flex gap-0.5 text-sm">
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(-1)}>
|
||||
Tilt Down
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-slate-700 px-1 py-0.5 text-slate-100 hover:bg-slate-600"
|
||||
onClick={() => goServoHome()}
|
||||
>
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => goServoHome()}>
|
||||
Center
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded bg-slate-800 px-1 py-0.5 text-slate-100 hover:bg-slate-700"
|
||||
onClick={() => handleNudge(1)}
|
||||
>
|
||||
<button type="button" className="flex-1 button-dark" onClick={() => handleNudge(1)}>
|
||||
Tilt Up
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="text-xs text-slate-400">
|
||||
Step: {formatDegrees(step)} · Pin GPIO {config?.pin}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -27,12 +27,12 @@ export default function DrivePanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span>Drive control</span>
|
||||
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
|
||||
<span>{roverId ? 'Ready to drive' : 'No rover assigned'}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<ActionCard
|
||||
title="Start Driving"
|
||||
description="Press to enable driving mode, then start moving. The headlamps should illuminate."
|
||||
@@ -58,21 +58,24 @@ export default function DrivePanel() {
|
||||
}
|
||||
|
||||
function ActionCard({ title, description, statuses, tone, onClick, disabled, footnote }) {
|
||||
const baseColor = tone === 'indigo' ? 'bg-indigo-600' : 'bg-emerald-600';
|
||||
const colors =
|
||||
tone === 'indigo'
|
||||
? { base: 'bg-indigo-600', hover: 'hover:bg-indigo-500' }
|
||||
: { base: 'bg-emerald-600', hover: 'hover:bg-emerald-500' };
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full rounded-lg ${baseColor} px-1 py-1 text-left text-white disabled:opacity-40`}
|
||||
className={`w-full ${colors.base} ${colors.hover} px-0.5 py-0.5 text-left text-white transition-colors disabled:opacity-40`}
|
||||
>
|
||||
<p className="text-base font-semibold">{title}</p>
|
||||
<p className="text-sm text-white/90">{description}</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<div className="mt-0.5 flex flex-wrap gap-0.5">
|
||||
{statuses.map((status) => (
|
||||
<span
|
||||
key={status.label}
|
||||
className={`rounded-full px-1 py-0.5 text-xs font-semibold ${
|
||||
className={`px-0.5 py-0.5 text-xs font-semibold ${
|
||||
status.active ? 'bg-lime-300 text-emerald-900' : 'bg-emerald-900 text-emerald-100'
|
||||
}`}
|
||||
>
|
||||
@@ -80,7 +83,7 @@ function ActionCard({ title, description, statuses, tone, onClick, disabled, foo
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{footnote && <p className="mt-1 text-xs text-emerald-50/80">{footnote}</p>}
|
||||
{footnote && <p className="mt-0.5 text-xs text-emerald-50/80">{footnote}</p>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,20 +15,14 @@ export default function DriverVideoPanel() {
|
||||
: null;
|
||||
const batteryConfig = batteryRecord?.battery ?? null;
|
||||
|
||||
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1">
|
||||
<section className="panel">
|
||||
{roverId ? (
|
||||
<div className="w-full">
|
||||
<VideoTile
|
||||
sessionInfo={info}
|
||||
label={roverId}
|
||||
muted={false}
|
||||
telemetryFrame={frame}
|
||||
batteryConfig={batteryConfig}
|
||||
/>
|
||||
</div>
|
||||
<VideoTile sessionInfo={info} label={roverLabel} telemetryFrame={frame} batteryConfig={batteryConfig} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-400">Assign a rover to view the video feed.</p>
|
||||
<p className="panel-muted">Assign a rover to view the video feed.</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ const ACTIONS = [
|
||||
|
||||
function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
return (
|
||||
<label className="block rounded border border-white/5 p-1">
|
||||
<label className="surface block">
|
||||
<div className="flex items-center justify-between text-xs text-slate-300">
|
||||
<span className="font-semibold text-slate-100">{label}</span>
|
||||
<span className="font-mono text-slate-400">{NUMBER_FORMAT.format(value)}</span>
|
||||
@@ -55,7 +55,7 @@ function SliderField({ label, description, min, max, step, value, onChange }) {
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
className="mt-1 w-full accent-emerald-400"
|
||||
className="mt-0.5 w-full accent-emerald-400"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
@@ -162,31 +162,27 @@ export default function GamepadMappingSettings() {
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#1d232b] p-1 text-sm text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Gamepad mapping</p>
|
||||
<p className="text-xs text-slate-400">Gamepad mapping</p>
|
||||
<p className="text-[0.65rem] text-slate-500">
|
||||
{gamepadConnected
|
||||
? 'Move sticks/axes with a big sweep or press buttons when capturing.'
|
||||
: 'Connect a controller to configure.'}
|
||||
</p>
|
||||
{capture && (
|
||||
<p className="mt-1 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||
<p className="mt-0.5 text-[0.7rem] text-emerald-400">Capturing {capture.label}…</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
|
||||
>
|
||||
<button type="button" onClick={() => reset()} className="button-dark text-xs">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">Sensitivity & feel</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<div className="space-y-0.5 surface">
|
||||
<p className="text-[0.7rem] text-slate-500">Sensitivity & feel</p>
|
||||
<div className="space-y-0.5">
|
||||
<SliderField
|
||||
label="Drive deadzone"
|
||||
description="Ignore small drive stick movement"
|
||||
@@ -226,27 +222,23 @@ export default function GamepadMappingSettings() {
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(grouped).map(([section, actions]) => (
|
||||
<div key={section} className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-500">{section}</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div key={section} className="space-y-0.5 surface">
|
||||
<p className="text-[0.7rem] text-slate-500">{section}</p>
|
||||
<div className="space-y-0.5">
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs">
|
||||
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{getValueLabel(action)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleClear(action)}
|
||||
className="rounded bg-slate-800 px-2 py-1 text-[0.65rem] uppercase tracking-wide"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button type="button" onClick={() => handleClear(action)} className="button-dark text-[0.7rem]">
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapture(action)}
|
||||
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${capture?.id === action.id ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`}
|
||||
className={`${capture?.id === action.id ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium`}
|
||||
>
|
||||
{capture?.id === action.id ? 'Waiting…' : 'Capture'}
|
||||
</button>
|
||||
|
||||
@@ -18,11 +18,11 @@ export default function HelpPanel({ layout }) {
|
||||
};
|
||||
const tips = presets[layout] || presets.desktop;
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<p className="text-sm text-slate-400">Help</p>
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{tips.map((tip) => (
|
||||
<li key={tip} className="rounded-sm bg-black/30 px-1 py-1 text-slate-200">
|
||||
<li key={tip} className="surface text-slate-200">
|
||||
{tip}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -71,51 +71,40 @@ export default function KeymapSettings() {
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Keyboard Layout</p>
|
||||
<p className="text-xs text-slate-400">Keyboard layout</p>
|
||||
<p className="text-[0.7rem] text-slate-500">Per-browser · click to change a binding</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetKeyBindings()}
|
||||
className="rounded-sm bg-black/40 px-2 py-1 text-xs uppercase tracking-wide text-slate-200 hover:bg-black/60"
|
||||
>
|
||||
Reset Defaults
|
||||
<button type="button" onClick={() => resetKeyBindings()} className="button-dark text-xs">
|
||||
Reset defaults
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="space-y-0.5">
|
||||
{Object.entries(grouped).map(([group, actions]) => (
|
||||
<div key={group} className="rounded border border-white/5 p-1">
|
||||
<p className="text-[0.7rem] uppercase tracking-wide text-slate-400">{group}</p>
|
||||
<div className="mt-1 space-y-1">
|
||||
<div key={group} className="space-y-0.5 surface">
|
||||
<p className="text-[0.75rem] text-slate-400">{group}</p>
|
||||
<div className="space-y-0.5">
|
||||
{actions.map((action) => {
|
||||
const value = currentKey(action.id);
|
||||
const isActive = active === action.id;
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="flex items-center justify-between rounded bg-black/30 px-1 py-1 text-xs"
|
||||
>
|
||||
<div key={action.id} className="surface-muted flex items-center justify-between text-xs">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-100">{action.label}</p>
|
||||
<p className="text-[0.65rem] text-slate-400">{formatKeyLabel(value)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancel()}
|
||||
className="rounded bg-red-600 px-2 py-1 text-[0.65rem] uppercase tracking-wide text-red-100"
|
||||
>
|
||||
<button type="button" onClick={() => cancel()} className="button-danger text-[0.7rem]">
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startCapture(action.id)}
|
||||
className={`rounded px-2 py-1 text-[0.65rem] uppercase tracking-wide ${isActive ? 'bg-emerald-500 text-emerald-950' : 'bg-slate-700 text-slate-100'}`}
|
||||
className={`${isActive ? 'px-0.5 py-0.5 bg-emerald-500 text-emerald-950 hover:bg-emerald-400' : 'button-dark'} text-[0.7rem] font-medium transition-colors`}
|
||||
>
|
||||
{isActive ? 'Press a key…' : 'Change'}
|
||||
</button>
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useSession } from '../context/SessionContext.jsx';
|
||||
export default function LogPanel() {
|
||||
const { logs } = useSession();
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="panel-section space-y-0.5 text-base">
|
||||
<div className="flex items-center justify-between text-sm text-slate-400">
|
||||
<span>Server logs</span>
|
||||
<span>{logs.length}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-64 overflow-y-auto rounded-sm bg-black/40 p-1 font-mono text-xs">
|
||||
<div className="surface h-64 overflow-y-auto space-y-0.5 font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<p>No logs yet.</p>
|
||||
) : (
|
||||
@@ -16,7 +16,7 @@ export default function LogPanel() {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
<div key={entry.id} className="mb-1">
|
||||
<div key={entry.id} className="surface-muted">
|
||||
<span className="text-amber-400">{entry.timestamp}</span>{' '}
|
||||
<span className="text-lime-400">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-teal-400">[{entry.label}]</span>}{' '}
|
||||
|
||||
@@ -96,7 +96,7 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="presentation"
|
||||
className={`relative w-full ${heightClass} select-none overflow-hidden rounded-lg border border-slate-800 bg-slate-900/70`}
|
||||
className={`relative w-full ${heightClass} select-none overflow-hidden bg-zinc-950`}
|
||||
style={{ touchAction: 'none' }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
@@ -114,11 +114,11 @@ function FloatingJoystick({ disabled, layout, radius, onMove, onStop }) {
|
||||
{visual.active && (
|
||||
<>
|
||||
<div
|
||||
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-cyan-400/60 bg-cyan-400/10"
|
||||
className="pointer-events-none absolute h-28 w-28 -translate-x-1/2 -translate-y-1/2 bg-cyan-400/10 outline outline-2 outline-cyan-400/60 [clip-path:circle(50%)]"
|
||||
style={{ left: visual.base.x, top: visual.base.y }}
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 rounded-full bg-cyan-300/80 shadow-lg"
|
||||
className="pointer-events-none absolute h-12 w-12 -translate-x-1/2 -translate-y-1/2 bg-cyan-300/80 shadow-lg [clip-path:circle(50%)]"
|
||||
style={{
|
||||
left: visual.base.x + visual.knob.x,
|
||||
top: visual.base.y + visual.knob.y,
|
||||
@@ -195,7 +195,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-slate-100">
|
||||
<div className="flex flex-col gap-0.5 text-slate-100">
|
||||
<DriveModeToggle size="compact" />
|
||||
<FloatingJoystick
|
||||
disabled={disabled}
|
||||
@@ -204,17 +204,12 @@ function MobileJoystickPanel({ layout }) {
|
||||
onMove={handleMove}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopAllMotion}
|
||||
disabled={disabled}
|
||||
className="rounded bg-red-600 px-2 py-2 text-xs font-semibold uppercase tracking-wide text-red-100 disabled:opacity-40"
|
||||
>
|
||||
<button type="button" onClick={stopAllMotion} disabled={disabled} className="button-danger w-full disabled:opacity-40">
|
||||
Panic Stop
|
||||
</button>
|
||||
{cameraEnabled && (
|
||||
<div className="rounded-lg bg-slate-900/70 p-2 text-xs">
|
||||
<div className="flex items-center justify-between text-[0.7rem] uppercase tracking-wide text-slate-400">
|
||||
<div className="bg-zinc-950 p-0.5 text-xs">
|
||||
<div className="flex items-center justify-between text-[0.75rem] text-slate-400">
|
||||
<span>Camera Tilt</span>
|
||||
<span className="font-mono text-slate-200">{cameraValue.toFixed(1)}°</span>
|
||||
</div>
|
||||
@@ -225,7 +220,7 @@ function MobileJoystickPanel({ layout }) {
|
||||
step={0.5}
|
||||
value={cameraValue}
|
||||
onChange={handleCameraSlider}
|
||||
className="mt-1 w-full accent-cyan-400"
|
||||
className="mt-0.5 w-full accent-cyan-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -269,9 +264,9 @@ function AuxMotorPanel({ orientation }) {
|
||||
const gridCols = orientation === 'landscape' ? 'grid-cols-1' : 'grid-cols-1 sm:grid-cols-2';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-slate-100">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Aux controls</p>
|
||||
<div className={`grid ${gridCols} gap-1`}>
|
||||
<div className="flex flex-col gap-0.5 text-slate-100">
|
||||
<p className="text-xs text-slate-400">Aux controls</p>
|
||||
<div className={`grid ${gridCols} gap-0.5`}>
|
||||
{AUX_BUTTONS.map((button) => (
|
||||
<button
|
||||
key={button.id}
|
||||
@@ -285,7 +280,7 @@ function AuxMotorPanel({ orientation }) {
|
||||
onPointerLeave={() => handleRelease(button)}
|
||||
onPointerCancel={() => handleRelease(button)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`rounded-lg px-2 py-2 text-left text-sm font-semibold uppercase tracking-wide text-white transition ${button.color} disabled:opacity-30`}
|
||||
className={`px-0.5 py-0.5 text-left text-sm font-semibold text-white transition ${button.color} hover:brightness-110 disabled:opacity-30`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
@@ -297,7 +292,7 @@ function AuxMotorPanel({ orientation }) {
|
||||
|
||||
export function MobileLandscapeAuxColumn() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<AuxMotorPanel orientation="landscape" />
|
||||
</div>
|
||||
);
|
||||
@@ -305,7 +300,7 @@ export function MobileLandscapeAuxColumn() {
|
||||
|
||||
export function MobileLandscapeControlColumn() {
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-1">
|
||||
<div className="flex h-full flex-col gap-0.5">
|
||||
<MobileJoystickPanel layout="landscape" />
|
||||
</div>
|
||||
);
|
||||
@@ -313,8 +308,8 @@ export function MobileLandscapeControlColumn() {
|
||||
|
||||
export default function MobilePortraitControls() {
|
||||
return (
|
||||
<section className="w-full">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<section className="panel">
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<AuxMotorPanel orientation="portrait" />
|
||||
<MobileJoystickPanel layout="portrait" />
|
||||
</div>
|
||||
|
||||
@@ -33,13 +33,13 @@ export default function ModeGateOverlay() {
|
||||
const details = getModeDetails(mode);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-2 py-2">
|
||||
<div className="w-full max-w-md space-y-2 rounded-lg bg-[#141821] p-2 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-1">
|
||||
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-0.5 py-0.5">
|
||||
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-lg font-semibold">{details.title}</p>
|
||||
<p className="text-sm text-slate-300">{details.description}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-black/40 p-1">
|
||||
<div className="surface-muted">
|
||||
<AuthPanel />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
|
||||
@@ -8,16 +8,16 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from './Tabs.jsx';
|
||||
|
||||
function RoomControlsPlaceholder() {
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Room controls</p>
|
||||
<p className="text-sm text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Room controls</p>
|
||||
<p className="text-slate-200">Coming soon: lighting, docks, and environmental toggles.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RightPaneTabs({ layout }) {
|
||||
return (
|
||||
<section className="rounded-sm bg-[#1a1d23] p-1 text-base text-slate-100">
|
||||
<section className="panel text-base">
|
||||
<Tabs defaultTab="telemetry">
|
||||
<TabList>
|
||||
<Tab id="telemetry">Rover Controls</Tab>
|
||||
@@ -27,14 +27,14 @@ export default function RightPaneTabs({ layout }) {
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="telemetry">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<DrivePanel />
|
||||
<CameraServoPanel />
|
||||
<TelemetryPanel />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="room">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<RoomCameraPanel />
|
||||
<RoomControlsPlaceholder />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export default function RoomCameraPanel() {
|
||||
return (
|
||||
<div className="min-h-[8rem] rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<div className="panel-section min-h-[8rem] space-y-0.5 text-base">
|
||||
<p className="text-center text-sm text-slate-400">Room camera</p>
|
||||
<p className="mt-1 text-center">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
<p className="text-center text-slate-200">Feed placeholder. Wire upcoming room cam here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ export default function SessionSnapshot() {
|
||||
const { session } = useSession();
|
||||
const payload = useMemo(() => JSON.stringify(session ?? {}, null, 2), [session]);
|
||||
return (
|
||||
<div className="rounded-sm bg-[#242a32] p-1 text-xs text-slate-100">
|
||||
<div className="panel-section space-y-0.5 text-xs">
|
||||
<p className="text-sm text-slate-400">Session snapshot</p>
|
||||
<pre className="mt-1 h-64 overflow-y-auto bg-black/40 p-1 text-[0.7rem] text-lime-300">{payload}</pre>
|
||||
<pre className="surface h-64 overflow-y-auto font-mono text-[0.7rem] text-lime-300">{payload}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,49 +42,49 @@ export default function SettingsPanel() {
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel id="keybindings">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<KeymapSettings />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="controller">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-0.5">
|
||||
<GamepadMappingSettings />
|
||||
</div>
|
||||
</TabPanel>
|
||||
<TabPanel id="admin">
|
||||
<div className="space-y-1">
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Manual OI commands</p>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<div className="space-y-0.5">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Manual OI commands</p>
|
||||
<div className="surface flex flex-wrap gap-0.5">
|
||||
{manualTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => sendOiCommand(tab.key)}
|
||||
disabled={!canControl}
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-30"
|
||||
className="button-dark text-xs disabled:opacity-30"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-sm text-slate-100">
|
||||
<p className="text-xs text-slate-400">Sensor stream</p>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<section className="panel-section space-y-0.5 text-sm">
|
||||
<p className="text-slate-400">Sensor stream</p>
|
||||
<div className="surface flex gap-0.5">
|
||||
{sensorButtons.map((btn) => (
|
||||
<button
|
||||
key={btn.key}
|
||||
type="button"
|
||||
onClick={() => handleSensorToggle(btn.enable)}
|
||||
disabled={!canControl}
|
||||
className="flex-1 rounded-sm bg-black/40 px-1 py-1 text-xs text-slate-200 disabled:opacity-30"
|
||||
className="flex-1 button-dark text-xs disabled:opacity-30"
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!canControl && <p className="mt-1 text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
||||
{!canControl && <p className="text-xs text-slate-500">Assign a rover to toggle streams.</p>}
|
||||
</section>
|
||||
<AuthPanel />
|
||||
<AdminPanel />
|
||||
|
||||
@@ -12,9 +12,9 @@ function useTabsContext() {
|
||||
|
||||
const TAB_VARIANTS = {
|
||||
primary: {
|
||||
base: 'flex-1 rounded-sm px-1 py-1 text-xs font-semibold transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500',
|
||||
active: 'bg-slate-100 text-slate-900',
|
||||
inactive: 'bg-black/30 text-slate-300 hover:text-slate-100',
|
||||
base: 'flex-1 px-0.5 py-0.5 text-sm font-medium transition-colors focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-slate-500',
|
||||
active: 'bg-sky-600 text-white',
|
||||
inactive: 'bg-zinc-800 text-slate-200 hover:bg-sky-500 hover:text-white',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function Tabs({ children, defaultTab, currentTab, onTabChange, va
|
||||
}
|
||||
|
||||
export function TabList({ children, className = '' }) {
|
||||
return <div className={classNames('flex gap-1', className)}>{children}</div>;
|
||||
return <div className={classNames('flex gap-0.5', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function Tab({ id, children, className = '', disabled = false }) {
|
||||
@@ -118,7 +118,7 @@ export function Tab({ id, children, className = '', disabled = false }) {
|
||||
}
|
||||
|
||||
export function TabPanels({ children, className = '' }) {
|
||||
return <div className={classNames('mt-1 space-y-1', className)}>{children}</div>;
|
||||
return <div className={classNames('mt-0.5 space-y-0.5', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function TabPanel({ id, children, keepMounted = false }) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function TelemetryPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-sm bg-[#242a32] p-1 text-base text-slate-100">
|
||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
||||
<div className="text-sm text-slate-400">
|
||||
<span>{connected ? 'online' : 'offline'}</span>
|
||||
<span> · role {session?.role || 'unknown'}</span>
|
||||
@@ -56,12 +56,12 @@ export default function TelemetryPanel() {
|
||||
<span> · driver {driverLabel}</span>
|
||||
</div>
|
||||
{!roverId ? (
|
||||
<p className="mt-1 text-[0.75rem] text-slate-400">Assign a rover to view sensors.</p>
|
||||
<p className="text-sm text-slate-500">Assign a rover to view sensors.</p>
|
||||
) : !frame ? (
|
||||
<p className="mt-1 text-[0.75rem] text-slate-400">Waiting for sensor frames…</p>
|
||||
<p className="text-sm text-slate-500">Waiting for sensor frames…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<div className="flex flex-wrap gap-0.5 text-sm">
|
||||
<Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity}` : null)} />
|
||||
<Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
|
||||
<Metric label="OI mode" value={formatMetric(sensors.oiMode?.label)} />
|
||||
@@ -70,7 +70,7 @@ export default function TelemetryPanel() {
|
||||
<Metric label="Left Encoder" value={formatMetric(sensors.encoderCountsLeft)} />
|
||||
<Metric label="Right Encoder" value={formatMetric(sensors.encoderCountsRight)} />
|
||||
</div>
|
||||
<div className="mt-1 flex gap-0.5">
|
||||
<div className="flex gap-0.5">
|
||||
<CliffBar value={sensors.cliffLeftSignal} />
|
||||
<CliffBar value={sensors.cliffFrontLeftSignal} />
|
||||
<CliffBar value={sensors.cliffFrontRightSignal} />
|
||||
@@ -80,24 +80,24 @@ export default function TelemetryPanel() {
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
{rawSnippet && (
|
||||
// console.log('rawSnippet:', rawSnippet),
|
||||
<pre className="mt-1 text-wrap break-words p-1 text-xs text-lime-300 bg-gray-700">
|
||||
<pre className="surface whitespace-pre-wrap break-words text-xs text-lime-300">
|
||||
{rawSnippet}
|
||||
</pre>
|
||||
)}
|
||||
<div className="mt-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm text-slate-400">Rovers</p>
|
||||
{roster.length === 0 ? (
|
||||
// if there ARE NO rovers
|
||||
<p className="text-sm text-slate-500">No roster data.</p>
|
||||
) : (
|
||||
// if there ARE rovers
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{roster.map((rover) => (
|
||||
<li key={rover.id} className="flex items-center justify-between gap-1">
|
||||
<li key={rover.id} className="surface flex items-center justify-between gap-0.5">
|
||||
<div>
|
||||
<p className="text-slate-200">{rover.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
@@ -109,7 +109,7 @@ export default function TelemetryPanel() {
|
||||
type="button"
|
||||
onClick={() => handleRequest(rover.id)}
|
||||
disabled={pending[rover.id]}
|
||||
className="rounded-sm bg-black/40 px-1 py-0.5 text-xs text-slate-200 disabled:opacity-40"
|
||||
className="button-dark disabled:opacity-40"
|
||||
>
|
||||
{pending[rover.id] ? '...' : 'request'}
|
||||
</button>
|
||||
@@ -130,15 +130,15 @@ function CliffBar({ value }) {
|
||||
const pct = value == null ? 0 : Math.max(0, Math.min(100, (value / 4095) * 100));
|
||||
const height = `${pct}%`;
|
||||
return (
|
||||
<div className="w-1/4 h-6 bg-gray-700 rounded-sm overflow-hidden">
|
||||
<div className="w-full bg-amber-400" style={{ height }} />
|
||||
<div className="surface-muted h-6 w-1/4">
|
||||
<div className="w-full bg-amber-500" style={{ height }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }) {
|
||||
return (
|
||||
<div className="bg-gray-700 rounded-sm p-0.5 text-sm">
|
||||
<div className="surface text-sm">
|
||||
{label}: {value}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,17 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
|
||||
}, []);
|
||||
|
||||
const ensurePlayback = useCallback(async () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
video.muted = true;
|
||||
await video.play();
|
||||
} catch {
|
||||
// Autoplay might still be blocked; retry logic elsewhere will handle it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const attemptUnmute = useCallback(
|
||||
(delay = 0) => {
|
||||
if (forceMute) return;
|
||||
@@ -52,6 +63,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
try {
|
||||
await ensurePlayback();
|
||||
video.muted = false;
|
||||
await video.play();
|
||||
setMuted(false);
|
||||
@@ -64,7 +76,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
|
||||
unmuteTimer.current = setTimeout(tryPlay, delay);
|
||||
},
|
||||
[forceMute],
|
||||
[ensurePlayback, forceMute],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
@@ -92,6 +104,9 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
if (!active) return;
|
||||
setStatus(nextStatus);
|
||||
setDetail(info || null);
|
||||
if (nextStatus === 'playing') {
|
||||
ensurePlayback();
|
||||
}
|
||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
||||
scheduleRestart();
|
||||
}
|
||||
@@ -116,7 +131,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
clearTimeout(resetMuteId);
|
||||
player?.stop();
|
||||
};
|
||||
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]);
|
||||
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart, ensurePlayback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'stopped' && sessionInfo?.url) {
|
||||
@@ -133,8 +148,8 @@ export default function VideoTile({ sessionInfo, label, forceMute = false, telem
|
||||
: status;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative w-full overflow-hidden rounded-sm bg-black aspect-video">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="relative w-full overflow-hidden bg-black aspect-video">
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted={forceMute || muted}
|
||||
@@ -157,12 +172,11 @@ function BatteryBar({ charge, config, label, status }) {
|
||||
const urgent = config?.Urgent ?? null;
|
||||
if (charge == null || full == null || warn == null) {
|
||||
return (
|
||||
<div className="rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200">
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
{/* <span>{label}</span> */}
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-400">Battery telemetry unavailable</p>
|
||||
<p className="text-xs text-slate-500">Battery telemetry unavailable</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,13 +191,12 @@ function BatteryBar({ charge, config, label, status }) {
|
||||
const warnTriggered = urgent != null && charge <= urgent;
|
||||
const barClass = depleted ? 'bg-red-500 animate-pulse' : warnTriggered ? 'bg-amber-400' : 'bg-emerald-500';
|
||||
return (
|
||||
<div className="space-y-2 rounded-sm bg-[#1e1e1e] px-1 py-1 text-sm text-slate-200">
|
||||
<div className="panel-section space-y-0.5 text-sm">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
{/* <span>{label}</span> */}
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
<div className="relative h-4 w-full rounded-full bg-slate-800">
|
||||
<div className={`h-full rounded-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
|
||||
<div className="relative h-4 w-full bg-zinc-900">
|
||||
<div className={`h-full transition-[width] ${barClass}`} style={{ width: `${percentDisplay}%` }} />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-xs font-semibold text-black/80">
|
||||
Battery {percentText}
|
||||
</span>
|
||||
@@ -204,73 +217,25 @@ function HudOverlay({ frame, label }) {
|
||||
|
||||
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
|
||||
|
||||
// const bumperBadges = [
|
||||
// { label: 'B-L', active: bumps.bumpLeft },
|
||||
// { label: 'B-R', active: bumps.bumpRight },
|
||||
// ];
|
||||
// const wheelBadges = [
|
||||
// { label: 'Drop L', active: bumps.wheelDropLeft },
|
||||
// { label: 'Drop R', active: bumps.wheelDropRight },
|
||||
// ];
|
||||
// console.log('session', session);
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
{/* <div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 text-[0.55rem] text-slate-400">
|
||||
<span className={`h-2 w-2 rounded-full ${pulse ? 'bg-emerald-300 shadow-[0_0_4px_rgba(16,185,129,0.8)]' : 'bg-slate-700'}`} />
|
||||
<span>sensor</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{bumperBadges.map((badge) => (
|
||||
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
{wheelBadges.map((badge) => (
|
||||
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
|
||||
))}
|
||||
</div> */}
|
||||
{/* status that tells you the name of your rover (label) */}
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex gap-2 rounded-full bg-gray-900/40 px-2 py-0.5 text-[0.65rem] font-medium text-slate-200">
|
||||
<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-xs text-slate-100">
|
||||
<span>You are driving {label || 'Unnamed Rover'}</span>
|
||||
<span>|</span>
|
||||
<span>{pulse ? 'Sensors Active' : 'No Recent Sensor Data'}</span>
|
||||
<span>{pulse ? 'Sensors active' : 'No recent sensors'}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* bump and wheel drops bar */}
|
||||
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex gap-1 rounded-full bg-gray-900/40 text-[0.55rem] font-medium">
|
||||
<div className={`px-0.5 rounded-full ${bumps.bumpLeft ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Left Bump
|
||||
</div>
|
||||
{/* left wheel drop */}
|
||||
<div className={`px-0.5 rounded-full ${bumps.wheelDropLeft ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Left Wheel Drop
|
||||
</div>
|
||||
{/* right wheel drop */}
|
||||
<div className={`px-0.5 rounded-full ${bumps.wheelDropRight ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Right Wheel Drop
|
||||
</div>
|
||||
{/* right bump */}
|
||||
<div className={`px-0.5 rounded-full ${bumps.bumpRight ? 'bg-red-500 text-white animate-pulse' : 'bg-black/40 text-slate-500'}`}>
|
||||
Right Bump
|
||||
</div>
|
||||
<div className="absolute top-0.5 left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/70 px-0.5 py-0.5 text-[0.6rem] font-medium text-slate-200">
|
||||
<div className={`px-0.5 py-0.5 ${bumps.bumpLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left bump</div>
|
||||
<div className={`px-0.5 py-0.5 ${bumps.wheelDropLeft ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Left wheel drop</div>
|
||||
<div className={`px-0.5 py-0.5 ${bumps.wheelDropRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right wheel drop</div>
|
||||
<div className={`px-0.5 py-0.5 ${bumps.bumpRight ? 'bg-red-600 text-white animate-pulse' : 'text-slate-500'}`}>Right bump</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// function HudBadge({ label, active }) {
|
||||
// return (
|
||||
// <span
|
||||
// className={`rounded-sm px-1 py-0.5 text-[0.55rem] ${active ? 'bg-emerald-500/30 text-emerald-100' : 'bg-black/40 text-slate-500'}`}
|
||||
// >
|
||||
// {label}
|
||||
// </span>
|
||||
// );
|
||||
// }
|
||||
|
||||
const OVERCURRENT_LABELS = {
|
||||
leftWheel: 'Left wheel',
|
||||
rightWheel: 'Right wheel',
|
||||
@@ -285,7 +250,7 @@ function OvercurrentOverlay({ motors }) {
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
|
||||
<div className="text-center text-2xl font-semibold text-white animate-pulse">
|
||||
<div>Overcurrent</div>
|
||||
<div className="mt-1 text-xl font-medium text-white">{labels.join(', ')}</div>
|
||||
<div className="mt-0.5 text-xl font-medium text-white">{labels.join(', ')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -37,25 +37,21 @@ export default function DriveModeToggle({ size = 'default' }) {
|
||||
}
|
||||
};
|
||||
|
||||
const pillClass =
|
||||
size === 'compact'
|
||||
? 'text-xs px-1 py-0.5'
|
||||
: 'text-sm px-1.5 py-0.5';
|
||||
const pillClass = size === 'compact' ? 'text-xs px-0.5 py-0.5' : 'text-sm px-0.5 py-0.5';
|
||||
|
||||
return (
|
||||
<div className="rounded-sm bg-black/30 p-1 text-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`rounded-full ${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}>
|
||||
<div className="bg-black/30 p-0.5 text-slate-100">
|
||||
<div className="flex items-center">
|
||||
<span className={`${driveReady ? 'bg-emerald-700' : 'bg-indigo-700'} ${pillClass}`}>
|
||||
{currentLabel}
|
||||
</span>
|
||||
<span className="text-[0.65rem] text-slate-400">Rover {roverId ?? '—'}</span>
|
||||
</div>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 text-xs">
|
||||
<div className="mt-0.5 grid grid-cols-2 gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDrive}
|
||||
disabled={disabled}
|
||||
className={`rounded-sm bg-emerald-600 px-1 py-1 font-semibold uppercase tracking-wide text-emerald-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
className={`bg-emerald-600 px-0.5 py-0.5 font-semibold text-emerald-50 transition-colors hover:bg-emerald-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
>
|
||||
Drive
|
||||
</button>
|
||||
@@ -63,7 +59,7 @@ export default function DriveModeToggle({ size = 'default' }) {
|
||||
type="button"
|
||||
onClick={handleDock}
|
||||
disabled={disabled}
|
||||
className={`rounded-sm bg-indigo-600 px-1 py-1 font-semibold uppercase tracking-wide text-indigo-50 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
className={`bg-indigo-600 px-0.5 py-0.5 font-semibold text-indigo-50 transition-colors hover:bg-indigo-500 disabled:opacity-40 ${size === 'compact' ? 'text-xs' : 'text-sm'}`}
|
||||
>
|
||||
Dock
|
||||
</button>
|
||||
|
||||
+36
-3
@@ -5,12 +5,45 @@
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
sans-serif;
|
||||
color: #0f172a;
|
||||
background-color: #f8fafc;
|
||||
@apply bg-black text-slate-100;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background-color: #0f172a;
|
||||
@apply bg-black text-slate-100;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.panel {
|
||||
@apply bg-zinc-800 text-slate-100 p-0.5;
|
||||
}
|
||||
|
||||
.panel-section {
|
||||
@apply bg-zinc-950 text-slate-100 p-0.5;
|
||||
}
|
||||
|
||||
.panel-muted {
|
||||
@apply text-sm text-slate-400;
|
||||
}
|
||||
|
||||
.surface {
|
||||
@apply bg-zinc-900/80 text-slate-100 border border-zinc-800 px-0.5 py-0.5;
|
||||
}
|
||||
|
||||
.surface-muted {
|
||||
@apply bg-zinc-950/80 text-slate-200 border border-zinc-900 px-0.5 py-0.5;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
@apply bg-zinc-900/80 text-slate-100 border border-zinc-800 px-0.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-slate-500;
|
||||
}
|
||||
|
||||
.button-dark {
|
||||
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-sky-600 hover:bg-sky-500;
|
||||
}
|
||||
|
||||
.button-danger {
|
||||
@apply px-0.5 py-0.5 text-sm font-medium text-white transition-colors bg-rose-600 hover:bg-rose-500;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export class WhepPlayer {
|
||||
if (!this.video) return;
|
||||
this.video.playsInline = true;
|
||||
this.video.autoplay = true;
|
||||
this.video.muted = true;
|
||||
this.video.disableRemotePlayback = true;
|
||||
if ('latencyHint' in HTMLMediaElement.prototype) {
|
||||
this.video.latencyHint = 'interactive';
|
||||
|
||||
@@ -7,28 +7,26 @@ import VideoTile from '../components/VideoTile.jsx';
|
||||
|
||||
function SpectatorStatus({ connected, ready, rosterCount }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<span className={`rounded-full px-3 py-1 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
||||
<div className="flex flex-wrap items-center gap-0.5 text-sm">
|
||||
<span className={`px-0.5 py-0.5 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
||||
{connected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
<span className={`rounded-full px-3 py-1 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
|
||||
<span className={`px-0.5 py-0.5 ${ready ? 'bg-blue-600/40 text-blue-100' : 'bg-slate-700 text-slate-200'}`}>
|
||||
Spectator mode {ready ? 'active' : 'pending…'}
|
||||
</span>
|
||||
<span className="rounded-full bg-slate-800 px-3 py-1 text-slate-200">
|
||||
Rovers: {rosterCount}
|
||||
</span>
|
||||
<span className="bg-slate-800 px-0.5 py-0.5 text-slate-200">Rovers: {rosterCount}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogStream({ logs }) {
|
||||
return (
|
||||
<section className="rounded-3xl border border-slate-800 bg-slate-900/70 p-4">
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<section className="panel space-y-0.5 text-sm">
|
||||
<header className="mb-0.5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-white">Server logs</h2>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-slate-500">latest {logs.length}</span>
|
||||
<span className="text-xs text-slate-500">latest {logs.length}</span>
|
||||
</header>
|
||||
<div className="h-60 overflow-y-auto rounded-2xl bg-slate-950/40 p-3 text-xs font-mono text-slate-300">
|
||||
<div className="surface h-60 overflow-y-auto space-y-0.5 text-xs font-mono text-slate-300">
|
||||
{logs.length === 0 ? (
|
||||
<p>No log entries yet.</p>
|
||||
) : (
|
||||
@@ -36,7 +34,7 @@ function LogStream({ logs }) {
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry) => (
|
||||
<div key={entry.id} className="mb-2">
|
||||
<div key={entry.id} className="surface-muted">
|
||||
<span className="text-slate-500">{entry.timestamp}</span>{' '}
|
||||
<span className="text-cyan-300">[{entry.level}]</span>{' '}
|
||||
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
|
||||
@@ -59,22 +57,22 @@ function formatSensors(sensors = {}) {
|
||||
function RoverSpectatorCard({ rover, frame, videoInfo }) {
|
||||
const sensorList = formatSensors(frame?.sensors);
|
||||
return (
|
||||
<article className="space-y-4 rounded-3xl border border-slate-800 bg-slate-900/70 p-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<article className="space-y-0.5 bg-zinc-900 p-0.5">
|
||||
<header className="flex flex-col gap-0.5">
|
||||
<h3 className="text-2xl font-semibold text-white">{rover.name}</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
|
||||
</p>
|
||||
</header>
|
||||
<VideoTile sessionInfo={videoInfo} label={rover.name} forceMute />
|
||||
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
|
||||
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
|
||||
<section className="surface">
|
||||
<p className="text-xs text-slate-500">Telemetry</p>
|
||||
{sensorList.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-slate-400">No sensor data yet.</p>
|
||||
<p className="mt-0.5 text-sm text-slate-400">No sensor data yet.</p>
|
||||
) : (
|
||||
<dl className="mt-3 grid gap-2 text-sm text-slate-200">
|
||||
<dl className="mt-0.5 grid gap-0.5 text-sm text-slate-200">
|
||||
{sensorList.map(({ key, value }) => (
|
||||
<div key={key} className="flex justify-between gap-4">
|
||||
<div key={key} className="flex justify-between gap-0.5">
|
||||
<dt className="text-slate-500">{key}</dt>
|
||||
<dd className="text-right font-semibold text-white">{value}</dd>
|
||||
</div>
|
||||
@@ -99,14 +97,14 @@ export default function SpectatorApp() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-slate-50">
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-6 px-6 py-10">
|
||||
<header className="space-y-3">
|
||||
<div className="min-h-screen bg-black text-slate-100">
|
||||
<main className="mx-auto flex max-w-7xl flex-col gap-0.5 px-0.5 py-0.5">
|
||||
<header className="space-y-0.5">
|
||||
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
|
||||
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
|
||||
<SpectatorStatus {...status} />
|
||||
</header>
|
||||
<section className="grid gap-6 lg:grid-cols-3">
|
||||
<section className="grid gap-0.5 lg:grid-cols-3">
|
||||
{roster.length === 0 ? (
|
||||
<p className="col-span-full text-slate-400">No rovers registered.</p>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user