diff --git a/server/src/services/ptzCameraService/index.js b/server/src/services/ptzCameraService/index.js index fd1182ef..4b6f3ab5 100644 --- a/server/src/services/ptzCameraService/index.js +++ b/server/src/services/ptzCameraService/index.js @@ -121,7 +121,7 @@ function describeParticipation(socket) { color: cameraConfig.color || DEFAULT_PTZ_COLOR, fallback: null, }, - enabled: entered && (isOperator || position > 0), + enabled: entered && (isOperator || position > 0) && participation.state.queue.length > 0, isActive: isOperator, turnsAhead: isOperator ? 0 : position || null, queueLength: participation.state.queue.length + (participation.state.operatorSocketId ? 1 : 0), diff --git a/server/src/services/ptzCameraService/participation.js b/server/src/services/ptzCameraService/participation.js index 3b12f853..a3cc09c4 100644 --- a/server/src/services/ptzCameraService/participation.js +++ b/server/src/services/ptzCameraService/participation.js @@ -22,19 +22,27 @@ function createParticipation({ io, events, emitChange, getTurnDurationMs, canPar } function scheduleTurn() { - clearTimeout(timer); + // Uncontested control has no deadline. Starting a new wait gets a full turn, + // while later queue changes must not reset an already running countdown. + if (!state.operatorSocketId || !state.queue.length) { + clearTimeout(timer); + timer = null; + state.deadline = null; + return; + } + if (timer) return; const duration = getTurnDurationMs(); state.deadline = Date.now() + duration; timer = setTimeout(() => { - // An expired operator stays when alone, but is not automatically requeued - // when another participant takes over. Preserve the existing PTZ rule. - state.queue = state.queue.filter((id) => eligible(id)); + timer = null; + state.queue = state.queue.filter(eligible); if (state.queue.length) { + // Preserve PTZ's explicit request-to-rejoin rule after a handoff. stopOperator('turn-expired'); advance('turn-expired'); } else { scheduleTurn(); - emitChange('turn-extended-empty-queue'); + emitChange('turn-uncontested'); } }, duration); } @@ -45,14 +53,15 @@ function createParticipation({ io, events, emitChange, getTurnDurationMs, canPar } function advance(reason) { + state.queue = state.queue.filter(eligible); while (!state.operatorSocketId && state.queue.length) { const next = state.queue.shift(); if (!eligible(next)) continue; state.operatorSocketId = next; state.generation += 1; - scheduleTurn(); events.emit('operator', { socketId: next, action: 'active' }); } + scheduleTurn(); emitChange(reason); } diff --git a/webui/src/ptz/components/PtzLayouts.jsx b/webui/src/ptz/components/PtzLayouts.jsx index 9036ee8a..e65544d1 100644 --- a/webui/src/ptz/components/PtzLayouts.jsx +++ b/webui/src/ptz/components/PtzLayouts.jsx @@ -37,7 +37,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
- +
{releasePending ? ( @@ -105,7 +105,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
- +
@@ -150,7 +150,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) { - + {/* Portrait keeps room lights immediately after chat as requested. */} diff --git a/webui/src/ptz/components/PtzMediaPane.jsx b/webui/src/ptz/components/PtzMediaPane.jsx index e3206d9f..b27f8de7 100644 --- a/webui/src/ptz/components/PtzMediaPane.jsx +++ b/webui/src/ptz/components/PtzMediaPane.jsx @@ -1,4 +1,5 @@ import PtzLiveVideo, { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx'; +import ChatExpansion from '../../components/HudOverlays/newgen/CornerPods/ChatExpansion.jsx'; import TopLeftPod from '../../components/HudOverlays/newgen/CornerPods/TopLeftPod.jsx'; import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js'; function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full' }) { @@ -35,6 +36,7 @@ function PtzMediaPane({ ptz, open, framed = true, compact = false }) { )} {ptz?.turn ? : null} + );