mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
Reference in New Issue
Block a user