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