// 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 ? (

) : (
{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}
);
}