// 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 { 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, useControlSelector } from '../../controls/index.js'; import { formatKeyLabel } from '../../controls/keymapUtils.js'; 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 (
{feed?.objectUrl ? ( {label} ) : (
Waiting for snapshot...
)} {/* 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. */}
Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}
); } function StatusRow({ label, value, tone = '' }) { return (
{label} {value}
); } 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 (
Turn queue
{operatorLabel ? (
Now {operatorLabel}
) : null} {hasQueue ? queue.map((entry, index) => (
{index + 1} {entry.label || entry.socketId || 'queued user'}
)) : (
No one waiting
)}
); } 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 ( Close : null} bodyClassName="space-y-0.5 p-1 text-sm" > {publisherProgress ? : null} {publisher.lastStderr ? (
{publisher.lastStderr}
) : null} {ptz?.blocked?.message ? (
{ptz.blocked.message}
) : null} {onRelease ? ( ) : null}
); } 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 (
); } 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 (
); } function PtzMobileControlsPanel({ ptz, disabled = false }) { return (
{/* 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. */}
); } 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} {/* Use the same key display component as the rest of the UI so PTZ controls read as normal mapped controls instead of custom labels. */}
))}
); } 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 { ptzRelease } = 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 ptzRelease(); onClose(); } finally { setReleasePending(false); } }; const desktopSidebar = ( <>
{isOperator ? (
) : (
Live PTZ controls unlock when your camera turn is active.
)}
{/* 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. */}
); const mobileSidebar = ( <>
{/* Mobile uses the same ChatPanel registration as desktop so the mapped chat key and the on-screen input stay on one shared chat implementation. */}
); const sidebarWidthClass = isMobile ? 'grid-cols-[minmax(0,1fr)_14rem]' : 'grid-cols-[minmax(0,1fr)_20rem]'; const controller = (
{shouldUseLiveVideo ? ( ) : ( )}
); /* 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, ptzRelease } = 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 ptzRelease(); onMessage?.('Left PTZ camera.'); } catch (err) { onMessage?.(err.message || 'Failed to leave PTZ camera.'); } finally { setPending(false); } }; return ( <>

State

{queueText}

Remaining

{formatRemaining(ptz?.deadline)}

Spotlight

{isSpotlightOn(ptz?.light) ? 'On' : 'Off'}

Infrared

{normalizeIrMode(ptz?.ir?.state)}

{ptz?.blocked?.message ? (

{ptz.blocked.message}

) : null}
{ptz?.isOperator ? ( <> ) : ( )}
setControllerOpen(false)} layout={layout} /> ); }