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 }) {