mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
awaw
This commit is contained in:
@@ -4,7 +4,12 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaArrowDown, FaArrowLeft, FaArrowRight, FaArrowUp, FaSearchMinus, FaSearchPlus, FaStop } from 'react-icons/fa';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import CameraTiltControl from '../CameraTiltControl/index.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
import { usePtzCameraSnapshot } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
||||
@@ -13,6 +18,8 @@ import { innerFlowClass } from './constants.js';
|
||||
|
||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
const PTZ_AUDIO_RETRY_MS = 1000;
|
||||
const PTZ_ZOOM_SLIDER_MIN = -1;
|
||||
const PTZ_ZOOM_SLIDER_MAX = 1;
|
||||
|
||||
function formatRemaining(deadline) {
|
||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||
@@ -22,6 +29,30 @@ function formatRemaining(deadline) {
|
||||
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' }}>
|
||||
@@ -40,6 +71,40 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera' }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 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 ControlButton({ title, children, onHold, className = '' }) {
|
||||
const activeRef = useRef(false);
|
||||
|
||||
@@ -78,6 +143,37 @@ function ControlButton({ title, children, onHold, className = '' }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
|
||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
||||
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' : ''} />
|
||||
{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 PtzLiveVideo({ enabled }) {
|
||||
const videoRef = useRef(null);
|
||||
const playerRef = useRef(null);
|
||||
@@ -187,104 +283,213 @@ function PtzLiveVideo({ enabled }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PtzController({ open, onClose }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const { ptzMove, ptzStop, ptzSpotlight, ptzIr, ptzRelease } = useSessionActions();
|
||||
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
|
||||
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 sendMove = useCallback(
|
||||
(payload) => {
|
||||
ptzMove(payload).catch(() => {});
|
||||
},
|
||||
[ptzMove],
|
||||
);
|
||||
|
||||
const stopMotion = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
|
||||
const holdMove = useCallback(
|
||||
(payload) => (phase) => {
|
||||
if (phase === 'start') sendMove(payload);
|
||||
else stopMotion();
|
||||
},
|
||||
[sendMove, stopMotion],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const toggleSpotlight = async () => {
|
||||
const toggleSpotlight = async (nextOn) => {
|
||||
if (disabled) return;
|
||||
setBusy('spotlight');
|
||||
try {
|
||||
await ptzSpotlight({ state: ptz?.light?.state ? 0 : 1 });
|
||||
await ptzSpotlight({ state: nextOn ? 1 : 0 });
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleIr = async () => {
|
||||
const cycleIr = async () => {
|
||||
if (disabled) return;
|
||||
setBusy('ir');
|
||||
try {
|
||||
await ptzIr({ state: ptz?.ir?.state === 'Off' ? 'Auto' : 'Off' });
|
||||
await ptzIr({ state: nextIrMode(irMode) });
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex bg-black text-slate-100">
|
||||
<main className="relative flex min-w-0 flex-1 items-center justify-center bg-black">
|
||||
{isOperator ? <PtzLiveVideo enabled /> : <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />}
|
||||
</main>
|
||||
<aside className="flex w-72 shrink-0 flex-col gap-1 border-l border-slate-700 bg-neutral-950 p-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-white">{ptz?.name || 'PTZ Camera'}</p>
|
||||
<p className="truncate text-xs text-slate-400">{isOperator ? 'operator' : 'snapshot view'}</p>
|
||||
<CardFrame title="Lights" bodyClassName="grid grid-cols-2 gap-0.5 p-1 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>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzManualControls({ disabled = false }) {
|
||||
const { ptzMove, ptzStop } = useSessionActions();
|
||||
const stopMotion = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
const holdMove = useCallback(
|
||||
(payload) => (phase) => {
|
||||
/*
|
||||
These visible controls are only a fallback/secondary surface. Keyboard,
|
||||
gamepad, and mobile drive input still route through the PTZ control
|
||||
adapter so all normal movement mixing stays in the camera layer.
|
||||
*/
|
||||
if (disabled) return;
|
||||
if (phase === 'start') ptzMove(payload).catch(() => {});
|
||||
else stopMotion();
|
||||
},
|
||||
[disabled, ptzMove, stopMotion],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardFrame title="Manual controls" bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<div className="grid grid-cols-3 gap-0.5">
|
||||
<div />
|
||||
<ControlButton title="Tilt up" onHold={holdMove({ tilt: 0.55 })} className="h-9"><FaArrowUp /></ControlButton>
|
||||
<div />
|
||||
<ControlButton title="Pan left" onHold={holdMove({ pan: -0.55 })} className="h-9"><FaArrowLeft /></ControlButton>
|
||||
<ControlButton title="Stop" onHold={(phase) => phase === 'start' && !disabled && stopMotion()} className="h-9"><FaStop /></ControlButton>
|
||||
<ControlButton title="Pan right" onHold={holdMove({ pan: 0.55 })} className="h-9"><FaArrowRight /></ControlButton>
|
||||
<div />
|
||||
<ControlButton title="Tilt down" onHold={holdMove({ tilt: -0.55 })} className="h-9"><FaArrowDown /></ControlButton>
|
||||
<div />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
<ControlButton title="Zoom in" onHold={holdMove({ zoom: 0.55 })} className="h-9"><FaSearchPlus /></ControlButton>
|
||||
<ControlButton title="Zoom out" onHold={holdMove({ zoom: -0.55 })} className="h-9"><FaSearchMinus /></ControlButton>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzZoomControl({ disabled = false }) {
|
||||
const { ptzMove, ptzStop } = useSessionActions();
|
||||
const sendZoom = useCallback(
|
||||
(value) => {
|
||||
if (disabled) return;
|
||||
const zoom = Math.max(-1, Math.min(1, Number(value) || 0));
|
||||
if (!zoom) {
|
||||
ptzStop().catch(() => {});
|
||||
return;
|
||||
}
|
||||
ptzMove({ pan: 0, tilt: 0, zoom }).catch(() => {});
|
||||
},
|
||||
[disabled, ptzMove, ptzStop],
|
||||
);
|
||||
const stopZoom = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
|
||||
return (
|
||||
<CardFrame title="Zoom" bodyClassName="p-1 text-sm">
|
||||
<CameraTiltControl
|
||||
value={0}
|
||||
min={PTZ_ZOOM_SLIDER_MIN}
|
||||
max={PTZ_ZOOM_SLIDER_MAX}
|
||||
step={1}
|
||||
label="Zoom"
|
||||
disabled={disabled}
|
||||
onChange={sendZoom}
|
||||
onCommit={stopZoom}
|
||||
className="rounded-xl border-2 border-emerald-300/70 bg-emerald-900 px-1 py-1 text-emerald-50"
|
||||
labelRowClass="text-xs text-emerald-100"
|
||||
labelClass="text-sm font-semibold"
|
||||
valueClass="hidden"
|
||||
sliderClass="w-full"
|
||||
accentClass="accent-emerald-400"
|
||||
endpointClass="text-[0.7rem] text-emerald-100/80"
|
||||
endpointLabelClass="w-14"
|
||||
/>
|
||||
<div className="mt-0.5 flex justify-between text-[0.7rem] text-slate-400">
|
||||
<span>out</span>
|
||||
<span>release stops</span>
|
||||
<span>in</span>
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzControlReference() {
|
||||
const keymap = useControlSelector((control) => control.state.keymap);
|
||||
const rows = [
|
||||
['Pan / tilt', 'Drive movement'],
|
||||
['Zoom in', formatKeyLabel(keymap?.cameraUp?.[0]) || 'camera up'],
|
||||
['Zoom out', formatKeyLabel(keymap?.cameraDown?.[0]) || 'camera down'],
|
||||
['Spotlight', formatKeyLabel(keymap?.headlightToggle?.[0]) || 'headlight'],
|
||||
['Infrared mode', formatKeyLabel(keymap?.laserToggle?.[0]) || 'laser'],
|
||||
];
|
||||
|
||||
return (
|
||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
||||
<span className="text-slate-400">{label}</span>
|
||||
<span className="truncate text-slate-100">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzController({ open, onClose }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const { ptzRelease } = useSessionActions();
|
||||
const snapshot = usePtzCameraSnapshot({ enabled: open && !isOperator });
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const releaseAndClose = async () => {
|
||||
setReleasePending(true);
|
||||
try {
|
||||
await ptzRelease();
|
||||
onClose();
|
||||
} finally {
|
||||
setReleasePending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] bg-black text-slate-100">
|
||||
<CardFrame hideHeader fillHeight clipOverflow={false} className="h-screen w-screen border-0" bodyClassName="grid h-full min-h-0 grid-cols-[minmax(0,1fr)_8.75rem] md:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
<main className="relative min-h-0 min-w-0 bg-black">
|
||||
{isOperator ? <PtzLiveVideo enabled /> : <PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />}
|
||||
</main>
|
||||
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto border-l border-neutral-600 bg-neutral-950 p-0.5 text-sm">
|
||||
<PtzStatePanel
|
||||
ptz={ptz}
|
||||
onClose={onClose}
|
||||
onRelease={isOperator ? releaseAndClose : null}
|
||||
releaseDisabled={releasePending}
|
||||
/>
|
||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
||||
{isOperator ? (
|
||||
<>
|
||||
<PtzLightingControls ptz={ptz} />
|
||||
<PtzZoomControl />
|
||||
<PtzManualControls />
|
||||
</>
|
||||
) : (
|
||||
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
||||
Live PTZ controls unlock when your camera turn is active.
|
||||
</CardFrame>
|
||||
)}
|
||||
<PtzControlReference />
|
||||
<div className="min-h-[18rem]">
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" fillHeight />
|
||||
</div>
|
||||
<button type="button" className="button-dark text-xs" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
<div className="surface-muted space-y-0.5 p-1 text-xs">
|
||||
<div className="flex justify-between gap-1"><span>Operator</span><span className="truncate text-slate-200">{ptz?.operatorLabel || 'none'}</span></div>
|
||||
<div className="flex justify-between gap-1"><span>Remaining</span><span>{formatRemaining(ptz?.deadline)}</span></div>
|
||||
<div className="flex justify-between gap-1"><span>Spotlight</span><span>{ptz?.light?.state ? 'On' : 'Off'}</span></div>
|
||||
<div className="flex justify-between gap-1"><span>IR</span><span>{ptz?.ir?.state || '--'}</span></div>
|
||||
</div>
|
||||
{isOperator ? (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<div />
|
||||
<ControlButton title="Tilt up" onHold={holdMove({ tilt: 0.55 })}><FaArrowUp /></ControlButton>
|
||||
<div />
|
||||
<ControlButton title="Pan left" onHold={holdMove({ pan: -0.55 })}><FaArrowLeft /></ControlButton>
|
||||
<ControlButton title="Stop" onHold={(phase) => phase === 'start' && stopMotion()}><FaStop /></ControlButton>
|
||||
<ControlButton title="Pan right" onHold={holdMove({ pan: 0.55 })}><FaArrowRight /></ControlButton>
|
||||
<div />
|
||||
<ControlButton title="Tilt down" onHold={holdMove({ tilt: -0.55 })}><FaArrowDown /></ControlButton>
|
||||
<div />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<ControlButton title="Zoom in" onHold={holdMove({ zoom: 0.55 })}><FaSearchPlus /></ControlButton>
|
||||
<ControlButton title="Zoom out" onHold={holdMove({ zoom: -0.55 })}><FaSearchMinus /></ControlButton>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<button type="button" className="button-dark text-xs" disabled={busy === 'spotlight'} onClick={toggleSpotlight}>
|
||||
Spotlight {ptz?.light?.state ? 'off' : 'on'}
|
||||
</button>
|
||||
<button type="button" className="button-dark text-xs" disabled={busy === 'ir'} onClick={toggleIr}>
|
||||
IR {ptz?.ir?.state === 'Off' ? 'auto' : 'off'}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="button-dark mt-auto text-xs" onClick={() => ptzRelease().finally(onClose)}>
|
||||
Release camera
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<p className="surface-muted p-2 text-xs text-slate-400">Live PTZ controls unlock when your camera turn is active.</p>
|
||||
)}
|
||||
</aside>
|
||||
</aside>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -313,6 +518,12 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false }) {
|
||||
onMessage?.('');
|
||||
try {
|
||||
const response = await ptzClaim();
|
||||
/*
|
||||
Requesting the PTZ camera is also the user's intent to enter camera mode.
|
||||
Open the controller immediately for operators and queued users so the
|
||||
camera surface does not require a second, redundant "open" click.
|
||||
*/
|
||||
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) {
|
||||
@@ -337,44 +548,55 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false }) {
|
||||
return (
|
||||
<>
|
||||
<CardFrame title="PTZ camera" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
||||
<div className={innerFlowClass}>
|
||||
<div className={`${innerFlowClass} md:grid md:grid-cols-[minmax(0,16rem)_minmax(0,1fr)] md:items-start`}>
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
<div className="grid w-full grid-cols-2 gap-1 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 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>
|
||||
<div className="surface-muted p-1">
|
||||
<p className="text-slate-500">Remaining</p>
|
||||
<p className="text-slate-100">{formatRemaining(ptz?.deadline)}</p>
|
||||
<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>
|
||||
{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="flex w-full flex-wrap justify-center gap-1">
|
||||
{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 text-xs"
|
||||
disabled={pending || !isVerified}
|
||||
onClick={ptz?.queuedPosition ? handleRelease : handleClaim}
|
||||
>
|
||||
{ptz?.queuedPosition ? 'Leave queue' : pending ? 'Requesting...' : 'Claim camera'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardFrame>
|
||||
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} />
|
||||
|
||||
Reference in New Issue
Block a user