new internal concept: operating mode. will allow for more UI and separate pages to be made to operate things other than rovers. Converted PTZ to new system.

This commit is contained in:
legop3
2026-09-22 14:42:21 -04:00
parent e750712a39
commit 846c9dae55
24 changed files with 236 additions and 336 deletions
@@ -132,11 +132,11 @@ export function ChatIdentity({ message, toolsToggle = null }) {
bot
</span>
) : null}
{message.roverId && (
{(message.roverId || message.operatingModeDisplay) && (
<RoverLabel
roverId={message.roverId}
name={message.roverName}
color={message.roverColor}
name={message.operatingModeDisplay?.name || message.roverName}
color={message.operatingModeDisplay?.color || message.roverColor}
fallback={message.roverId}
className="shrink-0 text-[0.7rem]"
/>
+4 -18
View File
@@ -51,31 +51,17 @@ function resolveTtsSettings(settings) {
function useChatComposerSessionState(allowSpectatorInput) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const users = useSessionSelector((state) => state.session?.users ?? []);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const chatTargetId = useMemo(() => {
/*
PTZ is intentionally not a roster entry, but session users expose the
current chat target for each socket. Prefer the self user entry so the TTS
control follows PTZ queue/operation state instead of only physical rover
assignment state.
*/
const self = users.find((entry) => entry?.socketId === socketId);
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
return self?.roverId || currentRoverId || null;
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
const rover = useMemo(
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
[chatTargetId, roster],
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roster],
);
const ptzTtsSupported = Boolean(
ptz?.id &&
String(chatTargetId) === String(ptz.id) &&
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
);
@@ -27,9 +27,8 @@ function detectSafari() {
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
const users = useSessionSelector((state) => state.session?.users || []);
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
@@ -49,24 +48,12 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
const chatTargetId = useMemo(() => {
/*
HUD chat does not render the full composer controls, but it still sends
TTS payloads when the active target supports speech. PTZ appears only in
the session user target, not the physical rover roster, so use the same
self-target resolution as the full chat panel.
*/
const self = users.find((entry) => entry?.socketId === socketId);
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
return self?.roverId || currentRoverId || null;
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
const rover = useMemo(
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
[chatTargetId, roverRoster],
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
[currentRoverId, roverRoster],
);
const ptzTtsSupported = Boolean(
ptz?.id &&
String(chatTargetId) === String(ptz.id) &&
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.isOperator || ptz?.queuedPosition),
);
@@ -0,0 +1,22 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useSessionActions } from '../../context/SessionContext.jsx';
export default function OperatingModeRouteSync() {
const { pathname } = useLocation();
const previousPath = useRef(pathname);
const { setOperatingMode, pushAlert } = useSessionActions();
useEffect(() => {
const leavingPtz = previousPath.current === '/ptz' && pathname !== '/ptz';
previousPath.current = pathname;
if (!leavingPtz) return;
// Observe actual navigation rather than component cleanup, which also runs
// during Strict Mode remounts. Entry remains gated by the PTZ request action.
setOperatingMode('rover').catch((err) => {
pushAlert({ title: 'Operating mode', message: err.message, color: '#f59e0b' });
});
}, [pathname, pushAlert, setOperatingMode]);
return null;
}
+28 -59
View File
@@ -121,6 +121,21 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
}
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
const [pending, setPending] = useState(false);
const participating = Boolean(ptz?.isOperator || ptz?.queuedPosition);
const changeTurn = async () => {
if (pending) return;
setPending(true);
try {
if (participating) await ptzRelease();
else await ptzClaim();
} catch (err) {
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setPending(false);
}
};
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
@@ -135,6 +150,9 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
selfId={selfId}
lookupUser={lookupUser}
/>
<button type="button" className="button-dark w-full text-xs" disabled={pending || !ptz?.canUse} onClick={changeTurn}>
{pending ? 'Please wait…' : ptz?.isOperator ? 'Release turn' : ptz?.queuedPosition ? 'Leave queue' : 'Request turn'}
</button>
</CardFrame>
);
}
@@ -691,59 +709,25 @@ export function PtzControllerPage({ layout = 'desktop' }) {
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 { ptzClaim, setOperatingMode, pushAlert } = useSessionActions();
const { stopAllMotion } = useControlActions();
const navigate = useNavigate();
const [releasePending, setReleasePending] = useState(false);
const autoClaimSocketRef = useRef(null);
const routeExitReleaseTimerRef = useRef(null);
const participantRef = useRef(false);
const closingThroughButtonRef = useRef(false);
const { value: pageSettings } = useSettingsNamespace('page', {
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
});
const isMobile = layout !== 'desktop';
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
// controls, and card interiors retain their purpose-built dark backgrounds.
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
useEffect(() => {
// Route-exit cleanup runs after the last render, so retain the latest
// server-confirmed membership without making the lifecycle effect resubscribe.
participantRef.current = isParticipant;
}, [isParticipant]);
useEffect(() => {
if (routeExitReleaseTimerRef.current) {
clearTimeout(routeExitReleaseTimerRef.current);
routeExitReleaseTimerRef.current = null;
}
return () => {
if (!participantRef.current || closingThroughButtonRef.current) return;
/*
Browser Back and route navigation unmount the PTZ page without invoking
its Close button. Defer release by one task so React Strict Mode's
development-only cleanup/remount cycle can cancel it in the next setup;
a real route exit has no replacement setup, so membership is released.
This is intentionally membership-gated. An admin release command can
revoke the current operator even when the admin is not that operator,
so an admin merely visiting/leaving a disabled or unjoined page must not
emit a release command.
*/
routeExitReleaseTimerRef.current = setTimeout(() => {
routeExitReleaseTimerRef.current = null;
ptzRelease().catch(() => {});
}, 0);
};
}, [ptzRelease]);
useEffect(() => {
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
// Wait for startup to publish readiness before consuming the one entry attempt.
if (!ptz.initialized && !ptz.error) return undefined;
if (ptz.isOperator || ptz.queuedPosition) {
/*
@@ -759,17 +743,8 @@ export function PtzControllerPage({ layout = 'desktop' }) {
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.
*/
// Handshake mode prevents rover assignment on direct loads/reconnects;
// this request checks eligibility and joins the camera's turn queue.
ptzClaim().catch((err) => {
if (!active) return;
pushAlert({
@@ -791,26 +766,20 @@ export function PtzControllerPage({ layout = 'desktop' }) {
const releaseAndClose = useCallback(async () => {
if (releasePending) return;
setReleasePending(true);
closingThroughButtonRef.current = 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?.();
if (ptz?.isOperator || ptz?.queuedPosition) {
await ptzRelease();
}
await setOperatingMode('rover');
navigate('/');
} catch (err) {
// A rejected manual release leaves the route mounted, so route-exit
// cleanup must remain armed for a later Back/navigation attempt.
closingThroughButtonRef.current = false;
throw err;
pushAlert({ title: 'PTZ camera', message: err.message, color: '#f59e0b' });
} finally {
setReleasePending(false);
}
}, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
}, [navigate, pushAlert, setOperatingMode, releasePending, stopAllMotion]);
if (!featureEnabled) {
return (
@@ -861,7 +830,7 @@ export default function PtzQueueCard() {
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 { ptzClaim, setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = usePtzQueueLookup(ptz);
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
@@ -901,7 +870,7 @@ export default function PtzQueueCard() {
if (pending) return;
setPending(true);
try {
await ptzRelease();
await setOperatingMode('rover');
} catch (err) {
alert(err.message || 'Failed to leave PTZ camera.');
} finally {
+3 -2
View File
@@ -141,10 +141,11 @@ export default function UserListPanel({
className={`flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId ? (
{user.roverId || user.operatingModeDisplay ? (
<RoverLabel
roverId={user.roverId}
color={rosterEntry(user.roverId)?.color}
name={user.operatingModeDisplay?.name}
color={user.operatingModeDisplay?.color || rosterEntry(user.roverId)?.color}
fallback={user.roverId}
className="text-[0.7rem]"
/>
@@ -340,7 +340,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
const { ptzRelease } = useSessionActions();
const { setOperatingMode } = useSessionActions();
/*
The VIP fullscreen surface is available to queued PTZ users too. Let queued
users see live video only when the central non-turn video policy allows it;
@@ -356,7 +356,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
const releaseAndClose = async () => {
setReleasePending(true);
try {
await ptzRelease();
await setOperatingMode('rover');
onClose();
} finally {
setReleasePending(false);
@@ -481,7 +481,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
const { ptzClaim, ptzRelease } = useSessionActions();
const { ptzClaim, setOperatingMode } = useSessionActions();
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const [controllerOpen, setControllerOpen] = useState(false);
@@ -527,7 +527,7 @@ export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout
const handleRelease = async () => {
setPending(true);
try {
await ptzRelease();
await setOperatingMode('rover');
onMessage?.('Left PTZ camera.');
} catch (err) {
onMessage?.(err.message || 'Failed to leave PTZ camera.');
+3 -1
View File
@@ -411,8 +411,10 @@ export function SessionProvider({ children }) {
emitWithAck('audioLevels:setPersonalAdjustmentRange', { maxAdjustmentPercent }),
setPrivateSafety: (roverId, safety = {}) =>
emitWithAck('session:privateSafety:set', { roverId, safety }),
ptzClaim: () => emitWithAck('ptzCamera:claim'),
setOperatingMode: (operatingMode) => emitWithAck('session:setOperatingMode', { operatingMode }),
ptzClaim: () => emitWithAck('session:setOperatingMode', { operatingMode: 'ptz' }),
ptzRelease: () => emitWithAck('ptzCamera:release'),
ptzRevokeOperator: () => emitWithAck('ptzCamera:revokeOperator'),
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
+1 -1
View File
@@ -33,7 +33,7 @@ export default function useUserIdentitySync({ identitySurface = 'passive' } = {}
const cookieUserId = (identity?.cookieUserId || '').trim();
const nickname = (profile?.nickname || '').trim();
const overseerEnabled = Boolean(overseerPreference?.enabled);
const normalizedIdentitySurface = identitySurface === 'driver' ? 'driver' : 'passive';
const normalizedIdentitySurface = ['driver', 'ptz'].includes(identitySurface) ? identitySurface : 'passive';
const sendIdentify = useCallback(async () => {
if (!ready || !connected || !socket?.id) return;
+2 -2
View File
@@ -20,9 +20,8 @@ function getIdentitySurface() {
*/
return window.location.pathname === '/'
|| window.location.pathname === '/old'
|| window.location.pathname === '/ptz'
? 'driver'
: 'passive';
: window.location.pathname === '/ptz' ? 'ptz' : 'passive';
}
async function buildSocketIdentity() {
@@ -43,6 +42,7 @@ async function buildSocketIdentity() {
audioAdjustments: currentSettings?.audioAdjustments || {},
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
identitySurface: getIdentitySurface(),
operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover',
};
}
+2
View File
@@ -20,6 +20,7 @@ import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
// Reporting and administration carry substantial route-specific libraries.
@@ -53,6 +54,7 @@ createRoot(document.getElementById('root')).render(
<DeterrenceChaos />
<BrowserRouter>
<AnalyticsReporter />
<OperatingModeRouteSync />
<Routes>
<Route path="/" element={<DriverPageRoot />} />
{/* The retired desktop composition remains available for direct comparison
+1 -7
View File
@@ -19,15 +19,9 @@ 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' });
useUserIdentitySync({ identitySurface: 'ptz' });
return (
<ControlSystemProvider>