mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
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:
@@ -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]"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user