mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
improving the UI side of ptz... and improving the way turns works with UI....
This commit is contained in:
@@ -1,40 +1,28 @@
|
||||
// Top-left Corner Pod
|
||||
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
|
||||
// Purpose: Renders the caller's turn-display object without knowing its operating mode.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useControlSelector } from '../../../../controls/index.js';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
|
||||
import RoverLabel from '../../../RoverLabel/index.jsx';
|
||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||
import ExpansionPanel from './ExpansionPanel.jsx';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
export default function TopLeftPod({ roverId }) {
|
||||
export default function TopLeftPod({ turns, compact = false }) {
|
||||
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
||||
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
|
||||
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
|
||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
const queue = turnInfo?.queue || [];
|
||||
// Direct ownership is published independently from the detailed queue and is
|
||||
// therefore the reliable initial-load/reconnect source for the current turn.
|
||||
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
||||
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
||||
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
||||
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
||||
const { target, labels, enabled: turnActive, queueLength, turnsAhead, isActive: isCurrentTurn } = turns;
|
||||
const deadline = turns.deadline;
|
||||
const idleDeadline = turns.idleDeadline;
|
||||
const durationSeconds = turns.durationMs / 1000;
|
||||
const hasTurnDeadline = Boolean(turnActive && deadline);
|
||||
const now = useSharedClock(1000, hasTurnDeadline);
|
||||
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
|
||||
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
|
||||
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
||||
const seconds = currentTurnSeconds == null || turnsAhead == null
|
||||
? null
|
||||
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
|
||||
const isCurrentTurn = turnsAhead === 0;
|
||||
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * durationSeconds;
|
||||
const isWaitingForTurn = turnActive && !isCurrentTurn;
|
||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
|
||||
@@ -81,25 +69,20 @@ export default function TopLeftPod({ roverId }) {
|
||||
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
|
||||
const gaugePercent = useMemo(() => {
|
||||
if (seconds == null) return 0;
|
||||
/*
|
||||
The server's rover turns are sixty seconds long. Waiting users need one complete
|
||||
turn added for each driver between the current driver and themselves. A complete
|
||||
queue rotation is a stable scale across handoffs, so the ring drains continuously
|
||||
instead of jumping back to full when the current driver changes.
|
||||
*/
|
||||
const rotationSeconds = Math.max(60, queue.length * 60);
|
||||
// Scale waiting time by this target's actual turn duration. PTZ and rover
|
||||
// turns have different lengths, but use the same presentation.
|
||||
const rotationSeconds = Math.max(durationSeconds, queueLength * durationSeconds);
|
||||
return Math.max(0, Math.min(1, seconds / rotationSeconds));
|
||||
}, [queue.length, seconds]);
|
||||
}, [durationSeconds, queueLength, seconds]);
|
||||
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
|
||||
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
|
||||
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
|
||||
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
|
||||
// The caller supplies its inactivity grace alongside the idle deadline.
|
||||
? Math.max(0, Math.min(1, idleSkipSeconds / (turns.idleGraceMs / 1000)))
|
||||
: gaugePercent;
|
||||
const showTimer = Boolean(turnActive && timerOpen);
|
||||
const showLargeTimer = isWaitingForTurn || showTurnCue;
|
||||
const timerLabel = seconds == null
|
||||
// Ownership should remain visible while the detailed deadline is in flight.
|
||||
? isCurrentTurn ? 'Your turn' : 'Waiting'
|
||||
? isCurrentTurn ? labels.activeTimer : labels.waitingTimer
|
||||
: seconds >= 60
|
||||
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
: `${seconds}s`;
|
||||
@@ -113,8 +96,8 @@ export default function TopLeftPod({ roverId }) {
|
||||
// The waiting/handoff state replaces the old full-screen turn cue.
|
||||
// It must be opaque and above every other in-video HUD surface so
|
||||
// sensor graphics, chat, and docking controls cannot muddy the text.
|
||||
? 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
||||
: 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
||||
? compact ? 'h-[min(12rem,45vw)] w-[min(12rem,45vw)] rounded-br-[6rem] bg-black' : 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
||||
: compact ? 'h-24 w-24 rounded-br-[3rem] bg-black/60' : 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
||||
}`}
|
||||
>
|
||||
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
||||
@@ -129,31 +112,31 @@ export default function TopLeftPod({ roverId }) {
|
||||
fixed-size so it never becomes a giant obstruction over the video. */}
|
||||
{showLargeTimer ? (
|
||||
<>
|
||||
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
||||
{showTurnCue ? 'It’s your turn!' : 'Someone else is driving'}
|
||||
<span className={`${compact ? 'mb-1 text-sm' : 'mb-2 text-[1.75rem]'} font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
||||
{showTurnCue ? labels.handoff : labels.waiting}
|
||||
</span>
|
||||
<strong className="text-[3.375rem] leading-none">
|
||||
<strong className={`${compact ? "text-2xl" : "text-[3.375rem]"} leading-none`}>
|
||||
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
|
||||
</strong>
|
||||
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
||||
<span className={`${compact ? 'mt-1 text-xs' : 'mt-2 text-[1rem]'} font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
||||
{showTurnCue
|
||||
? idleSkipSeconds != null
|
||||
? 'Start driving or your turn will be skipped'
|
||||
: 'You’re driving'
|
||||
: 'until your turn'}
|
||||
? labels.idleWarning
|
||||
: labels.active
|
||||
: labels.untilTurn}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong className="text-lg leading-none">{timerLabel}</strong>
|
||||
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
|
||||
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">{labels.timeRemaining}</span> : null}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
|
||||
<CornerPodToggle corner="top-left" expanded label={labels.hideTimer} onClick={() => setTimerOpen(false)} />
|
||||
</div>
|
||||
) : turnActive ? (
|
||||
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
|
||||
<CornerPodToggle corner="top-left" expanded={false} label={labels.showTimer} onClick={() => setTimerOpen(true)} />
|
||||
) : null}
|
||||
|
||||
{/* The rover name is an independent edge expansion. Its visibility control lives in
|
||||
@@ -166,10 +149,10 @@ export default function TopLeftPod({ roverId }) {
|
||||
panelClassName={`flex h-11 min-w-max items-center gap-2 bg-black/60 px-2 pt-3 ${showTimer ? '' : 'rounded-br-xl'}`}
|
||||
openDirection="down"
|
||||
closeDirection="up"
|
||||
openLabel="Show rover name"
|
||||
closeLabel="Hide rover name"
|
||||
openLabel={labels.showName}
|
||||
closeLabel={labels.hideName}
|
||||
>
|
||||
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
|
||||
<RoverLabel name={target.name} color={target.color} fallback={target.fallback} className="px-2 py-1 text-base" />
|
||||
</ExpansionPanel>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// New Drive Corner Pods
|
||||
// Purpose: Composes the four independently owned corner controls around the shared video stage.
|
||||
import TopLeftPod from './TopLeftPod.jsx';
|
||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||
import TopRightPod from './TopRightPod.jsx';
|
||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||
import BottomRightPod from './BottomRightPod.jsx';
|
||||
@@ -9,11 +10,13 @@ import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.
|
||||
|
||||
export default function CornerPods({ roverId }) {
|
||||
const layout = useDriverLayout();
|
||||
const turns = useSessionSelector((state) => state.session?.roster
|
||||
?.find((rover) => String(rover.id) === String(roverId))?.turn);
|
||||
const showPhysicalControlPods = layout === 'desktop';
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopLeftPod roverId={roverId} />
|
||||
{turns ? <TopLeftPod turns={turns} /> : null}
|
||||
<TopRightPod roverId={roverId} />
|
||||
{/* The mobile layouts already provide large touch controls around the video.
|
||||
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
// PTZ Camera UI
|
||||
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a
|
||||
// queueable controllable target instead of a VIP-panel card.
|
||||
// Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
|
||||
// composition; PTZ command authority, queue ownership, and stream authorization
|
||||
// remain server-owned.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
// Driver-page entry card; PTZ route lifecycle belongs to the PTZ page.
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
|
||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||
import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../../themes/index.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const PTZ_DEFAULT_COLOR = '#38bdf8';
|
||||
import { normalizePtzQueue, usePtzQueueLookup } from '../../ptz/queue.js';
|
||||
const PTZ_DEFAULT_COLOR = '#387bf8';
|
||||
|
||||
function formatRemaining(deadline, now) {
|
||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - now) / 1000));
|
||||
@@ -36,807 +18,17 @@ function formatRemaining(deadline, now) {
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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 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],
|
||||
);
|
||||
}
|
||||
|
||||
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full' }) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-black ${className}`}>
|
||||
{feed?.objectUrl ? (
|
||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
||||
)}
|
||||
{/*
|
||||
Snapshot mode should look like the regular rover video player: the
|
||||
camera name belongs to the surrounding card/menu, while the media pane
|
||||
only exposes stream health in the small top-left diagnostic overlay.
|
||||
*/}
|
||||
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
|
||||
const [pending, setPending] = useState(false);
|
||||
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
||||
const changeTurn = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
if (participating) await ptzRelease();
|
||||
else await ptzClaim();
|
||||
} catch (err) {
|
||||
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
|
||||
return (
|
||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<QueueUserChips
|
||||
targetId={ptz?.id || PTZ_CAMERA_ID}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
/>
|
||||
<button type="button" className="button-dark w-full text-xs" disabled={pending || !ptz?.canUse} onClick={changeTurn}>
|
||||
{pending ? 'Please wait…' : ptz?.isOperator ? 'Release turn' : ptz?.queuedPosition ? 'Leave queue' : 'Request turn'}
|
||||
</button>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
triggerTouchHaptic('button');
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<GPIOToggleControl
|
||||
label="Spotlight"
|
||||
on={spotlightOn}
|
||||
disabled={disabled || busy === 'spotlight'}
|
||||
onToggle={toggleSpotlight}
|
||||
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'}
|
||||
onClick={cycleIr}
|
||||
>
|
||||
<span className="text-sm font-semibold">Infrared</span>
|
||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
|
||||
const stopZoom = useCallback(() => {
|
||||
/*
|
||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
||||
cannot erase the other finger's intent.
|
||||
*/
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Publish held state once. The adapter owns the single motion heartbeat,
|
||||
so this button no longer creates a second interval whose queued callback
|
||||
could run after pointerup and restart zoom.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
event?.preventDefault?.();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
},
|
||||
[disabled, stopZoom],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
/*
|
||||
A touch surface can unmount during orientation changes or fullscreen
|
||||
close while a pointer is still down. Explicitly clear zoom here because
|
||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
||||
*/
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
return (
|
||||
<div className="mobile-touch-control space-y-0.5">
|
||||
<PtzMobileZoomButtons disabled={disabled} />
|
||||
<div className="mobile-touch-control h-44 min-h-0">
|
||||
{/*
|
||||
Reuse the rover control pad so touch intent still enters the normal
|
||||
control system. The PTZ adapter translates that same drive vector into
|
||||
pan/tilt commands only while this user is the PTZ operator.
|
||||
*/}
|
||||
<ControlPadPanel compact disabled={disabled} />
|
||||
</div>
|
||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
['Pan left', 'driveLeft'],
|
||||
['Pan right', 'driveRight'],
|
||||
['Zoom in', 'cameraUp'],
|
||||
['Zoom out', 'cameraDown'],
|
||||
['Spotlight', 'headlightToggle'],
|
||||
['Infrared mode', 'laserToggle'],
|
||||
];
|
||||
|
||||
return (
|
||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
||||
{rows.map(([label, actionId]) => (
|
||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzPresetPanel({ ptz }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const {
|
||||
ptzListPresets,
|
||||
ptzGotoPreset,
|
||||
ptzCreatePreset,
|
||||
ptzRemovePreset,
|
||||
} = useSessionActions();
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
|
||||
const isPresetAdmin = role === 'admin' || role === 'lockdown';
|
||||
const canCreatePreset = Boolean(ptz?.canUse);
|
||||
const canMoveToPreset = Boolean(ptz?.isOperator);
|
||||
|
||||
const refreshPresets = async () => {
|
||||
if (busy) return;
|
||||
setBusy('refresh');
|
||||
try {
|
||||
/*
|
||||
Presets live on the camera, not in browser state. A manual refresh gives
|
||||
admins a simple recovery path if another admin or the camera's native
|
||||
app changes preset storage while this UI is already open.
|
||||
*/
|
||||
await ptzListPresets();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to refresh PTZ presets.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreset = async (preset) => {
|
||||
if (!canMoveToPreset || busy || !preset?.token) return;
|
||||
setBusy(`goto:${preset.token}`);
|
||||
try {
|
||||
/*
|
||||
Moving to a preset is a physical camera move, so the server still checks
|
||||
that this browser owns the active PTZ turn before accepting the command.
|
||||
*/
|
||||
await ptzGotoPreset({ token: preset.token });
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to move to PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const createPreset = async (event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!canCreatePreset || busy || !trimmed) return;
|
||||
setBusy('create');
|
||||
try {
|
||||
/*
|
||||
ONVIF setPreset stores the camera's current physical position. The UI
|
||||
only sends the user's label; the server supplies the active profile
|
||||
token so browser code does not need camera profile internals, and the
|
||||
server still enforces the PTZ feature gate for raw socket callers.
|
||||
*/
|
||||
await ptzCreatePreset({ name: trimmed });
|
||||
setName('');
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to create PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const removePreset = async (preset) => {
|
||||
if (!isPresetAdmin || busy || !preset?.token) return;
|
||||
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
|
||||
if (!confirmed) return;
|
||||
setBusy(`remove:${preset.token}`);
|
||||
try {
|
||||
/*
|
||||
The token is the camera's durable preset identifier. Names are only UI
|
||||
labels and may not be unique, so deletion always targets the token.
|
||||
*/
|
||||
await ptzRemovePreset({ token: preset.token });
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to remove PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Position presets"
|
||||
fillHeight
|
||||
actions={(
|
||||
<button type="button" className="button-dark text-xs" disabled={Boolean(busy)} onClick={refreshPresets}>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
|
||||
>
|
||||
{ptz?.presetsError ? (
|
||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
||||
{ptz.presetsError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
|
||||
{/*
|
||||
Presets should behave like a compact pile of actions, not a table.
|
||||
flex-wrap lets each preset keep its natural button width and only
|
||||
starts a new visual line when the current line runs out of room.
|
||||
*/}
|
||||
{presets.length ? presets.map((preset) => {
|
||||
const gotoBusy = busy === `goto:${preset.token}`;
|
||||
const removeBusy = busy === `remove:${preset.token}`;
|
||||
return (
|
||||
<div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
|
||||
disabled={!canMoveToPreset || Boolean(busy)}
|
||||
onClick={() => goToPreset(preset)}
|
||||
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
|
||||
>
|
||||
{gotoBusy ? 'Moving...' : preset.name}
|
||||
</button>
|
||||
{isPresetAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-xs text-rose-200 disabled:opacity-50"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => removePreset(preset)}
|
||||
>
|
||||
{removeBusy ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
||||
No presets saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canCreatePreset ? (
|
||||
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
|
||||
<input
|
||||
className="field-input min-w-0 text-xs"
|
||||
value={name}
|
||||
maxLength={60}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Preset name"
|
||||
/>
|
||||
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
|
||||
{busy === 'create' ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPtzTurnModel(ptz, selfId) {
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
const isQueued = Boolean(ptz?.queuedPosition);
|
||||
return {
|
||||
enabled: Boolean(ptz && (isActive || isQueued || currentId)),
|
||||
targetId: ptz?.id || PTZ_CAMERA_ID,
|
||||
activeId: currentId,
|
||||
nextId,
|
||||
isActive,
|
||||
isNext: Boolean(selfId && nextId === selfId),
|
||||
deadline: ptz?.deadline || null,
|
||||
idleDeadline: null,
|
||||
showNotTurnNotice: Boolean(!isActive && (isQueued || currentId || queue.length)),
|
||||
showPreviewReason: false,
|
||||
};
|
||||
}
|
||||
|
||||
function PtzMediaPane({ ptz, open, framed = true }) {
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
/*
|
||||
PTZ has its own turn queue, so non-operators are the camera equivalent of a
|
||||
non-active rover driver. The server enforces the same policy in
|
||||
canRequestLiveVideo(); this branch only chooses the expected browser render
|
||||
path and never unlocks movement controls.
|
||||
*/
|
||||
/*
|
||||
A direct /ptz load renders before its automatic queue claim is acknowledged.
|
||||
Do not mount the live player during that short pre-claim window: its first
|
||||
token request would correctly be rejected, and PtzLiveVideo intentionally
|
||||
treats authorization rejection as a terminal snapshot fallback. Once the
|
||||
session confirms queue/operator membership, mounting the player creates a
|
||||
fresh authorized request without changing shared retry or server policy.
|
||||
*/
|
||||
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
||||
const media = (
|
||||
<>
|
||||
{shouldUseLiveVideo ? (
|
||||
<PtzLiveVideo enabled={open} startMuted={false} />
|
||||
) : (
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
)}
|
||||
<TurnsOverlay turnModel={turnModel} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!framed) {
|
||||
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
|
||||
<div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
|
||||
{media}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(7rem,0.22fr)] gap-0.5 overflow-hidden p-0.5">
|
||||
<div className="flex min-h-0 min-w-0 gap-0.5 overflow-hidden">
|
||||
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
||||
<PtzMediaPane ptz={ptz} open framed />
|
||||
</main>
|
||||
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
|
||||
while the shared PTZ page theme can show through the same compact gaps as the driver
|
||||
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?.isOperator ? (
|
||||
<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}`} />
|
||||
{/*
|
||||
Desktop keeps room lights as the final sidebar tool so camera
|
||||
turn controls and replay remain above the less-frequent room-wide
|
||||
actions. HomeAssistantControls owns its own feature and policy gate.
|
||||
*/}
|
||||
<HomeAssistantControls />
|
||||
</aside>
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
||||
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
{releasePending ? (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
|
||||
Closing...
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
{/*
|
||||
Landscape intentionally retains one control column beside the video.
|
||||
This is the established PTZ interaction and avoids forcing rover-style
|
||||
left/right columns onto a camera that has a smaller control inventory.
|
||||
*/}
|
||||
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
|
||||
{/*
|
||||
The video keeps one viewport of height, but the grid row is allowed to
|
||||
grow when the control column is taller. That makes the sidebar's tail
|
||||
extend below the video instead of forcing it into a nested scroller.
|
||||
*/}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
||||
</main>
|
||||
{/*
|
||||
The right control column is naturally taller than the viewport.
|
||||
Placing room lights after the fixed-height video uses that left-
|
||||
column space while the whole landscape page continues scrolling as
|
||||
one surface.
|
||||
*/}
|
||||
<HomeAssistantControls />
|
||||
</div>
|
||||
{/*
|
||||
Do not put overflow scrolling on this column. The surrounding PTZ
|
||||
landscape content is the single page scroller, so a swipe over either
|
||||
the video area or these controls advances the same document flow.
|
||||
*/}
|
||||
<aside className="min-h-0 space-y-0.5">
|
||||
{/*
|
||||
Landscape keeps all turn-critical controls in its one existing
|
||||
sidebar. Queue position belongs first so the operator can confirm
|
||||
control ownership before touching the camera, while replay follows
|
||||
the lighting buttons because it is the next secondary action in
|
||||
the same scroll column.
|
||||
*/}
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-landscape"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
||||
</main>
|
||||
{/*
|
||||
Portrait gives the video its full available width and places controls
|
||||
below it. Reusing the landscape sidebar width here was the source of the
|
||||
cramped portrait presentation, while the controls themselves remain the
|
||||
same shared PTZ controls used in landscape.
|
||||
*/}
|
||||
<section className="mobile-touch-control">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
||||
</section>
|
||||
<section className="space-y-0.5">
|
||||
{/*
|
||||
Replay and presets are compact secondary actions, so portrait places
|
||||
them in one equal-width row before the full-width queue and chat. The
|
||||
explicit two-column grid keeps this arrangement local to portrait and
|
||||
leaves the desktop and one-column landscape compositions unchanged.
|
||||
*/}
|
||||
<div className="grid grid-cols-2 items-start gap-0.5">
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-portrait"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
{/* Portrait keeps room lights immediately after chat as requested. */}
|
||||
<HomeAssistantControls />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { ptzClaim, setOperatingMode, pushAlert } = useSessionActions();
|
||||
const { stopAllMotion } = useControlActions();
|
||||
const navigate = useNavigate();
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
const autoClaimSocketRef = useRef(null);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||
});
|
||||
const isMobile = layout !== 'desktop';
|
||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
||||
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
|
||||
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
|
||||
// controls, and card interiors retain their purpose-built dark backgrounds.
|
||||
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
|
||||
// Wait for startup to publish readiness before consuming the one entry attempt.
|
||||
if (!ptz.initialized && !ptz.error) return undefined;
|
||||
|
||||
if (ptz.isOperator || ptz.queuedPosition) {
|
||||
/*
|
||||
Navigation from the driver queue normally arrives with membership
|
||||
already established. Mark this socket complete so later session syncs
|
||||
cannot turn that normal route transition into another claim request.
|
||||
*/
|
||||
autoClaimSocketRef.current = socketId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (autoClaimSocketRef.current === socketId) return undefined;
|
||||
autoClaimSocketRef.current = socketId;
|
||||
let active = true;
|
||||
|
||||
// Handshake mode prevents rover assignment on direct loads/reconnects;
|
||||
// this request checks eligibility and joins the camera's turn queue.
|
||||
ptzClaim().catch((err) => {
|
||||
if (!active) return;
|
||||
pushAlert({
|
||||
id: `ptz-auto-claim-${socketId}`,
|
||||
title: 'PTZ camera',
|
||||
message: err?.message || 'Unable to join the PTZ queue.',
|
||||
color: '#f59e0b',
|
||||
lifetimeMs: 6000,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Do not emit or update UI from a rejected request after this route has
|
||||
// unmounted; the server still owns completion of any request in flight.
|
||||
active = false;
|
||||
};
|
||||
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
|
||||
|
||||
const releaseAndClose = useCallback(async () => {
|
||||
if (releasePending) return;
|
||||
setReleasePending(true);
|
||||
try {
|
||||
/*
|
||||
Stop first so a held key/pointer cannot leave ONVIF continuous movement
|
||||
running while the server removes this socket from the PTZ queue.
|
||||
*/
|
||||
stopAllMotion?.();
|
||||
await setOperatingMode('rover');
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
|
||||
} finally {
|
||||
setReleasePending(false);
|
||||
}
|
||||
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
||||
<p>The PTZ camera is not available.</p>
|
||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
||||
</CardFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
||||
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
|
||||
transparent body is what lets every desktop and mobile PTZ composition share one
|
||||
continuous pattern without threading theme props into each individual child panel. */}
|
||||
<CardFrame
|
||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
||||
actions={isMobile ? null : (
|
||||
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
hideHeader={isMobile}
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
||||
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
|
||||
>
|
||||
{isMobile ? (
|
||||
layout === 'mobile-landscape' ? (
|
||||
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
) : (
|
||||
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
)
|
||||
) : (
|
||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
||||
)}
|
||||
</CardFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PtzQueueCard() {
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { ptzClaim, setOperatingMode } = useSessionActions();
|
||||
const { setOperatingMode } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
||||
const [pending, setPending] = useState(false);
|
||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
||||
const canUse = Boolean(ptz?.permissions?.canEnter);
|
||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
||||
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
|
||||
|
||||
@@ -850,13 +42,9 @@ export default function PtzQueueCard() {
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await ptzClaim();
|
||||
/*
|
||||
The server is authoritative for whether the click became an active turn
|
||||
or a queued wait. Open only after it confirms one of those states so a
|
||||
dock-guard rejection does not strand the user in fullscreen.
|
||||
*/
|
||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
||||
const response = await setOperatingMode('ptz');
|
||||
// Enter first; the page requests its initial turn after navigation.
|
||||
if (response?.operatingMode === 'ptz') {
|
||||
navigate('/ptz');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,596 +0,0 @@
|
||||
// Vip PTZ Camera Card
|
||||
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
|
||||
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import '../MobileControls/mobileControls.css';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
||||
import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import ControlHint from '../ControlHint/index.jsx';
|
||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
|
||||
function formatRemaining(deadline) {
|
||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||
if (!remaining) return '--';
|
||||
const minutes = Math.floor(remaining / 60);
|
||||
const seconds = remaining % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
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 PtzSnapshotPreview({ feed, label = 'PTZ Camera' }) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
||||
{feed?.objectUrl ? (
|
||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
||||
)}
|
||||
{/*
|
||||
Even on this legacy VIP surface, keep the video pane itself identical
|
||||
to rover media panes: no camera title inside the frame, only the small
|
||||
top-left stream status. Any PTZ name/context belongs to the card chrome
|
||||
around the media, not the media player.
|
||||
*/}
|
||||
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ label, value, tone = '' }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-1 text-xs">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className={`min-w-0 truncate font-medium ${tone || 'text-slate-100'}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPublisherProgress(progress = null) {
|
||||
/*
|
||||
ffmpeg progress is intentionally shown as raw operational numbers instead
|
||||
of translated prose. When the PTZ video feels delayed, fps/speed/drop/out
|
||||
time make it obvious whether ffmpeg itself is keeping up or the delay is
|
||||
somewhere before/after the transcoder.
|
||||
*/
|
||||
if (!progress) return null;
|
||||
return [
|
||||
progress.fps ? `fps ${progress.fps}` : null,
|
||||
progress.speed ? `speed ${progress.speed}` : null,
|
||||
progress.drop_frames ? `drop ${progress.drop_frames}` : null,
|
||||
progress.out_time ? `out ${progress.out_time}` : null,
|
||||
].filter(Boolean).join(' | ');
|
||||
}
|
||||
|
||||
function PtzQueueList({ queue = [], operatorLabel = '' }) {
|
||||
const hasQueue = Array.isArray(queue) && queue.length > 0;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs">Turn queue</div>
|
||||
<div className="space-y-0.5">
|
||||
{operatorLabel ? (
|
||||
<div className="surface flex items-center justify-between gap-1 text-xs">
|
||||
<span className="text-slate-400">Now</span>
|
||||
<span className="min-w-0 truncate text-emerald-200">{operatorLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hasQueue ? queue.map((entry, index) => (
|
||||
<div key={entry.socketId || `${entry.label}-${index}`} className="surface flex items-center justify-between gap-1 text-xs">
|
||||
<span className="text-slate-400">{index + 1}</span>
|
||||
<span className="min-w-0 truncate text-slate-100">{entry.label || entry.socketId || 'queued user'}</span>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="surface text-xs text-slate-400">No one waiting</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
|
||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
||||
const publisher = ptz?.publisher || {};
|
||||
const publisherStatus = publisher.running
|
||||
? `running${publisher.pid ? ` ${publisher.pid}` : ''}`
|
||||
: publisher.restartAt
|
||||
? 'restarting'
|
||||
: publisher.lastEvent || 'stopped';
|
||||
const publisherProgress = formatPublisherProgress(publisher.progress);
|
||||
const statusTone = ptz?.error ? 'text-amber-300' : ptz?.isOperator ? 'text-emerald-300' : 'text-slate-100';
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Camera state"
|
||||
actions={onClose ? <button type="button" className="button-dark text-xs" onClick={onClose}>Close</button> : null}
|
||||
bodyClassName="space-y-0.5 p-1 text-sm"
|
||||
>
|
||||
<StatusRow label="Mode" value={ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator'} tone={statusTone} />
|
||||
<StatusRow label="Operator" value={ptz?.operatorLabel || 'none'} />
|
||||
<StatusRow label="Remaining" value={formatRemaining(ptz?.deadline)} />
|
||||
<StatusRow label="Spotlight" value={spotlightOn ? 'On' : 'Off'} tone={spotlightOn ? 'text-emerald-300' : 'text-slate-200'} />
|
||||
<StatusRow label="Infrared mode" value={irMode} />
|
||||
<StatusRow label="Stream" value={ptz?.status || ptz?.error || 'idle'} tone={ptz?.error ? 'text-amber-300' : ''} />
|
||||
<StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
|
||||
{publisherProgress ? <StatusRow label="Progress" value={publisherProgress} /> : null}
|
||||
{publisher.lastStderr ? (
|
||||
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.68rem] leading-tight text-slate-200">
|
||||
{publisher.lastStderr}
|
||||
</div>
|
||||
) : null}
|
||||
{ptz?.blocked?.message ? (
|
||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
{ptz.blocked.message}
|
||||
</div>
|
||||
) : null}
|
||||
{onRelease ? (
|
||||
<button type="button" className="button-dark w-full text-xs" disabled={releaseDisabled} onClick={onRelease}>
|
||||
Release camera
|
||||
</button>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
triggerTouchHaptic('button');
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<GPIOToggleControl
|
||||
label="Spotlight"
|
||||
on={spotlightOn}
|
||||
disabled={disabled || busy === 'spotlight'}
|
||||
onToggle={toggleSpotlight}
|
||||
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'}
|
||||
onClick={cycleIr}
|
||||
>
|
||||
<span className="text-sm font-semibold">Infrared</span>
|
||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
const stopZoom = useCallback(() => {
|
||||
// This is a zoom-only release; the shared adapter retains any simultaneous
|
||||
// pan/tilt intent from the movement pad in its next combined motion state.
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
The adapter owns renewal for held PTZ state. Publishing the direction
|
||||
once avoids a component-local repeat timer and keeps this legacy card on
|
||||
the exact same motion path as the dedicated PTZ route.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
event?.preventDefault?.();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
},
|
||||
[disabled, stopZoom],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Orientation changes can unmount the button before pointerup. Clear the
|
||||
// held zoom state explicitly instead of waiting for the server watchdog.
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
return (
|
||||
<div className="mobile-touch-control space-y-0.5">
|
||||
<PtzMobileZoomButtons disabled={disabled} />
|
||||
<div className="mobile-touch-control h-44 min-h-0">
|
||||
{/*
|
||||
Reuse the rover mobile movement card instead of building a second PTZ
|
||||
joystick. Its drive vector goes through the shared internal control
|
||||
layer, where the PTZ adapter already converts movement plus speed mode
|
||||
into camera pan/tilt commands.
|
||||
*/}
|
||||
<ControlPadPanel compact disabled={disabled} />
|
||||
</div>
|
||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
['Pan left', 'driveLeft'],
|
||||
['Pan right', 'driveRight'],
|
||||
['Zoom in', 'cameraUp'],
|
||||
['Zoom out', 'cameraDown'],
|
||||
['Spotlight', 'headlightToggle'],
|
||||
['Infrared mode', 'laserToggle'],
|
||||
];
|
||||
|
||||
return (
|
||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
||||
{rows.map(([label, actionId]) => (
|
||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
{/* Use the same key display component as the rest of the UI so PTZ
|
||||
controls read as normal mapped controls instead of custom labels. */}
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
||||
const { setOperatingMode } = useSessionActions();
|
||||
/*
|
||||
The VIP fullscreen surface is available to queued PTZ users too. Let queued
|
||||
users see live video only when the central non-turn video policy allows it;
|
||||
all movement and light controls still remain guarded by isOperator.
|
||||
*/
|
||||
const shouldUseLiveVideo = isOperator || !nonTurnSnapshotsActive;
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const releaseAndClose = async () => {
|
||||
setReleasePending(true);
|
||||
try {
|
||||
await setOperatingMode('rover');
|
||||
onClose();
|
||||
} finally {
|
||||
setReleasePending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const desktopSidebar = (
|
||||
<>
|
||||
|
||||
<div className="shrink-0">
|
||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
||||
</div>
|
||||
{isOperator ? (
|
||||
<div className="shrink-0">
|
||||
<PtzLightingControls ptz={ptz} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0">
|
||||
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
||||
Live PTZ controls unlock when your camera turn is active.
|
||||
</CardFrame>
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<PtzControlReference />
|
||||
</div>
|
||||
<div className="min-h-48 flex-1">
|
||||
{/*
|
||||
The global chat key focuses the input registered by ChatPanel through
|
||||
ChatContext. Keeping a real ChatPanel mounted inside the PTZ fullscreen
|
||||
sidebar lets the normal keyboard path focus chat and lets Enter submit
|
||||
through the existing chat composer form instead of adding PTZ-specific
|
||||
chat handling.
|
||||
*/}
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzStatePanel
|
||||
ptz={ptz}
|
||||
onClose={onClose}
|
||||
onRelease={isOperator ? releaseAndClose : null}
|
||||
releaseDisabled={releasePending}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const mobileSidebar = (
|
||||
<>
|
||||
<div className="shrink-0">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!isOperator} />
|
||||
</div>
|
||||
<div className="min-h-40 flex-1">
|
||||
{/*
|
||||
Mobile uses the same ChatPanel registration as desktop so the mapped
|
||||
chat key and the on-screen input stay on one shared chat implementation.
|
||||
*/}
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzStatePanel
|
||||
ptz={ptz}
|
||||
onClose={onClose}
|
||||
onRelease={isOperator ? releaseAndClose : null}
|
||||
releaseDisabled={releasePending}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const sidebarWidthClass = isMobile
|
||||
? 'grid-cols-[minmax(0,1fr)_14rem]'
|
||||
: 'grid-cols-[minmax(0,1fr)_20rem]';
|
||||
|
||||
const controller = (
|
||||
<div className="fixed inset-0 z-110 h-dvh w-screen overflow-hidden bg-black text-slate-100">
|
||||
<CardFrame
|
||||
hideHeader
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
||||
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
|
||||
>
|
||||
<main className="relative min-h-0 min-w-0 bg-black">
|
||||
{shouldUseLiveVideo ? (
|
||||
<PtzLiveVideo enabled startMuted={false} />
|
||||
) : (
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
)}
|
||||
</main>
|
||||
<aside className="flex h-full min-h-0 items-stretch overflow-hidden border-l border-neutral-600 bg-neutral-950 text-sm">
|
||||
<div className="flex min-h-0 w-full flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
{isMobile ? mobileSidebar : desktopSidebar}
|
||||
</div>
|
||||
</aside>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
The controller is a true fullscreen surface, so mount it directly under
|
||||
document.body instead of inside the VIP tab/card tree. That keeps tab panel
|
||||
spacing, mobile banners, and parent overflow rules from creating visible
|
||||
gaps around a fixed-position camera interface.
|
||||
*/
|
||||
return createPortal(controller, document.body);
|
||||
}
|
||||
|
||||
export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout = 'desktop' }) {
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const { ptzClaim, setOperatingMode } = useSessionActions();
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const [controllerOpen, setControllerOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const wrapClass = fullWidth ? 'w-full' : 'mx-auto w-full max-w-xl';
|
||||
const queueText = useMemo(() => {
|
||||
if (ptz?.isOperator) return 'Your turn';
|
||||
if (ptz?.queuedPosition) return `Queue position ${ptz.queuedPosition}`;
|
||||
if (ptz?.operatorLabel) return `${ptz.operatorLabel} operating`;
|
||||
return 'Available';
|
||||
}, [ptz?.isOperator, ptz?.operatorLabel, ptz?.queuedPosition]);
|
||||
|
||||
if (!featureEnabled) return null;
|
||||
|
||||
const handleClaim = async () => {
|
||||
setPending(true);
|
||||
onMessage?.('');
|
||||
try {
|
||||
const response = await ptzClaim();
|
||||
/*
|
||||
Requesting the PTZ camera is only enough to open fullscreen after the
|
||||
server confirms the user actually became the operator or entered the
|
||||
queue. Dock-required failures reject before queue entry, so they must
|
||||
leave the user on this card with the dock/charge message visible.
|
||||
*/
|
||||
if (response?.state?.isOperator || response?.state?.queuedPosition) setControllerOpen(true);
|
||||
if (response?.state?.isOperator) onMessage?.('PTZ camera turn active.');
|
||||
else if (response?.state?.queuedPosition) onMessage?.(`Joined PTZ queue at position ${response.state.queuedPosition}.`);
|
||||
} catch (err) {
|
||||
/*
|
||||
Rover-to-rover switch denial uses the browser alert popup for the
|
||||
dock-and-charge message. PTZ claim denial should feel identical because
|
||||
it is enforcing the same "do not abandon an undocked rover" rule.
|
||||
*/
|
||||
alert(err.message || 'PTZ request failed.');
|
||||
onMessage?.(err.message || 'PTZ request failed.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelease = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
await setOperatingMode('rover');
|
||||
onMessage?.('Left PTZ camera.');
|
||||
} catch (err) {
|
||||
onMessage?.(err.message || 'Failed to leave PTZ camera.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardFrame title="PTZ camera" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||
<div className="grid w-full gap-0.5 md:grid-cols-[minmax(16rem,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
<div className="flex w-full min-w-0 flex-col gap-0.5">
|
||||
<div className="grid w-full grid-cols-2 gap-0.5 text-left text-xs">
|
||||
<div className="surface-muted p-1">
|
||||
<p className="text-slate-500">State</p>
|
||||
<p className="truncate text-slate-100">{queueText}</p>
|
||||
</div>
|
||||
<div className="surface-muted p-1">
|
||||
<p className="text-slate-500">Remaining</p>
|
||||
<p className="text-slate-100">{formatRemaining(ptz?.deadline)}</p>
|
||||
</div>
|
||||
<div className="surface-muted p-1">
|
||||
<p className="text-slate-500">Spotlight</p>
|
||||
<p className="truncate text-slate-100">{isSpotlightOn(ptz?.light) ? 'On' : 'Off'}</p>
|
||||
</div>
|
||||
<div className="surface-muted p-1">
|
||||
<p className="text-slate-500">Infrared</p>
|
||||
<p className="truncate text-slate-100">{normalizeIrMode(ptz?.ir?.state)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
||||
{ptz?.blocked?.message ? (
|
||||
<p className="w-full rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
||||
{ptz.blocked.message}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid w-full grid-cols-2 gap-0.5">
|
||||
{ptz?.isOperator ? (
|
||||
<>
|
||||
<button type="button" className="button-dark text-xs" onClick={() => setControllerOpen(true)}>
|
||||
Open controller
|
||||
</button>
|
||||
<button type="button" className="button-dark text-xs" disabled={pending} onClick={handleRelease}>
|
||||
Release
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark col-span-2 text-xs"
|
||||
disabled={pending || !isVerified}
|
||||
onClick={ptz?.queuedPosition ? handleRelease : handleClaim}
|
||||
>
|
||||
{ptz?.queuedPosition ? 'Leave queue' : pending ? 'Requesting...' : 'Claim camera'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -412,7 +412,7 @@ export function SessionProvider({ children }) {
|
||||
setPrivateSafety: (roverId, safety = {}) =>
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
setOperatingMode: (operatingMode) => emitWithAck('session:setOperatingMode', { operatingMode }),
|
||||
ptzClaim: () => emitWithAck('session:setOperatingMode', { operatingMode: 'ptz' }),
|
||||
ptzRequestTurn: () => emitWithAck('ptzCamera:requestTurn'),
|
||||
ptzRelease: () => emitWithAck('ptzCamera:release'),
|
||||
ptzRevokeOperator: () => emitWithAck('ptzCamera:revokeOperator'),
|
||||
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
||||
|
||||
@@ -104,7 +104,7 @@ export function usePtzControlAdapter() {
|
||||
const socket = useSocket();
|
||||
const { ptzSpotlight, ptzIr } = useSessionActions();
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
const isActive = Boolean(ptz?.permissions?.canControl);
|
||||
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
||||
const desiredMotionRef = useRef(PTZ_STOP);
|
||||
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Dedicated PTZ Route Root
|
||||
// Purpose: Mounts the PTZ controller as a real page with the same shared input
|
||||
// and identity systems used by the driver page.
|
||||
// Scope: Owns route-level providers and responsive selection only; camera state,
|
||||
// queue policy, and the visible controller remain in the shared PTZ component.
|
||||
// Scope: Mounts input/identity providers; the page owns session entry and layouts.
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { PtzControllerPage } from '../components/PtzCamera/index.jsx';
|
||||
import PtzControllerPage from './PtzControllerPage.jsx';
|
||||
import {
|
||||
ControlSystemProvider,
|
||||
GamepadInputManager,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
import { useControlActions } from '../controls/index.js';
|
||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { isFeatureEnabled } from '../lib/features.js';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
|
||||
import usePtzSession from './usePtzSession.js';
|
||||
import { PtzDesktopFullscreen, PtzMobileLandscape, PtzMobilePortrait } from './components/PtzLayouts.jsx';
|
||||
|
||||
export default function PtzControllerPage({ layout = 'desktop' }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const { setOperatingMode, pushAlert } = useSessionActions();
|
||||
const { stopAllMotion } = useControlActions();
|
||||
const navigate = useNavigate();
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||
});
|
||||
const isMobile = layout !== 'desktop';
|
||||
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
|
||||
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
|
||||
// controls, and card interiors retain their purpose-built dark backgrounds.
|
||||
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
|
||||
|
||||
const { entryError, retryEntry } = usePtzSession();
|
||||
|
||||
const releaseAndClose = useCallback(async () => {
|
||||
if (releasePending) return;
|
||||
setReleasePending(true);
|
||||
try {
|
||||
/*
|
||||
Stop first so a held key/pointer cannot leave ONVIF continuous movement
|
||||
running while the server removes this socket from the PTZ queue.
|
||||
*/
|
||||
stopAllMotion?.();
|
||||
await setOperatingMode('rover');
|
||||
navigate('/');
|
||||
} catch (err) {
|
||||
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
|
||||
} finally {
|
||||
setReleasePending(false);
|
||||
}
|
||||
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
|
||||
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
||||
<p>The PTZ camera is not available.</p>
|
||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
||||
</CardFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
||||
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
|
||||
transparent body is what lets every desktop and mobile PTZ composition share one
|
||||
continuous pattern without threading theme props into each individual child panel. */}
|
||||
<CardFrame
|
||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
||||
actions={isMobile ? null : (
|
||||
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
hideHeader={isMobile}
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
||||
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
|
||||
>
|
||||
{entryError || ptz?.error || ptz?.participation?.denialReason ? (
|
||||
<div className="shrink-0 p-1 text-sm text-amber-200">
|
||||
{ptz?.participation?.denialReason || entryError || ptz?.error}
|
||||
{ptz?.permissions?.canEnter ? <button type="button" className="button-dark ml-1" onClick={retryEntry}>Retry entry</button> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{isMobile ? (
|
||||
layout === 'mobile-landscape' ? (
|
||||
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
) : (
|
||||
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
)
|
||||
) : (
|
||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
||||
)}
|
||||
</CardFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useControlActions } from '../../controls/index.js';
|
||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||
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;
|
||||
triggerTouchHaptic('button');
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<GPIOToggleControl
|
||||
label="Spotlight"
|
||||
on={spotlightOn}
|
||||
disabled={disabled || busy === 'spotlight'}
|
||||
onToggle={toggleSpotlight}
|
||||
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'}
|
||||
onClick={cycleIr}
|
||||
>
|
||||
<span className="text-sm font-semibold">Infrared</span>
|
||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { setCameraAxisIntent } = useControlActions();
|
||||
|
||||
const stopZoom = useCallback(() => {
|
||||
/*
|
||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
||||
cannot erase the other finger's intent.
|
||||
*/
|
||||
setCameraAxisIntent(0);
|
||||
}, [setCameraAxisIntent]);
|
||||
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
Publish held state once. The adapter owns the single motion heartbeat,
|
||||
so this button no longer creates a second interval whose queued callback
|
||||
could run after pointerup and restart zoom.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
triggerTouchHaptic('button');
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setCameraAxisIntent(direction);
|
||||
},
|
||||
[disabled, setCameraAxisIntent],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
event?.preventDefault?.();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
},
|
||||
[disabled, stopZoom],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
/*
|
||||
A touch surface can unmount during orientation changes or fullscreen
|
||||
close while a pointer is still down. Explicitly clear zoom here because
|
||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
||||
*/
|
||||
setCameraAxisIntent(0);
|
||||
},
|
||||
[setCameraAxisIntent],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onPointerDown={startZoom(-1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom out
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onPointerDown={startZoom(1)}
|
||||
onPointerUp={stopFromPointer}
|
||||
onPointerCancel={stopFromPointer}
|
||||
onLostPointerCapture={stopFromPointer}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
Zoom in
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
||||
return (
|
||||
<div className="mobile-touch-control space-y-0.5">
|
||||
<PtzMobileZoomButtons disabled={disabled} />
|
||||
<div className="mobile-touch-control h-44 min-h-0">
|
||||
{/*
|
||||
Reuse the rover control pad so touch intent still enters the normal
|
||||
control system. The PTZ adapter translates that same drive vector into
|
||||
pan/tilt commands only while this user is the PTZ operator.
|
||||
*/}
|
||||
<ControlPadPanel compact disabled={disabled} />
|
||||
</div>
|
||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const rows = [
|
||||
['Tilt up', 'driveForward'],
|
||||
['Tilt down', 'driveBackward'],
|
||||
['Pan left', 'driveLeft'],
|
||||
['Pan right', 'driveRight'],
|
||||
['Zoom in', 'cameraUp'],
|
||||
['Zoom out', 'cameraDown'],
|
||||
['Spotlight', 'headlightToggle'],
|
||||
['Infrared mode', 'laserToggle'],
|
||||
];
|
||||
|
||||
return (
|
||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
||||
{rows.map(([label, actionId]) => (
|
||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export { PtzLightingControls, PtzMobileZoomButtons, PtzMobileControlsPanel, PtzControlReference };
|
||||
@@ -0,0 +1,161 @@
|
||||
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 { PtzPresetPanel } from './PtzPresetPanel.jsx';
|
||||
import { PtzMediaPane } from './PtzMediaPane.jsx';
|
||||
function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(7rem,0.22fr)] gap-0.5 overflow-hidden p-0.5">
|
||||
<div className="flex min-h-0 min-w-0 gap-0.5 overflow-hidden">
|
||||
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
||||
<PtzMediaPane ptz={ptz} open framed />
|
||||
</main>
|
||||
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
|
||||
while the shared PTZ page theme can show through the same compact gaps as the driver
|
||||
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}`} />
|
||||
{/*
|
||||
Desktop keeps room lights as the final sidebar tool so camera
|
||||
turn controls and replay remain above the less-frequent room-wide
|
||||
actions. HomeAssistantControls owns its own feature and policy gate.
|
||||
*/}
|
||||
<HomeAssistantControls />
|
||||
</aside>
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
||||
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
{releasePending ? (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
|
||||
Closing...
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
{/*
|
||||
Landscape intentionally retains one control column beside the video.
|
||||
This is the established PTZ interaction and avoids forcing rover-style
|
||||
left/right columns onto a camera that has a smaller control inventory.
|
||||
*/}
|
||||
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
|
||||
{/*
|
||||
The video keeps one viewport of height, but the grid row is allowed to
|
||||
grow when the control column is taller. That makes the sidebar's tail
|
||||
extend below the video instead of forcing it into a nested scroller.
|
||||
*/}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 z-110 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open compact framed={false} />
|
||||
</main>
|
||||
{/*
|
||||
The right control column is naturally taller than the viewport.
|
||||
Placing room lights after the fixed-height video uses that left-
|
||||
column space while the whole landscape page continues scrolling as
|
||||
one surface.
|
||||
*/}
|
||||
<HomeAssistantControls />
|
||||
</div>
|
||||
{/*
|
||||
Do not put overflow scrolling on this column. The surrounding PTZ
|
||||
landscape content is the single page scroller, so a swipe over either
|
||||
the video area or these controls advances the same document flow.
|
||||
*/}
|
||||
<aside className="min-h-0 space-y-0.5">
|
||||
{/*
|
||||
Landscape keeps all turn-critical controls in its one existing
|
||||
sidebar. Queue position belongs first so the operator can confirm
|
||||
control ownership before touching the camera, while replay follows
|
||||
the lighting buttons because it is the next secondary action in
|
||||
the same scroll column.
|
||||
*/}
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-landscape"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1 z-110 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open compact framed={false} />
|
||||
</main>
|
||||
{/*
|
||||
Portrait gives the video its full available width and places controls
|
||||
below it. Reusing the landscape sidebar width here was the source of the
|
||||
cramped portrait presentation, while the controls themselves remain the
|
||||
same shared PTZ controls used in landscape.
|
||||
*/}
|
||||
<section className="mobile-touch-control">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.permissions?.canControl} />
|
||||
</section>
|
||||
<section className="space-y-0.5">
|
||||
{/*
|
||||
Replay and presets are compact secondary actions, so portrait places
|
||||
them in one equal-width row before the full-width queue and chat. The
|
||||
explicit two-column grid keeps this arrangement local to portrait and
|
||||
leaves the desktop and one-column landscape compositions unchanged.
|
||||
*/}
|
||||
<div className="grid grid-cols-2 items-start gap-0.5">
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-portrait"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
{/* Portrait keeps room lights immediately after chat as requested. */}
|
||||
<HomeAssistantControls />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PtzDesktopFullscreen, PtzMobileLandscape, PtzMobilePortrait };
|
||||
@@ -0,0 +1,54 @@
|
||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.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' }) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-black ${className}`}>
|
||||
{feed?.objectUrl ? (
|
||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
||||
)}
|
||||
{/*
|
||||
Snapshot mode should look like the regular rover video player: the
|
||||
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="flex flex-col gap-0.5 leading-none">
|
||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 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" />
|
||||
) : (
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
)}
|
||||
{ptz?.turn ? <TopLeftPod compact={compact} turns={ptz.turn} /> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!framed) {
|
||||
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
|
||||
<div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
|
||||
{media}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { PtzSnapshotPreview, PtzMediaPane };
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useState } from 'react';
|
||||
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||
import CardFrame from '../../components/CardFrame/index.jsx';
|
||||
function PtzPresetPanel({ ptz }) {
|
||||
const {
|
||||
ptzListPresets,
|
||||
ptzGotoPreset,
|
||||
ptzCreatePreset,
|
||||
ptzRemovePreset,
|
||||
} = useSessionActions();
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState('');
|
||||
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
|
||||
const isPresetAdmin = Boolean(ptz?.permissions?.canRemovePreset);
|
||||
const canCreatePreset = Boolean(ptz?.permissions?.canCreatePreset);
|
||||
const canMoveToPreset = Boolean(ptz?.permissions?.canControl);
|
||||
|
||||
const refreshPresets = async () => {
|
||||
if (busy) return;
|
||||
setBusy('refresh');
|
||||
try {
|
||||
/*
|
||||
Presets live on the camera, not in browser state. A manual refresh gives
|
||||
admins a simple recovery path if another admin or the camera's native
|
||||
app changes preset storage while this UI is already open.
|
||||
*/
|
||||
await ptzListPresets();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to refresh PTZ presets.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPreset = async (preset) => {
|
||||
if (!canMoveToPreset || busy || !preset?.token) return;
|
||||
setBusy(`goto:${preset.token}`);
|
||||
try {
|
||||
/*
|
||||
Moving to a preset is a physical camera move, so the server still checks
|
||||
that this browser owns the active PTZ turn before accepting the command.
|
||||
*/
|
||||
await ptzGotoPreset({ token: preset.token });
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to move to PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const createPreset = async (event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (!canCreatePreset || busy || !trimmed) return;
|
||||
setBusy('create');
|
||||
try {
|
||||
/*
|
||||
ONVIF setPreset stores the camera's current physical position. The UI
|
||||
only sends the user's label; the server supplies the active profile
|
||||
token so browser code does not need camera profile internals, and the
|
||||
server still enforces the PTZ feature gate for raw socket callers.
|
||||
*/
|
||||
await ptzCreatePreset({ name: trimmed });
|
||||
setName('');
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to create PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const removePreset = async (preset) => {
|
||||
if (!isPresetAdmin || busy || !preset?.token) return;
|
||||
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
|
||||
if (!confirmed) return;
|
||||
setBusy(`remove:${preset.token}`);
|
||||
try {
|
||||
/*
|
||||
The token is the camera's durable preset identifier. Names are only UI
|
||||
labels and may not be unique, so deletion always targets the token.
|
||||
*/
|
||||
await ptzRemovePreset({ token: preset.token });
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to remove PTZ preset.');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title="Position presets"
|
||||
fillHeight
|
||||
actions={(
|
||||
<button type="button" className="button-dark text-xs" disabled={!ptz?.permissions?.canListPresets || Boolean(busy)} onClick={refreshPresets}>
|
||||
Refresh
|
||||
</button>
|
||||
)}
|
||||
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
|
||||
>
|
||||
{ptz?.presetsError ? (
|
||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
||||
{ptz.presetsError}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
|
||||
{/*
|
||||
Presets should behave like a compact pile of actions, not a table.
|
||||
flex-wrap lets each preset keep its natural button width and only
|
||||
starts a new visual line when the current line runs out of room.
|
||||
*/}
|
||||
{presets.length ? presets.map((preset) => {
|
||||
const gotoBusy = busy === `goto:${preset.token}`;
|
||||
const removeBusy = busy === `remove:${preset.token}`;
|
||||
return (
|
||||
<div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
|
||||
disabled={!canMoveToPreset || Boolean(busy)}
|
||||
onClick={() => goToPreset(preset)}
|
||||
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
|
||||
>
|
||||
{gotoBusy ? 'Moving...' : preset.name}
|
||||
</button>
|
||||
{isPresetAdmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark text-xs text-rose-200 disabled:opacity-50"
|
||||
disabled={Boolean(busy)}
|
||||
onClick={() => removePreset(preset)}
|
||||
>
|
||||
{removeBusy ? 'Removing...' : 'Remove'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}) : (
|
||||
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
||||
No presets saved.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canCreatePreset ? (
|
||||
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
|
||||
<input
|
||||
className="field-input min-w-0 text-xs"
|
||||
value={name}
|
||||
maxLength={60}
|
||||
disabled={Boolean(busy)}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Preset name"
|
||||
/>
|
||||
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
|
||||
{busy === 'create' ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export { PtzPresetPanel };
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
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';
|
||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
const { ptzRequestTurn, ptzRelease, pushAlert } = useSessionActions();
|
||||
const [pending, setPending] = useState(false);
|
||||
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
||||
const changeTurn = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
if (participating) await ptzRelease();
|
||||
else await ptzRequestTurn();
|
||||
} catch (err) {
|
||||
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
|
||||
return (
|
||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<QueueUserChips
|
||||
targetId={ptz?.id || PTZ_CAMERA_ID}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
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'}
|
||||
</button>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export { PtzQueueSummary };
|
||||
@@ -0,0 +1,42 @@
|
||||
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 };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export default function usePtzSession() {
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId);
|
||||
const canEnter = useSessionSelector((state) => Boolean(state.session?.ptzCamera?.permissions?.canEnter));
|
||||
const { setOperatingMode, ptzRequestTurn } = useSessionActions();
|
||||
const [entryError, setEntryError] = useState(null);
|
||||
const enter = useCallback(async () => {
|
||||
await setOperatingMode('ptz');
|
||||
await ptzRequestTurn();
|
||||
}, [setOperatingMode, ptzRequestTurn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !socketId || !canEnter) return;
|
||||
let active = true;
|
||||
// Entry happens on route mount/reconnect, not on queue changes. Releasing a
|
||||
// turn therefore leaves the page idle instead of silently requesting again.
|
||||
// Both server actions are serialized and idempotent under Strict Mode.
|
||||
(async () => {
|
||||
await setOperatingMode('ptz');
|
||||
if (active) await ptzRequestTurn();
|
||||
})().then(() => {
|
||||
if (active) setEntryError(null);
|
||||
}).catch((err) => {
|
||||
if (active) setEntryError(err.message);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [connected, socketId, canEnter, setOperatingMode, ptzRequestTurn]);
|
||||
|
||||
const retryEntry = useCallback(() => {
|
||||
enter().then(() => setEntryError(null)).catch((err) => setEntryError(err.message));
|
||||
}, [enter]);
|
||||
return { entryError, retryEntry };
|
||||
}
|
||||
Reference in New Issue
Block a user