mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
deeper pass
This commit is contained in:
@@ -20,7 +20,7 @@ function buildKey(alert) {
|
||||
|
||||
export default function AlertFeed({ scale = 1 }) {
|
||||
const alerts = useSessionSelector((state) => state.alerts);
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||
const { pushAlert } = useSessionActions();
|
||||
const socket = useSocket();
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -29,7 +29,7 @@ export default function AlertFeed({ scale = 1 }) {
|
||||
function onButtonIncrement(payload = {}) {
|
||||
const buttonId = Number(payload.buttonId);
|
||||
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
|
||||
const buttons = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : [];
|
||||
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
|
||||
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
|
||||
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
|
||||
const goal = Number.isFinite(button.goal) ? button.goal : 0;
|
||||
@@ -49,7 +49,7 @@ export default function AlertFeed({ scale = 1 }) {
|
||||
return () => {
|
||||
socket.off('buttonBox:increment', onButtonIncrement);
|
||||
};
|
||||
}, [pushAlert, session?.buttonBox?.buttons, socket]);
|
||||
}, [pushAlert, buttonBoxButtons, socket]);
|
||||
|
||||
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Purpose: Defines the Button Box Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
@@ -18,7 +18,7 @@ const BUTTON_TONES = {
|
||||
};
|
||||
|
||||
export default function ButtonBoxPanel() {
|
||||
const { session } = useSession();
|
||||
const buttonBoxButtons = useSessionSelector((state) => state.session?.buttonBox?.buttons ?? []);
|
||||
const socket = useSocket();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
|
||||
@@ -29,7 +29,7 @@ export default function ButtonBoxPanel() {
|
||||
: AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||
const buttons = useMemo(() => {
|
||||
const list = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : [];
|
||||
const list = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
|
||||
if (list.length === 4) return list;
|
||||
return [1, 2, 3, 4].map((id) => list.find((entry) => Number(entry?.id) === id) || {
|
||||
id,
|
||||
@@ -40,7 +40,7 @@ export default function ButtonBoxPanel() {
|
||||
rewardNumber: null,
|
||||
lastRewardAt: null,
|
||||
});
|
||||
}, [session?.buttonBox?.buttons]);
|
||||
}, [buttonBoxButtons]);
|
||||
|
||||
const [incFlash, setIncFlash] = useState({});
|
||||
const [rewardFlash, setRewardFlash] = useState({});
|
||||
|
||||
@@ -17,7 +17,9 @@ export default function ChatPanel({
|
||||
fillHeight = false,
|
||||
allowSpectatorInput = false,
|
||||
}) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const {
|
||||
messages,
|
||||
typing,
|
||||
@@ -38,13 +40,12 @@ export default function ChatPanel({
|
||||
const [engine, setEngine] = useState(() => ttsSettings?.engine || 'flite');
|
||||
const [voice, setVoice] = useState(() => ttsSettings?.voice || 'rms');
|
||||
const [pitch, setPitch] = useState(() => (Number.isFinite(ttsSettings?.pitch) ? ttsSettings.pitch : 50));
|
||||
const canChat = session?.role !== 'spectator' || allowSpectatorInput;
|
||||
const canChat = role !== 'spectator' || allowSpectatorInput;
|
||||
const listRef = useRef(null);
|
||||
const currentRoverId = session?.assignment?.roverId || null;
|
||||
|
||||
const rover = useMemo(
|
||||
() => session?.roster?.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, session?.roster],
|
||||
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||
[currentRoverId, roster],
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||
|
||||
|
||||
@@ -10,7 +10,12 @@ import { useControlSystem } from '../../controls/index.js';
|
||||
import VideoTile from '../VideoTile/index.jsx';
|
||||
|
||||
export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||
const {
|
||||
state: { song, lastControlIntentAt },
|
||||
overcurrentLimiter,
|
||||
@@ -20,19 +25,17 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||
const lastTurnRef = useRef({ active: false, roverId: null });
|
||||
useEffect(() => {
|
||||
if (session?.mode !== 'turns') {
|
||||
if (mode !== 'turns') {
|
||||
return undefined;
|
||||
}
|
||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [session?.mode]);
|
||||
const roverId = session?.assignment?.roverId;
|
||||
}, [mode]);
|
||||
const rosterEntry =
|
||||
roverId && session?.roster ? session.roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||
roverId && roster ? roster.find((item) => String(item.id) === String(roverId)) : null;
|
||||
const hasAudio = Boolean(rosterEntry?.media?.audioPublishUrl);
|
||||
const turnInfo = roverId ? session?.turnQueues?.[roverId] : null;
|
||||
const socketId = session?.socketId || null;
|
||||
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
|
||||
const turnInfo = roverId ? turnQueues?.[roverId] : null;
|
||||
const activeDriverId = roverId ? activeDrivers?.[roverId] : null;
|
||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||
const nextDriverId = useMemo(() => {
|
||||
const queue = turnInfo?.queue || [];
|
||||
@@ -49,8 +52,8 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const msUntilTurn = deadline ? deadline - now : null;
|
||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||
const isPreSwitchWindow =
|
||||
session?.mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const shouldShowVideo = session?.mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
|
||||
mode === 'turns' && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||
const shouldShowVideo = mode !== 'turns' || isActiveDriver || isPreSwitchWindow;
|
||||
const turnSeconds =
|
||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||
const idleSkipSeconds =
|
||||
@@ -75,20 +78,20 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
const audioInfo = roverId && hasAudio ? sources[`${roverId}-audio`] : null;
|
||||
const snapshotFeeds = useRoverSnapshots(roverId ? [roverId] : [], {
|
||||
enabled: Boolean(roverId),
|
||||
version: session?.mode,
|
||||
version: mode,
|
||||
});
|
||||
const snapshotFeed = roverId ? snapshotFeeds[roverId] || null : null;
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const batteryRecord =
|
||||
roverId && session?.roster
|
||||
? session.roster.find((item) => String(item.id) === String(roverId))
|
||||
roverId && roster
|
||||
? roster.find((item) => String(item.id) === String(roverId))
|
||||
: null;
|
||||
const batteryConfig = batteryRecord?.battery ?? null;
|
||||
|
||||
const roverLabel = batteryRecord?.name || (roverId ? `Rover ${roverId}` : '');
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.mode !== 'turns') {
|
||||
if (mode !== 'turns') {
|
||||
setTurnCueVisible(false);
|
||||
setTurnCueStartAt(null);
|
||||
lastTurnRef.current = { active: false, roverId: null };
|
||||
@@ -105,7 +108,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
|
||||
setTurnCueStartAt(null);
|
||||
}
|
||||
lastTurnRef.current = { active: isActiveDriver, roverId };
|
||||
}, [isActiveDriver, roverId, session?.mode]);
|
||||
}, [isActiveDriver, roverId, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!turnCueVisible || !turnCueStartAt) return;
|
||||
|
||||
@@ -9,8 +9,10 @@ const MAX_FONT_PX = 28;
|
||||
const MIN_FONT_PX = 14;
|
||||
|
||||
export default function GlobalObjectiveBanner({ layout = 'desktop', className = '', dismissable = true }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const goalText = session?.globalObjective?.text ? String(session.globalObjective.text).trim() : '';
|
||||
const goalText = useSessionSelector((state) => {
|
||||
const text = state.session?.globalObjective?.text;
|
||||
return text ? String(text).trim() : '';
|
||||
});
|
||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape' || layout === 'mobile';
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [fontSize, setFontSize] = useState(MAX_FONT_PX);
|
||||
|
||||
@@ -243,10 +243,9 @@ export default function HomeAssistantControls() {
|
||||
const {
|
||||
state: { keymap },
|
||||
} = useControlSystem();
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const ha = useSessionSelector((state) => state.session?.homeAssistant || null);
|
||||
const { homeAssistantToggle, homeAssistantSetLightColor, homeAssistantSetLightWhite } =
|
||||
useSessionActions();
|
||||
const ha = session?.homeAssistant;
|
||||
const entities = useMemo(() => ha?.entities || [], [ha?.entities]);
|
||||
const lightPolicy = ha?.lightPolicy || null;
|
||||
const controlsLocked = Boolean(lightPolicy?.locked || lightPolicy?.lockedOn);
|
||||
|
||||
@@ -28,19 +28,19 @@ function getModeDetails(mode = 'admin') {
|
||||
}
|
||||
|
||||
export default function ModeGateOverlay() {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const mode = session?.mode;
|
||||
const role = session?.role;
|
||||
const reason = session?.adminReason?.text || '';
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const timezone = session?.timezone || 'UTC';
|
||||
const discordUrl =
|
||||
session?.socials?.find((entry) => {
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
||||
const reasonUpdatedAt = useSessionSelector((state) => state.session?.adminReason?.updatedAt || null);
|
||||
const timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||
const discordUrl = useSessionSelector((state) => {
|
||||
const socials = state.session?.socials || [];
|
||||
const fromSocials = socials.find((entry) => {
|
||||
const key = String(entry?.id || entry?.label || '').toLowerCase();
|
||||
return key === 'discord';
|
||||
})?.url ||
|
||||
session?.discord?.invite ||
|
||||
null;
|
||||
})?.url;
|
||||
return fromSocials || state.session?.discord?.invite || null;
|
||||
});
|
||||
const restricted = RESTRICTED_MODES.has(mode);
|
||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
// Purpose: Defines the Nickname Form module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
|
||||
export default function NicknameForm({ compact = false }) {
|
||||
const { session, setNickname } = useSession();
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const { setNickname } = useSessionActions();
|
||||
const { value, save } = useSettingsNamespace('profile', { nickname: '' });
|
||||
const [nicknameInput, setNicknameInput] = useState(value.nickname || '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const canSetNickname = session?.role !== 'spectator';
|
||||
const canSetNickname = role !== 'spectator';
|
||||
|
||||
useEffect(() => {
|
||||
setNicknameInput(value.nickname || '');
|
||||
|
||||
@@ -35,10 +35,10 @@ export default function RawUserPilePanel({
|
||||
fillHeight = false,
|
||||
compact = false,
|
||||
}) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const canSetNickname = session?.role !== 'spectator';
|
||||
const users = session?.users ?? [];
|
||||
const selfId = session?.socketId || null;
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const canSetNickname = role !== 'spectator';
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Purpose: Defines the Replay Sources Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||
|
||||
@@ -22,8 +22,15 @@ function normalizeSources(list = []) {
|
||||
}
|
||||
|
||||
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
|
||||
const { session, triggerReplay } = useSession();
|
||||
const sources = normalizeSources(session?.replaySources || []);
|
||||
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const { triggerReplay } = useSessionActions();
|
||||
const sources = normalizeSources(replaySources || []);
|
||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -32,29 +39,28 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const [title, setTitle] = useState('');
|
||||
const [titleDirty, setTitleDirty] = useState(false);
|
||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||
const replayState = session?.replay || null;
|
||||
const [remainingMs, setRemainingMs] = useState(0);
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const roverId = assignmentRoverId;
|
||||
if (roverId) {
|
||||
return [`rover:${roverId}`];
|
||||
}
|
||||
return [];
|
||||
}, [session?.assignment?.roverId]);
|
||||
}, [assignmentRoverId]);
|
||||
|
||||
const defaultTitle = useMemo(() => {
|
||||
const self = Array.isArray(session?.users)
|
||||
? session.users.find((entry) => entry?.socketId === session?.socketId)
|
||||
const self = Array.isArray(users)
|
||||
? users.find((entry) => entry?.socketId === selfSocketId)
|
||||
: null;
|
||||
const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
|
||||
const roverId = session?.assignment?.roverId || null;
|
||||
const roverId = assignmentRoverId || null;
|
||||
const roverName =
|
||||
roverId && Array.isArray(session?.roster)
|
||||
? session.roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
|
||||
roverId && Array.isArray(roster)
|
||||
? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
|
||||
: 'a rover';
|
||||
return `${nickname} driving ${roverName}`;
|
||||
}, [session?.users, session?.socketId, session?.assignment?.roverId, session?.roster]);
|
||||
}, [users, selfSocketId, assignmentRoverId, roster]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = settings?.[panelId];
|
||||
@@ -106,7 +112,7 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
return () => clearInterval(interval);
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
||||
|
||||
const replayDisabled = busy || session?.mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setSelected((prev) => {
|
||||
|
||||
@@ -117,23 +117,26 @@ function DriveDockPanel() {
|
||||
|
||||
export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
const [activeTab, setActiveTab] = useState('telemetry');
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
||||
const ownRoverId = useSessionSelector((state) => String(state.session?.assignment?.roverId || '').trim());
|
||||
const ownAudioForward = useSessionSelector((state) => {
|
||||
const roverId = String(state.session?.assignment?.roverId || '').trim();
|
||||
return roverId ? state.session?.audioForward?.[roverId] || null : null;
|
||||
});
|
||||
const { state: controlState } = useControlSystem();
|
||||
const { value: vipAudio } = useSettingsNamespace('vipAudio', { openMicEnabled: false, pttMode: 'live' });
|
||||
const vipDotClass = session?.isVerified ? 'bg-emerald-400' : 'bg-red-600';
|
||||
const ownRoverId = String(session?.assignment?.roverId || '').trim();
|
||||
const ownAudioForward = ownRoverId ? session?.audioForward?.[ownRoverId] : null;
|
||||
const vipDotClass = isVerified ? 'bg-emerald-400' : 'bg-red-600';
|
||||
const pttActive = Boolean(controlState?.mic?.pttActive);
|
||||
const openMicEnabled = Boolean(vipAudio?.openMicEnabled);
|
||||
const pttMode = vipAudio?.pttMode === 'clip' ? 'clip' : 'live';
|
||||
const vipMicActive = Boolean(
|
||||
ownRoverId &&
|
||||
session?.isVerified &&
|
||||
isVerified &&
|
||||
(pttMode === 'clip' ? pttActive : (openMicEnabled || pttActive)),
|
||||
);
|
||||
const vipClipPlaying = Boolean(
|
||||
ownRoverId &&
|
||||
session?.isVerified &&
|
||||
isVerified &&
|
||||
pttMode === 'clip' &&
|
||||
ownAudioForward?.source === 'upload' &&
|
||||
ownAudioForward?.state === 'playing',
|
||||
@@ -149,7 +152,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full ${vipDotClass}`}
|
||||
aria-hidden="true"
|
||||
title={session?.isVerified ? 'Verified' : 'Not verified'}
|
||||
title={isVerified ? 'Verified' : 'Not verified'}
|
||||
/>
|
||||
</span>
|
||||
</Tab>
|
||||
|
||||
@@ -32,8 +32,7 @@ export default function RoomCameraPanel({
|
||||
hideHeader = false,
|
||||
panelId = null,
|
||||
}) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const cameras = session?.roomCameras || [];
|
||||
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
|
||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
|
||||
@@ -45,23 +45,20 @@ function formatLabel(user, selfId) {
|
||||
}
|
||||
|
||||
export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const { requestControl } = useSessionActions();
|
||||
const [pending, setPending] = useState({});
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
const canRequest = useMemo(() => session?.role && session.role !== 'spectator', [session?.role]);
|
||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||
const adminCapable = useMemo(
|
||||
() =>
|
||||
session?.role === 'admin' ||
|
||||
session?.role === 'lockdown' ||
|
||||
session?.role === 'lockdown-admin',
|
||||
[session?.role],
|
||||
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
|
||||
[role],
|
||||
);
|
||||
const roster = session?.roster ?? [];
|
||||
const turnQueues = session?.turnQueues ?? {};
|
||||
const users = session?.users ?? [];
|
||||
const selfId = session?.socketId || null;
|
||||
const hasDeadlines = useMemo(
|
||||
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
||||
[turnQueues],
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
// Social Buttons Grid
|
||||
// Purpose: Defines the Social Buttons Grid module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
|
||||
function normalizeSocials(session) {
|
||||
const configured = Array.isArray(session?.socials) ? session.socials : null;
|
||||
function normalizeSocials({ socials, discordInvite, kofiLink }) {
|
||||
const configured = Array.isArray(socials) ? socials : null;
|
||||
if (configured && configured.length) {
|
||||
return configured;
|
||||
}
|
||||
const fallback = [];
|
||||
if (session?.discord?.invite) {
|
||||
fallback.push({ id: 'discord', label: 'Discord', url: session.discord.invite });
|
||||
if (discordInvite) {
|
||||
fallback.push({ id: 'discord', label: 'Discord', url: discordInvite });
|
||||
}
|
||||
if (session?.kofi?.link) {
|
||||
fallback.push({ id: 'kofi', label: 'Ko-fi', url: session.kofi.link });
|
||||
if (kofiLink) {
|
||||
fallback.push({ id: 'kofi', label: 'Ko-fi', url: kofiLink });
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -28,8 +28,12 @@ function normalizeEntry(entry) {
|
||||
}
|
||||
|
||||
export default function SocialButtonsGrid({ className = '' }) {
|
||||
const { session } = useSession();
|
||||
const socials = normalizeSocials(session)
|
||||
const socialsInput = useSessionSelector((state) => ({
|
||||
socials: state.session?.socials ?? [],
|
||||
discordInvite: state.session?.discord?.invite || null,
|
||||
kofiLink: state.session?.kofi?.link || null,
|
||||
}));
|
||||
const socials = normalizeSocials(socialsInput)
|
||||
.map(normalizeEntry)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
|
||||
@@ -13,8 +13,13 @@ function formatMetric(value, fallback = '--') {
|
||||
|
||||
export default function TelemetryPanel() {
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
const roverId = session?.assignment?.roverId;
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const activeDriverId = useSessionSelector((state) => {
|
||||
const id = state.session?.assignment?.roverId;
|
||||
return id ? state.session?.activeDrivers?.[id] || null : null;
|
||||
});
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const frame = useTelemetryFrame(roverId);
|
||||
const sensors = frame?.sensors || {};
|
||||
const dockIr = useDockIr(sensors);
|
||||
@@ -25,14 +30,13 @@ export default function TelemetryPanel() {
|
||||
const capacity = sensors.batteryCapacityMah;
|
||||
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||
const rawSnippet = frame?.raw ? frame.raw : null;
|
||||
const activeDriverId = roverId ? session?.activeDrivers?.[roverId] : null;
|
||||
const driverLabel = useMemo(() => {
|
||||
if (!roverId) return 'n/a';
|
||||
if (!activeDriverId) return 'Available';
|
||||
if (activeDriverId === session?.socketId) return 'You';
|
||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||
if (activeDriverId === selfSocketId) return 'You';
|
||||
const user = users.find((entry) => entry.socketId === activeDriverId);
|
||||
return user?.nickname || activeDriverId.slice(0, 6);
|
||||
}, [activeDriverId, roverId, session?.socketId, session?.users]);
|
||||
}, [activeDriverId, roverId, selfSocketId, users]);
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base text-slate-100">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Turn Alert Listener
|
||||
// Purpose: Defines the Turn Alert Listener module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import turnSound from '../../assets/turn_alert.mp3';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
@@ -24,7 +24,9 @@ function useAudio(src, volume = 1) {
|
||||
}
|
||||
|
||||
export default function TurnAlertListener() {
|
||||
const session = useSessionSelector((state) => state.session);
|
||||
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);
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
@@ -33,9 +35,6 @@ export default function TurnAlertListener() {
|
||||
const playSound = useAudio(turnSound, effectiveAlertVolume);
|
||||
const seenRoversRef = useRef(new Set());
|
||||
|
||||
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]);
|
||||
const socketId = session?.socketId || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!socketId) return;
|
||||
const newlyMine = [];
|
||||
@@ -53,7 +52,7 @@ export default function TurnAlertListener() {
|
||||
});
|
||||
if (newlyMine.length === 0) return;
|
||||
newlyMine.forEach((roverId) => {
|
||||
const roverName = session?.roster?.find((r) => String(r.id) === String(roverId))?.name || roverId;
|
||||
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
|
||||
pushAlert({
|
||||
title: 'Your turn!',
|
||||
message: `You now control ${roverName}.`,
|
||||
@@ -61,7 +60,7 @@ export default function TurnAlertListener() {
|
||||
});
|
||||
});
|
||||
playSound();
|
||||
}, [assignments, playSound, pushAlert, session?.roster, socketId]);
|
||||
}, [assignments, playSound, pushAlert, roster, socketId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Purpose: Defines the User List Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useSession } from '../../context/SessionContext.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import NicknameForm from '../NicknameForm/index.jsx';
|
||||
import SocialButtonsGrid from '../SocialButtonsGrid/index.jsx';
|
||||
import { roverBadgeStyle, roverNameChromeStyle } from '../../lib/roverColor.js';
|
||||
@@ -55,13 +55,14 @@ export default function UserListPanel({
|
||||
compact = false,
|
||||
showBothTurnsAndUsers = false,
|
||||
}) {
|
||||
const { session } = useSession();
|
||||
const canSetNickname = session?.role !== 'spectator';
|
||||
const users = session?.users ?? [];
|
||||
const selfId = session?.socketId || null;
|
||||
const isTurnsMode = session?.mode === 'turns';
|
||||
const turnQueues = session?.turnQueues || {};
|
||||
const roster = session?.roster || [];
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
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 canSetNickname = role !== 'spectator';
|
||||
const isTurnsMode = mode === 'turns';
|
||||
const [turnView, setTurnView] = useState('queues');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user