big boy webui new new new new new 100 files changed 80 years

This commit is contained in:
legop3
2026-08-18 22:01:25 -04:00
parent 0f0f82e5f6
commit 737760ff56
79 changed files with 2714 additions and 388 deletions
@@ -0,0 +1,90 @@
// Bottom-left Corner Pod
// Purpose: Presents only the assigned rover's available physical light, laser, and horn controls.
import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
const ControlIcon = icon;
const toneClass = tone === 'horn'
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
>
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
<KeyPill label={keyLabel} />
</button>
);
}
export default function BottomLeftPod({ roverId }) {
const [open, setOpen] = usePodVisibility('peripherals', true);
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
const laser = useControlSelector((control) => control.pipeline?.laser);
const hornDevice = useControlSelector((control) => control.pipeline?.horn);
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const canControl = useCanControlRover(roverId);
const hornPointerRef = useRef(null);
const available = Boolean(headlight || laser || hornDevice);
if (!available) return null;
const startHornPointer = (event) => {
if (!canControl) return;
if (hornPointerRef.current != null) return;
event.preventDefault();
hornPointerRef.current = event.pointerId;
event.currentTarget.setPointerCapture?.(event.pointerId);
startHorn();
};
const stopHornPointer = (event) => {
if (hornPointerRef.current !== event.pointerId) return;
hornPointerRef.current = null;
stopHorn();
};
return (
<>
{open ? (
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
{/*
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
sixty-degree separation leaves a visible gap between the small circles while keeping
both controls equally distant from the horn. These explicit positions are the
rendered result of that geometry, not unrelated visual nudges.
*/}
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
)}
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} podOpen={open} onOpenChange={setHornSettingsOpen} /> : null}
</>
);
}
@@ -0,0 +1,101 @@
// Bottom-right Corner Pod
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
import { useCallback } from 'react';
import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ChatExpansion from './ChatExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
const ARC_CENTER = 72;
const ARC_RADIUS = 60;
const ARC_START_DEGREES = 105;
const ARC_SWEEP_DEGREES = 240;
function pointOnArc(fraction) {
// The unused third is centered on the physical bottom-right screen corner. Starting at
// 105 degrees and sweeping clockwise to 345 degrees leaves that exact corner-facing gap.
const degrees = ARC_START_DEGREES + fraction * ARC_SWEEP_DEGREES;
const radians = (degrees * Math.PI) / 180;
return {
x: ARC_CENTER + Math.cos(radians) * ARC_RADIUS,
y: ARC_CENTER + Math.sin(radians) * ARC_RADIUS,
};
}
export default function BottomRightPod({ roverId }) {
const layout = useDriverLayout();
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId);
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = Number(config?.minAngle);
const max = Number(config?.maxAngle);
const value = Number.isFinite(camera?.angle) ? camera.angle : Number(config?.homeAngle) || 0;
const fraction = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0.5;
const knob = pointOnArc(fraction);
// Keep this derived state in one place because it also tells the independent chat
// expansion whether it must offset itself above a visible camera-control pod.
const showCameraControls = layout === 'desktop';
const cameraPodOpen = showCameraControls && enabled && open;
const updateFromPointer = useCallback((event) => {
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
const pointerDegrees = ((Math.atan2(y - ARC_CENTER, x - ARC_CENTER) * 180) / Math.PI + 360) % 360;
let arcDegrees = (pointerDegrees - ARC_START_DEGREES + 360) % 360;
if (arcDegrees > ARC_SWEEP_DEGREES) {
/*
Pointer input inside the open corner-facing third is outside the slider. Snap it to
whichever visible endpoint is closer so the gap remains visually and behaviorally open.
*/
const distanceFromEnd = arcDegrees - ARC_SWEEP_DEGREES;
const distanceFromStart = 360 - arcDegrees;
arcDegrees = distanceFromStart < distanceFromEnd ? 0 : ARC_SWEEP_DEGREES;
}
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
setServoAngle(min + nextFraction * (max - min));
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
return (
<>
{cameraPodOpen ? (
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-tl-[4.25rem] bg-black/60">
{/* The pod shell and its visibility toggle stay usable while waiting, but
every camera mutation is blocked and visibly muted until control returns. */}
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-[8.5rem] w-[8.5rem] touch-none ${canControl ? '' : 'pointer-events-none opacity-40'}`} onPointerDown={updateFromPointer} onPointerMove={(event) => { if (event.buttons) updateFromPointer(event); }}>
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#064e3b" strokeWidth="12" strokeLinecap="round" />
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#34d399" strokeWidth="12" strokeLinecap="round" strokeDasharray={`${fraction} 1`} />
<circle cx={knob.x} cy={knob.y} r="7" fill="#ecfdf5" stroke="#059669" strokeWidth="3" />
</svg>
<button type="button" aria-label="Reset camera tilt" disabled={!canControl} onClick={() => setServoAngle(0)} className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded bg-black/55 px-1.5 py-1 font-bold text-white disabled:cursor-not-allowed disabled:opacity-40">
{/* The icon/value stack mirrors the battery pod and clarifies that this
circular gauge changes the live rover camera's tilt angle. */}
<FaVideo className="text-base" aria-hidden="true" />
<span className="text-sm leading-none">{value.toFixed(1)}°</span>
</button>
{/* These positions continue around the same circle just beyond the two slider endpoints.
Together they occupy the open third facing the corner without enlarging the pod. */}
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
</div>
) : showCameraControls && enabled ? (
<CornerPodToggle corner="bottom-right" expanded={false} label="Show camera tilt" onClick={() => setOpen(true)} />
) : null}
{/* Chat is an independent expansion. It remains usable even when this rover
has no camera-servo configuration or the camera pod is collapsed. */}
<ChatExpansion podOpen={cameraPodOpen} />
</>
);
}
@@ -0,0 +1,59 @@
// New-drive Chat Expansion
// Purpose: Keeps chat collapsed to a corner icon while reusing the established HUD composer behavior.
import { useCallback, useState } from 'react';
import { FaComment } from 'react-icons/fa';
import { useChatActions } from '../../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import HudChatInput from '../../HudChatInput/index.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function ChatExpansion({ podOpen }) {
const role = useSessionSelector((state) => state.session?.role || null);
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
const { blurChat, focusChat } = useChatActions();
const [open, setOpen] = useState(false);
const setChatOpen = useCallback((nextOpen) => {
const next = Boolean(nextOpen);
setOpen(next);
if (!next) blurChat();
}, [blurChat]);
const toggleChat = useCallback(() => {
if (open) {
setChatOpen(false);
return;
}
setOpen(true);
/* HudChatInput remains mounted while visually collapsed, so the existing
ChatContext HUD ref is ready immediately. Waiting one animation frame lets
the opening presentation commit before the browser paints the focus ring. */
window.requestAnimationFrame(focusChat);
}, [focusChat, open, setChatOpen]);
// Spectators use the sidebar transcript but cannot send through the legacy HUD
// composer, so hiding this expansion avoids presenting an inert control.
if (role === 'spectator') return null;
return (
<>
<button
type="button"
aria-label={open ? 'Close chat' : 'Open chat'}
aria-pressed={open}
onClick={toggleChat}
className={`pointer-events-auto absolute z-20 flex h-8 items-center justify-center gap-1 bg-black/60 px-1.5 text-sm text-white transition hover:bg-black/80 ${podOpen ? 'bottom-[8.5rem] right-0 rounded-tl-lg' : 'bottom-0 right-10 rounded-t-lg'}`}
>
<FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this
compact HUD hint without duplicating or hardcoding the default key. */}
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
</button>
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
</>
);
}
@@ -0,0 +1,49 @@
// Corner Pod Toggle
// Purpose: Reserves each exact video corner for a pod's always-reachable collapse control.
import { FaArrowUp } from 'react-icons/fa';
const CORNERS = {
'top-left': {
button: 'left-0 top-0 [clip-path:polygon(0_0,100%_0,0_100%)]',
icon: 'left-1 top-1',
collapseRotation: '-rotate-45',
expandRotation: 'rotate-[135deg]',
},
'top-right': {
button: 'right-0 top-0 [clip-path:polygon(0_0,100%_0,100%_100%)]',
icon: 'right-1 top-1',
collapseRotation: 'rotate-45',
expandRotation: '-rotate-[135deg]',
},
'bottom-left': {
button: 'bottom-0 left-0 [clip-path:polygon(0_0,0_100%,100%_100%)]',
icon: 'bottom-1 left-1',
collapseRotation: '-rotate-[135deg]',
expandRotation: 'rotate-45',
},
'bottom-right': {
button: 'bottom-0 right-0 [clip-path:polygon(100%_0,0_100%,100%_100%)]',
icon: 'bottom-1 right-1',
collapseRotation: 'rotate-[135deg]',
expandRotation: '-rotate-45',
},
};
export default function CornerPodToggle({ corner, expanded, label, onClick }) {
const placement = CORNERS[corner] || CORNERS['top-left'];
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`pointer-events-auto absolute z-40 h-10 w-10 bg-black/90 text-white/90 hover:bg-black hover:text-white ${placement.button}`}
>
{/* One arrow icon is rotated toward the physical corner for collapse and directly away
from it for expansion. The triangular hit target itself never moves or disappears. */}
<FaArrowUp
className={`absolute text-[0.65rem] ${placement.icon} ${expanded ? placement.collapseRotation : placement.expandRotation}`}
aria-hidden="true"
/>
</button>
);
}
@@ -0,0 +1,22 @@
// Expansion Toggle
// Purpose: Provides a slim edge strip that remains part of an expansion in both visibility states.
import { FaChevronDown, FaChevronLeft, FaChevronRight, FaChevronUp } from 'react-icons/fa';
const ICONS = { down: FaChevronDown, left: FaChevronLeft, right: FaChevronRight, up: FaChevronUp };
export default function ExpansionToggle({ direction, label, onClick, className = '' }) {
const Icon = ICONS[direction] || FaChevronLeft;
const horizontalEdge = direction === 'up' || direction === 'down';
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`flex shrink-0 items-center justify-center bg-black/60 text-[0.5rem] text-white/75 hover:bg-black hover:text-white ${horizontalEdge ? 'h-3 w-8' : 'h-8 w-3'} ${className}`}
>
{/* The black strip is intentionally retained around the chevron. A collapsed expansion is
therefore still a thin piece of that expansion, never a loose button over a pod. */}
<Icon aria-hidden="true" />
</button>
);
}
@@ -0,0 +1,78 @@
// Horn Settings Expansion
// Purpose: Keeps the persisted horn sound controls available independently of the peripheral pod.
import { useCallback } from 'react';
import { HORN_MAX_FREQUENCY } from '../../../../controls/constants.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import { HORN_SETTINGS_DEFAULTS } from '../../../../settings/namespaces.js';
import ExpansionToggle from './ExpansionToggle.jsx';
function clampFrequency(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
return Math.min(HORN_MAX_FREQUENCY, Math.round(numeric));
}
export default function HornSettingsExpansion({ open, podOpen, onOpenChange }) {
const { value, save } = useSettingsNamespace('horn', HORN_SETTINGS_DEFAULTS);
const waveform = value?.waveform === 'sine' ? 'sine' : 'saw';
const frequencies = [...(Array.isArray(value?.freqs) ? value.freqs : HORN_SETTINGS_DEFAULTS.freqs), 0, 0, 0, 0]
.slice(0, 4)
.map(clampFrequency);
const updateFrequency = useCallback((index, nextValue) => {
save((current) => {
// Build from the persisted values on every edit so rapid changes cannot overwrite a
// neighboring frequency input with a stale render-time copy.
const next = [...(Array.isArray(current?.freqs) ? current.freqs : HORN_SETTINGS_DEFAULTS.freqs), 0, 0, 0, 0]
.slice(0, 4)
.map(clampFrequency);
next[index] = clampFrequency(nextValue);
return { ...(current || {}), freqs: next };
});
}, [save]);
if (!open) {
return (
<div className={`pointer-events-auto absolute left-10 z-20 flex h-3 w-8 bg-black/60 ${podOpen ? 'bottom-40' : 'bottom-0'}`}>
<ExpansionToggle direction="up" label="Show horn settings" onClick={() => onOpenChange(true)} />
</div>
);
}
return (
<div className={`pointer-events-auto absolute left-0 z-20 w-52 rounded-tr-xl bg-black/60 p-2 text-xs text-white ${podOpen ? 'bottom-40' : 'bottom-0'}`}>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-semibold text-cyan-100">Horn settings</span>
{/* This arrow belongs to the expansion. Closing the peripheral pod never changes
hornSettings visibility; it only moves this panel into the vacated corner. */}
<ExpansionToggle direction="down" label="Hide horn settings" onClick={() => onOpenChange(false)} />
</div>
<label className="flex items-center justify-between gap-2 text-slate-300">
<span>Wave</span>
<select
value={waveform}
onChange={(event) => save((current) => ({ ...(current || {}), waveform: event.target.value === 'sine' ? 'sine' : 'saw' }))}
className="rounded bg-slate-900 px-2 py-1 text-white ring-1 ring-slate-600"
>
<option value="saw">Saw</option>
<option value="sine">Sine</option>
</select>
</label>
<div className="mt-2 grid grid-cols-2 gap-1.5">
{frequencies.map((frequency, index) => (
<label key={index} className="flex items-center gap-1 text-slate-400">
<span>{index + 1}</span>
<input
type="number"
min="0"
max={HORN_MAX_FREQUENCY}
value={frequency}
onChange={(event) => updateFrequency(index, event.target.value)}
className="min-w-0 flex-1 rounded bg-slate-900 px-1.5 py-1 text-right font-mono text-white ring-1 ring-slate-600"
/>
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,174 @@
// Top-left Corner Pod
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useControlSelector } from '../../../../controls/index.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
import RoverLabel from '../../../RoverLabel/index.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionToggle from './ExpansionToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
export default function TopLeftPod({ roverId }) {
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
const mode = useSessionSelector((state) => state.session?.mode || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const queue = turnInfo?.queue || [];
// Direct ownership is published independently from the detailed queue and is
// therefore the reliable initial-load/reconnect source for the current turn.
const currentDriverId = activeDriverId || turnInfo?.current || null;
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
const userIndex = socketId ? queue.indexOf(socketId) : -1;
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
const hasTurnDeadline = Boolean(turnActive && deadline);
const now = useSharedClock(1000, hasTurnDeadline);
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
const seconds = currentTurnSeconds == null || turnsAhead == null
? null
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
const isCurrentTurn = turnsAhead === 0;
const isWaitingForTurn = turnActive && !isCurrentTurn;
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
const previousCurrentRef = useRef(null);
useEffect(() => {
const wasCurrent = previousCurrentRef.current;
previousCurrentRef.current = isCurrentTurn;
let timer = 0;
if (turnActive && isCurrentTurn && wasCurrent !== true) {
const startedAt = Date.now();
/*
A direct-load current turn and a live ownership handoff both need the same cue. Defer
the visual state update by one task to remain compatible with the repo's React Compiler
rules while preserving the actual handoff timestamp for the minimum display period.
*/
timer = setTimeout(() => {
setTurnCueStartedAt(startedAt);
setShowTurnCue(true);
}, 0);
} else if ((!turnActive || !isCurrentTurn) && showTurnCue) {
timer = setTimeout(() => {
setShowTurnCue(false);
setTurnCueStartedAt(null);
}, 0);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isCurrentTurn, showTurnCue, turnActive]);
useEffect(() => {
if (!showTurnCue || !turnCueStartedAt || lastControlIntentAt <= turnCueStartedAt) return undefined;
/*
The first real control intent proves the user has noticed and started driving. Keep the
cue for at least two seconds anyway, then return to the compact timer. With no input the
cue remains large so the live server idle-skip deadline cannot be overlooked.
*/
const remainingMinimumMs = Math.max(0, 2000 - (Date.now() - turnCueStartedAt));
const timer = setTimeout(() => setShowTurnCue(false), remainingMinimumMs);
return () => clearTimeout(timer);
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
const gaugePercent = useMemo(() => {
if (seconds == null) return 0;
/*
The server's rover turns are sixty seconds long. Waiting users need one complete
turn added for each driver between the current driver and themselves. A complete
queue rotation is a stable scale across handoffs, so the ring drains continuously
instead of jumping back to full when the current driver changes.
*/
const rotationSeconds = Math.max(60, queue.length * 60);
return Math.max(0, Math.min(1, seconds / rotationSeconds));
}, [queue.length, seconds]);
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
: gaugePercent;
const showTimer = Boolean(turnActive && timerOpen);
const showLargeTimer = isWaitingForTurn || showTurnCue;
const timerLabel = seconds == null
// Ownership should remain visible while the detailed deadline is in flight.
? isCurrentTurn ? 'Your turn' : 'Waiting'
: seconds >= 60
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
: `${seconds}s`;
return (
<div className={`pointer-events-auto absolute left-0 top-0 flex items-start ${showLargeTimer ? 'z-[100]' : 'z-20'}`}>
{showTimer ? (
<div
className={`relative flex items-center justify-center transition-[width,height,border-radius,background-color] duration-500 ease-out motion-reduce:transition-none ${
showLargeTimer
// The waiting/handoff state replaces the old full-screen turn cue.
// It must be opaque and above every other in-video HUD surface so
// sensor graphics, chat, and docking controls cannot muddy the text.
? 'h-[25.5rem] w-[25.5rem] rounded-br-[12.75rem] bg-black'
: 'h-[8.5rem] w-[8.5rem] rounded-br-[4.25rem] bg-black/60'
}`}
>
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
used by the other pods instead of stacking SVG padding on top of shell padding. */}
<svg className="h-full w-full -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="41" fill="none" stroke="#334155" strokeWidth="10" />
<circle cx="50" cy="50" r="41" fill="none" stroke={showTurnCue ? '#fbbf24' : '#38bdf8'} strokeWidth="10" strokeLinecap="round" pathLength="1" strokeDasharray={`${visibleGaugePercent} 1`} />
</svg>
<span className={`absolute flex flex-col items-center text-center text-white ${showLargeTimer ? 'max-w-[55%]' : 'max-w-[70%]'}`}>
{/* The handoff text replaces the retired desktop full-screen TurnsOverlay.
Typography grows with the pod, while the corner toggle deliberately remains
fixed-size so it never becomes a giant obstruction over the video. */}
{showLargeTimer ? (
<>
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
{showTurnCue ? 'Its your turn!' : 'Someone else is driving'}
</span>
<strong className="text-[3.375rem] leading-none">
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
</strong>
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
{showTurnCue
? idleSkipSeconds != null
? 'Start driving or your turn will be skipped'
: 'Youre driving'
: 'until your turn'}
</span>
</>
) : (
<>
<strong className="text-lg leading-none">{timerLabel}</strong>
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
</>
)}
</span>
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
</div>
) : turnActive ? (
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
) : null}
{/* The rover name is an independent edge expansion. Its visibility control lives in
the expansion itself, and its position naturally moves into the corner whenever
the conditional timer pod is absent or manually collapsed. */}
{nameOpen ? (
<div className={`flex h-11 items-center gap-2 bg-black/60 px-2 ${showTimer ? '' : 'rounded-br-xl'}`}>
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
<ExpansionToggle direction="up" label="Hide rover name" onClick={() => setNameOpen(false)} />
</div>
) : (
<div className={`flex h-3 w-8 bg-black/60 ${showTimer ? '' : 'ml-10'}`}>
<ExpansionToggle direction="down" label="Show rover name" onClick={() => setNameOpen(true)} />
</div>
)}
</div>
);
}
@@ -0,0 +1,192 @@
// Top-right Corner Pod
// Purpose: Combines the battery/current gauge with a compact attached advanced-power expansion.
import { createElement, useMemo } from 'react';
import { FaArrowDown, FaArrowUp, FaBatteryHalf, FaBolt, FaExclamationTriangle, FaMemory, FaThermometerHalf, FaWifi } from 'react-icons/fa';
import { useControlSelector } from '../../../../controls/index.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useTelemetrySelector } from '../../../../context/TelemetryContext.jsx';
import { hostStatsEqual, selectHostStats, selectSpectatorTelemetry, spectatorTelemetryEqual } from '../../../../context/telemetryViews.js';
import CornerPodToggle from './CornerPodToggle.jsx';
import ExpansionToggle from './ExpansionToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
function finite(value) {
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function clampPercent(value) {
const number = finite(value);
return number == null ? 0 : Math.max(0, Math.min(100, number));
}
function MetricRow({ icon, label, value, percent, iconClass, fillClass }) {
return (
<div className="min-w-0" title={label}>
<div className="flex items-center gap-1">
{createElement(icon, { className: `shrink-0 text-[0.65rem] ${iconClass}`, 'aria-hidden': true })}
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
</div>
{/* Every meter uses an explicit real-world display range defined by its caller. The bar
therefore adds information instead of merely decorating the latest numeric value.
Keeping it on its own line gives both the title and meter the full panel width. */}
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-slate-700">
<div className={`h-full rounded-full ${fillClass}`} style={{ width: `${clampPercent(percent)}%` }} />
</div>
</div>
);
}
function WifiTile({ signal }) {
const bars = signal == null ? 0 : signal >= -55 ? 4 : signal >= -65 ? 3 : signal >= -75 ? 2 : 1;
const tone = signal == null ? 'bg-slate-600' : signal < -80 ? 'bg-red-400' : signal < -70 ? 'bg-amber-400' : 'bg-emerald-400';
return (
<div className="min-w-0" title="Wi-Fi signal strength">
<div className="flex items-center gap-1">
<FaWifi className={`text-[0.65rem] ${signal == null ? 'text-slate-400' : 'text-emerald-300'}`} aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">Wi-Fi signal</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{signal == null ? '--' : `${Math.round(signal)} dBm`}</strong>
</div>
<div className="mt-1 flex h-2 items-end gap-0.5" aria-hidden="true">
{[1, 2, 3, 4].map((bar) => (
<span key={bar} className={`flex-1 rounded-sm ${bar <= bars ? tone : 'bg-slate-700'}`} style={{ height: `${25 * bar}%` }} />
))}
</div>
</div>
);
}
function SpeedTile({ icon, label, value, colorClass }) {
return (
<div className="flex min-w-0 items-center gap-1" title={label}>
{createElement(icon, { className: colorClass, 'aria-hidden': true })}
<span className="min-w-0 flex-1 truncate text-[0.62rem] leading-none text-slate-200">{label}</span>
<strong className="shrink-0 text-[0.62rem] leading-none text-white">{value}</strong>
</div>
);
}
export default function TopRightPod({ roverId }) {
const [batteryOpen, setBatteryOpen] = usePodVisibility('battery', true);
const [powerOpen, setPowerOpen] = usePodVisibility('advancedPower', false);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const batteryState = useSessionSelector((state) => {
const rover = (state.session?.roster || []).find((entry) => String(entry.id) === String(roverId));
return rover?.batteryState || null;
});
const electrical = useTelemetrySelector(roverId, selectSpectatorTelemetry, spectatorTelemetryEqual);
const host = useTelemetrySelector(powerOpen ? roverId : null, selectHostStats, hostStatsEqual);
const percent = Math.max(0, Math.min(100, finite(batteryState?.percentDisplay) ?? 0));
const current = finite(electrical?.currentMa) ?? 0;
const currentPercent = Math.max(0, Math.min(1, Math.abs(current) / 2500));
const urgentBattery = Boolean(batteryState?.urgentActive);
const lowBattery = Boolean(batteryState?.warnActive || urgentBattery);
// Warning and urgent are separate server-owned thresholds. Amber gives the first threshold
// a clear but calm identity; red is reserved for the genuinely time-sensitive state.
const batteryTone = urgentBattery ? '#ef4444' : lowBattery ? '#f59e0b' : '#22c55e';
const currentTone = current < 0 ? '#f59e0b' : '#22c55e';
const circumference = 2 * Math.PI * 42;
const currentCircumference = 2 * Math.PI * 32;
const batteryDash = useMemo(() => `${(percent / 100) * circumference} ${circumference}`, [circumference, percent]);
const wifi = host?.wifi || {};
const signal = finite(wifi.signalDbm);
const voltage = finite(electrical?.voltageMv);
const batteryCharge = finite(electrical?.batteryChargeMah);
const batteryCapacity = finite(electrical?.batteryCapacityMah);
const cpuTemp = finite(host?.cpuTempC);
const memoryUsed = finite(host?.memoryUsedPct);
const voltagePercent = voltage == null ? 0 : ((voltage - 12000) / 5000) * 100;
const batteryMahPercent = batteryCharge != null && batteryCapacity > 0 ? (batteryCharge / batteryCapacity) * 100 : 0;
const cpuTempPercent = cpuTemp == null ? 0 : ((cpuTemp - 30) / 55) * 100;
const cpuTempTone = cpuTemp >= 80 ? 'bg-red-400' : cpuTemp >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const memoryTone = memoryUsed >= 90 ? 'bg-red-400' : memoryUsed >= 75 ? 'bg-amber-400' : 'bg-violet-400';
const download = finite(wifi.downloadMbps);
const upload = finite(wifi.uploadMbps);
const docked = Boolean(electrical?.homeBase);
const chargingLabel = String(electrical?.chargingStateLabel || '').toLowerCase();
const charging = docked && chargingLabel !== '' && chargingLabel !== 'not charging';
const autoDocking = !docked && !dockAssistActive && String(electrical?.oiModeLabel || '').toLowerCase() === 'passive';
// The warning describes the next useful fact instead of blindly telling every user to dock.
// This matters during assist, autonomous docking, and charging, where the old overlay's generic
// instruction was either redundant or actively misleading.
let warningMessage = urgentBattery ? 'Battery critical · Dock now' : 'Battery low · Dock soon';
if (charging) {
warningMessage = urgentBattery ? 'Battery critical · Charging' : 'Battery low · Charging';
} else if (docked) {
warningMessage = urgentBattery ? 'Battery critical · On dock' : 'Battery low · On dock';
} else if (dockAssistActive) {
warningMessage = urgentBattery ? 'Battery critical · Continue docking' : 'Battery low · Continue docking';
} else if (autoDocking) {
warningMessage = urgentBattery ? 'Battery critical · Returning to dock' : 'Battery low · Returning to dock';
}
return (
<div className="pointer-events-auto absolute right-0 top-0 z-20 flex flex-col items-end">
{batteryOpen ? (
<div className="relative flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-bl-[4.25rem] bg-black/60">
{/* Let the gauge geometry define the visible inset so this pod does not carry an
extra layer of shell padding that the camera pod does not have. */}
<svg className="h-[8.5rem] w-[8.5rem] -rotate-90" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="42" fill="none" stroke="#334155" strokeWidth="9" />
<circle cx="50" cy="50" r="42" fill="none" stroke={batteryTone} strokeWidth="9" strokeLinecap="round" strokeDasharray={batteryDash} />
<circle cx="50" cy="50" r="32" fill="none" stroke="#334155" strokeWidth="5" />
<circle cx="50" cy="50" r="32" fill="none" stroke={currentTone} strokeWidth="5" strokeLinecap="round" strokeDasharray={`${currentPercent * currentCircumference} ${currentCircumference}`} />
</svg>
{/* Keeping the icon and value in one centered stack makes the gauge's
meaning obvious without changing either circular telemetry ring. */}
<span className="absolute flex flex-col items-center justify-center gap-0.5 text-white">
{lowBattery ? (
<FaExclamationTriangle className={urgentBattery ? 'text-lg text-red-300' : 'text-lg text-amber-300'} aria-hidden="true" />
) : (
<FaBatteryHalf className="text-lg" aria-hidden="true" />
)}
<strong className="text-xl leading-none">{percent}%</strong>
</span>
<CornerPodToggle corner="top-right" expanded label="Hide battery pod" onClick={() => setBatteryOpen(false)} />
</div>
) : (
<CornerPodToggle corner="top-right" expanded={false} label="Show battery pod" onClick={() => setBatteryOpen(true)} />
)}
{/* This is status, not another docking control. Keeping it attached to the battery pod
preserves one canonical Dock action while still making the reason for urgency obvious. */}
{lowBattery ? (
<div
className={`absolute top-12 flex items-center gap-1.5 whitespace-nowrap rounded-l px-2.5 py-1.5 text-xs font-bold text-white transition-[right,background-color] ${
batteryOpen ? 'right-[8.5rem]' : 'right-0'
} ${
urgentBattery ? 'bg-red-950' : 'bg-amber-950'
}`}
role="status"
>
<FaExclamationTriangle className={urgentBattery ? 'text-red-300' : 'text-amber-300'} aria-hidden="true" />
<span>{warningMessage}</span>
</div>
) : null}
{/* Advanced power is an independently persisted right-edge expansion. Its own arrow is
retained when closed, and the whole panel moves into the corner if the pod closes. */}
{powerOpen ? (
<div className={`absolute right-0 w-56 rounded-bl-xl bg-black/60 p-1.5 pl-4 text-white ${batteryOpen ? 'top-[8.5rem]' : 'top-0'}`}>
<ExpansionToggle direction="right" label="Hide power and computer" onClick={() => setPowerOpen(false)} className="absolute left-0 top-1/2 -translate-y-1/2" />
<div className="space-y-1.5">
<MetricRow icon={FaBolt} label="Roomba voltage" value={voltage == null ? '--' : `${(voltage / 1000).toFixed(1)} V`} percent={voltagePercent} iconClass="text-sky-300" fillClass="bg-sky-400" />
<MetricRow icon={FaBolt} label="Roomba current" value={`${current > 0 ? '+' : ''}${Math.round(current)} mA`} percent={currentPercent * 100} iconClass={current < 0 ? 'text-amber-300' : 'text-emerald-300'} fillClass={current < 0 ? 'bg-amber-400' : 'bg-emerald-400'} />
<MetricRow icon={FaBatteryHalf} label="Battery charge" value={batteryCharge == null ? '--' : `${Math.round(batteryCharge)} mAh`} percent={batteryMahPercent} iconClass="text-emerald-300" fillClass="bg-emerald-400" />
<MetricRow icon={FaThermometerHalf} label="Computer temperature" value={cpuTemp == null ? '--' : `${cpuTemp.toFixed(1)} C`} percent={cpuTempPercent} iconClass={cpuTemp >= 80 ? 'text-red-300' : cpuTemp >= 70 ? 'text-amber-300' : 'text-emerald-300'} fillClass={cpuTempTone} />
<MetricRow icon={FaMemory} label="Memory usage" value={memoryUsed == null ? '--' : `${Math.round(memoryUsed)}%`} percent={memoryUsed} iconClass={memoryUsed >= 90 ? 'text-red-300' : memoryUsed >= 75 ? 'text-amber-300' : 'text-violet-300'} fillClass={memoryTone} />
<WifiTile signal={signal} />
<SpeedTile icon={FaArrowDown} label="Download speed" value={download == null ? '--' : `${download.toFixed(1)} Mb/s`} colorClass="text-sky-300" />
<SpeedTile icon={FaArrowUp} label="Upload speed" value={upload == null ? '--' : `${upload.toFixed(1)} Mb/s`} colorClass="text-violet-300" />
</div>
</div>
) : (
<div className={`absolute right-0 flex h-8 w-3 bg-black/60 ${batteryOpen ? 'top-[8.5rem]' : 'top-10'}`}>
<ExpansionToggle direction="left" label="Show power and computer" onClick={() => setPowerOpen(true)} />
</div>
)}
</div>
);
}
@@ -0,0 +1,25 @@
// New Drive Corner Pods
// Purpose: Composes the four independently owned corner controls around the shared video stage.
import TopLeftPod from './TopLeftPod.jsx';
import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
export default function CornerPods({ roverId }) {
const layout = useDriverLayout();
const showPhysicalControlPods = layout === 'desktop';
return (
<>
<TopLeftPod roverId={roverId} />
<TopRightPod roverId={roverId} />
{/* The mobile layouts already provide large touch controls around the video.
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted
on mobile and determines its own camera-control visibility from layout context. */}
<BottomRightPod roverId={roverId} />
</>
);
}
@@ -0,0 +1,14 @@
// Corner Pod Visibility
// Purpose: Persists each independent pod or expansion without coupling unrelated corner controls.
import { useCallback } from 'react';
import { useSettingsNamespace } from '../../../../settings/index.js';
export default function usePodVisibility(key, defaultOpen = true) {
const { value, save } = useSettingsNamespace('newdrivePods', {});
const open = value?.[key] == null ? defaultOpen : value[key] !== false;
const setOpen = useCallback(
(nextOpen) => save((current) => ({ ...(current || {}), [key]: Boolean(nextOpen) })),
[key, save],
);
return [open, setOpen];
}