mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
videotile
This commit is contained in:
@@ -64,16 +64,18 @@
|
|||||||
- [ ] gamepad mapping settings
|
- [ ] gamepad mapping settings
|
||||||
- [ ] mobile controls
|
- [ ] mobile controls
|
||||||
- [ ] top down map
|
- [ ] top down map
|
||||||
- [ ] video tile
|
- [x] video tile
|
||||||
- [ ] Sweep `webui` for unused or unneeded files/code with verification
|
- [ ] Sweep `webui` for unused or unneeded files/code with verification
|
||||||
|
|
||||||
### COMPLETED COMPONENTS
|
### COMPLETED COMPONENTS
|
||||||
- mini summary app
|
- mini summary app
|
||||||
- spectator app
|
- spectator app
|
||||||
|
- video tile
|
||||||
|
|
||||||
### LARGE CHANGES
|
### LARGE CHANGES
|
||||||
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
|
- Split `webui/src/mini/MiniSummaryApp.jsx` into folderized modules under `webui/src/mini/MiniSummaryApp/` with a compatibility entrypoint preserved.
|
||||||
- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved.
|
- Split `webui/src/spectate/SpectatorApp.jsx` into folderized modules under `webui/src/spectate/SpectatorApp/` with a compatibility entrypoint preserved.
|
||||||
|
- Split `webui/src/components/VideoTile.jsx` by extracting HUD, overlays, chat input, and constants into `webui/src/components/VideoTile/` while preserving the existing `VideoTile.jsx` public component API.
|
||||||
|
|
||||||
## Done criteria (per item)
|
## Done criteria (per item)
|
||||||
- [ ] Folderized structure created.
|
- [ ] Folderized structure created.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||||
<title>Multi Roomba Rover</title>
|
<title>Multi Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BK4DZOjR.js"></script>
|
<script type="module" crossorigin src="/assets/index-L5eomcx0.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DVTOmRBl.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { WhepPlayer } from '../lib/whepPlayer.js';
|
import { WhepPlayer } from '../lib/whepPlayer.js';
|
||||||
import TopDownMap from './TopDownMap.jsx';
|
|
||||||
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
|
||||||
import { useChat } from '../context/ChatContext.jsx';
|
|
||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
import SocialButton from './SocialButton.jsx';
|
import SocialButton from './SocialButton.jsx';
|
||||||
import BatteryBar from './BatteryBar.jsx';
|
import BatteryBar from './BatteryBar.jsx';
|
||||||
import { buildBatteryVisual } from '../lib/battery.js';
|
import { buildBatteryVisual } from '../lib/battery.js';
|
||||||
import { roverNameChromeStyle } from '../lib/roverColor.js';
|
import TurnCueOverlay from './VideoTile/TurnCueOverlay.jsx';
|
||||||
|
import HudOverlay from './VideoTile/HudOverlay.jsx';
|
||||||
const RESTART_DELAY_MS = 2000;
|
import OvercurrentOverlay from './VideoTile/OvercurrentOverlay.jsx';
|
||||||
const UNMUTE_RETRY_MS = 3000;
|
import LowBatteryOverlay from './VideoTile/LowBatteryOverlay.jsx';
|
||||||
const AUDIO_RETRY_MS = 3000;
|
import LightBumpBars from './VideoTile/LightBumpBars.jsx';
|
||||||
const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
import HudChatInput from './VideoTile/HudChatInput.jsx';
|
||||||
const DUCK_RELEASE_FADE_MS = 1000;
|
import {
|
||||||
|
RESTART_DELAY_MS,
|
||||||
|
UNMUTE_RETRY_MS,
|
||||||
|
AUDIO_RETRY_MS,
|
||||||
|
BRUSH_CURRENT_THRESHOLD_MA,
|
||||||
|
DUCK_RELEASE_FADE_MS,
|
||||||
|
} from './VideoTile/constants.js';
|
||||||
|
|
||||||
export default function VideoTile({
|
export default function VideoTile({
|
||||||
sessionInfo,
|
sessionInfo,
|
||||||
@@ -674,420 +678,3 @@ export default function VideoTile({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LightBumpBars({ sensors }) {
|
|
||||||
const values = [
|
|
||||||
sensors?.lightBumpLeftSignal,
|
|
||||||
sensors?.lightBumpFrontLeftSignal,
|
|
||||||
sensors?.lightBumpCenterLeftSignal,
|
|
||||||
sensors?.lightBumpCenterRightSignal,
|
|
||||||
sensors?.lightBumpFrontRightSignal,
|
|
||||||
sensors?.lightBumpRightSignal,
|
|
||||||
];
|
|
||||||
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
|
||||||
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
|
||||||
const hueFor = (v) => {
|
|
||||||
if (v == null || v <= 0) return 'hsl(200 60% 18%)';
|
|
||||||
const h = (200 + eased(v) * 360) % 360;
|
|
||||||
return `hsl(${h} 100% 60%)`;
|
|
||||||
};
|
|
||||||
const segments = 6;
|
|
||||||
const gap = 2;
|
|
||||||
const barHeight = 12;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex w-full items-center justify-center gap-0.5">
|
|
||||||
{values.map((v, idx) => {
|
|
||||||
const t = eased(v);
|
|
||||||
const dir = idx < segments / 2 ? -1 : 1; // left bars fill left, right bars fill right
|
|
||||||
const fill = `${t * 100}%`;
|
|
||||||
const color = hueFor(v);
|
|
||||||
return (
|
|
||||||
<div key={idx} className="relative flex-1 min-w-[0]" style={{ height: `${barHeight}px` }}>
|
|
||||||
<div className="h-full w-full overflow-hidden bg-slate-800" style={{ borderRadius: 0 }}>
|
|
||||||
<div
|
|
||||||
className="h-full"
|
|
||||||
style={{
|
|
||||||
width: fill,
|
|
||||||
background: color,
|
|
||||||
float: dir === -1 ? 'right' : 'left',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HudOverlay({
|
|
||||||
frame,
|
|
||||||
sensors,
|
|
||||||
label,
|
|
||||||
roverColor = null,
|
|
||||||
status,
|
|
||||||
audioStatus,
|
|
||||||
levelStatus,
|
|
||||||
desktopLayout = true,
|
|
||||||
layoutFormat = 'desktop',
|
|
||||||
variant = 'default',
|
|
||||||
driverLabel = null,
|
|
||||||
battery,
|
|
||||||
showTopDown = false,
|
|
||||||
mobileHud = false,
|
|
||||||
mapPosition = 'top-center',
|
|
||||||
turnTimerText = null,
|
|
||||||
labelScale = 1,
|
|
||||||
}) {
|
|
||||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
|
||||||
const isMobile = mobileHud;
|
|
||||||
const portraitMobile = layoutFormat === 'mobile-portrait';
|
|
||||||
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
|
||||||
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
|
||||||
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
|
|
||||||
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
|
|
||||||
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
|
|
||||||
const timerTextClass = isMobile ? 'text-[0.5rem]' : 'text-[0.7rem]';
|
|
||||||
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
|
||||||
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
|
||||||
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
|
||||||
const labelWrapperStyle = {
|
|
||||||
transform: `translateX(-50%) scale(${labelScale})`,
|
|
||||||
transformOrigin: 'center bottom',
|
|
||||||
};
|
|
||||||
const mapSize = '240px';
|
|
||||||
const mapScale = portraitMobile ? 0.3 : isMobile ? 0.33 : 0.7;
|
|
||||||
const mapOpacity = isMobile ? 0.6 : 0.7;
|
|
||||||
const mapStyle = {
|
|
||||||
width: mapSize,
|
|
||||||
height: mapSize,
|
|
||||||
opacity: mapOpacity,
|
|
||||||
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
|
|
||||||
transformOrigin:
|
|
||||||
mapPosition === 'bottom-left' ? 'bottom left' : mapPosition === 'top-center' ? 'top center' : 'top right',
|
|
||||||
...(mapPosition === 'bottom-left'
|
|
||||||
? { left: '0.25rem', bottom: '0.25rem' }
|
|
||||||
: mapPosition === 'top-center'
|
|
||||||
? { left: '50%', top: '0.25rem' }
|
|
||||||
: { right: '0.25rem', top: '0.25rem' }),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (variant === 'none') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variant === 'spectator') {
|
|
||||||
const telemetryEntries = [
|
|
||||||
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
|
||||||
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
|
||||||
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
|
||||||
['OI', sensors?.oiMode?.label || '--'],
|
|
||||||
];
|
|
||||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
|
||||||
const chargingLabel = sensors?.chargingState?.label || '';
|
|
||||||
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
|
||||||
const oiLabel = sensors?.oiMode?.label || 'Unknown';
|
|
||||||
const oiNormalized = oiLabel.toLowerCase();
|
|
||||||
const oiTone =
|
|
||||||
oiNormalized === 'full'
|
|
||||||
? 'bg-emerald-500/80 text-emerald-50'
|
|
||||||
: oiNormalized === 'safe'
|
|
||||||
? 'bg-amber-400/80 text-amber-950'
|
|
||||||
: oiNormalized === 'passive'
|
|
||||||
? 'bg-slate-700/80 text-slate-100'
|
|
||||||
: 'bg-slate-700/60 text-slate-200';
|
|
||||||
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
|
|
||||||
const chargingTone = charging
|
|
||||||
? 'bg-emerald-500/80 text-emerald-50'
|
|
||||||
: docked
|
|
||||||
? 'bg-amber-400/80 text-amber-950'
|
|
||||||
: 'bg-slate-700/70 text-slate-200';
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span>Status: {status}</span>
|
|
||||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
|
||||||
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
|
||||||
>
|
|
||||||
<div className="space-y-0.5 leading-tight">
|
|
||||||
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
|
|
||||||
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
|
||||||
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
|
||||||
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
|
||||||
</span>
|
|
||||||
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
|
||||||
</div>
|
|
||||||
{/* <span
|
|
||||||
className={`${isMobile ? 'text-[0.45rem]' : 'text-[0.6rem]'} uppercase tracking-wide text-slate-400`}
|
|
||||||
>
|
|
||||||
Telemetry
|
|
||||||
</span> */}
|
|
||||||
{telemetryEntries.map(([labelText, value]) => (
|
|
||||||
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
|
||||||
<span className="text-slate-400">{labelText}</span>
|
|
||||||
<span className="font-semibold text-white">{value}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
|
|
||||||
style={roverNameChromeStyle(roverColor, 0.18)}
|
|
||||||
>
|
|
||||||
{label || 'Unnamed Rover'}
|
|
||||||
</span>
|
|
||||||
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{showTopDown ? (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute rounded"
|
|
||||||
style={{
|
|
||||||
...mapStyle,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TopDownMap sensors={sensors} size={240} overlay />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span>Status: {status}</span>
|
|
||||||
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
|
||||||
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{turnTimerText ? (
|
|
||||||
<div
|
|
||||||
className={`absolute left-1/2 top-0.5 -translate-x-1/2 rounded bg-black/70 text-slate-100 ${timerPadClass} ${timerTextClass}`}
|
|
||||||
>
|
|
||||||
{turnTimerText}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
|
||||||
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
|
||||||
<span>
|
|
||||||
Rover:{' '}
|
|
||||||
<span
|
|
||||||
className="rounded px-1 py-[1px] border border-transparent"
|
|
||||||
style={roverNameChromeStyle(roverColor, 0.18)}
|
|
||||||
>
|
|
||||||
"{label || 'Unnamed Rover'}"
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTopDown && variant !== 'spectator' ? (
|
|
||||||
<div
|
|
||||||
className="pointer-events-none absolute rounded"
|
|
||||||
style={{
|
|
||||||
...mapStyle,
|
|
||||||
// padding: '0.1rem',
|
|
||||||
// background: 'rgba(0,0,0,0.6)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TopDownMap sensors={sensors} size={240} overlay />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TurnCueOverlay({
|
|
||||||
mobileHud = false,
|
|
||||||
turnSeconds = null,
|
|
||||||
isActiveDriver = false,
|
|
||||||
idleSkipSeconds = null,
|
|
||||||
}) {
|
|
||||||
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
|
|
||||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
|
||||||
const timerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
|
||||||
const padClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
|
||||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
|
||||||
return (
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
|
||||||
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
|
||||||
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
|
||||||
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
|
||||||
{showCountdown ? (
|
|
||||||
<div className={`text-red-100/90 ${timerClass}`}>
|
|
||||||
Idle skip in {idleSkipSeconds}s
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const OVERCURRENT_LABELS = {
|
|
||||||
leftWheel: 'Left wheel',
|
|
||||||
rightWheel: 'Right wheel',
|
|
||||||
mainBrush: 'Main brush',
|
|
||||||
sideBrush: 'Side brush',
|
|
||||||
limiter: 'Overcurrent limit',
|
|
||||||
};
|
|
||||||
|
|
||||||
function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
|
|
||||||
if (!motors?.length) return null;
|
|
||||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
|
||||||
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
|
||||||
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
|
||||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
|
||||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
|
||||||
const safeFill = Math.max(0, Math.min(1, fill));
|
|
||||||
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
|
||||||
>
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
|
||||||
</div>
|
|
||||||
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
|
||||||
<div>OVERCURRENT</div>
|
|
||||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// low battery overlay, change text based on warn / urgent. use percentage calculated same as BatteryBar. change text based on warn or urgent.
|
|
||||||
function LowBatteryOverlay({ battery, compact = false }) {
|
|
||||||
if (!battery?.available) return null;
|
|
||||||
if (!battery.warnActive && !battery.urgentActive) return null;
|
|
||||||
|
|
||||||
const message = battery.urgentActive
|
|
||||||
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
|
|
||||||
: 'Battery low! please dock and charge the rover soon.';
|
|
||||||
|
|
||||||
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
|
||||||
const textClass = compact ? 'text-sm' : 'text-2xl';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
|
|
||||||
>
|
|
||||||
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
|
||||||
<div>{message}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HudChatInput({ compact = false }) {
|
|
||||||
const session = useSessionSelector((state) => state.session);
|
|
||||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
|
|
||||||
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
|
||||||
const [draft, setDraft] = useState('');
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const canChat = session?.role !== 'spectator';
|
|
||||||
const hideHudChat = session?.role === 'spectator';
|
|
||||||
const currentRoverId = session?.assignment?.roverId || null;
|
|
||||||
const rover = useMemo(
|
|
||||||
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
|
||||||
[currentRoverId, session?.roster],
|
|
||||||
);
|
|
||||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
|
||||||
const ttsPayload = useMemo(() => {
|
|
||||||
if (!ttsSupported) return null;
|
|
||||||
const engine = ttsSettings?.engine === 'espeak' ? 'espeak' : 'flite';
|
|
||||||
if (engine === 'espeak') {
|
|
||||||
let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined;
|
|
||||||
if (typeof pitch === 'number') {
|
|
||||||
pitch = Math.max(0, Math.min(99, pitch));
|
|
||||||
}
|
|
||||||
return { speak: true, engine, pitch };
|
|
||||||
}
|
|
||||||
const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined;
|
|
||||||
return { speak: true, engine, voice };
|
|
||||||
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
|
||||||
const containerClass = compact
|
|
||||||
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
|
|
||||||
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
|
||||||
const inputClass = compact
|
|
||||||
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
|
||||||
: 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none';
|
|
||||||
const buttonClass = compact
|
|
||||||
? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
|
|
||||||
: 'rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
|
|
||||||
|
|
||||||
async function handleSend(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!canChat) return;
|
|
||||||
const clean = draft.trim();
|
|
||||||
if (!clean) return;
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
await sendMessage(clean, ttsPayload);
|
|
||||||
setDraft('');
|
|
||||||
blurChat();
|
|
||||||
setTypingActive(false);
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hideHudChat) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSend} className={containerClass}>
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
value={draft}
|
|
||||||
onChange={(event) => {
|
|
||||||
const next = event.target.value;
|
|
||||||
setDraft(next);
|
|
||||||
setTypingActive(Boolean(next.trim()));
|
|
||||||
}}
|
|
||||||
onFocus={(event) => {
|
|
||||||
onInputFocus(event);
|
|
||||||
setTypingActive(Boolean(draft.trim()));
|
|
||||||
}}
|
|
||||||
onBlur={(event) => {
|
|
||||||
onInputBlur(event);
|
|
||||||
setTypingActive(false);
|
|
||||||
}}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' && !draft.trim()) {
|
|
||||||
event.preventDefault();
|
|
||||||
blurChat();
|
|
||||||
setTypingActive(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
|
||||||
placeholder={canChat ? 'Chat (TTS)' : 'Spectator'}
|
|
||||||
disabled={!canChat}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canChat || sending}
|
|
||||||
className={buttonClass}
|
|
||||||
>
|
|
||||||
Speak
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Inline HUD chat input for driver speech messages.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useChat } from '../../context/ChatContext.jsx';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
|
||||||
|
export default function HudChatInput({ compact = false }) {
|
||||||
|
const session = useSessionSelector((state) => state.session);
|
||||||
|
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChat();
|
||||||
|
const { value: ttsSettings } = useSettingsNamespace('tts', { engine: 'flite', voice: 'rms', pitch: 50 });
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const canChat = session?.role !== 'spectator';
|
||||||
|
const hideHudChat = session?.role === 'spectator';
|
||||||
|
const currentRoverId = session?.assignment?.roverId || null;
|
||||||
|
const rover = useMemo(
|
||||||
|
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||||
|
[currentRoverId, session?.roster],
|
||||||
|
);
|
||||||
|
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||||
|
const ttsPayload = useMemo(() => {
|
||||||
|
if (!ttsSupported) return null;
|
||||||
|
const engine = ttsSettings?.engine === 'espeak' ? 'espeak' : 'flite';
|
||||||
|
if (engine === 'espeak') {
|
||||||
|
let pitch = Number.isFinite(ttsSettings?.pitch) ? Math.round(ttsSettings.pitch) : undefined;
|
||||||
|
if (typeof pitch === 'number') {
|
||||||
|
pitch = Math.max(0, Math.min(99, pitch));
|
||||||
|
}
|
||||||
|
return { speak: true, engine, pitch };
|
||||||
|
}
|
||||||
|
const voice = typeof ttsSettings?.voice === 'string' ? ttsSettings.voice : undefined;
|
||||||
|
return { speak: true, engine, voice };
|
||||||
|
}, [ttsSettings?.engine, ttsSettings?.pitch, ttsSettings?.voice, ttsSupported]);
|
||||||
|
const containerClass = compact
|
||||||
|
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
|
||||||
|
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
|
||||||
|
const inputClass = compact
|
||||||
|
? 'min-w-0 flex-1 bg-transparent text-[0.55rem] text-slate-100 placeholder:text-slate-400 focus:outline-none'
|
||||||
|
: 'min-w-0 flex-1 bg-transparent text-[0.7rem] text-slate-100 placeholder:text-slate-400 focus:outline-none';
|
||||||
|
const buttonClass = compact
|
||||||
|
? 'rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
|
||||||
|
: 'rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
|
||||||
|
|
||||||
|
async function handleSend(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canChat) return;
|
||||||
|
const clean = draft.trim();
|
||||||
|
if (!clean) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await sendMessage(clean, ttsPayload);
|
||||||
|
setDraft('');
|
||||||
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hideHudChat) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSend} className={containerClass}>
|
||||||
|
<input
|
||||||
|
className={inputClass}
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value;
|
||||||
|
setDraft(next);
|
||||||
|
setTypingActive(Boolean(next.trim()));
|
||||||
|
}}
|
||||||
|
onFocus={(event) => {
|
||||||
|
onInputFocus(event);
|
||||||
|
setTypingActive(Boolean(draft.trim()));
|
||||||
|
}}
|
||||||
|
onBlur={(event) => {
|
||||||
|
onInputBlur(event);
|
||||||
|
setTypingActive(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter' && !draft.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
blurChat();
|
||||||
|
setTypingActive(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={(el) => registerInputRef(el, { target: 'hud' })}
|
||||||
|
placeholder={canChat ? 'Chat (TTS)' : 'Spectator'}
|
||||||
|
disabled={!canChat}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={!canChat || sending} className={buttonClass}>
|
||||||
|
Speak
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// Telemetry HUD and map overlay for VideoTile.
|
||||||
|
import React from 'react';
|
||||||
|
import TopDownMap from '../TopDownMap.jsx';
|
||||||
|
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||||
|
|
||||||
|
export default function HudOverlay({
|
||||||
|
frame,
|
||||||
|
sensors,
|
||||||
|
label,
|
||||||
|
roverColor = null,
|
||||||
|
status,
|
||||||
|
audioStatus,
|
||||||
|
levelStatus,
|
||||||
|
desktopLayout = true,
|
||||||
|
layoutFormat = 'desktop',
|
||||||
|
variant = 'default',
|
||||||
|
driverLabel = null,
|
||||||
|
battery,
|
||||||
|
showTopDown = false,
|
||||||
|
mobileHud = false,
|
||||||
|
mapPosition = 'top-center',
|
||||||
|
turnTimerText = null,
|
||||||
|
labelScale = 1,
|
||||||
|
}) {
|
||||||
|
const isMobile = mobileHud;
|
||||||
|
const portraitMobile = layoutFormat === 'mobile-portrait';
|
||||||
|
const statusTextClass = isMobile ? 'text-[0.45rem]' : 'text-[0.65rem]';
|
||||||
|
const statusPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-1 py-0.5';
|
||||||
|
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 py-0.5';
|
||||||
|
const labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
|
||||||
|
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1';
|
||||||
|
const timerTextClass = isMobile ? 'text-[0.5rem]' : 'text-[0.7rem]';
|
||||||
|
const timerPadClass = isMobile ? 'px-0.5 py-0.25' : 'px-1 py-0.5';
|
||||||
|
const telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
|
||||||
|
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
|
||||||
|
const labelWrapperStyle = {
|
||||||
|
transform: `translateX(-50%) scale(${labelScale})`,
|
||||||
|
transformOrigin: 'center bottom',
|
||||||
|
};
|
||||||
|
const mapSize = '240px';
|
||||||
|
const mapScale = portraitMobile ? 0.3 : isMobile ? 0.33 : 0.7;
|
||||||
|
const mapOpacity = isMobile ? 0.6 : 0.7;
|
||||||
|
const mapStyle = {
|
||||||
|
width: mapSize,
|
||||||
|
height: mapSize,
|
||||||
|
opacity: mapOpacity,
|
||||||
|
transform: mapPosition === 'top-center' ? `translateX(-50%) scale(${mapScale})` : `scale(${mapScale})`,
|
||||||
|
transformOrigin:
|
||||||
|
mapPosition === 'bottom-left' ? 'bottom left' : mapPosition === 'top-center' ? 'top center' : 'top right',
|
||||||
|
...(mapPosition === 'bottom-left'
|
||||||
|
? { left: '0.25rem', bottom: '0.25rem' }
|
||||||
|
: mapPosition === 'top-center'
|
||||||
|
? { left: '50%', top: '0.25rem' }
|
||||||
|
: { right: '0.25rem', top: '0.25rem' }),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (variant === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'spectator') {
|
||||||
|
const telemetryEntries = [
|
||||||
|
['Voltage', sensors?.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||||
|
['Current', sensors?.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
||||||
|
['Charge', sensors?.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
||||||
|
['OI', sensors?.oiMode?.label || '--'],
|
||||||
|
];
|
||||||
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
|
const chargingLabel = sensors?.chargingState?.label || '';
|
||||||
|
const charging = Boolean(chargingLabel && chargingLabel.toLowerCase() !== 'not charging');
|
||||||
|
const oiLabel = sensors?.oiMode?.label || 'Unknown';
|
||||||
|
const oiNormalized = oiLabel.toLowerCase();
|
||||||
|
const oiTone =
|
||||||
|
oiNormalized === 'full'
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: oiNormalized === 'safe'
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: oiNormalized === 'passive'
|
||||||
|
? 'bg-slate-700/80 text-slate-100'
|
||||||
|
: 'bg-slate-700/60 text-slate-200';
|
||||||
|
const dockTone = docked ? 'bg-emerald-500/80 text-emerald-50' : 'bg-slate-700/70 text-slate-200';
|
||||||
|
const chargingTone = charging
|
||||||
|
? 'bg-emerald-500/80 text-emerald-50'
|
||||||
|
: docked
|
||||||
|
? 'bg-amber-400/80 text-amber-950'
|
||||||
|
: 'bg-slate-700/70 text-slate-200';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span>Status: {status}</span>
|
||||||
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
|
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`absolute ${telemetryPosClass} flex -translate-y-1/2 flex-col gap-0.5 bg-black/70 text-slate-100 ${statusTextClass} ${statusPadClass}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5 leading-tight">
|
||||||
|
<div className="flex flex-col gap-0.5 text-[0.75rem] font-semibold uppercase tracking-wide">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${dockTone}`}>{docked ? 'Docked' : 'Undocked'}</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${chargingTone}`}>
|
||||||
|
{charging ? 'Charging' : docked ? 'Not charging' : 'Not charging'}
|
||||||
|
</span>
|
||||||
|
<span className={`rounded px-1.5 py-0.5 ${oiTone}`}>OI: {oiLabel}</span>
|
||||||
|
</div>
|
||||||
|
{telemetryEntries.map(([labelText, value]) => (
|
||||||
|
<span key={labelText} className="flex items-center justify-between gap-0.5">
|
||||||
|
<span className="text-slate-400">{labelText}</span>
|
||||||
|
<span className="font-semibold text-white">{value}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-semibold text-white rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColor, 0.18)}
|
||||||
|
>
|
||||||
|
{label || 'Unnamed Rover'}
|
||||||
|
</span>
|
||||||
|
{driverLabel ? <span className="text-slate-300">• {driverLabel}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showTopDown ? (
|
||||||
|
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
|
||||||
|
<TopDownMap sensors={sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className={`absolute ${statusPosClass} font-medium text-slate-100 ${statusTextClass}`}>
|
||||||
|
<div className="flex flex-col gap-0.5 leading-none">
|
||||||
|
<span>Status: {status}</span>
|
||||||
|
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
|
||||||
|
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{turnTimerText ? (
|
||||||
|
<div
|
||||||
|
className={`absolute left-1/2 top-0.5 -translate-x-1/2 rounded bg-black/70 text-slate-100 ${timerPadClass} ${timerTextClass}`}
|
||||||
|
>
|
||||||
|
{turnTimerText}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
|
||||||
|
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
|
||||||
|
<span>
|
||||||
|
Rover:{' '}
|
||||||
|
<span
|
||||||
|
className="rounded px-1 py-[1px] border border-transparent"
|
||||||
|
style={roverNameChromeStyle(roverColor, 0.18)}
|
||||||
|
>
|
||||||
|
"{label || 'Unnamed Rover'}"
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTopDown && variant !== 'spectator' ? (
|
||||||
|
<div className="pointer-events-none absolute rounded" style={{ ...mapStyle }}>
|
||||||
|
<TopDownMap sensors={sensors} size={240} overlay />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Light bump signal bars used under the tile HUD.
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function LightBumpBars({ sensors }) {
|
||||||
|
const values = [
|
||||||
|
sensors?.lightBumpLeftSignal,
|
||||||
|
sensors?.lightBumpFrontLeftSignal,
|
||||||
|
sensors?.lightBumpCenterLeftSignal,
|
||||||
|
sensors?.lightBumpCenterRightSignal,
|
||||||
|
sensors?.lightBumpFrontRightSignal,
|
||||||
|
sensors?.lightBumpRightSignal,
|
||||||
|
];
|
||||||
|
const max = values.filter((v) => v != null).reduce((acc, v) => Math.max(acc, v), 1200);
|
||||||
|
const eased = (v) => Math.pow(Math.max(0, Math.min(1, (v ?? 0) / max)), 0.35);
|
||||||
|
const hueFor = (v) => {
|
||||||
|
if (v == null || v <= 0) return 'hsl(200 60% 18%)';
|
||||||
|
const h = (200 + eased(v) * 360) % 360;
|
||||||
|
return `hsl(${h} 100% 60%)`;
|
||||||
|
};
|
||||||
|
const segments = 6;
|
||||||
|
const barHeight = 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-center gap-0.5">
|
||||||
|
{values.map((v, idx) => {
|
||||||
|
const t = eased(v);
|
||||||
|
const dir = idx < segments / 2 ? -1 : 1;
|
||||||
|
const fill = `${t * 100}%`;
|
||||||
|
const color = hueFor(v);
|
||||||
|
return (
|
||||||
|
<div key={idx} className="relative flex-1 min-w-[0]" style={{ height: `${barHeight}px` }}>
|
||||||
|
<div className="h-full w-full overflow-hidden bg-slate-800" style={{ borderRadius: 0 }}>
|
||||||
|
<div
|
||||||
|
className="h-full"
|
||||||
|
style={{
|
||||||
|
width: fill,
|
||||||
|
background: color,
|
||||||
|
float: dir === -1 ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Low battery warning overlay.
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function LowBatteryOverlay({ battery, compact = false }) {
|
||||||
|
if (!battery?.available) return null;
|
||||||
|
if (!battery.warnActive && !battery.urgentActive) return null;
|
||||||
|
|
||||||
|
const message = battery.urgentActive
|
||||||
|
? 'BATTERY VERY LOW, DOCK THE ROVER AND CHARGE IMMEDIATELY!!'
|
||||||
|
: 'Battery low! please dock and charge the rover soon.';
|
||||||
|
|
||||||
|
const containerClass = compact ? 'p-2 top-6' : 'p-4 top-10';
|
||||||
|
const textClass = compact ? 'text-sm' : 'text-2xl';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute flex items-center justify-center bg-amber-900/60 left-1/2 -translate-x-1/2 ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className={`text-center font-semibold text-white animate-pulse ${textClass}`}>
|
||||||
|
<div>{message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Overcurrent warning overlay.
|
||||||
|
import React from 'react';
|
||||||
|
import { OVERCURRENT_LABELS } from './constants.js';
|
||||||
|
|
||||||
|
export default function OvercurrentOverlay({ motors, fill = 0, compact = false }) {
|
||||||
|
if (!motors?.length) return null;
|
||||||
|
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||||
|
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
||||||
|
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
||||||
|
const textClass = compact ? 'text-lg' : 'text-4xl';
|
||||||
|
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
||||||
|
const safeFill = Math.max(0, Math.min(1, fill));
|
||||||
|
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
||||||
|
>
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
||||||
|
</div>
|
||||||
|
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
||||||
|
<div>OVERCURRENT</div>
|
||||||
|
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Turn cue overlay shown to active drivers.
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function TurnCueOverlay({
|
||||||
|
mobileHud = false,
|
||||||
|
turnSeconds = null,
|
||||||
|
isActiveDriver = false,
|
||||||
|
idleSkipSeconds = null,
|
||||||
|
}) {
|
||||||
|
const titleClass = mobileHud ? 'text-3xl' : 'text-5xl';
|
||||||
|
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||||
|
const timerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||||
|
const padClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||||
|
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-black/55">
|
||||||
|
<div className={`flex flex-col items-center gap-0.5 rounded border border-amber-300/80 bg-black/70 ${padClass}`}>
|
||||||
|
<div className={`font-semibold text-amber-200 ${titleClass}`}>IT IS YOUR TURN!</div>
|
||||||
|
<div className={`text-amber-200/80 ${subClass}`}>Start driving!</div>
|
||||||
|
{showCountdown ? (
|
||||||
|
<div className={`text-red-100/90 ${timerClass}`}>
|
||||||
|
Idle skip in {idleSkipSeconds}s
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// VideoTile timing and threshold constants.
|
||||||
|
export const RESTART_DELAY_MS = 2000;
|
||||||
|
export const UNMUTE_RETRY_MS = 3000;
|
||||||
|
export const AUDIO_RETRY_MS = 3000;
|
||||||
|
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
||||||
|
export const DUCK_RELEASE_FADE_MS = 1000;
|
||||||
|
|
||||||
|
export const OVERCURRENT_LABELS = {
|
||||||
|
leftWheel: 'Left wheel',
|
||||||
|
rightWheel: 'Right wheel',
|
||||||
|
mainBrush: 'Main brush',
|
||||||
|
sideBrush: 'Side brush',
|
||||||
|
limiter: 'Overcurrent limit',
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user