more queue related convergence for the future.

This commit is contained in:
legop3
2026-09-27 17:19:56 -04:00
parent 7231809140
commit 34936ab05c
54 changed files with 219 additions and 368 deletions
@@ -10,7 +10,8 @@ const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('.
const { getConfiguredSocials } = require('../sessionService/configuration');
const { getMode, MODES } = require('../modeManager');
const roverManager = require('../roverManager');
const { getTurnQueues } = require('../turnService');
const { getTurnQueues, getActiveDrivers } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
const { getRoverSnapshotState } = require('../roverSnapshotService');
const { getRole } = require('../roleService');
@@ -157,22 +158,6 @@ function publicRoverIdSet(roster = []) {
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
}
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
const visible = publicIds instanceof Set ? publicIds : new Set();
const next = {};
/*
RoverQueuesPanel creates fallback rows for queue ids that are not present in
the roster, so the public payload must filter queues with the exact same
privacy boundary as the roster. Otherwise a closed-private rover can leak as
an orphan queue row after a private access grant assigns someone to it.
*/
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
if (!visible.has(String(roverId))) return;
next[roverId] = info;
});
return next;
}
function filterPublicUsers(users = [], publicIds) {
const visible = publicIds instanceof Set ? publicIds : new Set();
/*
@@ -203,6 +188,8 @@ function buildLocalInfo() {
const publicIds = publicRoverIdSet(publicRoster);
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
const turnQueues = getTurnQueues();
const activeDrivers = getActiveDrivers();
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
return {
instance: {
@@ -212,8 +199,12 @@ function buildLocalInfo() {
features,
updatedAt: Date.now(),
},
roster,
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
// Only public roster entries reach the builder; queue metadata follows the
// same visibility boundary instead of creating separate orphan queue rows.
roster: roster.map((rover) => ({
...rover,
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
})),
users,
roomCameras,
socials: features.socials ? getConfiguredSocials(config) : [],
@@ -349,7 +340,6 @@ function normalizeRemotePayload(entry, payload) {
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
},
roster: Array.isArray(payload?.roster) ? payload.roster : [],
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
users: Array.isArray(payload?.users) ? payload.users : [],
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
socials: Array.isArray(payload?.socials) ? payload.socials : [],
@@ -419,7 +409,6 @@ function markOffline(entry, error) {
publicUrl: previous.instance?.publicUrl || entry.url,
},
roster: previous.roster || [],
turnQueues: previous.turnQueues || {},
users: previous.users || [],
roomCameras: previous.roomCameras || [],
socials: previous.socials || [],
@@ -1,4 +1,5 @@
// PTZ policy and session facade. Camera IO and participation each own their lifecycle.
const { getNickname: getNicknameForQueue } = require('../nicknameService');
const { describeQueue } = require('../turnService/queueDisplay');
const EventEmitter = require('events');
@@ -118,8 +119,10 @@ function describeParticipation(socket) {
},
viewMode: socket && canRequestLiveVideo(socket) ? 'live' : 'snapshot',
turn: {
...describeQueue(queue, currentId),
userLabels: Object.fromEntries(queue.map((id) => [id, getSocketLabel(id)])),
...describeQueue(queue, currentId, queue.map((id) => {
const participant = io.sockets.sockets.get(id);
return { socketId: id, nickname: getNicknameForQueue(participant), role: getRole(participant) };
})),
target: {
id: PTZ_CAMERA_ID,
name: cameraConfig.name || 'PTZ Camera',
@@ -127,7 +130,7 @@ function describeParticipation(socket) {
fallback: null,
},
enabled: entered && (isOperator || position > 0) && participation.state.queue.length > 0,
isActive: isOperator,
ownsControl: isOperator,
turnsAhead: isOperator ? 0 : position || null,
queueLength: participation.state.queue.length + (participation.state.operatorSocketId ? 1 : 0),
deadline: participation.state.deadline,
@@ -1,14 +1,7 @@
const labels = {
...require('../turnService/labels'),
waiting: 'Someone else has control',
handoff: 'It’s your turn!',
active: 'You control the camera',
idleWarning: 'Start driving or your turn will be skipped',
activeTimer: 'Your turn',
waitingTimer: 'Waiting',
untilTurn: 'until your turn',
timeRemaining: 'left',
showTimer: 'Show turn timer',
hideTimer: 'Hide turn timer',
showName: 'Show camera name',
hideName: 'Hide camera name',
};
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
replay`, follows the camera currently controlled by that socket just as it
follows an assigned rover below.
turn.isActive is deliberately stricter than PTZ access or queue membership:
turn.ownsControl is deliberately stricter than PTZ access or queue membership:
spectators and users waiting for a camera turn must not silently replay a
camera they are not currently operating. Keeping this rule here also makes
every web replay entry point share the same default instead of teaching the
chat-command adapter about PTZ-specific state.
*/
if (ptzCameraService.getPublicState(socket).turn.isActive) {
if (ptzCameraService.getPublicState(socket).turn.ownsControl) {
const source = ptzCameraService.getReplaySource();
if (!source) return [];
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
+2 -3
View File
@@ -10,7 +10,7 @@ const { getMode, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
const { managerEvents } = roverManager;
const assignmentService = require('../assignmentService');
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
const { buildRoverTurn } = require('../turnService/display');
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
const {
@@ -197,6 +197,7 @@ function buildSession(socket) {
features,
roster: roster.map((rover) => ({
...rover,
permissions: { canControl: getRole(socket) !== 'spectator' && canDrive(rover.id, socket) },
turn: buildRoverTurn({
rover,
mode: getMode(),
@@ -212,8 +213,6 @@ function buildSession(socket) {
roverId: assignmentRoverId,
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
},
activeDrivers,
turnQueues,
roomCameras: getRoomCameras(),
ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(),
+6 -13
View File
@@ -2,16 +2,9 @@ const { describeQueue } = require('./queueDisplay');
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
const labels = {
...require('./labels'),
waiting: 'Someone else is driving',
handoff: 'It’s your turn!',
active: 'You’re driving',
idleWarning: 'Start driving or your turn will be skipped',
activeTimer: 'Your turn',
waitingTimer: 'Waiting',
untilTurn: 'until your turn',
timeRemaining: 'left',
showTimer: 'Show turn timer',
hideTimer: 'Hide turn timer',
showName: 'Show rover name',
hideName: 'Hide rover name',
};
@@ -25,14 +18,14 @@ function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users
const userIndex = socketId ? queue.indexOf(socketId) : -1;
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
const display = describeQueue(queue, currentDriverId, users);
return {
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
...describeQueue(queue, currentDriverId),
userLabels: Object.fromEntries(queue.map((id) => [id,
users.find((user) => user.socketId === id)?.nickname || id,
])),
...display,
// Null distinguishes a rover that has never had a queue from an empty queue.
queue: turnInfo ? display.queue : null,
enabled,
isActive: turnsAhead === 0,
ownsControl: Boolean(socketId && currentDriverId === socketId),
turnsAhead,
queueLength: queue.length,
deadline: turnInfo?.deadline || null,
+1 -2
View File
@@ -1,7 +1,6 @@
// turn Service
// Purpose: Defines the turn Service module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const { describeQueue } = require('./queueDisplay');
const io = require('../../globals/io');
const { sendAlert } = require('../alertService');
const { MODES, getMode, modeEvents } = require('../modeManager');
@@ -432,7 +431,7 @@ function getTurnQueues() {
driverQueues.forEach((queue, roverId) => {
payload[roverId] = {
mode,
...describeQueue(Array.from(queue.queue), queue.current),
queue: Array.from(queue.queue),
current: queue.current,
deadline: turnDeadlines.get(roverId) || null,
idleDeadline: idleDeadlines.get(roverId) || null,
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
handoff: 'It’s your turn!',
idleWarning: 'Start driving or your turn will be skipped',
activeTimer: 'Your turn',
waitingTimer: 'Waiting',
untilTurn: 'until your turn',
timeRemaining: 'left',
showTimer: 'Show turn timer',
hideTimer: 'Hide turn timer',
};
@@ -1,10 +1,20 @@
// Shared display ordering only; each service still owns its queue policy.
function describeQueue(queue, currentId) {
const currentIndex = currentId ? queue.indexOf(currentId) : -1;
const nextId = queue.length > 1
? queue[(currentIndex + 1) % queue.length]
: null;
return { queue, currentId, nextId };
// Queue entries are complete display records. No browser-side user lookup or
// naming fallback is needed, including when the queue comes from another server.
function describeQueue(socketIds, currentId, users) {
const byId = new Map(users.map((user) => [user.socketId, user]));
const currentIndex = currentId ? socketIds.indexOf(currentId) : -1;
return {
queue: socketIds.map((socketId) => {
const user = byId.get(socketId);
return {
socketId,
name: user?.nickname || user?.name || socketId.slice(0, 6) || 'unknown',
role: user?.role || null,
};
}),
currentId,
nextId: socketIds.length > 1 ? socketIds[(currentIndex + 1) % socketIds.length] : null,
};
}
module.exports = { describeQueue };
+1 -1
View File
@@ -63,7 +63,7 @@ function useChatComposerSessionState(allowSpectatorInput) {
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
);
return {
@@ -55,7 +55,7 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
const ptzTtsSupported = Boolean(
operatingMode === 'ptz' &&
ptz?.audio?.enabled &&
(ptz?.turn?.isActive || ptz?.turn?.turnsAhead > 0),
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
);
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
const ttsPayload = useMemo(() => {
@@ -40,10 +40,8 @@ function HudOverlay({
});
const derivedDriverLabel = useSessionSelector((state) => {
if (!effectiveRoverId || variant !== 'spectator') return null;
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
const users = state.session?.users || [];
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
return match?.nickname || match?.name || null;
const turn = state.session?.roster?.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
return turn?.queue?.find((entry) => entry.socketId === turn.currentId)?.name || null;
});
const resolvedSensors = sensors ?? null;
const resolvedLabel = label ?? rosterInfo.label ?? null;
@@ -7,16 +7,13 @@ import SocialButton from '../../SocialButton/index.jsx';
function TurnsOverlay({
roverId = null,
mobileHud = false,
turnModel = null,
}) {
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
const effectiveRoverId = roverId ?? assignedRoverId;
const mode = useSessionSelector((state) => state.session?.mode || null);
const roster = useSessionSelector((state) => state.session?.roster ?? []);
const users = useSessionSelector((state) => state.session?.users ?? []);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const [showTurnCue, setShowTurnCue] = useState(false);
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
@@ -30,30 +27,15 @@ function TurnsOverlay({
const subClass = mobileHud ? 'text-xs' : 'text-sm';
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
const activeDriverId = turnModel
? turnModel.activeId || null
: effectiveRoverId
? activeDrivers?.[effectiveRoverId] || null
: null;
const isActiveDriver = turnModel
? Boolean(turnModel.isActive)
: Boolean(socketId && activeDriverId === socketId);
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
const turnInfo = roster.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
const activeDriverId = turnInfo?.currentId || null;
const isActiveDriver = Boolean(turnInfo?.ownsControl);
const isTurnsMode = mode === 'turns';
const now = useSharedClock(1000, isTurnsMode);
const nextDriverId = useMemo(() => {
if (turnModel) return turnModel.nextId || null;
const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo, turnModel]);
const isNextDriver = turnModel
? Boolean(turnModel.isNext)
: Boolean(socketId && nextDriverId === socketId);
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const idleDeadline = turnInfo?.idleDeadline || null;
const msUntilTurn = deadline ? deadline - now : null;
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
const totalRovers = roster.length;
@@ -69,14 +51,10 @@ function TurnsOverlay({
});
return unique.size;
}, [users]);
const shouldUsePreviewByLoad = turnModel
? Boolean(turnModel.showPreviewReason)
: isTurnsMode && totalDrivers > totalRovers;
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
const isPreSwitchWindow =
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
const showNotTurnNotice = turnModel
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
: isTurnsMode && !isActiveDriver;
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
const turnSeconds =
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
@@ -105,12 +83,9 @@ function TurnsOverlay({
const turnTimerFlashActive = noticeFlashActive;
useEffect(() => {
/*
Rover turns and PTZ turns now arrive through the same render path. Use the
normalized isTurnsMode flag here instead of checking the server's rover
mode directly, otherwise PTZ can render the notice but never trigger the
"your turn" cue when camera ownership changes.
*/
// The legacy overlay still follows the server's turns mode; ownership
// and deadlines now come from the same turn object as the new HUD.
if (!isTurnsMode) {
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
/*
@@ -5,7 +5,7 @@ import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import ControlHint from '../../../ControlHint/index.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ChatExpansion from './ChatExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
@@ -28,7 +28,7 @@ function pointOnArc(fraction) {
}
export default function BottomRightPod({ roverId }) {
const layout = useDriverLayout();
const layout = useLayout();
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
@@ -12,7 +12,7 @@ export default function TopLeftPod({ turns, compact = false }) {
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
const { target, labels, enabled: turnActive, queueLength, turnsAhead, isActive: isCurrentTurn } = turns;
const { target, labels, enabled: turnActive, queueLength, turnsAhead, ownsControl: isCurrentTurn } = turns;
const deadline = turns.deadline;
const idleDeadline = turns.idleDeadline;
const durationSeconds = turns.durationMs / 1000;
@@ -6,10 +6,10 @@ import TopRightPod from './TopRightPod.jsx';
import BottomLeftPod from './BottomLeftPod.jsx';
import BottomRightPod from './BottomRightPod.jsx';
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
export default function CornerPods({ roverId }) {
const layout = useDriverLayout();
const layout = useLayout();
const turns = useSessionSelector((state) => state.session?.roster
?.find((rover) => String(rover.id) === String(roverId))?.turn);
const showPhysicalControlPods = layout === 'desktop';
@@ -11,7 +11,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/tel
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
import { useSettingsNamespace } from '../../../../settings/index.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
@@ -250,7 +250,7 @@ function UndockTransitionGhost({ onFinish }) {
}
export default function DockingHud({ roverId }) {
const layout = useDriverLayout();
const layout = useLayout();
const actions = useControlActions();
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
@@ -170,8 +170,6 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
key={remote.url}
title={remote.instance?.name || remote.url}
roster={remote.roster}
turnQueues={remote.turnQueues}
users={remote.users}
externalInstance={remote}
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
/>
@@ -221,8 +219,6 @@ function InterInstanceCards({ instances, centered = false, singleColumn = false
<RemoteMediaStrip remote={remote} />
<RoverQueuesPanel
roster={remote.roster}
turnQueues={remote.turnQueues}
users={remote.users}
externalInstance={remote}
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
/>
+2 -2
View File
@@ -11,7 +11,7 @@ import BottomSensorHud from '../HudOverlays/newgen/BottomSensorHud/index.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useControlSelector } from '../../controls/index.js';
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
import { useLayout } from '../../layouts/LayoutContext.jsx';
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
@@ -21,7 +21,7 @@ import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
const MOBILE_HUD_SCALE = 0.68;
export default function NewDriveVideo() {
const layout = useDriverLayout();
const layout = useLayout();
const mobileHud = layout !== 'desktop';
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
const videoMode = useDriverVideoModePolicy(roverId);
+5 -11
View File
@@ -7,7 +7,6 @@ import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import { isFeatureEnabled } from '../../lib/features.js';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
const PTZ_DEFAULT_COLOR = '#387bf8';
function formatRemaining(deadline, now) {
@@ -24,13 +23,11 @@ export default function PtzQueueCard() {
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const { setOperatingMode } = useSessionActions();
const navigate = useNavigate();
const lookupUser = useQueueUserLookup(ptz?.turn);
const { queue, currentId, nextId } = ptz?.turn || {};
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
const [pending, setPending] = useState(false);
const canUse = Boolean(ptz?.permissions?.canEnter);
const isParticipant = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
const timerLabel = ptz?.turn?.isActive && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
const isParticipant = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
const timerLabel = ptz?.turn?.ownsControl && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
if (!featureEnabled) return null;
@@ -77,16 +74,13 @@ export default function PtzQueueCard() {
name: ptz?.name || 'PTZ Camera',
color: ptz?.color || PTZ_DEFAULT_COLOR,
}}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={ptz?.turn}
selfId={selfId}
lookupUser={lookupUser}
canClick={canUse && !pending}
pending={pending}
buttonLabel={actionLabel}
batteryLabel={ptz?.turn?.isActive ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.isActive ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
timerLabel={timerLabel}
onRequest={handleRequest}
showAction={canUse}
+8 -30
View File
@@ -21,36 +21,20 @@ function roleColors(role) {
}
}
function formatQueueUserLabel(user, selfId) {
/*
Queue chips need to be readable even when a socket has no nickname yet.
Keeping the socket-prefix fallback here means rover queues and PTZ queues
degrade identically instead of each target inventing its own anonymous label.
*/
if (!user) return '';
const base = user.nickname || user.label || user.socketId?.slice(0, 6) || 'unknown';
if (user.socketId && user.socketId === selfId) {
return `${base} (you)`;
}
return base;
}
export function QueueUserChips({
targetId,
queue = [],
currentId = null,
nextId = null,
turn,
selfId = null,
lookupUser,
}) {
if (!queue.length) {
const { queue = [], currentId, nextId } = turn || {};
if (!queue?.length) {
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
}
return (
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((socketId, idx) => {
const user = lookupUser?.(socketId) || { socketId, nickname: null, role: null };
{queue.map((user, idx) => {
const { socketId } = user;
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
/*
@@ -69,7 +53,7 @@ export function QueueUserChips({
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
>
<span className={`${roleColors(user.role)} font-semibold`}>
{formatQueueUserLabel(user, selfId)}
{user.name}{socketId === selfId ? ' (you)' : ''}
</span>
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
@@ -82,11 +66,8 @@ export function QueueUserChips({
export default function QueueTargetRow({
target,
queue = [],
currentId = null,
nextId = null,
turn,
selfId = null,
lookupUser,
canClick = false,
pending = false,
locked = false,
@@ -164,11 +145,8 @@ export default function QueueTargetRow({
</div>
<QueueUserChips
targetId={targetId}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={turn}
selfId={selfId}
lookupUser={lookupUser}
/>
</div>
{showAction ? (
@@ -1,7 +1,6 @@
// constants
// Purpose: Defines the constants module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
export const RESTART_DELAY_MS = 2000;
export const UNMUTE_RETRY_MS = 3000;
export const AUDIO_RETRY_MS = 3000;
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
@@ -3,7 +3,6 @@
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
import { useSharedClock } from '../../hooks/useSharedClock.js';
import CardFrame from '../CardFrame/index.jsx';
import QueueTargetRow from '../QueueTargetRow/index.jsx';
@@ -97,16 +96,12 @@ function ScrollableQueueContent({ enabled = false, children }) {
export default function RoverQueuesPanel({
title = 'Rovers',
roster: rosterOverride = null,
turnQueues: turnQueuesOverride = null,
users: usersOverride = null,
externalInstance = null,
disabledOverlay = '',
fillHeight = false,
}) {
const role = useSessionSelector((state) => state.session?.role || null);
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
const selfId = useSessionSelector((state) => state.session?.socketId || null);
@@ -125,8 +120,6 @@ export default function RoverQueuesPanel({
const externalBlocked = Boolean(externalMode && disabledOverlay);
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues;
const users = Array.isArray(usersOverride) ? usersOverride : localUsers;
const canRequest = useMemo(
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
@@ -137,8 +130,8 @@ export default function RoverQueuesPanel({
[role],
);
const hasDeadlines = useMemo(
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
[turnQueues],
() => roster.some((rover) => rover.turn?.deadline || rover.turn?.idleDeadline),
[roster],
);
/*
Queue timers are shown in whole seconds, and several queue panels can be
@@ -147,14 +140,6 @@ export default function RoverQueuesPanel({
*/
const now = useSharedClock(1000, hasDeadlines);
const rosterItems = useMemo(() => {
const known = new Set(roster.map((rover) => String(rover.id)));
const extra = Object.keys(turnQueues || {})
.filter((id) => !known.has(String(id)))
.map((id) => ({ id, name: id, locked: false, batteryState: null }));
return [...roster, ...extra];
}, [roster, turnQueues]);
async function handleRequest(targetRoverId) {
if (!targetRoverId) return;
if (externalBlocked) return;
@@ -182,7 +167,6 @@ export default function RoverQueuesPanel({
}
}
const lookupUser = useQueueUserLookup(null, users);
async function handleRebootOwnRover() {
if (rebootPending) return;
@@ -224,14 +208,13 @@ export default function RoverQueuesPanel({
>
<ScrollableQueueContent enabled={fillHeight}>
<div className="relative space-y-0.5">
{rosterItems.length === 0 ? (
{roster.length === 0 ? (
<p className="text-sm text-slate-500">No rovers registered.</p>
) : (
<ul className="space-y-0.5 text-sm">
{rosterItems.map((rover) => {
{roster.map((rover) => {
const roverId = String(rover.id);
const info = turnQueues?.[roverId] || null;
const queue = info?.queue || [];
const info = rover.turn;
/*
A turn handoff and an inactivity skip are separate server
deadlines. Keep them separate through formatting so an idle
@@ -275,11 +258,8 @@ export default function RoverQueuesPanel({
<QueueTargetRow
key={rover.id}
target={{ ...rover, rover, roverId, id: roverId }}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={info}
selfId={selfId}
lookupUser={lookupUser}
canClick={canClickRow}
pending={Boolean(pending[roverId])}
locked={locked}
@@ -25,7 +25,6 @@ function useAudio(src, volume = 1) {
export default function TurnAlertListener() {
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const assignments = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const { pushAlert } = useSessionActions();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
@@ -38,18 +37,19 @@ export default function TurnAlertListener() {
useEffect(() => {
if (!socketId) return;
const newlyMine = [];
Object.entries(assignments).forEach(([roverId, info]) => {
if (!info || !info.current || info.current !== socketId) return;
roster.forEach((rover) => {
const roverId = String(rover.id);
if (!rover.turn?.ownsControl) return;
if (!seenRoversRef.current.has(roverId)) {
newlyMine.push(roverId);
seenRoversRef.current.add(roverId);
}
});
Object.keys(assignments).forEach((roverId) => {
if (!assignments[roverId] || assignments[roverId].current !== socketId) {
for (const roverId of seenRoversRef.current) {
if (!roster.some((rover) => String(rover.id) === roverId && rover.turn?.ownsControl)) {
seenRoversRef.current.delete(roverId);
}
});
}
if (newlyMine.length === 0) return;
newlyMine.forEach((roverId) => {
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
@@ -60,7 +60,7 @@ export default function TurnAlertListener() {
});
});
playSound();
}, [assignments, playSound, pushAlert, roster, socketId]);
}, [playSound, pushAlert, roster, socketId]);
return null;
}
+8 -22
View File
@@ -73,8 +73,8 @@ export default function UserListPanel({
const users = useSessionSelector((state) => state.session?.users ?? []);
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const mode = useSessionSelector((state) => state.session?.mode || null);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
const roster = useSessionSelector((state) => state.session?.roster || []);
const queuedRovers = roster.filter((rover) => rover.turn?.queue != null);
const isTurnsMode = mode === 'turns';
const [turnView, setTurnView] = useState('queues');
@@ -98,11 +98,6 @@ export default function UserListPanel({
[roster],
);
const lookupUser = useCallback(
(socketId) => users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null },
[users],
);
const secondsRemaining = useCallback((deadline) => {
if (!deadline) return null;
const ms = deadline - Date.now();
@@ -165,7 +160,7 @@ export default function UserListPanel({
return (
<CardFrame
title={!hideHeader ? (isTurnsMode ? (showQueuesSection ? 'Turn queues' : 'Users') : 'Users') : ''}
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length) : null}
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? queuedRovers.length : sorted.length) : null}
hideHeader={hideHeader}
fillHeight={fillHeight}
className={className}
@@ -209,21 +204,14 @@ export default function UserListPanel({
) : null}
{showQueuesSection ? (
<div className={`space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
{Object.keys(turnQueues || {}).length === 0 ? (
{queuedRovers.length === 0 ? (
<p className="text-sm text-slate-500">No turn queues yet.</p>
) : (
Object.entries(turnQueues).map(([roverId, info]) => {
queuedRovers.map(({ id: roverId, turn: info }) => {
const queue = info?.queue || [];
const deadline = info?.idleDeadline || info?.deadline || null;
const remaining = secondsRemaining(deadline);
const currentId = info?.current || null;
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
const nextId =
queue.length > 1
? currentIdx >= 0
? queue[(currentIdx + 1) % queue.length]
: queue[0]
: null;
const { currentId, nextId } = info;
return (
<div key={roverId} className={`flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
<div className="flex items-center gap-0.5">
@@ -240,11 +228,10 @@ export default function UserListPanel({
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
) : (
<div className="flex flex-wrap items-center gap-0.5">
{queue.map((socketId, idx) => {
const user = lookupUser(socketId);
{queue.map((user, idx) => {
const { socketId } = user;
const isCurrent = socketId === currentId;
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId);
const isAdmin =
user.role === 'admin' || user.role === 'lockdown';
const highlightClass = isCurrent
@@ -258,10 +245,9 @@ export default function UserListPanel({
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
>
<span className={`${roleColors(user.role)} font-semibold`}>
{formatLabel(user, selfId)}
{user.name}{socketId === selfId ? ' (you)' : ''}
</span>
{isAdmin && <span className="text-[0.7rem] text-amber-200">★</span>}
{/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
</span>
@@ -7,7 +7,7 @@ import { useSessionSelector } from '../../../context/SessionContext.jsx';
export default function DisplayPtzOperatorBadge() {
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const operatorLabel = String(ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || '').trim();
const operatorLabel = String(ptz?.turn?.queue?.find((entry) => entry.socketId === ptz.turn.currentId)?.name || '').trim();
const visible = Boolean(ptz?.enabled && operatorLabel);
return (
+3 -7
View File
@@ -13,13 +13,9 @@ export function formatUserName(user) {
}
export function findDriverForRover({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
if (!activeDriverId) return null;
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
return {
socketId: activeDriverId,
label: formatUserName(user || { socketId: activeDriverId }),
};
const turn = session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn;
const driver = turn?.queue?.find((entry) => entry.socketId === turn.currentId);
return driver ? { socketId: driver.socketId, label: driver.name } : null;
}
export function getDisplayBatteryVisual({ rover, frame }) {
+9 -6
View File
@@ -24,7 +24,8 @@ import {
usePageThemeClass,
} from '../themes/index.js';
import useLayoutMode from '../hooks/useLayoutMode.js';
import { DriverLayoutProvider } from '../layouts/driver/DriverLayoutContext.jsx';
import { DriverHelpProvider } from '../layouts/driver/DriverHelpContext.jsx';
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
import DriverLayoutRoot from '../layouts/driver/DriverLayoutRoot.jsx';
import UndockedPageExitGuard from '../components/UndockedPageExitGuard/index.jsx';
@@ -98,11 +99,13 @@ function DriverPageContent({ layout, oldDesktop }) {
<KeyboardInputManager />
<GamepadInputManager />
<main className={`relative flex w-full flex-col ${themeGapClass} text-base`}>
<DriverLayoutProvider layout={layout} openHelp={openHelp}>
{/* Route selection affects only which desktop composition is mounted;
all providers, overlays, inputs, and mobile layouts remain shared. */}
<DriverLayoutRoot oldDesktop={oldDesktop} />
</DriverLayoutProvider>
<LayoutProvider layout={layout}>
<DriverHelpProvider openHelp={openHelp}>
{/* Route selection affects only which desktop composition is mounted;
all providers, overlays, inputs, and mobile layouts remain shared. */}
<DriverLayoutRoot oldDesktop={oldDesktop} />
</DriverHelpProvider>
</LayoutProvider>
</main>
<AlertFeed />
<DuplicateIdentityOverlay />
+1 -19
View File
@@ -1,25 +1,7 @@
// Rover Control Availability
// Purpose: Mirrors the server turnService.canDrive rule for immediate, consistent UI disabling.
import { useSessionSelector } from '../context/SessionContext.jsx';
export function selectCanControlRover(state, roverId) {
const session = state.session;
if (!roverId || session?.role === 'spectator') return false;
if (session?.mode !== 'turns') return true;
const turnInfo = session?.turnQueues?.[roverId] || null;
const queue = Array.isArray(turnInfo?.queue) ? turnInfo.queue : [];
/* The server permits control when a turns queue is absent or contains at most
one driver. Only a genuinely shared queue requires the active socket id.
Keeping this exact ordering prevents initial session loading from disabling
an otherwise uncontested rover. The server remains the final authority. */
if (queue.length <= 1) return true;
/* activeDrivers is the server's direct ownership map and can arrive before
the richer queue snapshot during an initial load or reconnect. The queue
field remains a compatibility fallback for intermediate payloads. */
const activeDriverId = session?.activeDrivers?.[roverId] || turnInfo?.current || null;
return Boolean(session?.socketId && activeDriverId === session.socketId);
return Boolean(state.session?.roster?.find((rover) => String(rover.id) === String(roverId))?.permissions?.canControl);
}
export default function useCanControlRover(roverId) {
+3 -13
View File
@@ -1,12 +1,9 @@
import { useMemo } from 'react';
import { useSessionSelector } from '../context/SessionContext.jsx';
import { useSharedClock } from './useSharedClock.js';
export function useDriverVideoModePolicy(roverId) {
const mode = useSessionSelector((state) => state.session?.mode || null);
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
const nonTurnSnapshotsActive = useSessionSelector(
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
);
@@ -18,16 +15,9 @@ export function useDriverVideoModePolicy(roverId) {
*/
const now = useSharedClock(1000, isTurnsMode);
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : null;
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
const nextDriverId = useMemo(() => {
const queue = turnInfo?.queue || [];
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
const idx = queue.findIndex((id) => id === turnInfo.current);
if (idx === -1) return queue[0] || null;
return queue[(idx + 1) % queue.length] || null;
}, [turnInfo]);
const turnInfo = useSessionSelector((state) => state.session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn);
const isActiveDriver = Boolean(turnInfo?.ownsControl);
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
const deadline = turnInfo?.deadline || null;
const msUntilTurn = deadline ? deadline - now : null;
-10
View File
@@ -1,10 +0,0 @@
import { useCallback } from 'react';
import { useSessionSelector } from '../context/SessionContext.jsx';
export default function useQueueUserLookup(turn, usersOverride) {
const localUsers = useSessionSelector((state) => state.session?.users);
const users = usersOverride || localUsers;
return useCallback((socketId) => users?.find((user) => user.socketId === socketId)
|| { socketId, nickname: turn?.userLabels?.[socketId] || null, role: null },
[users, turn?.userLabels]);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { RESTART_DELAY_MS } from '../components/RoverMediaPlayer/constants.js';
import { RESTART_DELAY_MS } from '../lib/whepPlayback.js';
export default function useWhepRestart(enabled = true) {
const timer = useRef(null);
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext } from 'react';
const LayoutContext = createContext(null);
export function LayoutProvider({ layout, children }) {
return <LayoutContext.Provider value={layout}>{children}</LayoutContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useLayout() {
const layout = useContext(LayoutContext);
if (!layout) throw new Error('useLayout must be used within LayoutProvider.');
return layout;
}
@@ -0,0 +1,15 @@
// Driver Help is independent of responsive layout and only exists on this route.
import { createContext, useContext } from 'react';
const DriverHelpContext = createContext(null);
export function DriverHelpProvider({ openHelp, children }) {
return <DriverHelpContext.Provider value={openHelp}>{children}</DriverHelpContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useOpenDriverHelp() {
const openHelp = useContext(DriverHelpContext);
if (!openHelp) throw new Error('useOpenDriverHelp must be used within DriverHelpProvider.');
return openHelp;
}
@@ -1,38 +0,0 @@
// Driver Layout Context
// Purpose: Exposes the existing global Help-overlay action to deeply placed tab content.
// Scope: Avoids threading a layout callback through otherwise concrete composition files.
import { createContext, useContext } from 'react';
const DriverLayoutContext = createContext(null);
export function DriverLayoutProvider({ layout, openHelp, children }) {
/*
Layout mode is classified once at the driver-route boundary. Concrete
layouts and tabs read it here instead of each installing a resize listener
or receiving chains of layout/configuration props.
*/
return <DriverLayoutContext.Provider value={{ layout, openHelp }}>{children}</DriverLayoutContext.Provider>;
}
/*
This hook intentionally lives beside its tiny route-local provider so the
driver layout contract remains discoverable in one file. It is not a React
component export, so the Fast Refresh rule needs this narrow exception.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function useOpenDriverHelp() {
const context = useContext(DriverLayoutContext);
if (!context) {
throw new Error('useOpenDriverHelp must be used within DriverLayoutProvider.');
}
return context.openHelp;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useDriverLayout() {
const context = useContext(DriverLayoutContext);
if (!context) {
throw new Error('useDriverLayout must be used within DriverLayoutProvider.');
}
return context.layout;
}
@@ -5,10 +5,10 @@ import DesktopLayout from './DesktopLayout/index.jsx';
import OldDesktopLayout from './OldDesktopLayout/index.jsx';
import MobilePortraitLayout from './MobilePortraitLayout/index.jsx';
import MobileLandscapeLayout from './MobileLandscapeLayout/index.jsx';
import { useDriverLayout } from './DriverLayoutContext.jsx';
import { useLayout } from '../LayoutContext.jsx';
export default function DriverLayoutRoot({ oldDesktop = false }) {
const layout = useDriverLayout();
const layout = useLayout();
if (layout === 'desktop') {
// /old changes only the desktop composition. Mobile keeps one canonical
@@ -9,11 +9,11 @@ import { useTelemetryVisualPolicy } from '../../../context/TelemetryContext.jsx'
import { useFullscreenPrompt } from '../../../hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from '../../../settings/index.js';
import { themeGapClass } from '../../../themes/index.js';
import { useDriverLayout } from '../DriverLayoutContext.jsx';
import { useLayout } from '../../LayoutContext.jsx';
import './styles.css';
export default function MobileLayoutFrame({ children }) {
const layout = useDriverLayout();
const layout = useLayout();
useTelemetryVisualPolicy({ mobile: true });
const fullscreen = useFullscreenPrompt(layout);
const { value: pageSettings } = useSettingsNamespace('page', { swapMobileControlColumns: false });
@@ -5,11 +5,11 @@ import PtzQueueCard from '../../../components/PtzCamera/index.jsx';
import ReplaySourcesPanel from '../../../components/ReplaySourcesPanel/index.jsx';
import RoverQueuesPanel from '../../../components/RoverQueuesPanel/index.jsx';
import { themeGapClass } from '../../../themes/index.js';
import { useDriverLayout } from '../DriverLayoutContext.jsx';
import { useLayout } from '../../LayoutContext.jsx';
import MobileTabs from '../MobileTabs/index.jsx';
export default function MobileSecondaryContent() {
const layout = useDriverLayout();
const layout = useLayout();
const portrait = layout === 'mobile-portrait';
const replayPanelId = portrait
? 'replay-sources-mobile-portrait'
@@ -4,10 +4,10 @@ import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HomeAssistantControls from '../../../../../components/HomeAssistantControls/index.jsx';
import RoomCameraPanel from '../../../../../components/RoomCameraPanel/index.jsx';
import { themeStackClass } from '../../../../../themes/index.js';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
import { useLayout } from '../../../../LayoutContext.jsx';
export default function RoomControlsTab() {
const layout = useDriverLayout();
const layout = useLayout();
const panelId = layout === 'mobile-landscape' ? 'mobile-landscape-room' : 'mobile-portrait-room';
return (
<TabPanel id="roomcontrols">
@@ -11,10 +11,10 @@ import ButtonBoxPanel from '../../../../../components/ButtonBoxPanel/index.jsx';
import KinectPanel from '../../../../../components/KinectPanel/index.jsx';
import FleetReportsCard from '../../../../../components/FleetReportsCard/index.jsx';
import { themeGapClass } from '../../../../../themes/index.js';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
import { useLayout } from '../../../../LayoutContext.jsx';
export default function ActivitiesTab() {
const layout = useDriverLayout();
const layout = useLayout();
return (
<TabPanel id="activities">
<div className={`flex flex-col ${layout === 'desktop' ? 'gap-y-2' : themeGapClass}`}>
@@ -2,12 +2,12 @@
// Purpose: Owns the shared driver help panel placement.
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import HelpPanel from '../../../../../components/HelpPanel/index.jsx';
import { useOpenDriverHelp } from '../../../DriverLayoutContext.jsx';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
import { useOpenDriverHelp } from '../../../DriverHelpContext.jsx';
import { useLayout } from '../../../../LayoutContext.jsx';
export default function HelpTab() {
const openHelp = useOpenDriverHelp();
const layout = useDriverLayout();
const layout = useLayout();
return (
<TabPanel id="help">
<HelpPanel layout={layout} onOpenOverlay={openHelp} />
@@ -3,10 +3,10 @@
import { TabPanel } from '../../../../../components/Tabs/index.jsx';
import SettingsPanel from '../../../../../components/SettingsPanel/index.jsx';
import { themeStackClass } from '../../../../../themes/index.js';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
import { useLayout } from '../../../../LayoutContext.jsx';
export default function SettingsTab() {
const layout = useDriverLayout();
const layout = useLayout();
const wrapped = layout !== 'desktop';
return (
<TabPanel id="settings">
@@ -2,10 +2,10 @@
// Purpose: Owns the shared keep-mounted VIP panel lifecycle.
import { TabPanel, useTabIsActive } from '../../../../../components/Tabs/index.jsx';
import VipPanel from '../../../../../components/VipPanel/index.jsx';
import { useDriverLayout } from '../../../DriverLayoutContext.jsx';
import { useLayout } from '../../../../LayoutContext.jsx';
export default function VipTab() {
const layout = useDriverLayout();
const layout = useLayout();
const isActive = useTabIsActive('vip');
return (
<TabPanel id="vip" keepMounted>
+2
View File
@@ -1,5 +1,7 @@
import { WhepPlayer } from './whepPlayer.js';
export const RESTART_DELAY_MS = 2000;
const TERMINAL_STATES = ['error', 'failed', 'disconnected', 'closed'];
// A mounted effect owns exactly one connection. Invalidate its callbacks before
@@ -163,13 +163,12 @@ export default function MiniSummaryContent() {
const frames = useVisualTelemetryFrames();
const roster = session?.roster ?? [];
const ptz = session?.ptzCamera || null;
const ptzOperatorLabel = String(ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || '').trim();
const ptzOperatorLabel = String(ptz?.turn?.queue?.find((entry) => entry.socketId === ptz.turn.currentId)?.name || '').trim();
const hasActivePtzOperator = Boolean(ptz?.enabled && ptzOperatorLabel);
const [index, setIndex] = useState(0);
const activeDrivers = session?.activeDrivers || {};
const driverRoster = useMemo(
() => roster.filter((rover) => activeDrivers[rover.id]),
[roster, activeDrivers],
() => roster.filter((rover) => rover.turn?.currentId),
[roster],
);
const mediaRovers = useMemo(
() => roster.filter((rover) => rover?.id),
+4 -6
View File
@@ -4,12 +4,10 @@
import { buildBatteryVisual } from '../../lib/battery.js';
export function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
const mode = session?.mode;
const turnInfo = session?.turnQueues?.[roverId];
return mode === 'turns' && turnInfo?.current ? `${label}` : label;
const turn = session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn;
const driver = turn?.queue?.find((entry) => entry.socketId === turn.currentId);
const label = driver?.name || 'No driver';
return label;
}
export function getBatteryVisual({ rover, frame }) {
+4 -1
View File
@@ -4,6 +4,7 @@
// Scope: Mounts input/identity providers; the page owns session entry and layouts.
import AlertFeed from '../components/AlertFeed/index.jsx';
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
import PtzControllerPage from './PtzControllerPage.jsx';
import {
ControlSystemProvider,
@@ -26,7 +27,9 @@ function PtzRouteContent() {
<ControlSystemProvider>
<KeyboardInputManager />
<GamepadInputManager />
<PtzControllerPage layout={layout} />
<LayoutProvider layout={layout}>
<PtzControllerPage />
</LayoutProvider>
<AlertFeed />
<SocketConnectionPill />
</ControlSystemProvider>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { useLayout } from '../layouts/LayoutContext.jsx';
import { useNavigate } from 'react-router-dom';
import CardFrame from '../components/CardFrame/index.jsx';
import { useControlActions } from '../controls/index.js';
@@ -9,7 +10,8 @@ import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../themes/index.js';
import usePtzSession from './usePtzSession.js';
import { PtzDesktopFullscreen, PtzMobileLandscape, PtzMobilePortrait } from './components/PtzLayouts.jsx';
export default function PtzControllerPage({ layout = 'desktop' }) {
export default function PtzControllerPage() {
const layout = useLayout();
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
const { setOperatingMode, pushAlert } = useSessionActions();
+3 -3
View File
@@ -11,7 +11,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(7rem,0.22fr)] gap-0.5 overflow-hidden p-0.5">
<div className="flex min-h-0 min-w-0 gap-0.5 overflow-hidden">
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
<PtzMediaPane ptz={ptz} open framed />
<PtzMediaPane ptz={ptz} open />
</main>
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
while the shared PTZ page theme can show through the same compact gaps as the driver
@@ -65,7 +65,7 @@ function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
>
Close
</button>
<PtzMediaPane ptz={ptz} open compact framed={false} />
<PtzMediaPane ptz={ptz} open />
</main>
{/*
The right control column is naturally taller than the viewport.
@@ -116,7 +116,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
>
Close
</button>
<PtzMediaPane ptz={ptz} open compact framed={false} />
<PtzMediaPane ptz={ptz} open />
</main>
{/*
Portrait gives the video its full available width and places controls
+2 -4
View File
@@ -3,15 +3,13 @@ import { useControlActions, useControlSelector } from '../../controls/index.js';
import ControlHint from '../../components/ControlHint/index.jsx';
import PeripheralPod from '../../components/HudOverlays/newgen/CornerPods/PeripheralPod.jsx';
import RoundControl from '../../components/HudOverlays/newgen/CornerPods/RoundControl.jsx';
import usePodVisibility from '../../components/HudOverlays/newgen/CornerPods/usePodVisibility.js';
export default function PtzLightingPod() {
const [open, setOpen] = usePodVisibility('ptzLighting', true);
export default function PtzLightingPod({ open, onOpenChange }) {
const { setHeadlight, setLaser } = useControlActions();
const canControl = useControlSelector((control) => control.ptzControls.isActive);
const { spotlightOn, irMode, pending } = useControlSelector((control) => control.ptzControls.lighting);
return (
<PeripheralPod open={open} onOpenChange={setOpen} label="camera lights" className="h-24 w-44">
<PeripheralPod open={open} onOpenChange={onOpenChange} label="camera lights" className="h-24 w-44">
<RoundControl label="Spotlight" icon={FaLightbulb} keyLabel={<ControlHint actionId="headlightToggle" />} active={spotlightOn} disabled={!canControl || pending.spotlight} onClick={() => setHeadlight(!spotlightOn)} className="absolute left-5 top-1" />
<RoundControl label={`Infrared: ${irMode}`} icon={FaEye} value={irMode} keyLabel={<ControlHint actionId="laserToggle" />} active={irMode === 'On'} disabled={!canControl || pending.ir} onClick={() => setLaser()} className="absolute right-1 bottom-1" />
</PeripheralPod>
+10 -5
View File
@@ -1,3 +1,5 @@
import { useLayout } from '../../layouts/LayoutContext.jsx';
import usePodVisibility from '../../components/HudOverlays/newgen/CornerPods/usePodVisibility.js';
import PtzLightingPod from './PtzLightingPod.jsx';
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import ChatExpansion from '../../components/HudOverlays/newgen/CornerPods/ChatExpansion.jsx';
@@ -25,11 +27,14 @@ function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-
);
}
function PtzMediaPane({ ptz, open, framed = true, compact = false }) {
function PtzMediaPane({ ptz, open }) {
const layout = useLayout();
const isDesktop = layout === 'desktop';
const [lightingOpen, setLightingOpen] = usePodVisibility('ptzLighting', true);
const shouldUseLiveVideo = ptz?.viewMode === 'live';
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
const statusClassName = `pointer-events-none absolute left-1 ${compact ? 'bottom-1' : 'bottom-24'} z-20 text-[0.65rem] font-medium text-slate-100`;
const statusClassName = `pointer-events-none absolute left-1 ${isDesktop && lightingOpen ? 'bottom-24' : isDesktop ? 'bottom-10' : 'bottom-1'} z-20 text-[0.65rem] font-medium text-slate-100`;
const media = (
<>
{shouldUseLiveVideo ? (
@@ -37,14 +42,14 @@ function PtzMediaPane({ ptz, open, framed = true, compact = false }) {
) : (
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} statusClassName={statusClassName} />
)}
{ptz?.turn ? <TopLeftPod compact={compact} turns={ptz.turn} /> : null}
{ptz?.turn ? <TopLeftPod compact={!isDesktop} turns={ptz.turn} /> : null}
{/* Mobile already has lighting controls beside/below the video. */}
{!compact ? <PtzLightingPod /> : null}
{isDesktop ? <PtzLightingPod open={lightingOpen} onOpenChange={setLightingOpen} /> : null}
<ChatExpansion podOpen={false} />
</>
);
if (!framed) {
if (!isDesktop) {
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
}
+3 -9
View File
@@ -3,11 +3,10 @@ import { useSessionActions, useSessionSelector } from '../../context/SessionCont
import CardFrame from '../../components/CardFrame/index.jsx';
import { QueueUserChips } from '../../components/QueueTargetRow/index.jsx';
import { PTZ_CAMERA_ID } from '../../components/PtzLiveVideo/index.jsx';
import useQueueUserLookup from '../../hooks/useQueueUserLookup.js';
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
const { ptzRequestTurn, ptzRelease, pushAlert } = useSessionActions();
const [pending, setPending] = useState(false);
const participating = Boolean(ptz?.turn?.isActive || ptz?.turn?.turnsAhead);
const participating = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
const changeTurn = async () => {
if (pending) return;
setPending(true);
@@ -21,21 +20,16 @@ function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
}
};
const selfId = useSessionSelector((state) => state.session?.socketId || null);
const lookupUser = useQueueUserLookup(ptz?.turn);
const { queue, currentId, nextId } = ptz?.turn || {};
return (
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
<QueueUserChips
targetId={ptz?.id || PTZ_CAMERA_ID}
queue={queue}
currentId={currentId}
nextId={nextId}
turn={ptz?.turn}
selfId={selfId}
lookupUser={lookupUser}
/>
<button type="button" className="button-dark w-full text-xs" disabled={pending || !(participating ? ptz?.permissions?.canReleaseTurn : ptz?.permissions?.canRequestTurn)} onClick={changeTurn}>
{pending ? 'Please wait…' : ptz?.turn?.isActive ? 'Release turn' : ptz?.turn?.turnsAhead ? 'Leave queue' : 'Request turn'}
{pending ? 'Please wait…' : ptz?.turn?.ownsControl ? 'Release turn' : ptz?.turn?.turnsAhead ? 'Leave queue' : 'Request turn'}
</button>
</CardFrame>
);
@@ -122,7 +122,7 @@ export default function PtzSpectatorCard() {
<article className="flex min-h-64 flex-col rounded-sm bg-zinc-900 p-0 sm:min-h-72">
<PtzLiveOrSnapshot label={label} />
<div className="min-h-0 flex-1 space-y-0.5 overflow-hidden p-1 text-xs">
<InfoRow label="Operator" value={ptz?.turn?.userLabels?.[ptz?.turn?.currentId] || 'none'} />
<InfoRow label="Operator" value={ptz?.turn?.queue?.find((entry) => entry.socketId === ptz.turn.currentId)?.name || 'none'} />
<InfoRow label="Remaining" value={formatRemaining(ptz?.turn?.deadline)} />
<InfoRow label="Queue" value={queueCount ? `${queueCount} waiting` : 'empty'} />
<InfoRow label="Spotlight" value={isSpotlightOn(ptz?.light) ? 'On' : 'Off'} />
+4 -8
View File
@@ -2,12 +2,8 @@
// Purpose: Defines the utils module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
export function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
const mode = session?.mode;
const turnInfo = session?.turnQueues?.[roverId];
const driverText = mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
return driverText;
const turn = session?.roster?.find((rover) => String(rover.id) === String(roverId))?.turn;
const driver = turn?.queue?.find((entry) => entry.socketId === turn.currentId);
const label = driver?.name || 'No driver';
return session?.mode === 'turns' && turn?.currentId ? `${label} (turns)` : label;
}