mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
big loud turn indicators
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user