mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
big loud turn indicators
This commit is contained in:
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-D-77ZqXZ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cc3cNOzE.css">
|
||||
<script type="module" crossorigin src="/assets/index-CJBHX9So.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D46FE2Sk.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
|
||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||
@@ -9,9 +9,12 @@ import VideoTile from './VideoTile.jsx';
|
||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const { session } = useSession();
|
||||
const {
|
||||
state: { song },
|
||||
state: { song, lastControlIntentAt },
|
||||
} = useControlSystem();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [turnCueVisible, setTurnCueVisible] = useState(false);
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
const lastTurnRef = useRef({ active: false, roverId: null });
|
||||
useEffect(() => {
|
||||
if (session?.mode !== 'turns') {
|
||||
return undefined;
|
||||
@@ -38,10 +41,25 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
}, [turnInfo?.queue, turnInfo?.current]);
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||
const isPreSwitchWindow =
|
||||
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
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
|
||||
? [
|
||||
...(shouldShowVideo ? [{ type: 'rover', id: roverId, key: roverId }] : []),
|
||||
@@ -65,6 +83,33 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
|
||||
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 (
|
||||
<section className="panel">
|
||||
{roverId ? (
|
||||
@@ -79,6 +124,11 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
layoutFormat={layoutFormat}
|
||||
songNote={song?.note}
|
||||
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">
|
||||
|
||||
@@ -164,6 +164,14 @@ export default function UserListPanel({ hideNicknameForm = false, hideHeader = f
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
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 (
|
||||
<div key={roverId} className="surface-muted flex flex-col gap-0.25 text-sm">
|
||||
<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">
|
||||
{queue.map((socketId, idx) => {
|
||||
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 =
|
||||
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 (
|
||||
<span
|
||||
key={`${roverId}-${socketId}-${idx}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${
|
||||
isCurrent ? 'bg-sky-700 text-white' : 'bg-slate-800 text-slate-200'
|
||||
}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.8rem] ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatLabel(user, selfId)}
|
||||
</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>}
|
||||
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -52,6 +52,11 @@ export default function VideoTile({
|
||||
hudForceMap = false,
|
||||
hudMapPosition = 'top-right',
|
||||
fitParent = false,
|
||||
showTurnCue = false,
|
||||
turnTimerText = null,
|
||||
turnSeconds = null,
|
||||
isActiveDriver = false,
|
||||
idleSkipSeconds = null,
|
||||
}) {
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
@@ -387,6 +392,14 @@ export default function VideoTile({
|
||||
/>
|
||||
)}
|
||||
<audio ref={audioRef} autoPlay hidden />
|
||||
{showTurnCue ? (
|
||||
<TurnCueOverlay
|
||||
mobileHud={mobileHud}
|
||||
turnSeconds={turnSeconds}
|
||||
isActiveDriver={isActiveDriver}
|
||||
idleSkipSeconds={idleSkipSeconds}
|
||||
/>
|
||||
) : null}
|
||||
<HudOverlay
|
||||
frame={telemetryFrame}
|
||||
sensors={sensors}
|
||||
@@ -401,6 +414,7 @@ export default function VideoTile({
|
||||
showTopDown={showHudMap}
|
||||
mobileHud={mobileHud}
|
||||
mapPosition={hudMapPosition}
|
||||
turnTimerText={turnTimerText}
|
||||
/>
|
||||
<HudChatInput compact={mobileHud} />
|
||||
<OvercurrentOverlay motors={overcurrentMotors} compact={mobileHud} />
|
||||
@@ -409,12 +423,14 @@ export default function VideoTile({
|
||||
<BatteryBarVertical visual={batteryVisual} />
|
||||
) : null}
|
||||
{qualityNotice ? (
|
||||
<div
|
||||
className={`pointer-events-none absolute rounded bg-black/70 font-semibold text-amber-200 ${
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{qualityNotice}
|
||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2">
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -527,6 +543,7 @@ function HudOverlay({
|
||||
showTopDown = false,
|
||||
mobileHud = false,
|
||||
mapPosition = 'top-right',
|
||||
turnTimerText = null,
|
||||
}) {
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
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 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 mapSize = '240px';
|
||||
@@ -625,6 +644,13 @@ function HudOverlay({
|
||||
{audioStatus ? <span>Audio: {audioStatus}</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 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 = {
|
||||
leftWheel: 'Left wheel',
|
||||
rightWheel: 'Right wheel',
|
||||
|
||||
@@ -120,6 +120,10 @@ export function ControlSystemProvider({ children }) {
|
||||
[],
|
||||
);
|
||||
|
||||
const recordControlIntent = useCallback(() => {
|
||||
dispatch({ type: 'control/record-intent' });
|
||||
}, []);
|
||||
|
||||
const setDriveVector = useCallback(
|
||||
(vector, meta = {}) => {
|
||||
const computed = computeDifferentialSpeeds(vector, meta.speedOptions);
|
||||
@@ -127,9 +131,10 @@ export function ControlSystemProvider({ children }) {
|
||||
type: 'control/update-drive',
|
||||
payload: { ...computed, source: meta.source ?? null },
|
||||
});
|
||||
recordControlIntent();
|
||||
pipeline.sendDriveDirect(computed.speeds);
|
||||
},
|
||||
[pipeline],
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const setAuxMotors = useCallback(
|
||||
@@ -139,8 +144,9 @@ export function ControlSystemProvider({ children }) {
|
||||
type: 'control/set-aux-motors',
|
||||
payload,
|
||||
});
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline],
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const updateKeyBinding = useCallback(
|
||||
@@ -176,8 +182,9 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/set-camera-angle', payload: clamped });
|
||||
pipeline.sendServoAngle(clamped);
|
||||
servoAngleRef.current = clamped;
|
||||
recordControlIntent();
|
||||
},
|
||||
[pipeline],
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const nudgeServo = useCallback(
|
||||
@@ -217,10 +224,13 @@ export function ControlSystemProvider({ children }) {
|
||||
if (!session?.homeAssistant?.entities) {
|
||||
pendingLightsRef.current = true;
|
||||
}
|
||||
recordControlIntent();
|
||||
} else if (macroId === 'seek-dock') {
|
||||
recordControlIntent();
|
||||
}
|
||||
await pipeline.runMacroSteps(macro);
|
||||
},
|
||||
[pipeline, state.macros, session?.homeAssistant?.entities, turnOnAllLights],
|
||||
[pipeline, recordControlIntent, session?.homeAssistant?.entities, state.macros, turnOnAllLights],
|
||||
);
|
||||
|
||||
const stopAllMotion = useCallback(() => {
|
||||
@@ -256,7 +266,8 @@ export function ControlSystemProvider({ children }) {
|
||||
|
||||
const toggleNightVision = useCallback(() => {
|
||||
pipeline.sendNightVision('toggle');
|
||||
}, [pipeline]);
|
||||
recordControlIntent();
|
||||
}, [pipeline, recordControlIntent]);
|
||||
|
||||
const setSongNote = useCallback(
|
||||
(note) => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const initialControlState = {
|
||||
aux: createAuxState(),
|
||||
camera: createCameraState(),
|
||||
song: createSongState(),
|
||||
lastControlIntentAt: 0,
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
@@ -48,6 +49,7 @@ export function controlReducer(state, action) {
|
||||
drive: action.payload ? state.drive : createDriveState(),
|
||||
aux: action.payload ? state.aux : createAuxState(),
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||
};
|
||||
case 'control/set-mode':
|
||||
return state.mode === action.payload
|
||||
@@ -123,6 +125,12 @@ export function controlReducer(state, action) {
|
||||
drive: createDriveState(),
|
||||
aux: createAuxState(),
|
||||
song: createSongState(),
|
||||
lastControlIntentAt: 0,
|
||||
};
|
||||
case 'control/record-intent':
|
||||
return {
|
||||
...state,
|
||||
lastControlIntentAt: Date.now(),
|
||||
};
|
||||
case 'control/set-song-note':
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user