diff --git a/server/src/services/interInstanceService/index.js b/server/src/services/interInstanceService/index.js
index 5c79488d..8e216e44 100644
--- a/server/src/services/interInstanceService/index.js
+++ b/server/src/services/interInstanceService/index.js
@@ -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 || [],
diff --git a/server/src/services/ptzCameraService/index.js b/server/src/services/ptzCameraService/index.js
index dee4f00d..41006ce9 100644
--- a/server/src/services/ptzCameraService/index.js
+++ b/server/src/services/ptzCameraService/index.js
@@ -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,
diff --git a/server/src/services/ptzCameraService/turnLabels.js b/server/src/services/ptzCameraService/turnLabels.js
index 1ffe4fdb..6117c139 100644
--- a/server/src/services/ptzCameraService/turnLabels.js
+++ b/server/src/services/ptzCameraService/turnLabels.js
@@ -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',
};
diff --git a/server/src/services/replayEngineV2/replaySources.js b/server/src/services/replayEngineV2/replaySources.js
index b950a48d..36ee4406 100644
--- a/server/src/services/replayEngineV2/replaySources.js
+++ b/server/src/services/replayEngineV2/replaySources.js
@@ -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 }];
diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js
index 54fcaa40..f87953a8 100644
--- a/server/src/services/sessionService/index.js
+++ b/server/src/services/sessionService/index.js
@@ -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(),
diff --git a/server/src/services/turnService/display.js b/server/src/services/turnService/display.js
index cd0aa380..8df7798c 100644
--- a/server/src/services/turnService/display.js
+++ b/server/src/services/turnService/display.js
@@ -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,
diff --git a/server/src/services/turnService/index.js b/server/src/services/turnService/index.js
index 5145b3e9..f73eac54 100644
--- a/server/src/services/turnService/index.js
+++ b/server/src/services/turnService/index.js
@@ -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,
diff --git a/server/src/services/turnService/labels.js b/server/src/services/turnService/labels.js
new file mode 100644
index 00000000..8ba6bfd6
--- /dev/null
+++ b/server/src/services/turnService/labels.js
@@ -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',
+};
diff --git a/server/src/services/turnService/queueDisplay.js b/server/src/services/turnService/queueDisplay.js
index c8395113..c08f4bbb 100644
--- a/server/src/services/turnService/queueDisplay.js
+++ b/server/src/services/turnService/queueDisplay.js
@@ -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 };
diff --git a/webui/src/components/ChatPanel/index.jsx b/webui/src/components/ChatPanel/index.jsx
index 82f63b42..d8e33e2c 100644
--- a/webui/src/components/ChatPanel/index.jsx
+++ b/webui/src/components/ChatPanel/index.jsx
@@ -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 {
diff --git a/webui/src/components/HudOverlays/HudChatInput/index.jsx b/webui/src/components/HudOverlays/HudChatInput/index.jsx
index 43d6d8d9..ac977d3e 100644
--- a/webui/src/components/HudOverlays/HudChatInput/index.jsx
+++ b/webui/src/components/HudOverlays/HudChatInput/index.jsx
@@ -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(() => {
diff --git a/webui/src/components/HudOverlays/HudOverlay/index.jsx b/webui/src/components/HudOverlays/HudOverlay/index.jsx
index c04e369c..e209407d 100644
--- a/webui/src/components/HudOverlays/HudOverlay/index.jsx
+++ b/webui/src/components/HudOverlays/HudOverlay/index.jsx
@@ -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;
diff --git a/webui/src/components/HudOverlays/TurnsOverlay/index.jsx b/webui/src/components/HudOverlays/TurnsOverlay/index.jsx
index 612e784a..5c84dd04 100644
--- a/webui/src/components/HudOverlays/TurnsOverlay/index.jsx
+++ b/webui/src/components/HudOverlays/TurnsOverlay/index.jsx
@@ -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 };
/*
diff --git a/webui/src/components/HudOverlays/newgen/CornerPods/BottomRightPod.jsx b/webui/src/components/HudOverlays/newgen/CornerPods/BottomRightPod.jsx
index f6c8bbb2..cac75c17 100644
--- a/webui/src/components/HudOverlays/newgen/CornerPods/BottomRightPod.jsx
+++ b/webui/src/components/HudOverlays/newgen/CornerPods/BottomRightPod.jsx
@@ -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));
diff --git a/webui/src/components/HudOverlays/newgen/CornerPods/TopLeftPod.jsx b/webui/src/components/HudOverlays/newgen/CornerPods/TopLeftPod.jsx
index 303aca84..8e6ec266 100644
--- a/webui/src/components/HudOverlays/newgen/CornerPods/TopLeftPod.jsx
+++ b/webui/src/components/HudOverlays/newgen/CornerPods/TopLeftPod.jsx
@@ -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;
diff --git a/webui/src/components/HudOverlays/newgen/CornerPods/index.jsx b/webui/src/components/HudOverlays/newgen/CornerPods/index.jsx
index 11dd6f38..75d76b4b 100644
--- a/webui/src/components/HudOverlays/newgen/CornerPods/index.jsx
+++ b/webui/src/components/HudOverlays/newgen/CornerPods/index.jsx
@@ -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';
diff --git a/webui/src/components/HudOverlays/newgen/DockingHud/index.jsx b/webui/src/components/HudOverlays/newgen/DockingHud/index.jsx
index 2df3f76b..49a0311d 100644
--- a/webui/src/components/HudOverlays/newgen/DockingHud/index.jsx
+++ b/webui/src/components/HudOverlays/newgen/DockingHud/index.jsx
@@ -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
diff --git a/webui/src/components/InterInstancePanel/index.jsx b/webui/src/components/InterInstancePanel/index.jsx
index 0cc0c2e6..8b1b5d42 100644
--- a/webui/src/components/InterInstancePanel/index.jsx
+++ b/webui/src/components/InterInstancePanel/index.jsx
@@ -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
diff --git a/webui/src/components/NewDriveVideo/index.jsx b/webui/src/components/NewDriveVideo/index.jsx
index 916ad602..aa778d3e 100644
--- a/webui/src/components/NewDriveVideo/index.jsx
+++ b/webui/src/components/NewDriveVideo/index.jsx
@@ -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);
diff --git a/webui/src/components/PtzCamera/index.jsx b/webui/src/components/PtzCamera/index.jsx
index a58154a8..f0dc0435 100644
--- a/webui/src/components/PtzCamera/index.jsx
+++ b/webui/src/components/PtzCamera/index.jsx
@@ -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}
diff --git a/webui/src/components/QueueTargetRow/index.jsx b/webui/src/components/QueueTargetRow/index.jsx
index 31e092bb..9d6e484d 100644
--- a/webui/src/components/QueueTargetRow/index.jsx
+++ b/webui/src/components/QueueTargetRow/index.jsx
@@ -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
No queue.
;
}
return (
- {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}`}
>
- {formatQueueUserLabel(user, selfId)}
+ {user.name}{socketId === selfId ? ' (you)' : ''}
{isCurrent && now}
{isNext && next}
@@ -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({
{showAction ? (
diff --git a/webui/src/components/RoverMediaPlayer/constants.js b/webui/src/components/RoverMediaPlayer/constants.js
index 7ff4aaa2..0ce93f58 100644
--- a/webui/src/components/RoverMediaPlayer/constants.js
+++ b/webui/src/components/RoverMediaPlayer/constants.js
@@ -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;
diff --git a/webui/src/components/RoverQueuesPanel/index.jsx b/webui/src/components/RoverQueuesPanel/index.jsx
index 67ec6d9d..bfb9f632 100644
--- a/webui/src/components/RoverQueuesPanel/index.jsx
+++ b/webui/src/components/RoverQueuesPanel/index.jsx
@@ -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({
>
- {rosterItems.length === 0 ? (
+ {roster.length === 0 ? (
No rovers registered.
) : (
- {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({
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;
}
diff --git a/webui/src/components/UserListPanel/index.jsx b/webui/src/components/UserListPanel/index.jsx
index 55de3103..a17af4f5 100644
--- a/webui/src/components/UserListPanel/index.jsx
+++ b/webui/src/components/UserListPanel/index.jsx
@@ -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 (
- {Object.keys(turnQueues || {}).length === 0 ? (
+ {queuedRovers.length === 0 ? (
No turn queues yet.
) : (
- 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 (
@@ -240,11 +228,10 @@ export default function UserListPanel({
No drivers queued.
) : (
- {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}`}
>
- {formatLabel(user, selfId)}
+ {user.name}{socketId === selfId ? ' (you)' : ''}
{isAdmin &&
★}
- {/* {isSelf &&
YOU} */}
{isCurrent &&
now}
{isNext &&
next}
diff --git a/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx b/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx
index 6173ed77..dbd95a5f 100644
--- a/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx
+++ b/webui/src/display/ServerDisplayApp/components/DisplayPtzOperatorBadge.jsx
@@ -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 (
diff --git a/webui/src/display/ServerDisplayApp/utils.js b/webui/src/display/ServerDisplayApp/utils.js
index c40ebdf8..72d5cba9 100644
--- a/webui/src/display/ServerDisplayApp/utils.js
+++ b/webui/src/display/ServerDisplayApp/utils.js
@@ -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 }) {
diff --git a/webui/src/driver/DriverPageRoot.jsx b/webui/src/driver/DriverPageRoot.jsx
index 48b2011c..ab12aff0 100644
--- a/webui/src/driver/DriverPageRoot.jsx
+++ b/webui/src/driver/DriverPageRoot.jsx
@@ -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 }) {
-
- {/* Route selection affects only which desktop composition is mounted;
- all providers, overlays, inputs, and mobile layouts remain shared. */}
-
-
+
+
+ {/* Route selection affects only which desktop composition is mounted;
+ all providers, overlays, inputs, and mobile layouts remain shared. */}
+
+
+
diff --git a/webui/src/hooks/useCanControlRover.js b/webui/src/hooks/useCanControlRover.js
index 1882f64f..60383146 100644
--- a/webui/src/hooks/useCanControlRover.js
+++ b/webui/src/hooks/useCanControlRover.js
@@ -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) {
diff --git a/webui/src/hooks/useDriverVideoModePolicy.js b/webui/src/hooks/useDriverVideoModePolicy.js
index 0b803a92..4be81d2e 100644
--- a/webui/src/hooks/useDriverVideoModePolicy.js
+++ b/webui/src/hooks/useDriverVideoModePolicy.js
@@ -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;
diff --git a/webui/src/hooks/useQueueUserLookup.js b/webui/src/hooks/useQueueUserLookup.js
deleted file mode 100644
index ac643e48..00000000
--- a/webui/src/hooks/useQueueUserLookup.js
+++ /dev/null
@@ -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]);
-}
diff --git a/webui/src/hooks/useWhepRestart.js b/webui/src/hooks/useWhepRestart.js
index 30b2a787..73456da2 100644
--- a/webui/src/hooks/useWhepRestart.js
+++ b/webui/src/hooks/useWhepRestart.js
@@ -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);
diff --git a/webui/src/layouts/LayoutContext.jsx b/webui/src/layouts/LayoutContext.jsx
new file mode 100644
index 00000000..395fd93e
--- /dev/null
+++ b/webui/src/layouts/LayoutContext.jsx
@@ -0,0 +1,14 @@
+import { createContext, useContext } from 'react';
+
+const LayoutContext = createContext(null);
+
+export function LayoutProvider({ layout, children }) {
+ return
{children};
+}
+
+// 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;
+}
diff --git a/webui/src/layouts/driver/DriverHelpContext.jsx b/webui/src/layouts/driver/DriverHelpContext.jsx
new file mode 100644
index 00000000..ed5c3c39
--- /dev/null
+++ b/webui/src/layouts/driver/DriverHelpContext.jsx
@@ -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
{children};
+}
+
+// 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;
+}
diff --git a/webui/src/layouts/driver/DriverLayoutContext.jsx b/webui/src/layouts/driver/DriverLayoutContext.jsx
deleted file mode 100644
index f0a01e8b..00000000
--- a/webui/src/layouts/driver/DriverLayoutContext.jsx
+++ /dev/null
@@ -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
{children};
-}
-
-/*
- 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;
-}
diff --git a/webui/src/layouts/driver/DriverLayoutRoot.jsx b/webui/src/layouts/driver/DriverLayoutRoot.jsx
index da0b22fb..63221e7c 100644
--- a/webui/src/layouts/driver/DriverLayoutRoot.jsx
+++ b/webui/src/layouts/driver/DriverLayoutRoot.jsx
@@ -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
diff --git a/webui/src/layouts/driver/MobileLayoutFrame/index.jsx b/webui/src/layouts/driver/MobileLayoutFrame/index.jsx
index 33d0dfac..fc951489 100644
--- a/webui/src/layouts/driver/MobileLayoutFrame/index.jsx
+++ b/webui/src/layouts/driver/MobileLayoutFrame/index.jsx
@@ -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 });
diff --git a/webui/src/layouts/driver/MobileSecondaryContent/index.jsx b/webui/src/layouts/driver/MobileSecondaryContent/index.jsx
index 5c970cf1..a8e2d965 100644
--- a/webui/src/layouts/driver/MobileSecondaryContent/index.jsx
+++ b/webui/src/layouts/driver/MobileSecondaryContent/index.jsx
@@ -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'
diff --git a/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx b/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx
index 2cf32129..f2c542f0 100644
--- a/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx
+++ b/webui/src/layouts/driver/tabs/mobile/RoomControlsTab/index.jsx
@@ -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 (
diff --git a/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx b/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx
index c5a63273..46238552 100644
--- a/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx
+++ b/webui/src/layouts/driver/tabs/shared/ActivitiesTab/index.jsx
@@ -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 (
diff --git a/webui/src/layouts/driver/tabs/shared/HelpTab/index.jsx b/webui/src/layouts/driver/tabs/shared/HelpTab/index.jsx
index 981671b7..715c57ad 100644
--- a/webui/src/layouts/driver/tabs/shared/HelpTab/index.jsx
+++ b/webui/src/layouts/driver/tabs/shared/HelpTab/index.jsx
@@ -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 (
diff --git a/webui/src/layouts/driver/tabs/shared/SettingsTab/index.jsx b/webui/src/layouts/driver/tabs/shared/SettingsTab/index.jsx
index 65803e19..101aeefa 100644
--- a/webui/src/layouts/driver/tabs/shared/SettingsTab/index.jsx
+++ b/webui/src/layouts/driver/tabs/shared/SettingsTab/index.jsx
@@ -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 (
diff --git a/webui/src/layouts/driver/tabs/shared/VipTab/index.jsx b/webui/src/layouts/driver/tabs/shared/VipTab/index.jsx
index a604bf57..0bbb34c2 100644
--- a/webui/src/layouts/driver/tabs/shared/VipTab/index.jsx
+++ b/webui/src/layouts/driver/tabs/shared/VipTab/index.jsx
@@ -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 (
diff --git a/webui/src/lib/whepPlayback.js b/webui/src/lib/whepPlayback.js
index d0475c90..cb9f86a3 100644
--- a/webui/src/lib/whepPlayback.js
+++ b/webui/src/lib/whepPlayback.js
@@ -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
diff --git a/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx b/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx
index 7253ba38..c6bbb59b 100644
--- a/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx
+++ b/webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx
@@ -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),
diff --git a/webui/src/mini/MiniSummaryApp/utils.js b/webui/src/mini/MiniSummaryApp/utils.js
index 5fd162a9..3f6e5977 100644
--- a/webui/src/mini/MiniSummaryApp/utils.js
+++ b/webui/src/mini/MiniSummaryApp/utils.js
@@ -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 }) {
diff --git a/webui/src/ptz/PtzAppRoot.jsx b/webui/src/ptz/PtzAppRoot.jsx
index 80e5ac27..f88d3751 100644
--- a/webui/src/ptz/PtzAppRoot.jsx
+++ b/webui/src/ptz/PtzAppRoot.jsx
@@ -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() {
-
+
+
+
diff --git a/webui/src/ptz/PtzControllerPage.jsx b/webui/src/ptz/PtzControllerPage.jsx
index 5681338e..aa6b8b55 100644
--- a/webui/src/ptz/PtzControllerPage.jsx
+++ b/webui/src/ptz/PtzControllerPage.jsx
@@ -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();
diff --git a/webui/src/ptz/components/PtzLayouts.jsx b/webui/src/ptz/components/PtzLayouts.jsx
index 0b21c69d..4d070d5c 100644
--- a/webui/src/ptz/components/PtzLayouts.jsx
+++ b/webui/src/ptz/components/PtzLayouts.jsx
@@ -11,7 +11,7 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
-
+
{/* 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
-
+
{/*
The right control column is naturally taller than the viewport.
@@ -116,7 +116,7 @@ function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
>
Close
-
+
{/*
Portrait gives the video its full available width and places controls
diff --git a/webui/src/ptz/components/PtzLightingPod.jsx b/webui/src/ptz/components/PtzLightingPod.jsx
index b11dc317..fd02f8f7 100644
--- a/webui/src/ptz/components/PtzLightingPod.jsx
+++ b/webui/src/ptz/components/PtzLightingPod.jsx
@@ -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 (
-
+
} active={spotlightOn} disabled={!canControl || pending.spotlight} onClick={() => setHeadlight(!spotlightOn)} className="absolute left-5 top-1" />
} active={irMode === 'On'} disabled={!canControl || pending.ir} onClick={() => setLaser()} className="absolute right-1 bottom-1" />
diff --git a/webui/src/ptz/components/PtzMediaPane.jsx b/webui/src/ptz/components/PtzMediaPane.jsx
index 5a8fa327..a0b62d7d 100644
--- a/webui/src/ptz/components/PtzMediaPane.jsx
+++ b/webui/src/ptz/components/PtzMediaPane.jsx
@@ -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 }) {
) : (
)}
- {ptz?.turn ? : null}
+ {ptz?.turn ? : null}
{/* Mobile already has lighting controls beside/below the video. */}
- {!compact ? : null}
+ {isDesktop ? : null}
>
);
- if (!framed) {
+ if (!isDesktop) {
return {media}
;
}
diff --git a/webui/src/ptz/components/PtzQueuePanel.jsx b/webui/src/ptz/components/PtzQueuePanel.jsx
index 31948c3c..dc5bb54a 100644
--- a/webui/src/ptz/components/PtzQueuePanel.jsx
+++ b/webui/src/ptz/components/PtzQueuePanel.jsx
@@ -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 (
);
diff --git a/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx b/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx
index 62d31a57..7fc87a7b 100644
--- a/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx
+++ b/webui/src/spectate/SpectatorApp/components/PtzSpectatorCard.jsx
@@ -122,7 +122,7 @@ export default function PtzSpectatorCard() {
-
+ entry.socketId === ptz.turn.currentId)?.name || 'none'} />
diff --git a/webui/src/spectate/SpectatorApp/utils.js b/webui/src/spectate/SpectatorApp/utils.js
index 6f439e33..8cc69eb7 100644
--- a/webui/src/spectate/SpectatorApp/utils.js
+++ b/webui/src/spectate/SpectatorApp/utils.js
@@ -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;
}