// PTZ Spectator Card // Purpose: Adds the single room PTZ camera to the spectator rover grid. // Scope: Uses live WHEP only when the server authorizes this socket; otherwise // falls back to the PTZ snapshot feed that remote spectators are allowed to see. import PtzLiveVideo from '../../../components/PtzLiveVideo/index.jsx'; import { useSessionSelector } from '../../../context/SessionContext.jsx'; import { PTZ_CAMERA_ID, usePtzCameraSnapshots } from '../../../hooks/usePtzCameraSnapshot.js'; 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 normalizeInfraredMode(mode) { const normalized = String(mode || '').trim().toLowerCase(); if (normalized === 'on') return 'On'; if (normalized === 'off') return 'Off'; return 'Auto'; } function InfoRow({ label, value, tone = '' }) { return (
{label} {value}
); } function formatPublisherProgress(progress = null) { /* Keep the spectator card dense, but expose enough ffmpeg progress to tell if the PTZ transcoder is running behind when the live feed looks delayed. */ 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, ].filter(Boolean).join(' | '); } function PtzSnapshotFallback({ label, source }) { const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: true }); const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null; return (
{snapshot?.objectUrl ? ( {label} ) : (
{snapshot?.error || source?.error || 'Waiting for PTZ snapshot...'}
)} {/* Keep the PTZ snapshot fallback visually aligned with RoverMediaPlayer: the video surface owns only playback health, while the card around it owns identity/context. That prevents a second in-frame camera title and keeps fallback mode from looking different than live WHEP mode. */}
Status: {snapshot?.status || 'snapshot'}
); } function PtzLiveOrSnapshot({ label }) { const isExternalSpectatorSnapshotOnly = useSessionSelector((state) => state.session?.role === 'spectator' && state.session?.isLocalNetwork === false && state.session?.bandwidthSavings?.externalSpectatorVideo === 'snapshots', ); /* PTZ live authorization is server-owned, but the spectator page knows when the configured outcome is snapshot-only. Rendering the fallback directly avoids a guaranteed denied WHEP request for every external spectator card. */ if (isExternalSpectatorSnapshotOnly) { return ; } return ( } /> ); } export default function PtzSpectatorCard() { const ptz = useSessionSelector((state) => state.session?.ptzCamera || null); if (!ptz?.enabled) return null; const publisher = ptz?.publisher || {}; const publisherStatus = publisher.running ? 'running' : publisher.restartAt ? 'restarting' : publisher.lastEvent || 'stopped'; const publisherProgress = formatPublisherProgress(publisher.progress); const queueCount = Array.isArray(ptz?.queue) ? ptz.queue.length : 0; const label = ptz?.name || 'PTZ Camera'; return (
{publisherProgress ? : null} {publisher.lastStderr ? (
{publisher.lastStderr}
) : null}
); }