mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
new ptz ui
This commit is contained in:
@@ -38,6 +38,7 @@ import useUserIdentitySync from './hooks/useUserIdentitySync.js';
|
||||
import useIncomingInterInstanceTransfer from './hooks/useIncomingInterInstanceTransfer.js';
|
||||
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
|
||||
import PtzQueueCard from './components/PtzCamera/index.jsx';
|
||||
import VipPanel from './components/VipPanel/index.jsx';
|
||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
|
||||
@@ -253,6 +254,7 @@ function MobilePortraitLayout({ onOpenHelpOverlay, swapMobileControlColumns = fa
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-portrait" />
|
||||
<div className="space-y-0.5">
|
||||
<RoverQueuesPanel />
|
||||
<PtzQueueCard layout="mobile-portrait" />
|
||||
</div>
|
||||
</div>
|
||||
{/* <ControlSummary /> */}
|
||||
@@ -283,6 +285,7 @@ function MobileLandscapeLayout({ onOpenHelpOverlay, swapMobileControlColumns = f
|
||||
<ReplaySourcesPanel panelId="replay-sources-mobile-landscape" />
|
||||
<div className="space-y-0.5">
|
||||
<RoverQueuesPanel />
|
||||
<PtzQueueCard layout="mobile-landscape" />
|
||||
</div>
|
||||
</div>
|
||||
{/* <TelemetryPanel /> */}
|
||||
|
||||
@@ -127,6 +127,7 @@ export function ChatIdentity({ message, toolsToggle = null }) {
|
||||
{message.roverId && (
|
||||
<RoverLabel
|
||||
roverId={message.roverId}
|
||||
name={message.roverName}
|
||||
color={message.roverColor}
|
||||
fallback={message.roverId}
|
||||
className="shrink-0 text-[0.7rem]"
|
||||
|
||||
@@ -7,9 +7,10 @@ import SocialButton from '../../SocialButton/index.jsx';
|
||||
function TurnsOverlay({
|
||||
roverId = null,
|
||||
mobileHud = false,
|
||||
turnModel = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
@@ -29,21 +30,30 @@ function TurnsOverlay({
|
||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
|
||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||
const activeDriverId = turnModel
|
||||
? turnModel.activeId || null
|
||||
: effectiveRoverId
|
||||
? activeDrivers?.[effectiveRoverId] || null
|
||||
: null;
|
||||
const isActiveDriver = turnModel
|
||||
? Boolean(turnModel.isActive)
|
||||
: Boolean(socketId && activeDriverId === socketId);
|
||||
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
|
||||
const now = useSharedClock(1000, isTurnsMode);
|
||||
const nextDriverId = useMemo(() => {
|
||||
if (turnModel) return turnModel.nextId || null;
|
||||
const queue = turnInfo?.queue || [];
|
||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||
if (idx === -1) return queue[0] || null;
|
||||
return queue[(idx + 1) % queue.length] || null;
|
||||
}, [turnInfo]);
|
||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnInfo?.deadline || null;
|
||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||
}, [turnInfo, turnModel]);
|
||||
const isNextDriver = turnModel
|
||||
? Boolean(turnModel.isNext)
|
||||
: Boolean(socketId && nextDriverId === socketId);
|
||||
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
|
||||
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||
const totalRovers = roster.length;
|
||||
@@ -59,10 +69,14 @@ function TurnsOverlay({
|
||||
});
|
||||
return unique.size;
|
||||
}, [users]);
|
||||
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
||||
const shouldUsePreviewByLoad = turnModel
|
||||
? Boolean(turnModel.showPreviewReason)
|
||||
: isTurnsMode && totalDrivers > totalRovers;
|
||||
const isPreSwitchWindow =
|
||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||
const showNotTurnNotice = turnModel
|
||||
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
|
||||
: isTurnsMode && !isActiveDriver;
|
||||
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||
const turnSeconds =
|
||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||
@@ -82,19 +96,39 @@ function TurnsOverlay({
|
||||
const turnTimerFlashActive = noticeFlashActive;
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'turns') {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
/*
|
||||
Rover turns and PTZ turns now arrive through the same render path. Use the
|
||||
normalized isTurnsMode flag here instead of checking the server's rover
|
||||
mode directly, otherwise PTZ can render the notice but never trigger the
|
||||
"your turn" cue when camera ownership changes.
|
||||
*/
|
||||
if (!isTurnsMode) {
|
||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||
return;
|
||||
/*
|
||||
React Compiler's lint rules disallow immediate state writes from effect
|
||||
bodies. Defer the visual reset one macrotask; the ref reset above stays
|
||||
synchronous so later turn comparisons do not see stale ownership.
|
||||
*/
|
||||
const resetTimer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
}, 0);
|
||||
return () => clearTimeout(resetTimer);
|
||||
}
|
||||
const lastTurn = lastTurnRef.current;
|
||||
const nextActiveDriverId = activeDriverId || null;
|
||||
if (!socketId || !effectiveRoverId) {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||
return;
|
||||
/*
|
||||
No socket/target means there is no turn identity to compare. Reset the
|
||||
comparison ref immediately, then defer the visual state reset for the
|
||||
same React Compiler reason documented in the mode-disabled branch.
|
||||
*/
|
||||
const resetTimer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
}, 0);
|
||||
return () => clearTimeout(resetTimer);
|
||||
}
|
||||
if (!lastTurn.initialized || lastTurn.roverId !== effectiveRoverId) {
|
||||
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
|
||||
@@ -104,37 +138,76 @@ function TurnsOverlay({
|
||||
Boolean(lastTurn.activeDriverId) &&
|
||||
lastTurn.activeDriverId !== socketId &&
|
||||
nextActiveDriverId === socketId;
|
||||
let cueTimer = 0;
|
||||
if (becameActive) {
|
||||
setShowTurnCue(true);
|
||||
setTurnCueStartAt(Date.now());
|
||||
const cueStartAt = Date.now();
|
||||
/*
|
||||
The active-turn cue is still caused by this ownership transition, but
|
||||
React Compiler wants visual state writes scheduled from an async edge.
|
||||
Capture the timestamp now so the cue dismissal logic compares against
|
||||
the actual transition time, not the later timer callback time.
|
||||
*/
|
||||
cueTimer = setTimeout(() => {
|
||||
setShowTurnCue(true);
|
||||
setTurnCueStartAt(cueStartAt);
|
||||
}, 0);
|
||||
} else if (nextActiveDriverId !== socketId && showTurnCue) {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
cueTimer = setTimeout(() => {
|
||||
setShowTurnCue(false);
|
||||
setTurnCueStartAt(null);
|
||||
}, 0);
|
||||
}
|
||||
lastTurnRef.current = { initialized: true, roverId: effectiveRoverId, activeDriverId: nextActiveDriverId };
|
||||
}, [activeDriverId, mode, effectiveRoverId, socketId, showTurnCue]);
|
||||
return () => {
|
||||
if (cueTimer) clearTimeout(cueTimer);
|
||||
};
|
||||
}, [activeDriverId, effectiveRoverId, isTurnsMode, socketId, showTurnCue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showTurnCue || !turnCueStartAt) return;
|
||||
if (lastControlIntentAt > turnCueStartAt) {
|
||||
setShowTurnCue(false);
|
||||
/*
|
||||
Hide the large "your turn" cue after the first control intent, but
|
||||
schedule the state write outside the effect body so this shared overlay
|
||||
remains compatible with the repo's React Compiler lint settings.
|
||||
*/
|
||||
const timer = setTimeout(() => setShowTurnCue(false), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [lastControlIntentAt, showTurnCue, turnCueStartAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const lastIntent = Number(lastIntentRef.current) || 0;
|
||||
const nextIntent = Number(lastControlIntentAt) || 0;
|
||||
if (nextIntent > lastIntent && showNotTurnNotice) {
|
||||
setNotTurnFlashAt(Date.now());
|
||||
const flashAt = Date.now();
|
||||
/*
|
||||
The "not your turn" flash is a direct response to a recorded control
|
||||
intent. Deferring only the state write preserves the timestamp while
|
||||
satisfying the same effect-state lint rule as the turn cue reset.
|
||||
*/
|
||||
const timer = setTimeout(() => setNotTurnFlashAt(flashAt), 0);
|
||||
lastIntentRef.current = nextIntent;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
lastIntentRef.current = nextIntent;
|
||||
return undefined;
|
||||
}, [lastControlIntentAt, showNotTurnNotice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotTurnNotice || !notTurnFlashAt) return undefined;
|
||||
setNoticeFlashActive(true);
|
||||
const timer = setTimeout(() => setNoticeFlashActive(false), 650);
|
||||
return () => clearTimeout(timer);
|
||||
/*
|
||||
The flash has two timed edges: activate on the next task, then clear after
|
||||
the visible pulse duration. Owning both timers here keeps cleanup local
|
||||
when the user becomes operator or leaves the PTZ/rover turn surface.
|
||||
*/
|
||||
const startTimer = setTimeout(() => setNoticeFlashActive(true), 0);
|
||||
const endTimer = setTimeout(() => setNoticeFlashActive(false), 650);
|
||||
return () => {
|
||||
clearTimeout(startTimer);
|
||||
clearTimeout(endTimer);
|
||||
};
|
||||
}, [showNotTurnNotice, notTurnFlashAt]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
// 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 PTZ entry card and fullscreen composition; PTZ command authority,
|
||||
// queue ownership, and stream authorization remain server-owned.
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-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 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, useControlSelector } from '../../controls/index.js';
|
||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||
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 { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
|
||||
const PTZ_ZOOM_SPEED = 0.55;
|
||||
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 (
|
||||
<div className={`relative overflow-hidden bg-black ${className}`}>
|
||||
{feed?.objectUrl ? (
|
||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-1 py-0.5 text-xs font-semibold text-white">
|
||||
{label}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-2 left-2 rounded bg-black/70 px-2 py-1 text-xs text-slate-100">
|
||||
{feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}
|
||||
</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 PtzStatePanel({ ptz, compact = false }) {
|
||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
||||
const publisher = ptz?.publisher || {};
|
||||
const publisherStatus = publisher.running
|
||||
? 'running'
|
||||
: publisher.restartAt
|
||||
? 'restarting'
|
||||
: publisher.lastEvent || 'stopped';
|
||||
const mode = ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator';
|
||||
|
||||
return (
|
||||
<CardFrame title="Camera state" bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<StatusRow label="Mode" value={mode} tone={ptz?.isOperator ? 'text-emerald-300' : ''} />
|
||||
<StatusRow label="Operator" value={ptz?.operatorLabel || 'none'} />
|
||||
<StatusRow label="Remaining" value={formatRemaining(ptz?.deadline, now)} />
|
||||
<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' : ''} />
|
||||
{!compact ? <StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} /> : 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}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
|
||||
return (
|
||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
||||
<QueueUserChips
|
||||
targetId={ptz?.id || PTZ_CAMERA_ID}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
/>
|
||||
</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;
|
||||
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 { ptzMove, ptzStop } = useSessionActions();
|
||||
const stopZoom = useCallback(() => {
|
||||
ptzStop().catch(() => {});
|
||||
}, [ptzStop]);
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
/*
|
||||
The rover mobile movement pad is reused for PTZ pan/tilt through the
|
||||
control adapter, so zoom needs its own two hold buttons on mobile.
|
||||
*/
|
||||
event.preventDefault();
|
||||
if (disabled) return;
|
||||
ptzMove({ pan: 0, tilt: 0, zoom: direction * PTZ_ZOOM_SPEED }).catch(() => {});
|
||||
},
|
||||
[disabled, ptzMove],
|
||||
);
|
||||
const stopFromPointer = useCallback(
|
||||
(event) => {
|
||||
event?.preventDefault?.();
|
||||
if (disabled) return;
|
||||
stopZoom();
|
||||
},
|
||||
[disabled, stopZoom],
|
||||
);
|
||||
|
||||
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}
|
||||
onPointerLeave={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}
|
||||
onPointerLeave={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 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.
|
||||
*/}
|
||||
<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>
|
||||
<KeyPill label={keyLabelFor(keymap, actionId)} />
|
||||
</div>
|
||||
))}
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 w-full overflow-hidden bg-black">
|
||||
{isOperator ? (
|
||||
<PtzLiveVideo enabled={open} startMuted={false} label={ptz?.name || 'PTZ Camera'} />
|
||||
) : (
|
||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||
)}
|
||||
<TurnsOverlay turnModel={turnModel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
return (
|
||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(9rem,0.32fr)] gap-0.5 overflow-hidden p-0.5">
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_20rem] gap-0.5 overflow-hidden">
|
||||
<main className="min-h-0 min-w-0 overflow-hidden bg-black">
|
||||
<PtzMediaPane ptz={ptz} open />
|
||||
</main>
|
||||
<aside className="flex min-h-0 flex-col gap-0.5 overflow-y-auto bg-neutral-950 text-sm">
|
||||
{ptz?.isOperator ? (
|
||||
<PtzLightingControls ptz={ptz} />
|
||||
) : (
|
||||
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
||||
Live PTZ controls unlock when your camera turn is active.
|
||||
</CardFrame>
|
||||
)}
|
||||
<PtzControlReference />
|
||||
<PtzStatePanel ptz={ptz} />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" />
|
||||
<CardFrame title="Position presets" bodyClassName="p-1 text-xs text-slate-500">
|
||||
Presets will live here.
|
||||
</CardFrame>
|
||||
</aside>
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
</div>
|
||||
{releasePending ? (
|
||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded bg-black/80 px-2 py-1 text-xs text-slate-200">
|
||||
Closing...
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileFullscreen({ ptz, layout }) {
|
||||
const landscape = layout === 'mobile-landscape';
|
||||
const videoClass = landscape ? 'h-[62svh]' : 'h-[42svh]';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
<main className={`${videoClass} min-h-0 overflow-hidden bg-black`}>
|
||||
<PtzMediaPane ptz={ptz} open />
|
||||
</main>
|
||||
<section className="mobile-touch-control">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
||||
</section>
|
||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
||||
<ChatPanel title="Chat" />
|
||||
<div className="space-y-0.5">
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzStatePanel ptz={ptz} compact />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const { ptzRelease } = useSessionActions();
|
||||
const { stopAllMotion } = useControlActions();
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
||||
|
||||
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 ptzRelease();
|
||||
onClose?.();
|
||||
} finally {
|
||||
setReleasePending(false);
|
||||
}
|
||||
}, [onClose, ptzRelease, releasePending, stopAllMotion]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const controller = (
|
||||
<div className="fixed inset-0 z-[110] h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
|
||||
<CardFrame
|
||||
title={ptz?.name || 'PTZ Camera'}
|
||||
actions={(
|
||||
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
fillHeight
|
||||
clipOverflow={false}
|
||||
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
|
||||
bodyClassName="relative min-h-0 flex-1"
|
||||
>
|
||||
{isMobile ? (
|
||||
<PtzMobileFullscreen ptz={ptz} layout={layout} />
|
||||
) : (
|
||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
||||
)}
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(controller, document.body);
|
||||
}
|
||||
|
||||
export default function PtzQueueCard({ 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 role = useSessionSelector((state) => state.session?.role || null);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
||||
const lookupUser = usePtzQueueLookup(ptz);
|
||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
||||
const [controllerOpen, setControllerOpen] = useState(false);
|
||||
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) {
|
||||
setControllerOpen(true);
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
trackAnalyticsEvent('ptz_queue_join', { layout });
|
||||
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) {
|
||||
setControllerOpen(true);
|
||||
}
|
||||
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
|
||||
} catch (err) {
|
||||
trackAnalyticsEvent('ptz_queue_join_result', {
|
||||
layout,
|
||||
status: 'failed',
|
||||
reason: err?.message || 'unknown',
|
||||
});
|
||||
alert(err.message || 'PTZ request failed.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeave = async () => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
try {
|
||||
await ptzRelease();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to leave PTZ camera.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const actionLabel = pending
|
||||
? '...'
|
||||
: ptz?.isOperator
|
||||
? 'Open'
|
||||
: ptz?.queuedPosition
|
||||
? 'Open'
|
||||
: 'request';
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
<QueueTargetRow
|
||||
target={{
|
||||
id: ptz?.id || PTZ_CAMERA_ID,
|
||||
name: ptz?.name || 'PTZ Camera',
|
||||
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
||||
description: ptz?.isOperator
|
||||
? 'Live camera turn active'
|
||||
: ptz?.queuedPosition
|
||||
? `Queue position ${ptz.queuedPosition}`
|
||||
: 'Pan, tilt, and zoom camera',
|
||||
}}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canUse && !pending}
|
||||
pending={pending}
|
||||
buttonLabel={actionLabel}
|
||||
batteryLabel={ptz?.isOperator ? 'LIVE' : ptz?.queuedPosition ? `#${ptz.queuedPosition}` : '--'}
|
||||
batteryClassName={ptz?.isOperator ? 'text-emerald-300' : ptz?.queuedPosition ? 'text-sky-300' : 'text-slate-400'}
|
||||
timerLabel={timerLabel}
|
||||
onRequest={handleRequest}
|
||||
showAction={canUse}
|
||||
/>
|
||||
</ul>
|
||||
{isParticipant ? (
|
||||
<button type="button" className="button-dark w-full text-xs" disabled={pending} onClick={handleLeave}>
|
||||
Leave PTZ queue
|
||||
</button>
|
||||
) : null}
|
||||
{!canUse ? (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/75 px-2 text-center text-sm font-semibold text-slate-100">
|
||||
Verify your account to use the PTZ camera.
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
<PtzFullscreenController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Queue Target Row
|
||||
// Purpose: Renders the shared queue-row visual language used by rover queues and PTZ.
|
||||
// Scope: Owns row chrome, queue chips, timer labels, and row/button event plumbing;
|
||||
// callers still own target-specific permission checks and request actions.
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
default:
|
||||
return 'text-sky-300';
|
||||
}
|
||||
}
|
||||
|
||||
function formatQueueUserLabel(user, selfId) {
|
||||
/*
|
||||
Queue chips need to be readable even when a socket has no nickname yet.
|
||||
Keeping the socket-prefix fallback here means rover queues and PTZ queues
|
||||
degrade identically instead of each target inventing its own anonymous label.
|
||||
*/
|
||||
if (!user) return '';
|
||||
const base = user.nickname || user.label || user.socketId?.slice(0, 6) || 'unknown';
|
||||
if (user.socketId && user.socketId === selfId) {
|
||||
return `${base} (you)`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function QueueUserChips({
|
||||
targetId,
|
||||
queue = [],
|
||||
currentId = null,
|
||||
nextId = null,
|
||||
selfId = null,
|
||||
lookupUser,
|
||||
}) {
|
||||
if (!queue.length) {
|
||||
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{queue.map((socketId, idx) => {
|
||||
const user = lookupUser?.(socketId) || { socketId, nickname: null, role: null };
|
||||
const isCurrent = socketId === currentId;
|
||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||
/*
|
||||
These classes intentionally mirror the original rover queue styling.
|
||||
PTZ feeds the same current/next model into this component, so the user
|
||||
does not have to learn a different visual vocabulary for camera turns.
|
||||
*/
|
||||
const highlightClass = isCurrent
|
||||
? 'bg-sky-600 text-white ring-2 ring-amber-300'
|
||||
: isNext
|
||||
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
|
||||
: 'bg-slate-800 text-slate-200';
|
||||
return (
|
||||
<span
|
||||
key={`${targetId}-${socketId}-${idx}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.7rem] ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatQueueUserLabel(user, selfId)}
|
||||
</span>
|
||||
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QueueTargetRow({
|
||||
target,
|
||||
queue = [],
|
||||
currentId = null,
|
||||
nextId = null,
|
||||
selfId = null,
|
||||
lookupUser,
|
||||
canClick = false,
|
||||
pending = false,
|
||||
locked = false,
|
||||
lockedBlocked = false,
|
||||
privateOpen = false,
|
||||
buttonLabel = '',
|
||||
batteryLabel = '',
|
||||
batteryClassName = 'text-slate-400',
|
||||
timerLabel = '',
|
||||
thumbnailUrl = '',
|
||||
onRequest,
|
||||
showAction = true,
|
||||
}) {
|
||||
const targetId = String(target?.id || '');
|
||||
const targetLabel = target?.label || target?.name || targetId;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={classNames(
|
||||
'surface flex flex-wrap items-start justify-between gap-0.5',
|
||||
canClick && 'cursor-pointer',
|
||||
locked
|
||||
? 'bg-red-900/40'
|
||||
: privateOpen
|
||||
? 'bg-amber-700/35 border border-amber-200/30'
|
||||
: null,
|
||||
)}
|
||||
onClick={() => {
|
||||
/*
|
||||
The whole row is a large target because queue selection is one of the
|
||||
main touch/click actions on the page. The caller still decides whether
|
||||
clicking is currently allowed, so disabled PTZ and locked rover states
|
||||
cannot accidentally request control through the shared renderer.
|
||||
*/
|
||||
if (!canClick) return;
|
||||
onRequest?.(targetId);
|
||||
}}
|
||||
>
|
||||
{thumbnailUrl ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt=""
|
||||
className="h-8 w-10 shrink-0 rounded border border-slate-700 bg-black object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<p className="min-w-0 flex items-center gap-0.5 whitespace-nowrap text-slate-200">
|
||||
<RoverLabel
|
||||
rover={target?.rover || null}
|
||||
roverId={target?.roverId ?? targetId}
|
||||
name={targetLabel}
|
||||
color={target?.color || null}
|
||||
fallback={targetId}
|
||||
/>
|
||||
{target?.description ? (
|
||||
<span className="min-w-0 flex-1 truncate text-[0.7rem] text-slate-400">
|
||||
{target.description}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{timerLabel ? (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
{timerLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{batteryLabel ? (
|
||||
<span className={classNames('text-[0.75rem] font-semibold', batteryClassName)}>
|
||||
{batteryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<QueueUserChips
|
||||
targetId={targetId}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
/>
|
||||
</div>
|
||||
{showAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
/*
|
||||
Stop propagation so button clicks do not double-fire the row
|
||||
request. This keeps mouse, touch, and keyboard activation on the
|
||||
explicit button consistent with clicking the row background.
|
||||
*/
|
||||
event.stopPropagation();
|
||||
onRequest?.(targetId);
|
||||
}}
|
||||
disabled={pending || lockedBlocked || !canClick}
|
||||
className={classNames(
|
||||
'button-dark disabled:opacity-40',
|
||||
locked && 'bg-red-600/70 text-white hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import HelpPanel from '../HelpPanel/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import { LinkButtonsPanel } from '../UserListPanel/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import PtzQueueCard from '../PtzCamera/index.jsx';
|
||||
import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
||||
import TopDownMap from '../TopDownMap/index.jsx';
|
||||
import DriveDockAction from '../DriveDockAction/index.jsx';
|
||||
@@ -201,7 +202,10 @@ function QueueReplayLinksRow() {
|
||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||
</div>
|
||||
<LinkButtonsPanel className="min-w-0 basis-0 grow-[0.75]" />
|
||||
<div className={`min-w-0 basis-0 grow-[0.75] ${themeStackClass}`}>
|
||||
<LinkButtonsPanel />
|
||||
<PtzQueueCard layout="desktop" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
import { PTZ_CAMERA_ID, usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||
import RoomCameraFeed from '../RoomCameraFeed/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
@@ -105,11 +106,14 @@ function useCameraPanelSubscriptionGate() {
|
||||
}
|
||||
|
||||
export default function RoomCameraPanel(props) {
|
||||
const enabled = useSessionSelector((state) => isFeatureEnabled(state, 'roomCameras'));
|
||||
const enabled = useSessionSelector((state) =>
|
||||
isFeatureEnabled(state, 'roomCameras') || isFeatureEnabled(state, 'ptzCamera'),
|
||||
);
|
||||
|
||||
/*
|
||||
Room-camera visibility belongs with the room-camera panel. This keeps every
|
||||
route free to mount the panel without duplicating the server feature rule.
|
||||
Camera-panel visibility belongs with the panel. PTZ is included here because
|
||||
the user-facing request is "show it as a room camera"; the rendering path
|
||||
still uses the same RoomCameraFeed tile as ordinary room cameras.
|
||||
*/
|
||||
if (!enabled) return null;
|
||||
|
||||
@@ -123,13 +127,35 @@ function RoomCameraPanelContent({
|
||||
hideHeader = false,
|
||||
panelId = null,
|
||||
}) {
|
||||
const roomCamerasEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'roomCameras'));
|
||||
const ptzEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
|
||||
const cameraSources = useMemo(() => {
|
||||
const base = roomCamerasEnabled ? cameras : [];
|
||||
if (!ptzEnabled || !ptz) return base;
|
||||
/*
|
||||
PTZ snapshots use a different socket namespace from room cameras, but the
|
||||
display model is intentionally the same: an id, a label, and a feed object.
|
||||
Marking the type lets the subscription layer stay separate while the tile
|
||||
renderer remains shared.
|
||||
*/
|
||||
return [
|
||||
...base,
|
||||
{
|
||||
id: PTZ_CAMERA_ID,
|
||||
name: ptz.name || 'PTZ Camera',
|
||||
type: 'ptz',
|
||||
},
|
||||
];
|
||||
}, [cameras, ptz, ptzEnabled, roomCamerasEnabled]);
|
||||
const cameraIds = useMemo(
|
||||
() => cameras.map((camera) => camera.id),
|
||||
[cameras],
|
||||
() => cameraSources.filter((camera) => camera.type !== 'ptz').map((camera) => camera.id),
|
||||
[cameraSources],
|
||||
);
|
||||
const { panelRef, isPanelVisible } = useCameraPanelSubscriptionGate();
|
||||
const feedMap = useRoomCameraSnapshots(cameraIds, { enabled: isPanelVisible });
|
||||
const ptzFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: isPanelVisible && ptzEnabled });
|
||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
normalizeOrientation(
|
||||
@@ -152,7 +178,7 @@ function RoomCameraPanelContent({
|
||||
);
|
||||
const containerClass =
|
||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameraSources.length > 0;
|
||||
const applyOrientation = (next) => {
|
||||
setOrientation(next);
|
||||
if (panelId) {
|
||||
@@ -160,7 +186,7 @@ function RoomCameraPanelContent({
|
||||
}
|
||||
};
|
||||
|
||||
if (cameras.length === 0) {
|
||||
if (cameraSources.length === 0) {
|
||||
return (
|
||||
<div ref={panelRef}>
|
||||
<EmptyState />
|
||||
@@ -195,8 +221,8 @@ function RoomCameraPanelContent({
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
<div className={containerClass}>
|
||||
{cameras.map((camera) => {
|
||||
const feed = feedMap[camera.id] || null;
|
||||
{cameraSources.map((camera) => {
|
||||
const feed = camera.type === 'ptz' ? ptzFeeds[camera.id] || null : feedMap[camera.id] || null;
|
||||
return (
|
||||
<article key={camera.id} className="w-full space-y-0.5 p-0.5">
|
||||
{/* <header className="space-y-0.5">
|
||||
|
||||
@@ -5,17 +5,13 @@ import { useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import RoverLabel from '../RoverLabel/index.jsx';
|
||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
|
||||
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx';
|
||||
import { isFeatureEnabled } from '../../lib/features.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function formatBattery(rover) {
|
||||
const percent = rover?.batteryState?.percentDisplay;
|
||||
if (percent == null) return '--';
|
||||
@@ -29,27 +25,6 @@ function batteryClass(rover) {
|
||||
return 'text-emerald-300';
|
||||
}
|
||||
|
||||
function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
default:
|
||||
return 'text-sky-300';
|
||||
}
|
||||
}
|
||||
|
||||
function formatLabel(user, selfId) {
|
||||
if (!user) return '';
|
||||
const base = user.nickname || user.socketId?.slice(0, 6) || 'unknown';
|
||||
if (user.socketId && user.socketId === selfId) {
|
||||
return `${base} (you)`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export default function RoverQueuesPanel({
|
||||
title = 'Rovers',
|
||||
roster: rosterOverride = null,
|
||||
@@ -192,129 +167,59 @@ export default function RoverQueuesPanel({
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{rosterItems.map((rover) => {
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remainingSeconds =
|
||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: lockedBlocked
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<li
|
||||
key={rover.id}
|
||||
className={classNames(
|
||||
'surface flex flex-wrap items-start justify-between gap-0.5',
|
||||
canClickRow && 'cursor-pointer',
|
||||
locked
|
||||
? 'bg-red-900/40'
|
||||
: isPrivateOpen
|
||||
? 'bg-amber-700/35 border border-amber-200/30'
|
||||
: null,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!canClickRow) return;
|
||||
handleRequest(rover.id);
|
||||
}}
|
||||
>
|
||||
{externalMode && rover?.snapshots?.latestUrl ? (
|
||||
<img
|
||||
src={rover.snapshots.latestUrl}
|
||||
alt=""
|
||||
className="h-8 w-10 shrink-0 rounded border border-slate-700 bg-black object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<p className="min-w-0 flex items-center gap-0.5 whitespace-nowrap text-slate-200">
|
||||
<RoverLabel rover={rover} fallback={roverId} />
|
||||
{rover.description ? (
|
||||
<span className="min-w-0 flex-1 truncate text-[0.7rem] text-slate-400">
|
||||
{rover.description}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{showTimer ? (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
|
||||
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={classNames('text-[0.75rem] font-semibold', batteryClass(rover))}>
|
||||
{formatBattery(rover)}
|
||||
</span>
|
||||
</div>
|
||||
{queue.length === 0 ? (
|
||||
<p className="text-[0.7rem] text-slate-500">No queue.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{queue.map((socketId, idx) => {
|
||||
const user = lookupUser(socketId);
|
||||
const isCurrent = socketId === currentId;
|
||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||
const highlightClass = isCurrent
|
||||
? 'bg-sky-600 text-white ring-2 ring-amber-300'
|
||||
: isNext
|
||||
? 'bg-emerald-700/60 text-emerald-100 ring-1 ring-emerald-300/70'
|
||||
: 'bg-slate-800 text-slate-200';
|
||||
return (
|
||||
<span
|
||||
key={`${roverId}-${socketId}-${idx}`}
|
||||
className={`flex items-center gap-0.5 rounded px-1 text-[0.7rem] ${highlightClass}`}
|
||||
>
|
||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||
{formatLabel(user, selfId)}
|
||||
</span>
|
||||
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleRequest(rover.id);
|
||||
}}
|
||||
disabled={pending[roverId] || lockedBlocked}
|
||||
className={classNames(
|
||||
'button-dark disabled:opacity-40',
|
||||
locked && 'bg-red-600/70 text-white hover:bg-red-600',
|
||||
)}
|
||||
>
|
||||
{buttonLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
const roverId = String(rover.id);
|
||||
const info = turnQueues?.[roverId] || null;
|
||||
const queue = info?.queue || [];
|
||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||
const remainingSeconds =
|
||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||
const currentId = info?.current || null;
|
||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||
const nextId =
|
||||
queue.length > 1
|
||||
? currentIdx >= 0
|
||||
? queue[(currentIdx + 1) % queue.length]
|
||||
: queue[0]
|
||||
: null;
|
||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||
const locked = Boolean(rover.locked);
|
||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||
const buttonLabel = pending[roverId]
|
||||
? '...'
|
||||
: lockedBlocked
|
||||
? lockLabel
|
||||
: externalMode
|
||||
? 'Open'
|
||||
: 'request';
|
||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||
return (
|
||||
<QueueTargetRow
|
||||
key={rover.id}
|
||||
target={{ ...rover, rover, roverId, id: roverId }}
|
||||
queue={queue}
|
||||
currentId={currentId}
|
||||
nextId={nextId}
|
||||
selfId={selfId}
|
||||
lookupUser={lookupUser}
|
||||
canClick={canClickRow}
|
||||
pending={Boolean(pending[roverId])}
|
||||
locked={locked}
|
||||
lockedBlocked={lockedBlocked}
|
||||
privateOpen={isPrivateOpen}
|
||||
buttonLabel={buttonLabel}
|
||||
batteryLabel={formatBattery(rover)}
|
||||
batteryClassName={batteryClass(rover)}
|
||||
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
||||
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
||||
onRequest={handleRequest}
|
||||
showAction={Boolean(canRequest)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -11,9 +11,8 @@ import VipVerificationCard from '../vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||
import VipPtzCameraCard from '../vip/VipPtzCameraCard.jsx';
|
||||
|
||||
export default function VipPanel({ layout = 'desktop' }) {
|
||||
export default function VipPanel() {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const {
|
||||
identifySession,
|
||||
@@ -70,7 +69,6 @@ export default function VipPanel({ layout = 'desktop' }) {
|
||||
<div className="lg:col-span-2">
|
||||
{isVerified ? (
|
||||
<div className="space-y-2">
|
||||
<VipPtzCameraCard onMessage={setMessage} fullWidth layout={layout} />
|
||||
<VipMidiBeeperCard />
|
||||
<VipAudioUploadCard
|
||||
ownRoverId={ownRoverId}
|
||||
|
||||
Reference in New Issue
Block a user