deeper pass

This commit is contained in:
legop3
2026-05-02 17:12:38 -04:00
parent 2f570d92d7
commit 837701eee8
27 changed files with 296 additions and 273 deletions
+3 -3
View File
@@ -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({});
+6 -5
View File
@@ -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);
+18 -15
View File
@@ -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);
+11 -11
View File
@@ -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());
+4 -3
View File
@@ -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) => {
+10 -7
View File
@@ -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);
+10 -6
View File
@@ -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;
}
+9 -8
View File
@@ -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(() => {