// 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 the driver-page PTZ entry card and the dedicated PTZ route
// composition; PTZ command authority, queue ownership, and stream authorization
// remain server-owned.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-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 HomeAssistantControls from '../HomeAssistantControls/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 } from '../../controls/index.js';
import ControlHint from '../ControlHint/index.jsx';
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 { useSettingsNamespace } from '../../settings/index.js';
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../../themes/index.js';
import { triggerTouchHaptic } from '../../lib/touchHaptics.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 ? (
) : (
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.
*/}
);
}
function PtzMobileZoomButtons({ disabled = false }) {
const { setCameraAxisIntent } = useControlActions();
const stopZoom = useCallback(() => {
/*
Zero only releases the zoom axis. The PTZ adapter combines it with any
pan/tilt direction still held on the movement pad, so lifting one finger
cannot erase the other finger's intent.
*/
setCameraAxisIntent(0);
}, [setCameraAxisIntent]);
const startZoom = useCallback(
(direction) => (event) => {
/*
Publish held state once. The adapter owns the single motion heartbeat,
so this button no longer creates a second interval whose queued callback
could run after pointerup and restart zoom.
*/
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(
() => () => {
/*
A touch surface can unmount during orientation changes or fullscreen
close while a pointer is still down. Explicitly clear zoom here because
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
*/
setCameraAxisIntent(0);
},
[setCameraAxisIntent],
);
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 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 canCreatePreset = Boolean(ptz?.canUse);
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 (!canCreatePreset || busy || !trimmed) return;
setBusy('create');
try {
/*
ONVIF setPreset stores the camera's current physical position. The UI
only sends the user's label; the server supplies the active profile
token so browser code does not need camera profile internals, and the
server still enforces the PTZ feature gate for raw socket callers.
*/
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 should behave like a compact pile of actions, not a table.
flex-wrap lets each preset keep its natural button width and only
starts a new visual line when the current line runs out of room.
*/}
{presets.length ? presets.map((preset) => {
const gotoBusy = busy === `goto:${preset.token}`;
const removeBusy = busy === `remove:${preset.token}`;
return (
{isPresetAdmin ? (
) : null}
);
}) : (
No presets saved.
)}
{canCreatePreset ? (
) : 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 isParticipant = Boolean(isOperator || ptz?.queuedPosition);
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
/*
PTZ has its own turn queue, so non-operators are the camera equivalent of a
non-active rover driver. The server enforces the same policy in
canRequestLiveVideo(); this branch only chooses the expected browser render
path and never unlocks movement controls.
*/
/*
A direct /ptz load renders before its automatic queue claim is acknowledged.
Do not mount the live player during that short pre-claim window: its first
token request would correctly be rejected, and PtzLiveVideo intentionally
treats authorization rejection as a terminal snapshot fallback. Once the
session confirms queue/operator membership, mounting the player creates a
fresh authorized request without changing shared retry or server policy.
*/
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
const media = (
<>
{shouldUseLiveVideo ? (
) : (
)}
>
);
if (!framed) {
return
{media}
;
}
return (
{media}
);
}
function PtzDesktopFullscreen({ ptz, releasePending }) {
return (
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
while the shared PTZ page theme can show through the same compact gaps as the driver
layout instead of being covered by one solid sidebar rectangle. */}
{/*
Landscape intentionally retains one control column beside the video.
This is the established PTZ interaction and avoids forcing rover-style
left/right columns onto a camera that has a smaller control inventory.
*/}
{/*
The video keeps one viewport of height, but the grid row is allowed to
grow when the control column is taller. That makes the sidebar's tail
extend below the video instead of forcing it into a nested scroller.
*/}
{/*
The right control column is naturally taller than the viewport.
Placing room lights after the fixed-height video uses that left-
column space while the whole landscape page continues scrolling as
one surface.
*/}
{/*
Do not put overflow scrolling on this column. The surrounding PTZ
landscape content is the single page scroller, so a swipe over either
the video area or these controls advances the same document flow.
*/}
{/*
Portrait gives the video its full available width and places controls
below it. Reusing the landscape sidebar width here was the source of the
cramped portrait presentation, while the controls themselves remain the
same shared PTZ controls used in landscape.
*/}
{/*
Replay and presets are compact secondary actions, so portrait places
them in one equal-width row before the full-width queue and chat. The
explicit two-column grid keeps this arrangement local to portrait and
leaves the desktop and one-column landscape compositions unchanged.
*/}
{/* Portrait keeps room lights immediately after chat as requested. */}
);
}
export function PtzControllerPage({ layout = 'desktop' }) {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const { ptzClaim, setOperatingMode, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false);
const autoClaimSocketRef = useRef(null);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
// Wait for startup to publish readiness before consuming the one entry attempt.
if (!ptz.initialized && !ptz.error) return undefined;
if (ptz.isOperator || ptz.queuedPosition) {
/*
Navigation from the driver queue normally arrives with membership
already established. Mark this socket complete so later session syncs
cannot turn that normal route transition into another claim request.
*/
autoClaimSocketRef.current = socketId;
return undefined;
}
if (autoClaimSocketRef.current === socketId) return undefined;
autoClaimSocketRef.current = socketId;
let active = true;
// Handshake mode prevents rover assignment on direct loads/reconnects;
// this request checks eligibility and joins the camera's turn queue.
ptzClaim().catch((err) => {
if (!active) return;
pushAlert({
id: `ptz-auto-claim-${socketId}`,
title: 'PTZ camera',
message: err?.message || 'Unable to join the PTZ queue.',
color: '#f59e0b',
lifetimeMs: 6000,
});
});
return () => {
// Do not emit or update UI from a rejected request after this route has
// unmounted; the server still owns completion of any request in flight.
active = false;
};
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
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 setOperatingMode('rover');
navigate('/');
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setReleasePending(false);
}
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
if (!featureEnabled) {
return (
The PTZ camera is not available.
);
}
return (
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
transparent body is what lets every desktop and mobile PTZ composition share one
continuous pattern without threading theme props into each individual child panel. */}
Close
)}
hideHeader={isMobile}
fillHeight
clipOverflow={false}
className="h-dvh w-screen rounded-none border-0 bg-black!"
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
>
{isMobile ? (
layout === 'mobile-landscape' ? (
) : (
)
) : (
)}
);
}
export default function PtzQueueCard() {
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, setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
const now = useSharedClock(1000, Boolean(ptz?.deadline));
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) {
navigate('/ptz');
return;
}
setPending(true);
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) {
navigate('/ptz');
}
} catch (err) {
alert(err.message || 'PTZ request failed.');
} finally {
setPending(false);
}
};
const handleLeave = async () => {
if (pending) return;
setPending(true);
try {
await setOperatingMode('rover');
} catch (err) {
alert(err.message || 'Failed to leave PTZ camera.');
} finally {
setPending(false);
}
};
const actionLabel = pending
? '...'
: ptz?.isOperator
? 'Open'
: ptz?.queuedPosition
? 'Open'
: 'request';
return (