From 34936ab05c2b44a32743079372370d2fd52e02dc Mon Sep 17 00:00:00 2001 From: legop3 Date: Sun, 27 Sep 2026 17:19:56 -0400 Subject: [PATCH] more queue related convergence for the future. --- .../services/interInstanceService/index.js | 31 ++++------- server/src/services/ptzCameraService/index.js | 9 ++-- .../services/ptzCameraService/turnLabels.js | 9 +--- .../services/replayEngineV2/replaySources.js | 4 +- server/src/services/sessionService/index.js | 5 +- server/src/services/turnService/display.js | 19 +++---- server/src/services/turnService/index.js | 3 +- server/src/services/turnService/labels.js | 10 ++++ .../src/services/turnService/queueDisplay.js | 24 ++++++--- webui/src/components/ChatPanel/index.jsx | 2 +- .../HudOverlays/HudChatInput/index.jsx | 2 +- .../HudOverlays/HudOverlay/index.jsx | 6 +-- .../HudOverlays/TurnsOverlay/index.jsx | 53 +++++-------------- .../newgen/CornerPods/BottomRightPod.jsx | 4 +- .../newgen/CornerPods/TopLeftPod.jsx | 2 +- .../HudOverlays/newgen/CornerPods/index.jsx | 4 +- .../HudOverlays/newgen/DockingHud/index.jsx | 4 +- .../components/InterInstancePanel/index.jsx | 4 -- webui/src/components/NewDriveVideo/index.jsx | 4 +- webui/src/components/PtzCamera/index.jsx | 16 ++---- webui/src/components/QueueTargetRow/index.jsx | 38 +++---------- .../components/RoverMediaPlayer/constants.js | 1 - .../src/components/RoverQueuesPanel/index.jsx | 32 +++-------- .../components/TurnAlertListener/index.jsx | 14 ++--- webui/src/components/UserListPanel/index.jsx | 30 +++-------- .../components/DisplayPtzOperatorBadge.jsx | 2 +- webui/src/display/ServerDisplayApp/utils.js | 10 ++-- webui/src/driver/DriverPageRoot.jsx | 15 +++--- webui/src/hooks/useCanControlRover.js | 20 +------ webui/src/hooks/useDriverVideoModePolicy.js | 16 ++---- webui/src/hooks/useQueueUserLookup.js | 10 ---- webui/src/hooks/useWhepRestart.js | 2 +- webui/src/layouts/LayoutContext.jsx | 14 +++++ .../src/layouts/driver/DriverHelpContext.jsx | 15 ++++++ .../layouts/driver/DriverLayoutContext.jsx | 38 ------------- webui/src/layouts/driver/DriverLayoutRoot.jsx | 4 +- .../driver/MobileLayoutFrame/index.jsx | 4 +- .../driver/MobileSecondaryContent/index.jsx | 4 +- .../tabs/mobile/RoomControlsTab/index.jsx | 4 +- .../tabs/shared/ActivitiesTab/index.jsx | 4 +- .../driver/tabs/shared/HelpTab/index.jsx | 6 +-- .../driver/tabs/shared/SettingsTab/index.jsx | 4 +- .../driver/tabs/shared/VipTab/index.jsx | 4 +- webui/src/lib/whepPlayback.js | 2 + .../MiniSummaryApp/MiniSummaryContent.jsx | 7 ++- webui/src/mini/MiniSummaryApp/utils.js | 10 ++-- webui/src/ptz/PtzAppRoot.jsx | 5 +- webui/src/ptz/PtzControllerPage.jsx | 4 +- webui/src/ptz/components/PtzLayouts.jsx | 6 +-- webui/src/ptz/components/PtzLightingPod.jsx | 6 +-- webui/src/ptz/components/PtzMediaPane.jsx | 15 ++++-- webui/src/ptz/components/PtzQueuePanel.jsx | 12 ++--- .../components/PtzSpectatorCard.jsx | 2 +- webui/src/spectate/SpectatorApp/utils.js | 12 ++--- 54 files changed, 219 insertions(+), 368 deletions(-) create mode 100644 server/src/services/turnService/labels.js delete mode 100644 webui/src/hooks/useQueueUserLookup.js create mode 100644 webui/src/layouts/LayoutContext.jsx create mode 100644 webui/src/layouts/driver/DriverHelpContext.jsx delete mode 100644 webui/src/layouts/driver/DriverLayoutContext.jsx 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; }