mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
replay panel and PTZ ui improvements
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-C8ryXPtn.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-PKUQHBrm.css">
|
||||
<script type="module" crossorigin src="/assets/index-9jcNKvk8.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSRq98ER.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+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