mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
601 lines
23 KiB
React
601 lines
23 KiB
React
// 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 (
|
|
<div className="relative w-full overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
|
{feed?.objectUrl ? (
|
|
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
|
)}
|
|
{/*
|
|
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.
|
|
*/}
|
|
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
|
<div className="flex flex-col gap-0.5 leading-none">
|
|
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusRow({ label, value, tone = '' }) {
|
|
return (
|
|
<div className="flex items-center justify-between gap-1 text-xs">
|
|
<span className="text-slate-400">{label}</span>
|
|
<span className={`min-w-0 truncate font-medium ${tone || 'text-slate-100'}`}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-0.5">
|
|
<div className="panel-muted text-xs">Turn queue</div>
|
|
<div className="space-y-0.5">
|
|
{operatorLabel ? (
|
|
<div className="surface flex items-center justify-between gap-1 text-xs">
|
|
<span className="text-slate-400">Now</span>
|
|
<span className="min-w-0 truncate text-emerald-200">{operatorLabel}</span>
|
|
</div>
|
|
) : null}
|
|
{hasQueue ? queue.map((entry, index) => (
|
|
<div key={entry.socketId || `${entry.label}-${index}`} className="surface flex items-center justify-between gap-1 text-xs">
|
|
<span className="text-slate-400">{index + 1}</span>
|
|
<span className="min-w-0 truncate text-slate-100">{entry.label || entry.socketId || 'queued user'}</span>
|
|
</div>
|
|
)) : (
|
|
<div className="surface text-xs text-slate-400">No one waiting</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<CardFrame
|
|
title="Camera state"
|
|
actions={onClose ? <button type="button" className="button-dark text-xs" onClick={onClose}>Close</button> : null}
|
|
bodyClassName="space-y-0.5 p-1 text-sm"
|
|
>
|
|
<StatusRow label="Mode" value={ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator'} tone={statusTone} />
|
|
<StatusRow label="Operator" value={ptz?.operatorLabel || 'none'} />
|
|
<StatusRow label="Remaining" value={formatRemaining(ptz?.deadline)} />
|
|
<StatusRow label="Spotlight" value={spotlightOn ? 'On' : 'Off'} tone={spotlightOn ? 'text-emerald-300' : 'text-slate-200'} />
|
|
<StatusRow label="Infrared mode" value={irMode} />
|
|
<StatusRow label="Stream" value={ptz?.status || ptz?.error || 'idle'} tone={ptz?.error ? 'text-amber-300' : ''} />
|
|
<StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
|
|
{publisherProgress ? <StatusRow label="Progress" value={publisherProgress} /> : null}
|
|
{publisher.lastStderr ? (
|
|
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap break-words font-mono text-[0.68rem] leading-tight text-slate-200">
|
|
{publisher.lastStderr}
|
|
</div>
|
|
) : null}
|
|
{ptz?.blocked?.message ? (
|
|
<div className="rounded border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
|
{ptz.blocked.message}
|
|
</div>
|
|
) : null}
|
|
{onRelease ? (
|
|
<button type="button" className="button-dark w-full text-xs" disabled={releaseDisabled} onClick={onRelease}>
|
|
Release camera
|
|
</button>
|
|
) : null}
|
|
</CardFrame>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
<GPIOToggleControl
|
|
label="Spotlight"
|
|
on={spotlightOn}
|
|
disabled={disabled || busy === 'spotlight'}
|
|
onToggle={toggleSpotlight}
|
|
heightClass="min-h-14"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
|
|
disabled={disabled || busy === 'ir'}
|
|
onClick={cycleIr}
|
|
>
|
|
<span className="text-sm font-semibold">Infrared</span>
|
|
<span className="rounded bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
<button
|
|
type="button"
|
|
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
disabled={disabled}
|
|
onPointerDown={startZoom(-1)}
|
|
onPointerUp={stopFromPointer}
|
|
onPointerCancel={stopFromPointer}
|
|
onLostPointerCapture={stopFromPointer}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
>
|
|
Zoom out
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
disabled={disabled}
|
|
onPointerDown={startZoom(1)}
|
|
onPointerUp={stopFromPointer}
|
|
onPointerCancel={stopFromPointer}
|
|
onLostPointerCapture={stopFromPointer}
|
|
onContextMenu={(event) => event.preventDefault()}
|
|
>
|
|
Zoom in
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
|
return (
|
|
<div className="mobile-touch-control space-y-0.5">
|
|
<PtzMobileZoomButtons disabled={disabled} />
|
|
<div className="mobile-touch-control h-44 min-h-0">
|
|
{/*
|
|
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.
|
|
*/}
|
|
<ControlPadPanel compact disabled={disabled} />
|
|
</div>
|
|
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
|
{rows.map(([label, actionId]) => (
|
|
<div key={label} className="surface flex items-center justify-between gap-1">
|
|
<span className="text-slate-400">{label}</span>
|
|
{/* Use the same key display component as the rest of the UI so PTZ
|
|
controls read as normal mapped controls instead of custom labels. */}
|
|
<KeyPill label={keyLabelFor(keymap, actionId)} />
|
|
</div>
|
|
))}
|
|
</CardFrame>
|
|
);
|
|
}
|
|
|
|
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 = (
|
|
<>
|
|
|
|
<div className="shrink-0">
|
|
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
|
</div>
|
|
{isOperator ? (
|
|
<div className="shrink-0">
|
|
<PtzLightingControls ptz={ptz} />
|
|
</div>
|
|
) : (
|
|
<div className="shrink-0">
|
|
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
|
Live PTZ controls unlock when your camera turn is active.
|
|
</CardFrame>
|
|
</div>
|
|
)}
|
|
<div className="shrink-0">
|
|
<PtzControlReference />
|
|
</div>
|
|
<div className="min-h-[12rem] flex-1">
|
|
{/*
|
|
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.
|
|
*/}
|
|
<ChatPanel fillHeight title="Chat" />
|
|
</div>
|
|
<div className="shrink-0">
|
|
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
|
</div>
|
|
<div className="shrink-0">
|
|
<PtzStatePanel
|
|
ptz={ptz}
|
|
onClose={onClose}
|
|
onRelease={isOperator ? releaseAndClose : null}
|
|
releaseDisabled={releasePending}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const mobileSidebar = (
|
|
<>
|
|
<div className="shrink-0">
|
|
<PtzMobileControlsPanel ptz={ptz} disabled={!isOperator} />
|
|
</div>
|
|
<div className="min-h-[10rem] flex-1">
|
|
{/*
|
|
Mobile uses the same ChatPanel registration as desktop so the mapped
|
|
chat key and the on-screen input stay on one shared chat implementation.
|
|
*/}
|
|
<ChatPanel fillHeight title="Chat" />
|
|
</div>
|
|
<div className="shrink-0">
|
|
<ReplaySourcesPanel
|
|
panelId="ptz-controller-replay-mobile"
|
|
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0">
|
|
<PtzStatePanel
|
|
ptz={ptz}
|
|
onClose={onClose}
|
|
onRelease={isOperator ? releaseAndClose : null}
|
|
releaseDisabled={releasePending}
|
|
/>
|
|
</div>
|
|
<div className="shrink-0">
|
|
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const sidebarWidthClass = isMobile
|
|
? 'grid-cols-[minmax(0,1fr)_14rem]'
|
|
: 'grid-cols-[minmax(0,1fr)_20rem]';
|
|
|
|
const controller = (
|
|
<div className="fixed inset-0 z-[110] h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
|
|
<CardFrame
|
|
hideHeader
|
|
fillHeight
|
|
clipOverflow={false}
|
|
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
|
|
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
|
|
>
|
|
<main className="relative min-h-0 min-w-0 bg-black">
|
|
{shouldUseLiveVideo ? (
|
|
<PtzLiveVideo enabled startMuted={false} />
|
|
) : (
|
|
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
|
)}
|
|
</main>
|
|
<aside className="flex h-full min-h-0 items-stretch overflow-hidden border-l border-neutral-600 bg-neutral-950 text-sm">
|
|
<div className="flex min-h-0 w-full flex-col gap-0.5 overflow-y-auto p-0.5">
|
|
{isMobile ? mobileSidebar : desktopSidebar}
|
|
</div>
|
|
</aside>
|
|
</CardFrame>
|
|
</div>
|
|
);
|
|
|
|
/*
|
|
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 (
|
|
<>
|
|
<CardFrame title="PTZ camera" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
|
<div className="grid w-full gap-0.5 md:grid-cols-[minmax(16rem,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
|
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
|
<div className="flex w-full min-w-0 flex-col gap-0.5">
|
|
<div className="grid w-full grid-cols-2 gap-0.5 text-left text-xs">
|
|
<div className="surface-muted p-1">
|
|
<p className="text-slate-500">State</p>
|
|
<p className="truncate text-slate-100">{queueText}</p>
|
|
</div>
|
|
<div className="surface-muted p-1">
|
|
<p className="text-slate-500">Remaining</p>
|
|
<p className="text-slate-100">{formatRemaining(ptz?.deadline)}</p>
|
|
</div>
|
|
<div className="surface-muted p-1">
|
|
<p className="text-slate-500">Spotlight</p>
|
|
<p className="truncate text-slate-100">{isSpotlightOn(ptz?.light) ? 'On' : 'Off'}</p>
|
|
</div>
|
|
<div className="surface-muted p-1">
|
|
<p className="text-slate-500">Infrared</p>
|
|
<p className="truncate text-slate-100">{normalizeIrMode(ptz?.ir?.state)}</p>
|
|
</div>
|
|
</div>
|
|
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
|
{ptz?.blocked?.message ? (
|
|
<p className="w-full rounded border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
|
{ptz.blocked.message}
|
|
</p>
|
|
) : null}
|
|
<div className="grid w-full grid-cols-2 gap-0.5">
|
|
{ptz?.isOperator ? (
|
|
<>
|
|
<button type="button" className="button-dark text-xs" onClick={() => setControllerOpen(true)}>
|
|
Open controller
|
|
</button>
|
|
<button type="button" className="button-dark text-xs" disabled={pending} onClick={handleRelease}>
|
|
Release
|
|
</button>
|
|
</>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="button-dark col-span-2 text-xs"
|
|
disabled={pending || !isVerified}
|
|
onClick={ptz?.queuedPosition ? handleRelease : handleClaim}
|
|
>
|
|
{ptz?.queuedPosition ? 'Leave queue' : pending ? 'Requesting...' : 'Claim camera'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardFrame>
|
|
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
|
|
</>
|
|
);
|
|
}
|