From 72318091402f4fafc56b8d061607c771ac2ebc71 Mon Sep 17 00:00:00 2001 From: legop3 Date: Thu, 24 Sep 2026 18:36:24 -0400 Subject: [PATCH] gonna break stuff now --- server/src/services/ptzCameraService/index.js | 18 +-- .../services/replayEngineV2/replaySources.js | 4 +- server/src/services/sessionService/index.js | 1 + server/src/services/turnService/display.js | 7 +- server/src/services/turnService/index.js | 3 +- .../src/services/turnService/queueDisplay.js | 10 ++ webui/src/components/ChatPanel/index.jsx | 2 +- .../HudOverlays/HudChatInput/index.jsx | 2 +- .../newgen/CornerPods/BottomLeftPod.jsx | 69 ++++-------- .../newgen/CornerPods/PeripheralPod.jsx | 12 ++ .../newgen/CornerPods/RoundControl.jsx | 25 +++++ webui/src/components/PtzCamera/index.jsx | 29 ++--- webui/src/components/PtzLiveVideo/index.jsx | 103 +++--------------- .../src/components/RoverMediaPlayer/index.jsx | 100 +++++------------ .../src/components/RoverQueuesPanel/index.jsx | 14 +-- webui/src/controls/ptzControlAdapter.js | 37 +++++-- .../components/DisplayPtzOperatorBadge.jsx | 2 +- webui/src/hooks/useQueueUserLookup.js | 10 ++ webui/src/hooks/useWhepRestart.js | 28 +++++ .../layouts/driver/DesktopLayout/index.jsx | 2 +- webui/src/lib/whepPlayback.js | 26 +++++ webui/src/main.jsx | 1 - .../MiniSummaryApp/MiniSummaryContent.jsx | 2 +- webui/src/ptz/components/PtzControls.jsx | 70 +++--------- webui/src/ptz/components/PtzLayouts.jsx | 14 +-- webui/src/ptz/components/PtzLightingPod.jsx | 19 ++++ webui/src/ptz/components/PtzMediaPane.jsx | 12 +- webui/src/ptz/components/PtzQueuePanel.jsx | 10 +- webui/src/ptz/queue.js | 42 ------- .../components/PtzSpectatorCard.jsx | 6 +- 30 files changed, 292 insertions(+), 388 deletions(-) create mode 100644 server/src/services/turnService/queueDisplay.js create mode 100644 webui/src/components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx create mode 100644 webui/src/components/HudOverlays/newgen/CornerPods/RoundControl.jsx create mode 100644 webui/src/hooks/useQueueUserLookup.js create mode 100644 webui/src/hooks/useWhepRestart.js create mode 100644 webui/src/lib/whepPlayback.js create mode 100644 webui/src/ptz/components/PtzLightingPod.jsx delete mode 100644 webui/src/ptz/queue.js diff --git a/server/src/services/ptzCameraService/index.js b/server/src/services/ptzCameraService/index.js index 4b6f3ab5..dee4f00d 100644 --- a/server/src/services/ptzCameraService/index.js +++ b/server/src/services/ptzCameraService/index.js @@ -1,4 +1,5 @@ // PTZ policy and session facade. Camera IO and participation each own their lifecycle. +const { describeQueue } = require('../turnService/queueDisplay'); const EventEmitter = require('events'); const io = require('../../globals/io'); @@ -96,6 +97,8 @@ function describeParticipation(socket) { const entered = getOperatingMode(socket) === 'ptz' && Boolean(socket?.data?.ptzEntered); const isOperator = Boolean(socket && participation.state.operatorSocketId === socket.id); const position = socket ? participation.state.queue.indexOf(socket.id) + 1 : 0; + const currentId = participation.state.operatorSocketId; + const queue = currentId ? [currentId, ...participation.state.queue] : [...participation.state.queue]; const ready = enabled && runtime.state.initialized; const canControl = !denialReason && entered && isOperator && ready; return { @@ -115,6 +118,8 @@ function describeParticipation(socket) { }, viewMode: socket && canRequestLiveVideo(socket) ? 'live' : 'snapshot', turn: { + ...describeQueue(queue, currentId), + userLabels: Object.fromEntries(queue.map((id) => [id, getSocketLabel(id)])), target: { id: PTZ_CAMERA_ID, name: cameraConfig.name || 'PTZ Camera', @@ -135,11 +140,6 @@ function describeParticipation(socket) { } function getPublicState(socket = null) { - const socketId = socket?.id || null; - const queue = participation.state.queue.map((id) => ({ - socketId: id, - label: getSocketLabel(id), - })); return { enabled, id: PTZ_CAMERA_ID, @@ -149,11 +149,6 @@ function getPublicState(socket = null) { initializing: runtime.state.initializing, error: runtime.state.error, streamPath: runtime.state.streamPath, - operatorSocketId: participation.state.operatorSocketId, - operatorLabel: getSocketLabel(participation.state.operatorSocketId), - queue, - deadline: participation.state.deadline, - blocked: null, status: runtime.state.status, light: runtime.state.light, ir: runtime.state.ir, @@ -162,9 +157,6 @@ function getPublicState(socket = null) { publisher: runtime.state.publisher, reolinkApi: runtime.state.reolinkApi, audio: runtime.getAudioState(), - isOperator: Boolean(socketId && participation.state.operatorSocketId === socketId), - queuedPosition: socketId ? participation.state.queue.indexOf(socketId) + 1 || null : null, - canUse: socket ? canUsePtzFeature(socket) : false, ...describeParticipation(socket), }; } diff --git a/server/src/services/replayEngineV2/replaySources.js b/server/src/services/replayEngineV2/replaySources.js index 0047bbd7..b950a48d 100644 --- a/server/src/services/replayEngineV2/replaySources.js +++ b/server/src/services/replayEngineV2/replaySources.js @@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) { replay`, follows the camera currently controlled by that socket just as it follows an assigned rover below. - isOperator is deliberately stricter than PTZ access or queue membership: + turn.isActive is deliberately stricter than PTZ access or queue membership: spectators and users waiting for a camera turn must not silently replay a camera they are not currently operating. Keeping this rule here also makes every web replay entry point share the same default instead of teaching the chat-command adapter about PTZ-specific state. */ - if (ptzCameraService.getPublicState(socket).isOperator) { + if (ptzCameraService.getPublicState(socket).turn.isActive) { const source = ptzCameraService.getReplaySource(); if (!source) return []; return [{ type: source.type, id: String(source.id), label: source.label || source.id }]; diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js index c068c0ef..54fcaa40 100644 --- a/server/src/services/sessionService/index.js +++ b/server/src/services/sessionService/index.js @@ -203,6 +203,7 @@ function buildSession(socket) { socketId: socket?.id, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], + users, }), })), odometers: roverManager.getOdometersForSocket(socket), diff --git a/server/src/services/turnService/display.js b/server/src/services/turnService/display.js index 8cf51f11..cd0aa380 100644 --- a/server/src/services/turnService/display.js +++ b/server/src/services/turnService/display.js @@ -1,3 +1,4 @@ +const { describeQueue } = require('./queueDisplay'); const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants'); const labels = { @@ -16,7 +17,7 @@ const labels = { }; // Build this from visibility-filtered session data so private rover state stays private. -function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId }) { +function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users }) { const queue = turnInfo?.queue || []; // Direct ownership can arrive before queue details on load/reconnect. const currentDriverId = activeDriverId || turnInfo?.current || null; @@ -26,6 +27,10 @@ function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId }) { const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null; return { target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id }, + ...describeQueue(queue, currentDriverId), + userLabels: Object.fromEntries(queue.map((id) => [id, + users.find((user) => user.socketId === id)?.nickname || id, + ])), enabled, isActive: turnsAhead === 0, turnsAhead, diff --git a/server/src/services/turnService/index.js b/server/src/services/turnService/index.js index f73eac54..5145b3e9 100644 --- a/server/src/services/turnService/index.js +++ b/server/src/services/turnService/index.js @@ -1,6 +1,7 @@ // turn Service // Purpose: Defines the turn Service module and the helpers/state used by this service unit. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. +const { describeQueue } = require('./queueDisplay'); const io = require('../../globals/io'); const { sendAlert } = require('../alertService'); const { MODES, getMode, modeEvents } = require('../modeManager'); @@ -431,7 +432,7 @@ function getTurnQueues() { driverQueues.forEach((queue, roverId) => { payload[roverId] = { mode, - queue: Array.from(queue.queue), + ...describeQueue(Array.from(queue.queue), queue.current), current: queue.current, deadline: turnDeadlines.get(roverId) || null, idleDeadline: idleDeadlines.get(roverId) || null, diff --git a/server/src/services/turnService/queueDisplay.js b/server/src/services/turnService/queueDisplay.js new file mode 100644 index 00000000..c8395113 --- /dev/null +++ b/server/src/services/turnService/queueDisplay.js @@ -0,0 +1,10 @@ +// Shared display ordering only; each service still owns its queue policy. +function describeQueue(queue, currentId) { + const currentIndex = currentId ? queue.indexOf(currentId) : -1; + const nextId = queue.length > 1 + ? queue[(currentIndex + 1) % queue.length] + : null; + return { queue, currentId, nextId }; +} + +module.exports = { describeQueue }; diff --git a/webui/src/components/ChatPanel/index.jsx b/webui/src/components/ChatPanel/index.jsx index 971e7ae0..82f63b42 100644 --- a/webui/src/components/ChatPanel/index.jsx +++ b/webui/src/components/ChatPanel/index.jsx @@ -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 { diff --git a/webui/src/components/HudOverlays/HudChatInput/index.jsx b/webui/src/components/HudOverlays/HudChatInput/index.jsx index 2197ee59..43d6d8d9 100644 --- a/webui/src/components/HudOverlays/HudChatInput/index.jsx +++ b/webui/src/components/HudOverlays/HudChatInput/index.jsx @@ -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(() => { diff --git a/webui/src/components/HudOverlays/newgen/CornerPods/BottomLeftPod.jsx b/webui/src/components/HudOverlays/newgen/CornerPods/BottomLeftPod.jsx index c7614c15..b70a92fc 100644 --- a/webui/src/components/HudOverlays/newgen/CornerPods/BottomLeftPod.jsx +++ b/webui/src/components/HudOverlays/newgen/CornerPods/BottomLeftPod.jsx @@ -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 ( - - ); -} - 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 ? ( -
- {/* - 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 ? } active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null} - {headlight ? } 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 ? } active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null} - setOpen(false)} /> -
- ) : ( - setOpen(true)} /> - )} + + {/* + 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 ? } active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null} + {headlight ? } 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 ? } active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null} + {hornDevice ? : null} ); diff --git a/webui/src/components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx b/webui/src/components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx new file mode 100644 index 00000000..17a3a4f7 --- /dev/null +++ b/webui/src/components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx @@ -0,0 +1,12 @@ +import CornerPodToggle from './CornerPodToggle.jsx'; + +export default function PeripheralPod({ open, onOpenChange, label, children, className = 'h-40 w-40' }) { + return open ? ( +
+ {children} + onOpenChange(false)} /> +
+ ) : ( + onOpenChange(true)} /> + ); +} diff --git a/webui/src/components/HudOverlays/newgen/CornerPods/RoundControl.jsx b/webui/src/components/HudOverlays/newgen/CornerPods/RoundControl.jsx new file mode 100644 index 00000000..f895b387 --- /dev/null +++ b/webui/src/components/HudOverlays/newgen/CornerPods/RoundControl.jsx @@ -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 ( + + ); +} + diff --git a/webui/src/components/PtzCamera/index.jsx b/webui/src/components/PtzCamera/index.jsx index ba2f0df1..a58154a8 100644 --- a/webui/src/components/PtzCamera/index.jsx +++ b/webui/src/components/PtzCamera/index.jsx @@ -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 ( @@ -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} diff --git a/webui/src/components/PtzLiveVideo/index.jsx b/webui/src/components/PtzLiveVideo/index.jsx index 1b1ee5d6..a14542fa 100644 --- a/webui/src/components/PtzLiveVideo/index.jsx +++ b/webui/src/components/PtzLiveVideo/index.jsx @@ -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); }, []); diff --git a/webui/src/components/RoverMediaPlayer/index.jsx b/webui/src/components/RoverMediaPlayer/index.jsx index b00cfc7e..15cb54a0 100644 --- a/webui/src/components/RoverMediaPlayer/index.jsx +++ b/webui/src/components/RoverMediaPlayer/index.jsx @@ -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, diff --git a/webui/src/components/RoverQueuesPanel/index.jsx b/webui/src/components/RoverQueuesPanel/index.jsx index bb54d783..67ec6d9d 100644 --- a/webui/src/components/RoverQueuesPanel/index.jsx +++ b/webui/src/components/RoverQueuesPanel/index.jsx @@ -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 diff --git a/webui/src/controls/ptzControlAdapter.js b/webui/src/controls/ptzControlAdapter.js index 091e7d93..6ad89f8e 100644 --- a/webui/src/controls/ptzControlAdapter.js +++ b/webui/src/controls/ptzControlAdapter.js @@ -2,7 +2,7 @@ // Purpose: Hooks the single PTZ camera into the internal control action layer. // Scope: Owns PTZ-specific control mixing and socket commands so keyboard, // mobile, desktop, and gamepad inputs do not each learn camera-specific rules. -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSocket } from '../context/SocketContext.jsx'; import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx'; @@ -102,9 +102,27 @@ function nextIrMode(currentMode) { export function usePtzControlAdapter() { const socket = useSocket(); - const { ptzSpotlight, ptzIr } = useSessionActions(); + const { ptzSpotlight, ptzIr, pushAlert } = useSessionActions(); const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const isActive = Boolean(ptz?.permissions?.canControl); + const [lightingPending, setLightingPending] = useState({}); + const lightingPendingRef = useRef({}); + const spotlightOn = isSpotlightOn(ptz?.light); + const irMode = normalizeIrMode(ptz?.ir?.state); + + const sendLighting = useCallback((key, send) => { + // Keyboard repeats and simultaneous touch/key input share one in-flight + // command per light, so neither can race a pending state change. + if (lightingPendingRef.current[key]) return; + lightingPendingRef.current[key] = true; + setLightingPending((current) => ({ ...current, [key]: true })); + send().catch((err) => { + pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' }); + }).finally(() => { + lightingPendingRef.current[key] = false; + setLightingPending((current) => ({ ...current, [key]: false })); + }); + }, [pushAlert]); const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP)); const desiredMotionRef = useRef(PTZ_STOP); const panTiltIntentRef = useRef({ pan: 0, tilt: 0 }); @@ -227,7 +245,7 @@ export function usePtzControlAdapter() { const setSpotlight = useCallback( (nextOn) => { if (!isActive) return false; - const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light); + const desiredOn = typeof nextOn === 'boolean' ? nextOn : !spotlightOn; /* Lighting keybinds should use the exact acknowledged command action as the visible PTZ buttons. Movement remains fire-and-forget because it is @@ -235,10 +253,10 @@ export function usePtzControlAdapter() { from the existing authorization/error contract and must not maintain a second socket-only behavior merely because its source is a keybind. */ - ptzSpotlight({ state: desiredOn ? 1 : 0 }).catch(() => {}); + sendLighting('spotlight', () => ptzSpotlight({ state: desiredOn ? 1 : 0 })); return true; }, - [isActive, ptz?.light, ptzSpotlight], + [isActive, spotlightOn, ptzSpotlight, sendLighting], ); const setIr = useCallback( @@ -246,13 +264,13 @@ export function usePtzControlAdapter() { if (!isActive) return false; const desiredState = typeof nextOn === 'boolean' ? (nextOn ? 'On' : 'Off') - : nextIrMode(ptz?.ir?.state); + : nextIrMode(irMode); // Match the button path for the same reason as spotlight above. The // shared laser key continues to select IR; only its transport is unified. - ptzIr({ state: desiredState }).catch(() => {}); + sendLighting('ir', () => ptzIr({ state: desiredState })); return true; }, - [isActive, ptz?.ir?.state, ptzIr], + [isActive, irMode, ptzIr, sendLighting], ); useEffect(() => { @@ -300,12 +318,13 @@ export function usePtzControlAdapter() { () => ({ isActive, state: ptz, + lighting: { spotlightOn, irMode, pending: lightingPending }, applyDriveVector, setZoomIntent, setSpotlight, setIr, stopMotion, }), - [applyDriveVector, isActive, ptz, setIr, setSpotlight, setZoomIntent, stopMotion], + [applyDriveVector, isActive, ptz, spotlightOn, irMode, lightingPending, setIr, setSpotlight, setZoomIntent, stopMotion], ); } diff --git a/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx b/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx index c4bd9a08..6173ed77 100644 --- a/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx +++ b/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx @@ -7,7 +7,7 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx'; export default function DisplayPtzOperatorBadge() { const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); - const operatorLabel = String(ptz?.operatorLabel || '').trim(); + const operatorLabel = String(ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || '').trim(); const visible = Boolean(ptz?.enabled && operatorLabel); return ( diff --git a/webui/src/hooks/useQueueUserLookup.js b/webui/src/hooks/useQueueUserLookup.js new file mode 100644 index 00000000..ac643e48 --- /dev/null +++ b/webui/src/hooks/useQueueUserLookup.js @@ -0,0 +1,10 @@ +import { useCallback } from 'react'; +import { useSessionSelector } from '../context/SessionContext.jsx'; + +export default function useQueueUserLookup(turn, usersOverride) { + const localUsers = useSessionSelector((state) => state.session?.users); + const users = usersOverride || localUsers; + return useCallback((socketId) => users?.find((user) => user.socketId === socketId) + || { socketId, nickname: turn?.userLabels?.[socketId] || null, role: null }, + [users, turn?.userLabels]); +} diff --git a/webui/src/hooks/useWhepRestart.js b/webui/src/hooks/useWhepRestart.js new file mode 100644 index 00000000..30b2a787 --- /dev/null +++ b/webui/src/hooks/useWhepRestart.js @@ -0,0 +1,28 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { RESTART_DELAY_MS } from '../components/RoverMediaPlayer/constants.js'; + +export default function useWhepRestart(enabled = true) { + const timer = useRef(null); + const enabledRef = useRef(enabled); + const [restartToken, setRestartToken] = useState(0); + const cancelRestart = useCallback(() => { + clearTimeout(timer.current); + timer.current = null; + }, []); + useEffect(() => { + enabledRef.current = enabled; + if (!enabled) cancelRestart(); + }, [enabled, cancelRestart]); + const scheduleRestart = useCallback(() => { + if (!enabledRef.current) return; + cancelRestart(); + // Re-negotiate instead of reusing the failed PeerConnection. Callers that + // own session requests also use this token to obtain fresh authorization. + timer.current = setTimeout(() => { + timer.current = null; + setRestartToken(Date.now()); + }, RESTART_DELAY_MS); + }, [cancelRestart]); + useEffect(() => cancelRestart, [cancelRestart]); + return { restartToken, scheduleRestart, cancelRestart }; +} diff --git a/webui/src/layouts/driver/DesktopLayout/index.jsx b/webui/src/layouts/driver/DesktopLayout/index.jsx index 2e220e3f..c16ce407 100644 --- a/webui/src/layouts/driver/DesktopLayout/index.jsx +++ b/webui/src/layouts/driver/DesktopLayout/index.jsx @@ -58,9 +58,9 @@ function CommunityColumn() { - + ); } diff --git a/webui/src/lib/whepPlayback.js b/webui/src/lib/whepPlayback.js new file mode 100644 index 00000000..d0475c90 --- /dev/null +++ b/webui/src/lib/whepPlayback.js @@ -0,0 +1,26 @@ +import { WhepPlayer } from './whepPlayer.js'; + +const TERMINAL_STATES = ['error', 'failed', 'disconnected', 'closed']; + +// A mounted effect owns exactly one connection. Invalidate its callbacks before +// stopping it so a late rejection or cleanup status cannot restart old media. +export function startWhepPlayback({ onStatus, onError, scheduleRestart, terminalStates = TERMINAL_STATES, ...options }) { + let active = true; + const player = new WhepPlayer({ + ...options, + onStatus: (status, detail) => { + if (!active) return; + onStatus(status, detail); + if (terminalStates.includes(String(status || '').toLowerCase())) scheduleRestart(); + }, + }); + player.start().catch((error) => { + if (!active) return; + onError(error); + scheduleRestart(); + }); + return () => { + active = false; + player.stop(); + }; +} diff --git a/webui/src/main.jsx b/webui/src/main.jsx index 0ee5c8f0..632fb8b0 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -64,7 +64,6 @@ createRoot(document.getElementById('root')).render( } /> } /> } /> - } /> , 'setup')} /> , 'administration')} /> {/* diff --git a/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx b/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx index 7c5c4d71..7253ba38 100644 --- a/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx +++ b/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx @@ -163,7 +163,7 @@ export default function MiniSummaryContent() { const frames = useVisualTelemetryFrames(); const roster = session?.roster ?? []; const ptz = session?.ptzCamera || null; - const ptzOperatorLabel = String(ptz?.operatorLabel || '').trim(); + const ptzOperatorLabel = String(ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || '').trim(); const hasActivePtzOperator = Boolean(ptz?.enabled && ptzOperatorLabel); const [index, setIndex] = useState(0); const activeDrivers = session?.activeDrivers || {}; diff --git a/webui/src/ptz/components/PtzControls.jsx b/webui/src/ptz/components/PtzControls.jsx index 0a9abf1a..47924e01 100644 --- a/webui/src/ptz/components/PtzControls.jsx +++ b/webui/src/ptz/components/PtzControls.jsx @@ -1,61 +1,19 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useControlActions } from '../../controls/index.js'; -import { useSessionActions } from '../../context/SessionContext.jsx'; +import { useCallback, useEffect } from 'react'; +import { useControlActions, useControlSelector } from '../../controls/index.js'; import { triggerTouchHaptic } from '../../lib/touchHaptics.js'; import CardFrame from '../../components/CardFrame/index.jsx'; import ControlPadPanel from '../../components/MobileControls/ControlPadPanel.jsx'; import GPIOToggleControl from '../../components/GPIOToggleControl/index.jsx'; import KeyPill from '../../components/vip/VipAudioUploadCard/KeyPill.jsx'; import ControlHint from '../../components/ControlHint/index.jsx'; -function isSpotlightOn(light = {}) { - if (typeof light?.on === 'boolean') return light.on; - const raw = light?.state; - if (typeof raw === 'string') { - const normalized = raw.trim().toLowerCase(); - return !['', '0', 'off', 'false'].includes(normalized); - } - return Boolean(Number(raw)); -} - -function normalizeIrMode(mode) { - const normalized = String(mode || '').trim().toLowerCase(); - if (normalized === 'on') return 'On'; - if (normalized === 'off') return 'Off'; - return 'Auto'; -} - -function nextIrMode(currentMode) { - const current = normalizeIrMode(currentMode); - if (current === 'Auto') return 'On'; - if (current === 'On') return 'Off'; - return 'Auto'; -} - -function PtzLightingControls({ ptz, disabled = false }) { - const { ptzSpotlight, ptzIr } = useSessionActions(); - const [busy, setBusy] = useState(''); - const spotlightOn = isSpotlightOn(ptz?.light); - const irMode = normalizeIrMode(ptz?.ir?.state); - - const toggleSpotlight = async (nextOn) => { - if (disabled) return; - setBusy('spotlight'); - try { - await ptzSpotlight({ state: nextOn ? 1 : 0 }); - } finally { - setBusy(''); - } - }; - - const cycleIr = async () => { - if (disabled) return; +function PtzLightingControls({ disabled = false }) { + const { setHeadlight, setLaser } = useControlActions(); + const { spotlightOn, irMode, pending } = useControlSelector((control) => control.ptzControls.lighting); + const canControl = useControlSelector((control) => control.ptzControls.isActive); + const blocked = disabled || !canControl; + const cycleIr = () => { triggerTouchHaptic('button'); - setBusy('ir'); - try { - await ptzIr({ state: nextIrMode(irMode) }); - } finally { - setBusy(''); - } + setLaser(); }; return ( @@ -63,14 +21,14 @@ function PtzLightingControls({ ptz, disabled = false }) { ); diff --git a/webui/src/ptz/queue.js b/webui/src/ptz/queue.js deleted file mode 100644 index 6f678c47..00000000 --- a/webui/src/ptz/queue.js +++ /dev/null @@ -1,42 +0,0 @@ -import { useCallback } from 'react'; -import { useSessionSelector } from '../context/SessionContext.jsx'; - -function normalizePtzQueue(ptz = null) { - /* - The PTZ service exposes the current operator separately from the waiting - queue, while the rover queue row expects one ordered queue plus a current id. - Normalizing once keeps every PTZ surface consistent with the shared queue - renderer without making that renderer understand PTZ service internals. - */ - const currentId = ptz?.operatorSocketId || null; - const waiting = Array.isArray(ptz?.queue) - ? ptz.queue.map((entry) => entry?.socketId || entry).filter(Boolean) - : []; - const queue = currentId ? [currentId, ...waiting.filter((id) => id !== currentId)] : waiting; - const nextId = currentId ? waiting[0] || null : queue[0] || null; - return { queue, currentId, nextId }; -} - -function usePtzQueueLookup(ptz = null) { - const users = useSessionSelector((state) => state.session?.users ?? []); - return useCallback( - (socketId) => { - const fromUsers = users.find((entry) => entry.socketId === socketId); - if (fromUsers) return fromUsers; - if (ptz?.operatorSocketId === socketId) { - return { socketId, nickname: ptz?.operatorLabel || null, role: null }; - } - const fromQueue = Array.isArray(ptz?.queue) - ? ptz.queue.find((entry) => (entry?.socketId || entry) === socketId) - : null; - return { - socketId, - nickname: fromQueue?.label || null, - role: null, - }; - }, - [ptz, users], - ); -} - -export { normalizePtzQueue, usePtzQueueLookup }; diff --git a/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx b/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx index 867af59a..62d31a57 100644 --- a/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx +++ b/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx @@ -115,15 +115,15 @@ export default function PtzSpectatorCard() { ? 'restarting' : publisher.lastEvent || 'stopped'; const publisherProgress = formatPublisherProgress(publisher.progress); - const queueCount = Array.isArray(ptz?.queue) ? ptz.queue.length : 0; + const queueCount = Math.max(0, (ptz?.turn?.queueLength || 0) - (ptz?.turn?.currentId ? 1 : 0)); const label = ptz?.name || 'PTZ Camera'; return (
- - + +