mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
replay panel and PTZ ui improvements
This commit is contained in:
+1
-30
@@ -52,36 +52,7 @@ import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
import { trackAnalyticsEvent } from './analytics/index.js';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
return window.innerWidth >= 1024
|
||||
? 'desktop'
|
||||
: window.innerWidth > window.innerHeight
|
||||
? 'mobile-landscape'
|
||||
: 'mobile-portrait';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function updateMode() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const { innerWidth, innerHeight } = window;
|
||||
if (innerWidth >= 1024) {
|
||||
setMode('desktop');
|
||||
} else if (innerWidth > innerHeight) {
|
||||
setMode('mobile-landscape');
|
||||
} else {
|
||||
setMode('mobile-portrait');
|
||||
}
|
||||
}
|
||||
updateMode();
|
||||
window.addEventListener('resize', updateMode);
|
||||
return () => window.removeEventListener('resize', updateMode);
|
||||
}, []);
|
||||
|
||||
return mode;
|
||||
}
|
||||
import useLayoutMode from './hooks/useLayoutMode.js';
|
||||
|
||||
function DesktopLayout({ layout, onOpenHelpOverlay }) {
|
||||
return (
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// 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.
|
||||
// Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
|
||||
// composition; PTZ command authority, queue ownership, and stream authorization
|
||||
// remain server-owned.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
|
||||
@@ -116,45 +118,6 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -223,7 +186,7 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
||||
}
|
||||
|
||||
function PtzMobileZoomButtons({ disabled = false }) {
|
||||
const { nudgeServo, stopAllMotion } = useControlActions();
|
||||
const { nudgeServo } = useControlActions();
|
||||
const repeatTimerRef = useRef(null);
|
||||
|
||||
const stopZoom = useCallback(() => {
|
||||
@@ -238,8 +201,13 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
||||
clearInterval(repeatTimerRef.current);
|
||||
repeatTimerRef.current = null;
|
||||
}
|
||||
stopAllMotion();
|
||||
}, [stopAllMotion]);
|
||||
/*
|
||||
Zero is a zoom-only release signal in the PTZ adapter. Using the global
|
||||
stop action here previously erased a simultaneously held pan/tilt vector,
|
||||
making mixed touch controls unexpectedly stop the camera.
|
||||
*/
|
||||
nudgeServo(0);
|
||||
}, [nudgeServo]);
|
||||
|
||||
const startZoom = useCallback(
|
||||
(direction) => (event) => {
|
||||
@@ -540,6 +508,7 @@ function buildPtzTurnModel(ptz, selfId) {
|
||||
|
||||
function PtzMediaPane({ ptz, open, framed = true }) {
|
||||
const isOperator = Boolean(ptz?.isOperator);
|
||||
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
|
||||
const nonTurnSnapshotsActive = useSessionSelector(
|
||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
||||
);
|
||||
@@ -550,7 +519,15 @@ function PtzMediaPane({ ptz, open, framed = true }) {
|
||||
canRequestLiveVideo(); this branch only chooses the expected browser render
|
||||
path and never unlocks movement controls.
|
||||
*/
|
||||
const shouldUseLiveVideo = isOperator || !nonTurnSnapshotsActive;
|
||||
/*
|
||||
A direct /ptz load renders before its automatic queue claim is acknowledged.
|
||||
Do not mount the live player during that short pre-claim window: its first
|
||||
token request would correctly be rejected, and PtzLiveVideo intentionally
|
||||
treats authorization rejection as a terminal snapshot fallback. Once the
|
||||
session confirms queue/operator membership, mounting the player creates a
|
||||
fresh authorized request without changing shared retry or server policy.
|
||||
*/
|
||||
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
|
||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
||||
@@ -595,8 +572,13 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
</CardFrame>
|
||||
)}
|
||||
<PtzControlReference />
|
||||
<PtzStatePanel ptz={ptz} />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
||||
{/*
|
||||
Desktop keeps room controls as the final sidebar tool so camera
|
||||
turn controls and replay remain above the less-frequent room-wide
|
||||
actions. HomeAssistantControls owns its own feature and policy gate.
|
||||
*/}
|
||||
<HomeAssistantControls />
|
||||
</aside>
|
||||
</div>
|
||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
||||
@@ -612,50 +594,174 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PtzMobileFullscreen({ ptz, layout, onClose, releasePending = false }) {
|
||||
const landscape = layout === 'mobile-landscape';
|
||||
const topHeightClass = landscape ? 'h-full min-h-[calc(100dvh-0.25rem)]' : 'h-[48dvh]';
|
||||
const topGridClass = landscape
|
||||
? 'grid-cols-[minmax(0,1fr)_13rem]'
|
||||
: 'grid-cols-[minmax(0,1fr)_11rem]';
|
||||
|
||||
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
<section className={`mobile-touch-control grid ${topHeightClass} min-h-48 shrink-0 ${topGridClass} gap-0.5`}>
|
||||
<main className="relative min-h-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
||||
</main>
|
||||
<aside className="min-h-0 overflow-y-auto">
|
||||
{/*
|
||||
Landscape intentionally retains one control column beside the video.
|
||||
This is the established PTZ interaction and avoids forcing rover-style
|
||||
left/right columns onto a camera that has a smaller control inventory.
|
||||
*/}
|
||||
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
|
||||
{/*
|
||||
The video keeps one viewport of height, but the grid row is allowed to
|
||||
grow when the control column is taller. That makes the sidebar's tail
|
||||
extend below the video instead of forcing it into a nested scroller.
|
||||
*/}
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
||||
</main>
|
||||
{/*
|
||||
The right control column is naturally taller than the viewport.
|
||||
Placing room controls after the fixed-height video uses that left-
|
||||
column space while the whole landscape page continues scrolling as
|
||||
one surface.
|
||||
*/}
|
||||
<HomeAssistantControls />
|
||||
</div>
|
||||
{/*
|
||||
Do not put overflow scrolling on this column. The surrounding PTZ
|
||||
landscape content is the single page scroller, so a swipe over either
|
||||
the video area or these controls advances the same document flow.
|
||||
*/}
|
||||
<aside className="min-h-0 space-y-0.5">
|
||||
{/*
|
||||
Landscape keeps all turn-critical controls in its one existing
|
||||
sidebar. Queue position belongs first so the operator can confirm
|
||||
control ownership before touching the camera, while replay follows
|
||||
the lighting buttons because it is the next secondary action in
|
||||
the same scroll column.
|
||||
*/}
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-landscape"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</aside>
|
||||
</section>
|
||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
<div className="space-y-0.5">
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
<PtzStatePanel ptz={ptz} compact />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
|
||||
</div>
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
|
||||
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
||||
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-1 top-1 z-50 rounded border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow disabled:opacity-50"
|
||||
disabled={releasePending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
||||
</main>
|
||||
{/*
|
||||
Portrait gives the video its full available width and places controls
|
||||
below it. Reusing the landscape sidebar width here was the source of the
|
||||
cramped portrait presentation, while the controls themselves remain the
|
||||
same shared PTZ controls used in landscape.
|
||||
*/}
|
||||
<section className="mobile-touch-control">
|
||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
||||
</section>
|
||||
<section className="space-y-0.5">
|
||||
{/*
|
||||
Replay and presets are compact secondary actions, so portrait places
|
||||
them in one equal-width row before the full-width queue and chat. The
|
||||
explicit two-column grid keeps this arrangement local to portrait and
|
||||
leaves the desktop and one-column landscape compositions unchanged.
|
||||
*/}
|
||||
<div className="grid grid-cols-2 items-start gap-0.5">
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile-portrait"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
<PtzPresetPanel ptz={ptz} />
|
||||
</div>
|
||||
<PtzQueueSummary ptz={ptz} />
|
||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
||||
{/* Portrait keeps room controls immediately after chat as requested. */}
|
||||
<HomeAssistantControls />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PtzControllerPage({ layout = 'desktop' }) {
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const { ptzRelease } = useSessionActions();
|
||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
|
||||
const { stopAllMotion } = useControlActions();
|
||||
const navigate = useNavigate();
|
||||
const [releasePending, setReleasePending] = useState(false);
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
||||
const autoClaimSocketRef = useRef(null);
|
||||
const isMobile = layout !== 'desktop';
|
||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
||||
|
||||
useEffect(() => {
|
||||
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
|
||||
|
||||
if (ptz.isOperator || ptz.queuedPosition) {
|
||||
/*
|
||||
Navigation from the driver queue normally arrives with membership
|
||||
already established. Mark this socket complete so later session syncs
|
||||
cannot turn that normal route transition into another claim request.
|
||||
*/
|
||||
autoClaimSocketRef.current = socketId;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (autoClaimSocketRef.current === socketId) return undefined;
|
||||
autoClaimSocketRef.current = socketId;
|
||||
let active = true;
|
||||
|
||||
/*
|
||||
A direct /ptz load still receives the ordinary user role first, which can
|
||||
briefly assign a rover. Claiming through the existing server action is
|
||||
deliberate: ptzCameraService releases that rover ownership before it
|
||||
activates or queues this socket, keeping one authoritative transition.
|
||||
|
||||
The socket-keyed ref suppresses repeats caused by session updates and
|
||||
React's development effect replay. The server claim is also idempotent for
|
||||
an existing operator/queue member, which covers an acknowledgement racing
|
||||
with a fresh public-state sync.
|
||||
*/
|
||||
ptzClaim().catch((err) => {
|
||||
if (!active) return;
|
||||
pushAlert({
|
||||
id: `ptz-auto-claim-${socketId}`,
|
||||
title: 'PTZ camera',
|
||||
message: err?.message || 'Unable to join the PTZ queue.',
|
||||
color: '#f59e0b',
|
||||
lifetimeMs: 6000,
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Do not emit or update UI from a rejected request after this route has
|
||||
// unmounted; the server still owns completion of any request in flight.
|
||||
active = false;
|
||||
};
|
||||
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
|
||||
|
||||
const releaseAndClose = useCallback(async () => {
|
||||
if (releasePending) return;
|
||||
@@ -666,24 +772,28 @@ export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
|
||||
running while the server removes this socket from the PTZ queue.
|
||||
*/
|
||||
stopAllMotion?.();
|
||||
await ptzRelease();
|
||||
onClose?.();
|
||||
if (ptz?.isOperator || ptz?.queuedPosition) {
|
||||
await ptzRelease();
|
||||
}
|
||||
navigate('/');
|
||||
} finally {
|
||||
setReleasePending(false);
|
||||
}
|
||||
}, [onClose, ptzRelease, releasePending, stopAllMotion]);
|
||||
}, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!featureEnabled) {
|
||||
return (
|
||||
<main className="flex min-h-[100dvh] items-center justify-center bg-black p-2 text-slate-100">
|
||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
||||
<p>The PTZ camera is not available.</p>
|
||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
||||
</CardFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const controller = (
|
||||
/*
|
||||
The PTZ controller needs to cover the driver page, but it must not become
|
||||
the top-most application layer. Global fullscreen overlays like help,
|
||||
quickstart, mode gates, and connection warnings are still part of the
|
||||
active app state while PTZ is open, so this portal intentionally sits
|
||||
below their z-30+ overlay stack instead of hiding them.
|
||||
*/
|
||||
<div className="fixed inset-0 z-20 h-[100dvh] w-[100vw] overflow-hidden bg-black text-slate-100">
|
||||
return (
|
||||
<main className="h-[100dvh] w-full overflow-hidden bg-black text-slate-100">
|
||||
<CardFrame
|
||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
||||
actions={isMobile ? null : (
|
||||
@@ -698,20 +808,17 @@ export function PtzFullscreenController({ open, onClose, layout = 'desktop' }) {
|
||||
bodyClassName="relative min-h-0 flex-1"
|
||||
>
|
||||
{isMobile ? (
|
||||
<PtzMobileFullscreen
|
||||
ptz={ptz}
|
||||
layout={layout}
|
||||
onClose={releaseAndClose}
|
||||
releasePending={releasePending}
|
||||
/>
|
||||
layout === 'mobile-landscape' ? (
|
||||
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
) : (
|
||||
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
||||
)
|
||||
) : (
|
||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
||||
)}
|
||||
</CardFrame>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
return createPortal(controller, document.body);
|
||||
}
|
||||
|
||||
export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
@@ -721,10 +828,10 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
||||
const navigate = useNavigate();
|
||||
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);
|
||||
@@ -735,7 +842,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
const handleRequest = async () => {
|
||||
if (!canUse || pending) return;
|
||||
if (isParticipant) {
|
||||
setControllerOpen(true);
|
||||
navigate('/ptz');
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
@@ -748,7 +855,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
dock-guard rejection does not strand the user in fullscreen.
|
||||
*/
|
||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
||||
setControllerOpen(true);
|
||||
navigate('/ptz');
|
||||
}
|
||||
trackAnalyticsEvent('ptz_queue_join_result', { layout, status: 'accepted' });
|
||||
} catch (err) {
|
||||
@@ -784,8 +891,7 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
: 'request';
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
||||
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
<QueueTargetRow
|
||||
target={{
|
||||
@@ -823,8 +929,6 @@ export default function PtzQueueCard({ layout = 'desktop' }) {
|
||||
Verify your account to use the PTZ camera.
|
||||
</div>
|
||||
) : null}
|
||||
</CardFrame>
|
||||
<PtzFullscreenController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
|
||||
</>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,13 +35,16 @@ function selectedKeysEqual(left, right) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
|
||||
export default function ReplaySourcesPanel({
|
||||
panelId = 'replay-sources',
|
||||
fillHeight = false,
|
||||
defaultSelectedKey = null,
|
||||
}) {
|
||||
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { triggerReplay } = useSessionActions();
|
||||
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||
@@ -65,20 +68,35 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const activeReplayJob = useSessionSelector((state) => (
|
||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
||||
));
|
||||
const latestReplayJobId = latestReplay?.jobId || null;
|
||||
// The job id is deliberately local to this mounted panel. Reading the global
|
||||
// latestReplay value here caused a newly mounted panel to resurrect the last
|
||||
// replay popup even though this panel did not request it. The job record can
|
||||
// remain in shared session state for asynchronous socket updates; selecting
|
||||
// it through this panel-owned id keeps popup ownership and lifetime local.
|
||||
const panelReplay = activeReplayJob?.media || null;
|
||||
const panelReplayJobId = panelReplay?.jobId || null;
|
||||
const showPanelReplay = Boolean(
|
||||
latestReplay?.url &&
|
||||
latestReplayJobId &&
|
||||
dismissedPanelReplayId !== latestReplayJobId,
|
||||
panelReplay?.url &&
|
||||
panelReplayJobId &&
|
||||
dismissedPanelReplayId !== panelReplayJobId,
|
||||
);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
const roverId = assignmentRoverId;
|
||||
if (roverId) {
|
||||
return [`rover:${roverId}`];
|
||||
const availableDefaultKey = useMemo(() => {
|
||||
// PTZ layouts provide their camera key explicitly so entering the dedicated
|
||||
// camera page does not inherit the user's assigned rover. Waiting until the
|
||||
// source is actually advertised also handles the initial session load: an
|
||||
// unavailable key is never left selected, but it becomes the default as
|
||||
// soon as the server publishes that replay source.
|
||||
if (defaultSelectedKey && sources.some((source) => source.key === defaultSelectedKey)) {
|
||||
return defaultSelectedKey;
|
||||
}
|
||||
return [];
|
||||
}, [assignmentRoverId]);
|
||||
const roverKey = assignmentRoverId ? `rover:${assignmentRoverId}` : null;
|
||||
if (roverKey && sources.some((source) => source.key === roverKey)) {
|
||||
return roverKey;
|
||||
}
|
||||
return null;
|
||||
}, [assignmentRoverId, defaultSelectedKey, sources]);
|
||||
const defaults = useMemo(() => (availableDefaultKey ? [availableDefaultKey] : []), [availableDefaultKey]);
|
||||
|
||||
const defaultTitle = useMemo(() => {
|
||||
const roverId = assignmentRoverId || null;
|
||||
@@ -225,9 +243,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
{showPanelReplay ? (
|
||||
<div className="absolute bottom-[calc(100%+0.125rem)] left-1/2 z-[70] w-[min(20rem,calc(100vw-1rem))] -translate-x-1/2">
|
||||
<ReplayReadyPopup
|
||||
replay={latestReplay}
|
||||
replay={panelReplay}
|
||||
variant="floating-panel"
|
||||
onClose={() => setDismissedPanelReplayId(latestReplayJobId)}
|
||||
onClose={() => setDismissedPanelReplayId(panelReplayJobId)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -261,6 +279,16 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
setTitleDirty(true);
|
||||
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Enter is the keyboard equivalent of clicking Replay. Ignore
|
||||
// composition events so confirming an IME candidate cannot
|
||||
// accidentally submit a replay before the title is complete.
|
||||
// handleReplay remains the single authority for cooldown,
|
||||
// lockdown, busy, and empty-source checks.
|
||||
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
|
||||
event.preventDefault();
|
||||
handleReplay();
|
||||
}}
|
||||
placeholder={defaultTitle}
|
||||
maxLength={120}
|
||||
/>
|
||||
|
||||
@@ -384,7 +384,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" />
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzStatePanel
|
||||
@@ -410,7 +410,10 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||
<ChatPanel fillHeight title="Chat" />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ReplaySourcesPanel panelId="ptz-controller-replay-mobile" />
|
||||
<ReplaySourcesPanel
|
||||
panelId="ptz-controller-replay-mobile"
|
||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PtzStatePanel
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// mobile, desktop, and gamepad inputs do not each learn camera-specific rules.
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
const PTZ_STOP = { pan: 0, tilt: 0, zoom: 0 };
|
||||
const PTZ_SPEEDS = {
|
||||
@@ -98,9 +98,12 @@ function nextIrMode(currentMode) {
|
||||
|
||||
export function usePtzControlAdapter() {
|
||||
const socket = useSocket();
|
||||
const { ptzSpotlight, ptzIr } = useSessionActions();
|
||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||
const isActive = Boolean(ptz?.isOperator);
|
||||
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
||||
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
||||
const zoomIntentRef = useRef(0);
|
||||
const zoomStopTimerRef = useRef(null);
|
||||
|
||||
const emitPtz = useCallback(
|
||||
@@ -116,6 +119,10 @@ export function usePtzControlAdapter() {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
// A true global stop is used for blur, route close, and control release, so
|
||||
// it deliberately clears every independently tracked PTZ axis intent.
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
const stopSignature = payloadSignature(PTZ_STOP);
|
||||
if (lastMotionSignatureRef.current === stopSignature) return;
|
||||
lastMotionSignatureRef.current = stopSignature;
|
||||
@@ -146,7 +153,20 @@ export function usePtzControlAdapter() {
|
||||
const applyDriveVector = useCallback(
|
||||
(vector, meta = {}) => {
|
||||
if (!isActive) return false;
|
||||
sendMotion(buildPanTiltPayload(vector, meta));
|
||||
const panTilt = buildPanTiltPayload(vector, meta);
|
||||
panTiltIntentRef.current = {
|
||||
pan: panTilt.pan,
|
||||
tilt: panTilt.tilt,
|
||||
};
|
||||
/*
|
||||
ONVIF continuous movement accepts pan, tilt, and zoom in one command.
|
||||
Preserve the current zoom intent when a direction update arrives so a
|
||||
keyboard or touch event on one axis cannot erase another held axis.
|
||||
*/
|
||||
sendMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion],
|
||||
@@ -157,7 +177,17 @@ export function usePtzControlAdapter() {
|
||||
if (!isActive) return false;
|
||||
const sign = axisSign(direction);
|
||||
if (!sign) {
|
||||
stopMotion();
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
}
|
||||
zoomIntentRef.current = 0;
|
||||
/*
|
||||
Releasing zoom must not call the global PTZ stop. Re-emit the retained
|
||||
pan/tilt intent with zoom cleared so a held direction continues
|
||||
immediately instead of waiting for another directional key event.
|
||||
*/
|
||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
||||
return true;
|
||||
}
|
||||
/*
|
||||
@@ -166,7 +196,11 @@ export function usePtzControlAdapter() {
|
||||
the payload is identical, otherwise holding "camera up" only sends the
|
||||
first zoom command and every later nudge is de-duped away.
|
||||
*/
|
||||
sendMotion({ pan: 0, tilt: 0, zoom: sign * PTZ_SPEEDS.medium }, { force: true });
|
||||
zoomIntentRef.current = sign * PTZ_SPEEDS.medium;
|
||||
sendMotion({
|
||||
...panTiltIntentRef.current,
|
||||
zoom: zoomIntentRef.current,
|
||||
}, { force: true });
|
||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
||||
/*
|
||||
Existing rover camera controls are nudge/slider based, not hold-based.
|
||||
@@ -175,21 +209,31 @@ export function usePtzControlAdapter() {
|
||||
*/
|
||||
zoomStopTimerRef.current = setTimeout(() => {
|
||||
zoomStopTimerRef.current = null;
|
||||
stopMotion();
|
||||
zoomIntentRef.current = 0;
|
||||
// A zoom pulse ending restores, rather than stops, any direction that
|
||||
// is still held in the independent pan/tilt intent.
|
||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
||||
}, ZOOM_PULSE_MS);
|
||||
return true;
|
||||
},
|
||||
[isActive, sendMotion, stopMotion],
|
||||
[isActive, sendMotion],
|
||||
);
|
||||
|
||||
const setSpotlight = useCallback(
|
||||
(nextOn) => {
|
||||
if (!isActive) return false;
|
||||
const desiredOn = typeof nextOn === 'boolean' ? nextOn : !isSpotlightOn(ptz?.light);
|
||||
emitPtz('ptzCamera:spotlight', { state: desiredOn ? 1 : 0 });
|
||||
/*
|
||||
Lighting keybinds should use the exact acknowledged command action as
|
||||
the visible PTZ buttons. Movement remains fire-and-forget because it is
|
||||
continuous and high frequency, but a discrete light toggle benefits
|
||||
from the existing authorization/error contract and must not maintain a
|
||||
second socket-only behavior merely because its source is a keybind.
|
||||
*/
|
||||
ptzSpotlight({ state: desiredOn ? 1 : 0 }).catch(() => {});
|
||||
return true;
|
||||
},
|
||||
[emitPtz, isActive, ptz?.light],
|
||||
[isActive, ptz?.light, ptzSpotlight],
|
||||
);
|
||||
|
||||
const setIr = useCallback(
|
||||
@@ -198,15 +242,19 @@ export function usePtzControlAdapter() {
|
||||
const desiredState = typeof nextOn === 'boolean'
|
||||
? (nextOn ? 'On' : 'Off')
|
||||
: nextIrMode(ptz?.ir?.state);
|
||||
emitPtz('ptzCamera:ir', { state: desiredState });
|
||||
// Match the button path for the same reason as spotlight above. The
|
||||
// shared laser key continues to select IR; only its transport is unified.
|
||||
ptzIr({ state: desiredState }).catch(() => {});
|
||||
return true;
|
||||
},
|
||||
[emitPtz, isActive, ptz?.ir?.state],
|
||||
[isActive, ptz?.ir?.state, ptzIr],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) return undefined;
|
||||
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||
zoomIntentRef.current = 0;
|
||||
if (zoomStopTimerRef.current) {
|
||||
clearTimeout(zoomStopTimerRef.current);
|
||||
zoomStopTimerRef.current = null;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Responsive Layout Mode Hook
|
||||
// Purpose: Gives control-capable routes the same desktop, mobile-landscape,
|
||||
// and mobile-portrait breakpoint policy.
|
||||
// Scope: Classifies viewport geometry only; each route still owns its actual
|
||||
// component arrangement so PTZ and rover controls can remain purpose-built.
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function readLayoutMode() {
|
||||
if (typeof window === 'undefined') return 'desktop';
|
||||
if (window.innerWidth >= 1024) return 'desktop';
|
||||
return window.innerWidth > window.innerHeight ? 'mobile-landscape' : 'mobile-portrait';
|
||||
}
|
||||
|
||||
export default function useLayoutMode() {
|
||||
const [mode, setMode] = useState(readLayoutMode);
|
||||
|
||||
useEffect(() => {
|
||||
function updateMode() {
|
||||
/*
|
||||
Orientation changes are exposed as viewport resizes on the browsers
|
||||
supported by this UI. Reading both dimensions here keeps the route
|
||||
responsive without maintaining a second orientation event lifecycle.
|
||||
*/
|
||||
setMode(readLayoutMode());
|
||||
}
|
||||
|
||||
updateMode();
|
||||
window.addEventListener('resize', updateMode);
|
||||
return () => window.removeEventListener('resize', updateMode);
|
||||
}, []);
|
||||
|
||||
return mode;
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import SessionDocumentTitle from './components/SessionDocumentTitle/index.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
@@ -37,6 +38,13 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/database" element={<DatabaseAdminApp />} />
|
||||
{/*
|
||||
PTZ is a separate route so the driver layout and its replay
|
||||
panel are not mounted behind the camera controller. This
|
||||
also makes orientation changes a PTZ layout concern instead
|
||||
of a local overlay-open state owned by the driver page.
|
||||
*/}
|
||||
<Route path="/ptz" element={<PtzAppRoot />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Dedicated PTZ Route Root
|
||||
// Purpose: Mounts the PTZ controller as a real page with the same shared input
|
||||
// and identity systems used by the driver page.
|
||||
// Scope: Owns route-level providers and responsive selection only; camera state,
|
||||
// queue policy, and the visible controller remain in the shared PTZ component.
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { PtzControllerPage } from '../components/PtzCamera/index.jsx';
|
||||
import {
|
||||
ControlSystemProvider,
|
||||
GamepadInputManager,
|
||||
KeyboardInputManager,
|
||||
} from '../controls/index.js';
|
||||
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
||||
import useIncomingInterInstanceTransfer from '../hooks/useIncomingInterInstanceTransfer.js';
|
||||
import useLayoutMode from '../hooks/useLayoutMode.js';
|
||||
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
||||
|
||||
function PtzRouteContent() {
|
||||
const layout = useLayoutMode();
|
||||
|
||||
/*
|
||||
Navigating away from the driver route unmounts its identity hooks. The PTZ
|
||||
route is still an active control surface, so it must keep the same driver
|
||||
identity heartbeat alive instead of allowing the session to become passive
|
||||
while someone operates or waits for the camera.
|
||||
*/
|
||||
useDefaultNickname();
|
||||
useIncomingInterInstanceTransfer();
|
||||
useUserIdentitySync({ identitySurface: 'driver' });
|
||||
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<PtzControllerPage layout={layout} />
|
||||
<AlertFeed />
|
||||
<SocketConnectionPill />
|
||||
</ControlSystemProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PtzAppRoot() {
|
||||
return <PtzRouteContent />;
|
||||
}
|
||||
Reference in New Issue
Block a user