// 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...
)}
{label}
{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 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 (
Presets will live here.
{releasePending ? (
Closing...
) : null}
); } function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) { const landscape = layout === 'mobile-landscape'; const topHeightClass = landscape ? 'h-[72dvh]' : '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 = (
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} /> ); }