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

This commit is contained in:
legop3
2026-09-24 18:36:24 -04:00
parent 8ca714cf1a
commit 7231809140
30 changed files with 292 additions and 388 deletions
+5 -13
View File
@@ -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),
};
}
@@ -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 }];
@@ -203,6 +203,7 @@ function buildSession(socket) {
socketId: socket?.id,
turnInfo: turnQueues[rover.id],
activeDriverId: activeDrivers[rover.id],
users,
}),
})),
odometers: roverManager.getOdometersForSocket(socket),
+6 -1
View File
@@ -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,
+2 -1
View File
@@ -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,
@@ -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 };
+1 -1
View File
@@ -63,7 +63,7 @@ function useChatComposerSessionState(allowSpectatorInput) {
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
);
return {
@@ -55,7 +55,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
const ttsPayload = useMemo(() => {
@@ -6,33 +6,11 @@ import { useControlActions, useControlSelector } from '../../../../controls/inde
import ControlHint from '../../../ControlHint/index.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import RoundControl from './RoundControl.jsx';
import PeripheralPod from './PeripheralPod.jsx';
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
const ControlIcon = icon;
const toneClass = tone === 'horn'
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
>
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
<KeyPill label={keyLabel} />
</button>
);
}
export default function BottomLeftPod({ roverId }) {
const [open, setOpen] = usePodVisibility('peripherals', true);
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
@@ -67,30 +45,25 @@ export default function BottomLeftPod({ roverId }) {
return (
<>
{open ? (
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
{/*
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
sixty-degree separation leaves a visible gap between the small circles while keeping
both controls equally distant from the horn. These explicit positions are the
rendered result of that geometry, not unrelated visual nudges.
*/}
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
)}
<PeripheralPod open={open} onOpenChange={setOpen} label="rover controls">
{/*
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
sixty-degree separation leaves a visible gap between the small circles while keeping
both controls equally distant from the horn. These explicit positions are the
rendered result of that geometry, not unrelated visual nudges.
*/}
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={<ControlHint actionId="hornHonk" />} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
</PeripheralPod>
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
</>
);
@@ -0,0 +1,12 @@
import CornerPodToggle from './CornerPodToggle.jsx';
export default function PeripheralPod({ open, onOpenChange, label, children, className = 'h-40 w-40' }) {
return open ? (
<div className={`pointer-events-auto absolute bottom-0 left-0 z-20 rounded-tr-[4.25rem] bg-black/60 ${className}`}>
{children}
<CornerPodToggle corner="bottom-left" expanded label={`Hide ${label}`} onClick={() => onOpenChange(false)} />
</div>
) : (
<CornerPodToggle corner="bottom-left" expanded={false} label={`Show ${label}`} onClick={() => onOpenChange(true)} />
);
}
@@ -0,0 +1,25 @@
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '', value = null }) {
const ControlIcon = icon;
const toneClass = tone === 'horn'
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
>
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
{value != null ? <span className="text-xs font-semibold leading-none">{value}</span> : null}
<KeyPill label={keyLabel} />
</button>
);
}
+9 -20
View File
@@ -7,7 +7,7 @@ import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import { normalizePtzQueue, usePtzQueueLookup } from '../../ptz/queue.js';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
const PTZ_DEFAULT_COLOR = '#387bf8';
function formatRemaining(deadline, now) {
@@ -24,13 +24,13 @@ export default function PtzQueueCard() {
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const now = useSharedClock(1000, Boolean(ptz?.deadline));
const lookupUser = useQueueUserLookup(ptz?.turn);
const { queue, currentId, nextId } = ptz?.turn || {};
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.permissions?.canEnter);
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
const isParticipant = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
const timerLabel = ptz?.turn?.isActive && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
if (!featureEnabled) return null;
@@ -66,13 +66,7 @@ export default function PtzQueueCard() {
}
};
const actionLabel = pending
? '...'
: ptz?.isOperator
? 'Open'
: ptz?.queuedPosition
? 'Open'
: 'request';
const actionLabel = pending ? '...' : isParticipant ? 'Open' : 'request';
return (
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
@@ -82,11 +76,6 @@ export default function PtzQueueCard() {
id: ptz?.id || PTZ_CAMERA_ID,
name: ptz?.name || 'PTZ Camera',
color: ptz?.color || PTZ_DEFAULT_COLOR,
// description: ptz?.isOperator
// ? 'Live camera turn active'
// : ptz?.queuedPosition
// ? `Queue position ${ptz.queuedPosition}`
// : 'Pan, tilt, and zoom camera',
}}
queue={queue}
currentId={currentId}
@@ -96,8 +85,8 @@ export default function PtzQueueCard() {
canClick={canUse && !pending}
pending={pending}
buttonLabel={actionLabel}
batteryLabel={ptz?.isOperator ? 'LIVE' : ptz?.queuedPosition ? `#${ptz.queuedPosition}` : '--'}
batteryClassName={ptz?.isOperator ? 'text-emerald-300' : ptz?.queuedPosition ? 'text-sky-300' : 'text-slate-400'}
batteryLabel={ptz?.turn?.isActive ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.isActive ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
timerLabel={timerLabel}
onRequest={handleRequest}
showAction={canUse}
+16 -87
View File
@@ -1,15 +1,14 @@
// PTZ Live Video
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { RESTART_DELAY_MS } from '../RoverMediaPlayer/constants.js';
import { startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js';
export const PTZ_CAMERA_ID = 'ptz-camera';
const PTZ_AUDIO_RETRY_MS = 1000;
const TERMINAL_WHEP_STATES = new Set(['error', 'failed', 'disconnected', 'closed']);
const AUTHORIZATION_ERROR_RE = /not authorized/i;
function isAuthorizationError(error) {
@@ -25,14 +24,10 @@ export default function PtzLiveVideo({
fallback = null,
}) {
const videoRef = useRef(null);
const retryTimerRef = useRef(null);
const playTimerRef = useRef(null);
const enabledRef = useRef(enabled);
const fallbackRef = useRef(false);
const playerGenerationRef = useRef(0);
const [status, setStatus] = useState('idle');
const [detail, setDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0);
const { restartToken, scheduleRestart, cancelRestart } = useWhepRestart(enabled);
const sources = useVideoRequests(
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
{ enabled, version: restartToken },
@@ -41,100 +36,34 @@ export default function PtzLiveVideo({
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
useEffect(() => {
enabledRef.current = enabled;
}, [enabled]);
useEffect(() => {
fallbackRef.current = shouldUseFallback;
}, [shouldUseFallback]);
useEffect(() => {
if (enabled && !shouldUseFallback) return undefined;
/*
A retry that was scheduled before the server denied live access should not
keep firing in snapshot mode. Clear it when live playback is no longer the
active display policy.
*/
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
return undefined;
}, [enabled, shouldUseFallback]);
const scheduleRestart = useCallback(() => {
if (!enabledRef.current || fallbackRef.current) return;
/*
WHEP sessions are one-shot browser/server negotiations. When the camera
reboots, the old PeerConnection and token can look alive enough to keep a
black element on screen, but they are not useful anymore. Bumping this
token forces useVideoRequests to ask the server for a new MediaMTX auth
session before creating the next WhepPlayer.
*/
clearTimeout(retryTimerRef.current);
retryTimerRef.current = setTimeout(() => {
retryTimerRef.current = null;
setRestartToken(Date.now());
}, RESTART_DELAY_MS);
}, []);
// An authorization denial switches to snapshots and cancels any queued retry.
if (shouldUseFallback) cancelRestart();
}, [shouldUseFallback, cancelRestart]);
useEffect(() => {
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
let active = true;
const generation = playerGenerationRef.current + 1;
playerGenerationRef.current = generation;
const player = new WhepPlayer({
const video = videoRef.current;
const stop = startWhepPlayback({
url: source.url,
token: source.token,
video: videoRef.current,
startMuted,
scheduleRestart,
onStatus: (nextStatus, info) => {
/*
Old PeerConnection callbacks can arrive after React has already
cleaned up this effect for a newer token. Only the currently-owned
generation is allowed to update status or schedule another restart.
*/
if (!active || playerGenerationRef.current !== generation) return;
const normalized = String(nextStatus || '').toLowerCase();
setStatus(nextStatus || 'unknown');
setDetail(info || null);
if (TERMINAL_WHEP_STATES.has(normalized)) {
scheduleRestart();
}
},
onError: (err) => {
setStatus('error');
setDetail(err.message || 'WHEP start failed');
},
});
player.start().catch((err) => {
if (!active || playerGenerationRef.current !== generation) return;
setStatus('error');
setDetail(err.message || 'WHEP start failed');
scheduleRestart();
});
return () => {
/*
Mark inactive before stop() because WhepPlayer reports "stopped" during
normal cleanup. Cleanup-driven stops should not immediately schedule the
next retry; only the replacement effect should own the new connection.
*/
active = false;
player.stop();
};
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
useEffect(() => {
const video = videoRef.current;
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
const generation = playerGenerationRef.current;
const handleEnded = () => {
if (playerGenerationRef.current !== generation) return;
setStatus('stopped');
setDetail('ended');
scheduleRestart();
};
const handleError = () => {
if (playerGenerationRef.current !== generation) return;
setStatus('error');
setDetail(video.error?.message || 'video element error');
scheduleRestart();
@@ -145,8 +74,9 @@ export default function PtzLiveVideo({
return () => {
video.removeEventListener('ended', handleEnded);
video.removeEventListener('error', handleError);
stop();
};
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
useEffect(() => {
const video = videoRef.current;
@@ -178,7 +108,6 @@ export default function PtzLiveVideo({
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
useEffect(() => () => {
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (playTimerRef.current) clearInterval(playTimerRef.current);
}, []);
+30 -70
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { WhepPlayer } from '../../lib/whepPlayer.js';
import { startWhepPlayback } from '../../lib/whepPlayback.js';
import useWhepRestart from '../../hooks/useWhepRestart.js';
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
import {
mainBrushAudioTelemetryEqual,
@@ -11,7 +12,6 @@ import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSettingsNamespace } from '../../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
import {
RESTART_DELAY_MS,
UNMUTE_RETRY_MS,
AUDIO_RETRY_MS,
BRUSH_CURRENT_THRESHOLD_MA,
@@ -212,10 +212,10 @@ export default function RoverMediaPlayer({
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
}
: mainBrushTelemetry;
const { restartToken, scheduleRestart } = useWhepRestart();
const { restartToken: audioRestartToken, scheduleRestart: scheduleAudioRestart } = useWhepRestart();
const videoRef = useRef(null);
const audioRef = useRef(null);
const restartTimer = useRef(null);
const audioRestartTimer = useRef(null);
const audioPlayInterval = useRef(null);
const unmuteTimer = useRef(null);
const volumeFadeFrameRef = useRef(null);
@@ -224,8 +224,6 @@ export default function RoverMediaPlayer({
const [detail, setDetail] = useState(null);
const [audioStatus, setAudioStatus] = useState('idle');
const [audioDetail, setAudioDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0);
const [audioRestartToken, setAudioRestartToken] = useState(0);
const [muted, setMuted] = useState(true);
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
@@ -341,15 +339,6 @@ export default function RoverMediaPlayer({
logAudio('settings/update');
}, [logAudio]);
const scheduleRestart = useCallback(() => {
clearTimeout(restartTimer.current);
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
}, []);
const scheduleAudioRestart = useCallback(() => {
clearTimeout(audioRestartTimer.current);
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS);
}, []);
const ensurePlayback = useCallback(async () => {
const video = videoRef.current;
if (!video) return;
@@ -397,8 +386,6 @@ export default function RoverMediaPlayer({
useEffect(
() => () => {
clearTimeout(restartTimer.current);
clearTimeout(audioRestartTimer.current);
clearTimeout(unmuteTimer.current);
clearInterval(audioPlayInterval.current);
if (volumeFadeFrameRef.current) {
@@ -426,41 +413,27 @@ export default function RoverMediaPlayer({
if (!isVideoVisible || usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
return undefined;
}
let active = true;
let player;
const resetMuteId = setTimeout(() => setMuted(true), 0);
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
setDetail(info || null);
if (nextStatus === 'playing') {
ensurePlayback();
}
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
scheduleRestart();
}
};
player = new WhepPlayer({
const stop = startWhepPlayback({
url: resolvedSessionInfo.url,
token: resolvedSessionInfo.token,
video: videoRef.current,
receiveAudio: !hasDedicatedAudio,
onStatus: handleStatus,
scheduleRestart,
onStatus: (nextStatus, info) => {
logAudio('video/status', { nextStatus, info: info || null });
setStatus(nextStatus);
setDetail(info || null);
if (nextStatus === 'playing') ensurePlayback();
},
onError: (err) => {
setStatus('error');
setDetail(err.message);
},
});
player.start().catch((err) => {
if (!active) return;
setStatus('error');
setDetail(err.message);
scheduleRestart();
});
return () => {
active = false;
clearTimeout(resetMuteId);
player?.stop();
stop();
};
}, [
usingSnapshot,
@@ -536,37 +509,24 @@ export default function RoverMediaPlayer({
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
return undefined;
}
let active = true;
let player;
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('audio/status', { nextStatus, info: info || null });
setAudioStatus(nextStatus);
setAudioDetail(info || null);
if (['error', 'failed'].includes(nextStatus)) {
scheduleAudioRestart();
}
};
player = new WhepPlayer({
return startWhepPlayback({
url: resolvedAudioSessionInfo.url,
token: resolvedAudioSessionInfo.token,
video: audioRef.current,
audioOnly: true,
onStatus: handleStatus,
// Dedicated rover audio historically retries only errors and failures.
terminalStates: ['error', 'failed'],
scheduleRestart: scheduleAudioRestart,
onStatus: (nextStatus, info) => {
logAudio('audio/status', { nextStatus, info: info || null });
setAudioStatus(nextStatus);
setAudioDetail(info || null);
},
onError: (err) => {
setAudioStatus('error');
setAudioDetail(err.message);
},
});
player.start().catch((err) => {
if (!active) return;
setAudioStatus('error');
setAudioDetail(err.message);
scheduleAudioRestart();
});
return () => {
active = false;
player?.stop();
};
}, [
resolvedAudioSessionInfo?.url,
resolvedAudioSessionInfo?.token,
@@ -3,6 +3,7 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx';
@@ -181,8 +182,7 @@ export default function RoverQueuesPanel({
}
}
const lookupUser = (socketId) =>
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
const lookupUser = useQueueUserLookup(null, users);
async function handleRebootOwnRover() {
if (rebootPending) return;
@@ -246,14 +246,8 @@ export default function RoverQueuesPanel({
const idleRemainingSeconds = idleDeadline
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
: null;
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
const currentId = info?.currentId || null;
const nextId = info?.nextId || null;
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
const timerLabel = isSelfCurrent
+28 -9
View File
@@ -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],
);
}
@@ -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 (
+10
View File
@@ -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]);
}
+28
View File
@@ -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 };
}
@@ -58,9 +58,9 @@ function CommunityColumn() {
<OverseerPreferencePanel />
<ReplaySourcesPanel panelId="newdrive-replay-sources" />
<RoverQueuesPanel title="Rovers" />
<RawUserPilePanel compact hideNicknameForm />
<LinkButtonsPanel fillHeight={false} />
<PtzQueueCard layout="desktop" />
<RawUserPilePanel compact hideNicknameForm />
</div>
);
}
+26
View File
@@ -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();
};
}
-1
View File
@@ -64,7 +64,6 @@ createRoot(document.getElementById('root')).render(
<Route path="/mini" element={<MiniSummaryApp />} />
<Route path="/display" element={<ServerDisplayApp />} />
<Route path="/scanner" element={<ScannerApp />} />
<Route path="/database" element={<Navigate to="/admin?section=users" replace />} />
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
{/*
@@ -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 || {};
+14 -56
View File
@@ -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 }) {
<GPIOToggleControl
label="Spotlight"
on={spotlightOn}
disabled={disabled || busy === 'spotlight'}
onToggle={toggleSpotlight}
disabled={blocked || pending.spotlight}
onToggle={setHeadlight}
heightClass="min-h-14"
/>
<button
type="button"
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
disabled={disabled || busy === 'ir'}
disabled={blocked || pending.ir}
onClick={cycleIr}
>
<span className="text-sm font-semibold">Infrared</span>
@@ -158,7 +116,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
);
}
function PtzMobileControlsPanel({ ptz, disabled = false }) {
function PtzMobileControlsPanel({ disabled = false }) {
return (
<div className="mobile-touch-control space-y-0.5">
<PtzMobileZoomButtons disabled={disabled} />
@@ -170,7 +128,7 @@ function PtzMobileControlsPanel({ ptz, disabled = false }) {
*/}
<ControlPadPanel compact disabled={disabled} />
</div>
<PtzLightingControls ptz={ptz} disabled={disabled} />
<PtzLightingControls disabled={disabled} />
</div>
);
}
+3 -11
View File
@@ -1,10 +1,9 @@
import CardFrame from '../../components/CardFrame/index.jsx';
import ChatPanel from '../../components/ChatPanel/index.jsx';
import HomeAssistantControls from '../../components/HomeAssistantControls/index.jsx';
import ReplaySourcesPanel from '../../components/ReplaySourcesPanel/index.jsx';
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import { PtzQueueSummary } from './PtzQueuePanel.jsx';
import { PtzLightingControls, PtzMobileControlsPanel, PtzControlReference } from './PtzControls.jsx';
import { PtzMobileControlsPanel, PtzControlReference } from './PtzControls.jsx';
import { PtzPresetPanel } from './PtzPresetPanel.jsx';
import { PtzMediaPane } from './PtzMediaPane.jsx';
function PtzDesktopFullscreen({ ptz, releasePending }) {
@@ -19,13 +18,6 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
layout instead of being covered by one solid sidebar rectangle. */}
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
<PtzQueueSummary ptz={ptz} />
{ptz?.permissions?.canControl ? (
<PtzLightingControls ptz={ptz} />
) : (
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
Live PTZ controls unlock when your camera turn is active.
</CardFrame>
)}
<PtzControlReference />
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
{/*
@@ -97,7 +89,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
the same scroll column.
*/}
<PtzQueueSummary ptz={ptz} />
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
<PtzMobileControlsPanel disabled={!ptz?.permissions?.canControl} />
<ReplaySourcesPanel
panelId="ptz-controller-replay-mobile-landscape"
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
@@ -133,7 +125,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
same shared PTZ controls used in landscape.
*/}
<section className="mobile-touch-control">
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
<PtzMobileControlsPanel disabled={!ptz?.permissions?.canControl} />
</section>
<section className="space-y-0.5">
{/*
@@ -0,0 +1,19 @@
import { FaLightbulb, FaEye } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../controls/index.js';
import ControlHint from '../../components/ControlHint/index.jsx';
import PeripheralPod from '../../components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx';
import RoundControl from '../../components/HudOverlays/newgen/CornerPods/RoundControl.jsx';
import usePodVisibility from '../../components/HudOverlays/newgen/CornerPods/usePodVisibility.js';
export default function PtzLightingPod() {
const [open, setOpen] = usePodVisibility('ptzLighting', true);
const { setHeadlight, setLaser } = useControlActions();
const canControl = useControlSelector((control) => control.ptzControls.isActive);
const { spotlightOn, irMode, pending } = useControlSelector((control) => control.ptzControls.lighting);
return (
<PeripheralPod open={open} onOpenChange={setOpen} label="camera lights" className="h-24 w-44">
<RoundControl label="Spotlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={spotlightOn} disabled={!canControl || pending.spotlight} onClick={() => setHeadlight(!spotlightOn)} className="absolute left-5 top-1" />
<RoundControl label={`Infrared: ${irMode}`} icon={FaEye} value={irMode} keyLabel={<ControlHint actionId="laserToggle" />} active={irMode === 'On'} disabled={!canControl || pending.ir} onClick={() => setLaser()} className="absolute right-1 bottom-1" />
</PeripheralPod>
);
}
+8 -4
View File
@@ -1,8 +1,9 @@
import PtzLightingPod from './PtzLightingPod.jsx';
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' }) {
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full', statusClassName = 'pointer-events-none absolute left-1 bottom-1 z-20 font-medium text-slate-100 text-[0.65rem]' }) {
return (
<div className={`relative overflow-hidden bg-black ${className}`}>
{feed?.objectUrl ? (
@@ -15,7 +16,7 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
camera name belongs to the surrounding card/menu, while the media pane
only exposes stream health in the small bottom-left diagnostic overlay.
*/}
<div className="pointer-events-none absolute left-1 bottom-1 z-20 font-medium text-slate-100 text-[0.65rem]">
<div className={statusClassName}>
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
</div>
@@ -28,14 +29,17 @@ function PtzMediaPane({ ptz, open, framed = true, compact = false }) {
const shouldUseLiveVideo = ptz?.viewMode === 'live';
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const statusClassName = `pointer-events-none absolute left-1 ${compact ? 'bottom-1' : 'bottom-24'} z-20 text-[0.65rem] font-medium text-slate-100`;
const media = (
<>
{shouldUseLiveVideo ? (
<PtzLiveVideo enabled={open} startMuted={false} statusClassName="pointer-events-none absolute left-1 bottom-1 z-20 text-[0.65rem] font-medium text-slate-100" />
<PtzLiveVideo enabled={open} startMuted={false} statusClassName={statusClassName} />
) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} statusClassName={statusClassName} />
)}
{ptz?.turn ? <TopLeftPod compact={compact} turns={ptz.turn} /> : null}
{/* Mobile already has lighting controls beside/below the video. */}
{!compact ? <PtzLightingPod /> : null}
<ChatExpansion podOpen={false} />
</>
);
+5 -5
View File
@@ -3,11 +3,11 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
import CardFrame from '../../components/CardFrame/index.jsx';
import { QueueUserChips } from '../../components/QueueTargetRow/index.jsx';
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import { normalizePtzQueue, usePtzQueueLookup } from '../queue.js';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const { ptzRequestTurn, ptzRelease, pushAlert } = useSessionActions();
const [pending, setPending] = useState(false);
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const participating = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
const changeTurn = async () => {
if (pending) return;
setPending(true);
@@ -21,8 +21,8 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
}
};
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const lookupUser = useQueueUserLookup(ptz?.turn);
const { queue, currentId, nextId } = ptz?.turn || {};
return (
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
@@ -35,7 +35,7 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
lookupUser={lookupUser}
/>
<button type="button" className="button-dark w-full text-xs" disabled={pending || !(participating ? ptz?.permissions?.canReleaseTurn : ptz?.permissions?.canRequestTurn)} onClick={changeTurn}>
{pending ? 'Please wait…' : ptz?.isOperator ? 'Release turn' : ptz?.queuedPosition ? 'Leave queue' : 'Request turn'}
{pending ? 'Please wait…' : ptz?.turn?.isActive ? 'Release turn' : ptz?.turn?.turnsAhead ? 'Leave queue' : 'Request turn'}
</button>
</CardFrame>
);
-42
View File
@@ -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 };
@@ -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 (
<article className="flex min-h-64 flex-col rounded-sm bg-zinc-900 p-0 sm:min-h-72">
<PtzLiveOrSnapshot label={label} />
<div className="min-h-0 flex-1 space-y-0.5 overflow-hidden p-1 text-xs">
<InfoRow label="Operator" value={ptz?.operatorLabel || 'none'} />
<InfoRow label="Remaining" value={formatRemaining(ptz?.deadline)} />
<InfoRow label="Operator" value={ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || 'none'} />
<InfoRow label="Remaining" value={formatRemaining(ptz?.turn?.deadline)} />
<InfoRow label="Queue" value={queueCount ? `${queueCount} waiting` : 'empty'} />
<InfoRow label="Spotlight" value={isSpotlightOn(ptz?.light) ? 'On' : 'Off'} />
<InfoRow label="Infrared" value={normalizeInfraredMode(ptz?.ir?.state)} />