gonna break stuff now
Container image / image (push) Failing after 51s

This commit is contained in:
legop3
2026-09-24 18:36:24 -04:00
parent 8ca714cf1a
commit 7231809140
30 changed files with 292 additions and 388 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ function useChatComposerSessionState(allowSpectatorInput) {
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
);
return {
@@ -55,7 +55,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
const ttsPayload = useMemo(() => {
@@ -6,33 +6,11 @@ import { useControlActions, useControlSelector } from '../../../../controls/inde
import ControlHint from '../../../ControlHint/index.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import RoundControl from './RoundControl.jsx';
import PeripheralPod from './PeripheralPod.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);
@@ -67,30 +45,25 @@ export default function BottomLeftPod({ roverId }) {
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={<ControlHint actionId="hornHonk" />} 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={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} 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)} />
)}
<PeripheralPod open={open} onOpenChange={setOpen} label="rover controls">
{/*
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={<ControlHint actionId="hornHonk" />} 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={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
</PeripheralPod>
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
</>
);
@@ -0,0 +1,12 @@
import CornerPodToggle from './CornerPodToggle.jsx';
export default function PeripheralPod({ open, onOpenChange, label, children, className = 'h-40 w-40' }) {
return open ? (
<div className={`pointer-events-auto absolute bottom-0 left-0 z-20 rounded-tr-[4.25rem] bg-black/60 ${className}`}>
{children}
<CornerPodToggle corner="bottom-left" expanded label={`Hide ${label}`} onClick={() => onOpenChange(false)} />
</div>
) : (
<CornerPodToggle corner="bottom-left" expanded={false} label={`Show ${label}`} onClick={() => onOpenChange(true)} />
);
}
@@ -0,0 +1,25 @@
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '', value = null }) {
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" />
{value != null ? <span className="text-xs font-semibold leading-none">{value}</span> : null}
<KeyPill label={keyLabel} />
</button>
);
}
+9 -20
View File
@@ -7,7 +7,7 @@ import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { normalizePtzQueue, usePtzQueueLookup } from '../../ptz/queue.js';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
const PTZ_DEFAULT_COLOR = '#387bf8';
function formatRemaining(deadline, now) {
@@ -24,13 +24,13 @@ export default function PtzQueueCard() {
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const now = useSharedClock(1000, Boolean(ptz?.deadline));
const lookupUser = useQueueUserLookup(ptz?.turn);
const { queue, currentId, nextId } = ptz?.turn || {};
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.permissions?.canEnter);
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
const isParticipant = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
const timerLabel = ptz?.turn?.isActive && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
if (!featureEnabled) return null;
@@ -66,13 +66,7 @@ export default function PtzQueueCard() {
}
};
const actionLabel = pending
? '...'
: ptz?.isOperator
? 'Open'
: ptz?.queuedPosition
? 'Open'
: 'request';
const actionLabel = pending ? '...' : isParticipant ? 'Open' : 'request';
return (
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
@@ -82,11 +76,6 @@ export default function PtzQueueCard() {
id: ptz?.id || PTZ_CAMERA_ID,
name: ptz?.name || 'PTZ Camera',
color: ptz?.color || PTZ_DEFAULT_COLOR,
// description: ptz?.isOperator
// ? 'Live camera turn active'
// : ptz?.queuedPosition
// ? `Queue position ${ptz.queuedPosition}`
// : 'Pan, tilt, and zoom camera',
}}
queue={queue}
currentId={currentId}
@@ -96,8 +85,8 @@ export default function PtzQueueCard() {
canClick={canUse && !pending}
pending={pending}
buttonLabel={actionLabel}
batteryLabel={ptz?.isOperator ? 'LIVE' : ptz?.queuedPosition ? `#${ptz.queuedPosition}` : '--'}
batteryClassName={ptz?.isOperator ? 'text-emerald-300' : ptz?.queuedPosition ? 'text-sky-300' : 'text-slate-400'}
batteryLabel={ptz?.turn?.isActive ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.isActive ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
timerLabel={timerLabel}
onRequest={handleRequest}
showAction={canUse}
+16 -87
View File
@@ -1,15 +1,14 @@
// PTZ Live Video
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { RESTART_DELAY_MS } from '../RoverMediaPlayer/constants.js';
import { startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js';
export const PTZ_CAMERA_ID = 'ptz-camera';
const PTZ_AUDIO_RETRY_MS = 1000;
const TERMINAL_WHEP_STATES = new Set(['error', 'failed', 'disconnected', 'closed']);
const AUTHORIZATION_ERROR_RE = /not authorized/i;
function isAuthorizationError(error) {
@@ -25,14 +24,10 @@ export default function PtzLiveVideo({
fallback = null,
}) {
const videoRef = useRef(null);
const retryTimerRef = useRef(null);
const playTimerRef = useRef(null);
const enabledRef = useRef(enabled);
const fallbackRef = useRef(false);
const playerGenerationRef = useRef(0);
const [status, setStatus] = useState('idle');
const [detail, setDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0);
const { restartToken, scheduleRestart, cancelRestart } = useWhepRestart(enabled);
const sources = useVideoRequests(
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
{ enabled, version: restartToken },
@@ -41,100 +36,34 @@ export default function PtzLiveVideo({
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
fallbackRef.current = shouldUseFallback;
}, [shouldUseFallback]);
useEffect(() => {
if (enabled && !shouldUseFallback) return undefined;
/*
A retry that was scheduled before the server denied live access should not
keep firing in snapshot mode. Clear it when live playback is no longer the
active display policy.
*/
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
return undefined;
}, [enabled, shouldUseFallback]);
const scheduleRestart = useCallback(() => {
if (!enabledRef.current || fallbackRef.current) return;
/*
WHEP sessions are one-shot browser/server negotiations. When the camera
reboots, the old PeerConnection and token can look alive enough to keep a
black element on screen, but they are not useful anymore. Bumping this
token forces useVideoRequests to ask the server for a new MediaMTX auth
session before creating the next WhepPlayer.
*/
clearTimeout(retryTimerRef.current);
retryTimerRef.current = setTimeout(() => {
retryTimerRef.current = null;
setRestartToken(Date.now());
}, RESTART_DELAY_MS);
}, []);
// An authorization denial switches to snapshots and cancels any queued retry.
if (shouldUseFallback) cancelRestart();
}, [shouldUseFallback, cancelRestart]);
useEffect(() => {
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
let active = true;
const generation = playerGenerationRef.current + 1;
playerGenerationRef.current = generation;
const player = new WhepPlayer({
const video = videoRef.current;
const stop = startWhepPlayback({
url: source.url,
token: source.token,
video: videoRef.current,
startMuted,
scheduleRestart,
onStatus: (nextStatus, info) => {
/*
Old PeerConnection callbacks can arrive after React has already
cleaned up this effect for a newer token. Only the currently-owned
generation is allowed to update status or schedule another restart.
*/
if (!active || playerGenerationRef.current !== generation) return;
const normalized = String(nextStatus || '').toLowerCase();
setStatus(nextStatus || 'unknown');
setDetail(info || null);
if (TERMINAL_WHEP_STATES.has(normalized)) {
scheduleRestart();
}
},
onError: (err) => {
setStatus('error');
setDetail(err.message || 'WHEP start failed');
},
});
player.start().catch((err) => {
if (!active || playerGenerationRef.current !== generation) return;
setStatus('error');
setDetail(err.message || 'WHEP start failed');
scheduleRestart();
});
return () => {
/*
Mark inactive before stop() because WhepPlayer reports "stopped" during
normal cleanup. Cleanup-driven stops should not immediately schedule the
next retry; only the replacement effect should own the new connection.
*/
active = false;
player.stop();
};
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
useEffect(() => {
const video = videoRef.current;
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
const generation = playerGenerationRef.current;
const handleEnded = () => {
if (playerGenerationRef.current !== generation) return;
setStatus('stopped');
setDetail('ended');
scheduleRestart();
};
const handleError = () => {
if (playerGenerationRef.current !== generation) return;
setStatus('error');
setDetail(video.error?.message || 'video element error');
scheduleRestart();
@@ -145,8 +74,9 @@ export default function PtzLiveVideo({
return () => {
video.removeEventListener('ended', handleEnded);
video.removeEventListener('error', handleError);
stop();
};
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
useEffect(() => {
const video = videoRef.current;
@@ -178,7 +108,6 @@ export default function PtzLiveVideo({
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
useEffect(() => () => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (playTimerRef.current) clearInterval(playTimerRef.current);
}, []);
+30 -70
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import {
mainBrushAudioTelemetryEqual,
@@ -11,7 +12,6 @@ import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import {
RESTART_DELAY_MS,
UNMUTE_RETRY_MS,
AUDIO_RETRY_MS,
BRUSH_CURRENT_THRESHOLD_MA,
@@ -212,10 +212,10 @@ export default function RoverMediaPlayer({
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
}
: mainBrushTelemetry;
const { restartToken, scheduleRestart } = useWhepRestart();
const { restartToken: audioRestartToken, scheduleRestart: scheduleAudioRestart } = useWhepRestart();
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
const audioRestartTimer = useRef(null);
const audioPlayInterval = useRef(null);
const unmuteTimer = useRef(null);
const volumeFadeFrameRef = useRef(null);
@@ -224,8 +224,6 @@ export default function RoverMediaPlayer({
const [detail, setDetail] = useState(null);
const [audioStatus, setAudioStatus] = useState('idle');
const [audioDetail, setAudioDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0);
const [audioRestartToken, setAudioRestartToken] = useState(0);
const [muted, setMuted] = useState(true);
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
@@ -341,15 +339,6 @@ export default function RoverMediaPlayer({
logAudio('settings/update');
}, [logAudio]);
const scheduleRestart = useCallback(() => {
clearTimeout(restartTimer.current);
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
}, []);
const scheduleAudioRestart = useCallback(() => {
clearTimeout(audioRestartTimer.current);
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS);
}, []);
const ensurePlayback = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
@@ -397,8 +386,6 @@ export default function RoverMediaPlayer({
useEffect(
() => () => {
clearTimeout(restartTimer.current);
clearTimeout(audioRestartTimer.current);
clearTimeout(unmuteTimer.current);
clearInterval(audioPlayInterval.current);
if (volumeFadeFrameRef.current) {
@@ -426,41 +413,27 @@ export default function RoverMediaPlayer({
if (!isVideoVisible || usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
return undefined;
}
let active = true;
let player;
const resetMuteId = setTimeout(() => setMuted(true), 0);
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
setDetail(info || null);
if (nextStatus === 'playing') {
ensurePlayback();
}
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
scheduleRestart();
}
};
player = new WhepPlayer({
const stop = startWhepPlayback({
url: resolvedSessionInfo.url,
token: resolvedSessionInfo.token,
video: videoRef.current,
receiveAudio: !hasDedicatedAudio,
onStatus: handleStatus,
scheduleRestart,
onStatus: (nextStatus, info) => {
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
setDetail(info || null);
if (nextStatus === 'playing') ensurePlayback();
},
onError: (err) => {
setStatus('error');
setDetail(err.message);
},
});
player.start().catch((err) => {
if (!active) return;
setStatus('error');
setDetail(err.message);
scheduleRestart();
});
return () => {
active = false;
clearTimeout(resetMuteId);
player?.stop();
stop();
};
}, [
usingSnapshot,
@@ -536,37 +509,24 @@ export default function RoverMediaPlayer({
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
return undefined;
}
let active = true;
let player;
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('audio/status', { nextStatus, info: info || null });
setAudioStatus(nextStatus);
setAudioDetail(info || null);
if (['error', 'failed'].includes(nextStatus)) {
scheduleAudioRestart();
}
};
player = new WhepPlayer({
return startWhepPlayback({
url: resolvedAudioSessionInfo.url,
token: resolvedAudioSessionInfo.token,
video: audioRef.current,
audioOnly: true,
onStatus: handleStatus,
// Dedicated rover audio historically retries only errors and failures.
terminalStates: ['error', 'failed'],
scheduleRestart: scheduleAudioRestart,
onStatus: (nextStatus, info) => {
logAudio('audio/status', { nextStatus, info: info || null });
setAudioStatus(nextStatus);
setAudioDetail(info || null);
},
onError: (err) => {
setAudioStatus('error');
setAudioDetail(err.message);
},
});
player.start().catch((err) => {
if (!active) return;
setAudioStatus('error');
setAudioDetail(err.message);
scheduleAudioRestart();
});
return () => {
active = false;
player?.stop();
};
}, [
resolvedAudioSessionInfo?.url,
resolvedAudioSessionInfo?.token,
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx';
@@ -181,8 +182,7 @@ export default function RoverQueuesPanel({
}
}
const lookupUser = (socketId) =>
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
const lookupUser = useQueueUserLookup(null, users);
async function handleRebootOwnRover() {
if (rebootPending) return;
@@ -246,14 +246,8 @@ export default function RoverQueuesPanel({
const idleRemainingSeconds = idleDeadline
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
: null;
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;
const currentId = info?.currentId || null;
const nextId = info?.nextId || null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const timerLabel = isSelfCurrent