// 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 PTZ entry card and fullscreen composition; PTZ command authority, // queue ownership, and stream authorization remain server-owned. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-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 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, useControlSelector } from '../../controls/index.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js'; 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 { trackAnalyticsEvent } from '../../analytics/index.js'; const PTZ_DEFAULT_COLOR = '#38bdf8'; function formatRemaining(deadline, now) { const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - 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 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 (
{feed?.objectUrl ? ( {label} ) : (
Waiting for snapshot...
)} {/* 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. */}
Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}
); } function StatusRow({ label, value, tone = '' }) { return (
{label} {value}
); } function PtzStatePanel({ ptz, compact = false }) { const now = useSharedClock(1000, Boolean(ptz?.deadline)); const spotlightOn = isSpotlightOn(ptz?.light); const irMode = normalizeIrMode(ptz?.ir?.state); const publisher = ptz?.publisher || {}; const publisherStatus = publisher.running ? 'running' : publisher.restartAt ? 'restarting' : publisher.lastEvent || 'stopped'; const mode = ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator'; return ( {!compact ? : null} {ptz?.blocked?.message ? (
{ptz.blocked.message}
) : null}
); } function PtzQueueSummary({ ptz, title = 'PTZ queue' }) { const selfId = useSessionSelector((state) => state.session?.socketId || null); const lookupUser = usePtzQueueLookup(ptz); const { queue, currentId, nextId } = normalizePtzQueue(ptz); return ( ); } 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; setBusy('ir'); try { await ptzIr({ state: nextIrMode(irMode) }); } finally { setBusy(''); } }; return (
); } function PtzMobileZoomButtons({ disabled = false }) { const { nudgeServo, stopAllMotion } = useControlActions(); const repeatTimerRef = useRef(null); const stopZoom = useCallback(() => { /* Mobile zoom is intentionally routed through the normal camera-up/down control action instead of emitting PTZ socket commands directly. That keeps the zoom buttons on the same path as keyboard/gamepad camera tilt, and the PTZ adapter remains the one place that translates "camera nudge" into Reolink zoom pulses. */ if (repeatTimerRef.current) { clearInterval(repeatTimerRef.current); repeatTimerRef.current = null; } stopAllMotion(); }, [stopAllMotion]); const startZoom = useCallback( (direction) => (event) => { /* Send an immediate nudge and then repeat while held. The adapter turns each nudge into a short zoom pulse, so repeating the standard action is the simplest way to get continuous hold-to-zoom without adding another PTZ-specific command loop. */ event.preventDefault(); if (disabled) return; stopZoom(); nudgeServo(direction); repeatTimerRef.current = setInterval(() => { nudgeServo(direction); }, 120); }, [disabled, nudgeServo, stopZoom], ); 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. Clear the repeat timer here so a held zoom button cannot keep firing camera-up/down actions after the mobile controls have disappeared. */ if (repeatTimerRef.current) { clearInterval(repeatTimerRef.current); repeatTimerRef.current = null; } }, [], ); return (
); } function PtzMobileControlsPanel({ ptz, disabled = false }) { return (
{/* 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. */}
); } function keyLabelFor(keymap, actionId) { return formatKeyLabel(keymap?.[actionId]?.[0]); } function PtzControlReference() { const keymap = useControlSelector((control) => control.state.keymap); 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 ( {rows.map(([label, actionId]) => (
{label}
))}
); } 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 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 (!isPresetAdmin || busy || !trimmed) return; setBusy('create'); try { /* ONVIF setPreset stores the camera's current physical position. The UI only sends the admin's label; the server supplies the active profile token so browser code does not need to know camera profile internals. */ 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 ( Refresh )} bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs" > {ptz?.presetsError ? (
{ptz.presetsError}
) : null}
{presets.length ? presets.map((preset) => { const gotoBusy = busy === `goto:${preset.token}`; const removeBusy = busy === `remove:${preset.token}`; return (
{isPresetAdmin ? ( ) : null}
); }) : (
No presets saved.
)}
{isPresetAdmin ? (
setName(event.target.value)} placeholder="Preset name" />
) : null}
); } 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 selfId = useSessionSelector((state) => state.session?.socketId || null); const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator }); const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null; const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]); const media = ( <> {isOperator ? ( ) : ( )} ); if (!framed) { return
{media}
; } return (
{media}
); } function PtzDesktopFullscreen({ ptz, releasePending }) { return (
{releasePending ? (
Closing...
) : null}
); } function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) { const landscape = layout === 'mobile-landscape'; const topHeightClass = landscape ? 'h-full min-h-[calc(100dvh-0.25rem)]' : 'h-[48dvh]'; const topGridClass = landscape ? 'grid-cols-[minmax(0,1fr)_13rem]' : 'grid-cols-[minmax(0,1fr)_11rem]'; return (
); } export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) { const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); const { ptzRelease } = useSessionActions(); const { stopAllMotion } = useControlActions(); const [releasePending, setReleasePending] = useState(false); const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape'; 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 ptzRelease(); onClose?.(); } finally { setReleasePending(false); } }, [onClose, ptzRelease, releasePending, stopAllMotion]); if (!open) return null; const controller = ( /* The PTZ controller needs to cover the driver page, but it must not become the top-most application layer. Global fullscreen overlays like help, quickstart, mode gates, and connection warnings are still part of the active app state while PTZ is open, so this portal intentionally sits below their z-30+ overlay stack instead of hiding them. */
Close )} hideHeader={isMobile} fillHeight clipOverflow={false} className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black" bodyClassName="relative min-h-0 flex-1" > {isMobile ? ( ) : ( )}
); return createPortal(controller, document.body); } export default function PtzQueueCard({ 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 role = useSessionSelector((state) => state.session?.role || null); const selfId = useSessionSelector((state) => state.session?.socketId || null); const { ptzClaim, ptzRelease } = useSessionActions(); const lookupUser = usePtzQueueLookup(ptz); const { queue, currentId, nextId } = normalizePtzQueue(ptz); const now = useSharedClock(1000, Boolean(ptz?.deadline)); const [controllerOpen, setControllerOpen] = useState(false); const [pending, setPending] = useState(false); const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown'); const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition); const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : ''; if (!featureEnabled) return null; const handleRequest = async () => { if (!canUse || pending) return; if (isParticipant) { setControllerOpen(true); return; } setPending(true); trackAnalyticsEvent('ptz_queue_join', { layout }); 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) { setControllerOpen(true); } trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' }); } catch (err) { trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'failed', reason: err?.message || 'unknown', }); alert(err.message || 'PTZ request failed.'); } finally { setPending(false); } }; const handleLeave = async () => { if (pending) return; setPending(true); try { await ptzRelease(); } catch (err) { alert(err.message || 'Failed to leave PTZ camera.'); } finally { setPending(false); } }; const actionLabel = pending ? '...' : ptz?.isOperator ? 'Open' : ptz?.queuedPosition ? 'Open' : 'request'; return ( <>
{isParticipant ? ( ) : null} {!canUse ? (
Verify your account to use the PTZ camera.
) : null}
setControllerOpen(false)} layout={layout} /> ); }