big loud turn indicators

This commit is contained in:
legop3
2026-01-15 18:25:33 -05:00
parent f129449d5a
commit 705b38ea7b
10 changed files with 275 additions and 139 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-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-D-77ZqXZ.js"></script> <script type="module" crossorigin src="/assets/index-CJBHX9So.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cc3cNOzE.css"> <link rel="stylesheet" crossorigin href="/assets/index-D46FE2Sk.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+52 -2
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx'; import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js'; import { useVideoRequests } from '../hooks/useVideoRequests.js';
@@ -9,9 +9,12 @@ import VideoTile from './VideoTile.jsx';
export default function DriverVideoPanel({layoutFormat = 'desktop'}) { export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const { session } = useSession(); const { session } = useSession();
const { const {
state: { song }, state: { song, lastControlIntentAt },
} = useControlSystem(); } = useControlSystem();
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [turnCueVisible, setTurnCueVisible] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
const lastTurnRef = useRef({ active: false, roverId: null });
useEffect(() => { useEffect(() => {
if (session?.mode !== 'turns') { if (session?.mode !== 'turns') {
return undefined; return undefined;
@@ -38,10 +41,25 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
}, [turnInfo?.queue, turnInfo?.current]); }, [turnInfo?.queue, turnInfo?.current]);
const isNextDriver = Boolean(socketId && nextDriverId === socketId); const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null; const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null; const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const isPreSwitchWindow = const isPreSwitchWindow =
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0; session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow; const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
const idleSkipSeconds =
msUntilIdleSkip != null && Number.isFinite(msUntilIdleSkip)
? Math.max(0, Math.ceil(msUntilIdleSkip / 1000))
: null;
const turnTimerText = isActiveDriver
? turnSeconds != null
? `${turnSeconds}s left`
: null
: isNextDriver && turnSeconds != null
? `Your turn in ${turnSeconds}s`
: null;
const entries = roverId const entries = roverId
? [ ? [
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []), ...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
@@ -65,6 +83,33 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : ''); const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
useEffect(() => {
if (session?.mode !== 'turns') {
setTurnCueVisible(false);
setTurnCueStartAt(null);
lastTurnRef.current = { active: false, roverId: null };
return;
}
const lastTurn = lastTurnRef.current;
const becameActive = isActiveDriver && !lastTurn.active;
const roverChanged = isActiveDriver && roverId && roverId !== lastTurn.roverId;
if (becameActive || roverChanged) {
setTurnCueVisible(true);
setTurnCueStartAt(Date.now());
} else if (!isActiveDriver && lastTurn.active) {
setTurnCueVisible(false);
setTurnCueStartAt(null);
}
lastTurnRef.current = { active: isActiveDriver, roverId };
}, [isActiveDriver, roverId, session?.mode]);
useEffect(() => {
if (!turnCueVisible || !turnCueStartAt) return;
if (lastControlIntentAt > turnCueStartAt) {
setTurnCueVisible(false);
}
}, [lastControlIntentAt, turnCueStartAt, turnCueVisible]);
return ( return (
<section className="panel"> <section className="panel">
{roverId ? ( {roverId ? (
@@ -79,6 +124,11 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
layoutFormat={layoutFormat} layoutFormat={layoutFormat}
songNote={song?.note} songNote={song?.note}
qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null} qualityNotice={!shouldShowVideo ? 'Preview feed (low FPS) until your turn.' : null}
showTurnCue={turnCueVisible}
turnTimerText={turnTimerText}
turnSeconds={turnSeconds}
isActiveDriver={isActiveDriver}
idleSkipSeconds={idleSkipSeconds}
/> />
) : ( ) : (
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video"> <div className="panel-muted content-center text-center text-sm text-slate-400 aspect-video">
+19 -4
View File
@@ -164,6 +164,14 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
const queue = info?.queue || []; const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null; const deadline = info?.idleDeadline || info?.deadline || null;
const remaining = secondsRemaining(deadline); const remaining = secondsRemaining(deadline);
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
return ( return (
<div key={roverId} className="surface-muted flex flex-col gap-0.25 text-sm"> <div key={roverId} className="surface-muted flex flex-col gap-0.25 text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -180,21 +188,28 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
<div className="flex flex-wrap items-center gap-1"> <div className="flex flex-wrap items-center gap-1">
{queue.map((socketId, idx) => { {queue.map((socketId, idx) => {
const user = lookupUser(socketId); const user = lookupUser(socketId);
const isCurrent = socketId === info?.current; const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId);
const isAdmin = const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin'; user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
const highlightClass = isCurrent
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
: isNext
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
: 'bg-slate-800 text-slate-200';
return ( return (
<span <span
key={`${roverId}-${socketId}-${idx}`} key={`${roverId}-${socketId}-${idx}`}
className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${ className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${highlightClass}`}
isCurrent ? 'bg-sky-700 text-white' : 'bg-slate-800 text-slate-200'
}`}
> >
<span className={`${roleColors(user.role)} font-semibold`}> <span className={`${roleColors(user.role)} font-semibold`}>
{formatLabel(user, selfId)} {formatLabel(user, selfId)}
</span> </span>
{isAdmin && <span className="text-[0.7rem] text-amber-200"></span>} {isAdmin && <span className="text-[0.7rem] text-amber-200"></span>}
{isSelf && <span className="text-[0.7rem] text-white">YOU</span>}
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>} {isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
</span> </span>
); );
})} })}
+58 -6
View File
@@ -52,6 +52,11 @@ export default function VideoTile({
hudForceMap = false, hudForceMap = false,
hudMapPosition = 'top-right', hudMapPosition = 'top-right',
fitParent = false, fitParent = false,
showTurnCue = false,
turnTimerText = null,
turnSeconds = null,
isActiveDriver = false,
idleSkipSeconds = null,
}) { }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const audioRef = useRef(null); const audioRef = useRef(null);
@@ -387,6 +392,14 @@ export default function VideoTile({
/> />
)} )}
<audio ref={audioRef} autoPlay hidden /> <audio ref={audioRef} autoPlay hidden />
{showTurnCue ? (
<TurnCueOverlay
mobileHud={mobileHud}
turnSeconds={turnSeconds}
isActiveDriver={isActiveDriver}
idleSkipSeconds={idleSkipSeconds}
/>
) : null}
<HudOverlay <HudOverlay
frame={telemetryFrame} frame={telemetryFrame}
sensors={sensors} sensors={sensors}
@@ -401,6 +414,7 @@ export default function VideoTile({
showTopDown={showHudMap} showTopDown={showHudMap}
mobileHud={mobileHud} mobileHud={mobileHud}
mapPosition={hudMapPosition} mapPosition={hudMapPosition}
turnTimerText={turnTimerText}
/> />
<HudChatInput compact={mobileHud} /> <HudChatInput compact={mobileHud} />
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} /> <OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
@@ -409,12 +423,14 @@ export default function VideoTile({
<BatteryBarVertical visual={batteryVisual} /> <BatteryBarVertical visual={batteryVisual} />
) : null} ) : null}
{qualityNotice ? ( {qualityNotice ? (
<div <div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
className={`pointer-events-none absolute rounded bg-black/70 font-semibold text-amber-200 ${ <div
mobileHud ? 'left-0.5 top-0.5 px-0.5 py-0.25 text-[0.55rem]' : 'left-1 top-1 px-1 py-0.5 text-xs' className={`mx-auto w-fit rounded border border-amber-300/80 bg-black/75 text-amber-200 ${
}`} mobileHud ? 'px-2 py-1 text-[0.6rem]' : 'px-3 py-1.5 text-sm'
> }`}
{qualityNotice} >
{qualityNotice}
</div>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -527,6 +543,7 @@ function HudOverlay({
showTopDown = false, showTopDown = false,
mobileHud = false, mobileHud = false,
mapPosition = 'top-right', mapPosition = 'top-right',
turnTimerText = null,
}) { }) {
const bumps = sensors?.bumpsAndWheelDrops || {}; const bumps = sensors?.bumpsAndWheelDrops || {};
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
@@ -544,6 +561,8 @@ function HudOverlay({
const labelPadClass = isMobile ? 'px-0.25 py-0.25' : 'px-0.5 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 labelTextClass = isMobile ? 'text-[0.55rem]' : 'text-[0.8rem]';
const statusPosClass = isMobile ? 'left-0.5 top-0.5' : 'left-1 top-1'; 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 telemetryPosClass = isMobile ? 'left-0.5 top-1/2' : 'left-1 top-1/2';
const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5'; const labelPosClass = isMobile ? 'bottom-0.5' : 'bottom-0.5';
const mapSize = '240px'; const mapSize = '240px';
@@ -625,6 +644,13 @@ function HudOverlay({
{audioStatus ? <span>Audio: {audioStatus}</span> : null} {audioStatus ? <span>Audio: {audioStatus}</span> : null}
</div> </div>
</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 <div
className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`} className={`absolute ${labelPosClass} left-1/2 flex -translate-x-1/2 gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
> >
@@ -648,6 +674,32 @@ function HudOverlay({
); );
} }
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-1 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 = { const OVERCURRENT_LABELS = {
leftWheel: 'Left wheel', leftWheel: 'Left wheel',
rightWheel: 'Right wheel', rightWheel: 'Right wheel',
+16 -5
View File
@@ -120,6 +120,10 @@ export function ControlSystemProvider({ children }) {
[], [],
); );
const recordControlIntent = useCallback(() => {
dispatch({ type: 'control/record-intent' });
}, []);
const setDriveVector = useCallback( const setDriveVector = useCallback(
(vector, meta = {}) => { (vector, meta = {}) => {
const computed = computeDifferentialSpeeds(vector, meta.speedOptions); const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
@@ -127,9 +131,10 @@ export function ControlSystemProvider({ children }) {
type: 'control/update-drive', type: 'control/update-drive',
payload: { ...computed, source: meta.source ?? null }, payload: { ...computed, source: meta.source ?? null },
}); });
recordControlIntent();
pipeline.sendDriveDirect(computed.speeds); pipeline.sendDriveDirect(computed.speeds);
}, },
[pipeline], [pipeline, recordControlIntent],
); );
const setAuxMotors = useCallback( const setAuxMotors = useCallback(
@@ -139,8 +144,9 @@ export function ControlSystemProvider({ children }) {
type: 'control/set-aux-motors', type: 'control/set-aux-motors',
payload, payload,
}); });
recordControlIntent();
}, },
[pipeline], [pipeline, recordControlIntent],
); );
const updateKeyBinding = useCallback( const updateKeyBinding = useCallback(
@@ -176,8 +182,9 @@ export function ControlSystemProvider({ children }) {
dispatch({ type: 'control/set-camera-angle', payload: clamped }); dispatch({ type: 'control/set-camera-angle', payload: clamped });
pipeline.sendServoAngle(clamped); pipeline.sendServoAngle(clamped);
servoAngleRef.current = clamped; servoAngleRef.current = clamped;
recordControlIntent();
}, },
[pipeline], [pipeline, recordControlIntent],
); );
const nudgeServo = useCallback( const nudgeServo = useCallback(
@@ -217,10 +224,13 @@ export function ControlSystemProvider({ children }) {
if (!session?.homeAssistant?.entities) { if (!session?.homeAssistant?.entities) {
pendingLightsRef.current = true; pendingLightsRef.current = true;
} }
recordControlIntent();
} else if (macroId === 'seek-dock') {
recordControlIntent();
} }
await pipeline.runMacroSteps(macro); await pipeline.runMacroSteps(macro);
}, },
[pipeline, state.macros, session?.homeAssistant?.entities, turnOnAllLights], [pipeline, recordControlIntent, session?.homeAssistant?.entities, state.macros, turnOnAllLights],
); );
const stopAllMotion = useCallback(() => { const stopAllMotion = useCallback(() => {
@@ -256,7 +266,8 @@ export function ControlSystemProvider({ children }) {
const toggleNightVision = useCallback(() => { const toggleNightVision = useCallback(() => {
pipeline.sendNightVision('toggle'); pipeline.sendNightVision('toggle');
}, [pipeline]); recordControlIntent();
}, [pipeline, recordControlIntent]);
const setSongNote = useCallback( const setSongNote = useCallback(
(note) => { (note) => {
+8
View File
@@ -34,6 +34,7 @@ export const initialControlState = {
aux: createAuxState(), aux: createAuxState(),
camera: createCameraState(), camera: createCameraState(),
song: createSongState(), song: createSongState(),
lastControlIntentAt: 0,
macros: DEFAULT_MACROS, macros: DEFAULT_MACROS,
keymap: DEFAULT_KEYMAP, keymap: DEFAULT_KEYMAP,
inputs: {}, inputs: {},
@@ -48,6 +49,7 @@ export function controlReducer(state, action) {
drive: action.payload ? state.drive : createDriveState(), drive: action.payload ? state.drive : createDriveState(),
aux: action.payload ? state.aux : createAuxState(), aux: action.payload ? state.aux : createAuxState(),
song: action.payload ? state.song : createSongState(), song: action.payload ? state.song : createSongState(),
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
}; };
case 'control/set-mode': case 'control/set-mode':
return state.mode === action.payload return state.mode === action.payload
@@ -123,6 +125,12 @@ export function controlReducer(state, action) {
drive: createDriveState(), drive: createDriveState(),
aux: createAuxState(), aux: createAuxState(),
song: createSongState(), song: createSongState(),
lastControlIntentAt: 0,
};
case 'control/record-intent':
return {
...state,
lastControlIntentAt: Date.now(),
}; };
case 'control/set-song-note': case 'control/set-song-note':
return { return {