mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
slopfixing / issue 007
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Xz-F59g5.js"></script>
|
<script type="module" crossorigin src="/assets/index-CCuQOOMm.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BVKa8Zph.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -53,21 +53,44 @@ export default function AlertFeed({ scale = 1 }) {
|
|||||||
|
|
||||||
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
|
||||||
|
|
||||||
useEffect(() => {
|
const { visible, nextExpiryAt } = useMemo(() => {
|
||||||
if (!latest.length) return undefined;
|
|
||||||
const interval = setInterval(() => setNow(Date.now()), 200);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [latest.length]);
|
|
||||||
|
|
||||||
const visibleByKey = new Map();
|
const visibleByKey = new Map();
|
||||||
|
let soonestExpiryAt = null;
|
||||||
|
|
||||||
latest.forEach((item) => {
|
latest.forEach((item) => {
|
||||||
const age = now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0);
|
const receivedAt = item.alert.receivedAt ?? item.alert.timestamp ?? 0;
|
||||||
|
const age = now - receivedAt;
|
||||||
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
|
const lifetimeMs = Number.isFinite(item.alert.lifetimeMs) ? item.alert.lifetimeMs : LIFETIME_MS;
|
||||||
|
const expiryAt = receivedAt + lifetimeMs;
|
||||||
|
|
||||||
if (age <= lifetimeMs) {
|
if (age <= lifetimeMs) {
|
||||||
visibleByKey.set(item.key, { ...item, age });
|
visibleByKey.set(item.key, { ...item, age });
|
||||||
|
/*
|
||||||
|
Alerts do not render a live progress value, so polling every 200ms is
|
||||||
|
unnecessary. Tracking the earliest expiry lets this component sleep
|
||||||
|
until one visible toast actually needs to disappear.
|
||||||
|
*/
|
||||||
|
soonestExpiryAt = soonestExpiryAt == null ? expiryAt : Math.min(soonestExpiryAt, expiryAt);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const visible = Array.from(visibleByKey.values()).slice(-3);
|
|
||||||
|
return {
|
||||||
|
visible: Array.from(visibleByKey.values()).slice(-3),
|
||||||
|
nextExpiryAt: soonestExpiryAt,
|
||||||
|
};
|
||||||
|
}, [latest, now]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nextExpiryAt == null) return undefined;
|
||||||
|
/*
|
||||||
|
The small padding avoids waking a few milliseconds before the browser's
|
||||||
|
current Date.now value crosses the expiry boundary. Without it, React can
|
||||||
|
render the same alert once more and schedule a second near-immediate timer.
|
||||||
|
*/
|
||||||
|
const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 25;
|
||||||
|
const timer = setTimeout(() => setNow(Date.now()), delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [nextExpiryAt]);
|
||||||
|
|
||||||
if (!visible.length) return null;
|
if (!visible.length) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
import { useControlSelector } from '../../../controls/index.js';
|
import { useControlSelector } from '../../../controls/index.js';
|
||||||
|
import { useSharedClock } from '../../../hooks/useSharedClock.js';
|
||||||
import SocialButton from '../../SocialButton/index.jsx';
|
import SocialButton from '../../SocialButton/index.jsx';
|
||||||
|
|
||||||
function TurnsOverlay({
|
function TurnsOverlay({
|
||||||
@@ -16,7 +17,6 @@ function TurnsOverlay({
|
|||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||||
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
|
const [noticeFlashActive, setNoticeFlashActive] = useState(false);
|
||||||
@@ -32,19 +32,20 @@ function TurnsOverlay({
|
|||||||
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
const turnInfo = effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
||||||
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
|
const activeDriverId = effectiveRoverId ? activeDrivers?.[effectiveRoverId] : null;
|
||||||
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
const isActiveDriver = Boolean(socketId && activeDriverId === socketId);
|
||||||
|
const isTurnsMode = mode === 'turns';
|
||||||
|
const now = useSharedClock(1000, isTurnsMode);
|
||||||
const nextDriverId = useMemo(() => {
|
const nextDriverId = useMemo(() => {
|
||||||
const queue = turnInfo?.queue || [];
|
const queue = turnInfo?.queue || [];
|
||||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||||
if (idx === -1) return queue[0] || null;
|
if (idx === -1) return queue[0] || null;
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
return queue[(idx + 1) % queue.length] || null;
|
||||||
}, [turnInfo?.queue, turnInfo?.current]);
|
}, [turnInfo]);
|
||||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||||
const deadline = turnInfo?.deadline || null;
|
const deadline = turnInfo?.deadline || null;
|
||||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
const msUntilTurn = deadline ? deadline - now : null;
|
||||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||||
const isTurnsMode = mode === 'turns';
|
|
||||||
const totalRovers = roster.length;
|
const totalRovers = roster.length;
|
||||||
const totalDrivers = useMemo(() => {
|
const totalDrivers = useMemo(() => {
|
||||||
const unique = new Set();
|
const unique = new Set();
|
||||||
@@ -80,12 +81,6 @@ function TurnsOverlay({
|
|||||||
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
const showCountdown = isActiveDriver && typeof idleSkipSeconds === 'number';
|
||||||
const turnTimerFlashActive = noticeFlashActive;
|
const turnTimerFlashActive = noticeFlashActive;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') return undefined;
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'turns') {
|
if (mode !== 'turns') {
|
||||||
setShowTurnCue(false);
|
setShowTurnCue(false);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Mode Gate Overlay
|
// Mode Gate Overlay
|
||||||
// Purpose: Defines the Mode Gate Overlay module and the local helpers/components used in this file.
|
// Purpose: Defines the Mode Gate Overlay module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import AuthPanel from '../AuthPanel/index.jsx';
|
import AuthPanel from '../AuthPanel/index.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import SocialButton from '../SocialButton/index.jsx';
|
import SocialButton from '../SocialButton/index.jsx';
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import ChatPanel from '../ChatPanel/index.jsx';
|
||||||
|
|
||||||
@@ -30,13 +31,18 @@ export default function ModeGateOverlay() {
|
|||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const reason = useSessionSelector((state) => state.session?.adminReason?.text || '');
|
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 timezone = useSessionSelector((state) => state.session?.timezone || 'UTC');
|
||||||
const restricted = RESTRICTED_MODES.has(mode);
|
const restricted = RESTRICTED_MODES.has(mode);
|
||||||
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
const privileged = mode === 'lockdown' ? LOCKDOWN_ROLES.has(role) : PRIVILEGED_ROLES.has(role);
|
||||||
const [now, setNow] = useState(() => new Date());
|
/*
|
||||||
|
The overlay is mounted for the whole app, but the server-time display is
|
||||||
|
only visible while access is actually blocked. Gating the shared clock here
|
||||||
|
prevents the hidden overlay from registering a permanent interval.
|
||||||
|
*/
|
||||||
|
const nowMs = useSharedClock(1000, restricted && !privileged);
|
||||||
|
|
||||||
const serverTime = useMemo(() => {
|
const serverTime = useMemo(() => {
|
||||||
|
const now = new Date(nowMs);
|
||||||
try {
|
try {
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
timeZone: timezone,
|
timeZone: timezone,
|
||||||
@@ -44,15 +50,10 @@ export default function ModeGateOverlay() {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
}).format(now);
|
}).format(now);
|
||||||
} catch (err) {
|
} catch {
|
||||||
return now.toLocaleTimeString();
|
return now.toLocaleTimeString();
|
||||||
}
|
}
|
||||||
}, [now, timezone]);
|
}, [nowMs, timezone]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!restricted || privileged) {
|
if (!restricted || privileged) {
|
||||||
return null;
|
return null;
|
||||||
@@ -73,11 +74,6 @@ export default function ModeGateOverlay() {
|
|||||||
{reason ? reason : 'No reason set.'}
|
{reason ? reason : 'No reason set.'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
<p className="text-center text-sm text-slate-300">Server time: {serverTime}</p>
|
||||||
{/* {reasonUpdatedAt ? (
|
|
||||||
<p className="text-[0.7rem] text-slate-500">
|
|
||||||
Updated {new Date(reasonUpdatedAt).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
) : null} */}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="surface-muted">
|
<div className="surface-muted">
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import RoverLabel from '../RoverLabel/index.jsx';
|
import RoverLabel from '../RoverLabel/index.jsx';
|
||||||
@@ -51,7 +52,6 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [titleDirty, setTitleDirty] = useState(false);
|
const [titleDirty, setTitleDirty] = useState(false);
|
||||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||||
const [remainingMs, setRemainingMs] = useState(0);
|
|
||||||
const [activeJobId, setActiveJobId] = useState(null);
|
const [activeJobId, setActiveJobId] = useState(null);
|
||||||
const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null);
|
const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null);
|
||||||
// Settings keys include the panel id because the same replay source control is
|
// Settings keys include the panel id because the same replay source control is
|
||||||
@@ -138,19 +138,22 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
|||||||
};
|
};
|
||||||
}, [sources]);
|
}, [sources]);
|
||||||
|
|
||||||
useEffect(() => {
|
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs);
|
||||||
if (!replayState?.lastTriggeredAt || !replayState?.cooldownMs) {
|
const replayCooldownEndsAt = hasReplayCooldown
|
||||||
setRemainingMs(0);
|
? replayState.lastTriggeredAt + replayState.cooldownMs
|
||||||
return undefined;
|
: 0;
|
||||||
}
|
const cooldownNow = useSharedClock(1000, hasReplayCooldown);
|
||||||
const update = () => {
|
const remainingMs = useMemo(() => {
|
||||||
const next = replayState.lastTriggeredAt + replayState.cooldownMs - Date.now();
|
if (!hasReplayCooldown) return 0;
|
||||||
setRemainingMs(Math.max(0, next));
|
/*
|
||||||
};
|
The button only shows whole seconds, so a shared one-second clock gives the
|
||||||
update();
|
same useful information without each mounted replay panel owning a 250ms
|
||||||
const interval = setInterval(update, 250);
|
interval. The exact server cooldown still decides whether the action is
|
||||||
return () => clearInterval(interval);
|
accepted; this value is only the local disabled-state/display estimate.
|
||||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
*/
|
||||||
|
const next = replayCooldownEndsAt - cooldownNow;
|
||||||
|
return Math.max(0, next);
|
||||||
|
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
|
||||||
|
|
||||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||||
const selectedSet = useMemo(() => {
|
const selectedSet = useMemo(() => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Rover Queues Panel
|
// Rover Queues Panel
|
||||||
// Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file.
|
// Purpose: Defines the Rover Queues 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.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import RoverLabel from '../RoverLabel/index.jsx';
|
import RoverLabel from '../RoverLabel/index.jsx';
|
||||||
|
|
||||||
@@ -61,7 +62,6 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
const { requestControl, rebootOwnRover } = useSessionActions();
|
const { requestControl, rebootOwnRover } = useSessionActions();
|
||||||
const [pending, setPending] = useState({});
|
const [pending, setPending] = useState({});
|
||||||
const [rebootPending, setRebootPending] = useState(false);
|
const [rebootPending, setRebootPending] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
|
||||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||||
const adminCapable = useMemo(
|
const adminCapable = useMemo(
|
||||||
@@ -72,14 +72,12 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
|||||||
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
||||||
[turnQueues],
|
[turnQueues],
|
||||||
);
|
);
|
||||||
|
/*
|
||||||
useEffect(() => {
|
Queue timers are shown in whole seconds, and several queue panels can be
|
||||||
if (!hasDeadlines) return undefined;
|
mounted across desktop/mobile/spectator layouts. Sharing the one-second
|
||||||
const timer = setInterval(() => {
|
clock keeps those labels in sync while using a single interval globally.
|
||||||
setNow(Date.now());
|
*/
|
||||||
}, 1000);
|
const now = useSharedClock(1000, hasDeadlines);
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [hasDeadlines]);
|
|
||||||
|
|
||||||
const rosterItems = useMemo(() => {
|
const rosterItems = useMemo(() => {
|
||||||
const known = new Set(roster.map((rover) => String(rover.id)));
|
const known = new Set(roster.map((rover) => String(rover.id)));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
|
import { useSharedClock } from './useSharedClock.js';
|
||||||
|
|
||||||
export function useDriverVideoModePolicy(roverId) {
|
export function useDriverVideoModePolicy(roverId) {
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
@@ -8,7 +9,13 @@ export function useDriverVideoModePolicy(roverId) {
|
|||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const isTurnsMode = mode === 'turns';
|
||||||
|
/*
|
||||||
|
This policy only switches preview/full video around a multi-second turn
|
||||||
|
boundary. A shared one-second clock is responsive enough for that UI policy
|
||||||
|
and avoids running a separate 250ms timer beside the TurnsOverlay countdown.
|
||||||
|
*/
|
||||||
|
const now = useSharedClock(1000, isTurnsMode);
|
||||||
|
|
||||||
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
|
const turnInfo = roverId ? turnQueues?.[roverId] || null : null;
|
||||||
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : null;
|
const activeDriverId = roverId ? activeDrivers?.[roverId] || null : null;
|
||||||
@@ -19,11 +26,10 @@ export function useDriverVideoModePolicy(roverId) {
|
|||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
const idx = queue.findIndex((id) => id === turnInfo.current);
|
||||||
if (idx === -1) return queue[0] || null;
|
if (idx === -1) return queue[0] || null;
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
return queue[(idx + 1) % queue.length] || null;
|
||||||
}, [turnInfo?.queue, turnInfo?.current]);
|
}, [turnInfo]);
|
||||||
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||||
const deadline = turnInfo?.deadline || null;
|
const deadline = turnInfo?.deadline || null;
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
const msUntilTurn = deadline ? deadline - now : null;
|
||||||
const isTurnsMode = mode === 'turns';
|
|
||||||
const totalRovers = roster.length;
|
const totalRovers = roster.length;
|
||||||
const totalDrivers = useMemo(() => {
|
const totalDrivers = useMemo(() => {
|
||||||
const unique = new Set();
|
const unique = new Set();
|
||||||
@@ -43,11 +49,5 @@ export function useDriverVideoModePolicy(roverId) {
|
|||||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||||
const forceSnapshotByTurnPolicy = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
const forceSnapshotByTurnPolicy = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode !== 'turns') return undefined;
|
|
||||||
const timer = setInterval(() => setNow(Date.now()), 250);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [mode]);
|
|
||||||
|
|
||||||
return forceSnapshotByTurnPolicy ? 'snapshot' : null;
|
return forceSnapshotByTurnPolicy ? 'snapshot' : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Shared Clock Hook
|
||||||
|
// Purpose: Lets display-only countdown components share one browser interval per cadence.
|
||||||
|
// Scope: Keeps timers out of control/safety paths; callers opt in only for UI text that can tolerate coarse updates.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const clockStores = new Map();
|
||||||
|
|
||||||
|
function nowMs() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDelay(delayMs) {
|
||||||
|
const delay = Number(delayMs);
|
||||||
|
if (!Number.isFinite(delay) || delay <= 0) return 1000;
|
||||||
|
return Math.max(16, Math.round(delay));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStore(delayMs) {
|
||||||
|
const delay = normalizeDelay(delayMs);
|
||||||
|
const existing = clockStores.get(delay);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
delay,
|
||||||
|
timer: null,
|
||||||
|
visibilityHandler: null,
|
||||||
|
current: nowMs(),
|
||||||
|
listeners: new Set(),
|
||||||
|
};
|
||||||
|
clockStores.set(delay, store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStore(store) {
|
||||||
|
if (store.timer) {
|
||||||
|
clearInterval(store.timer);
|
||||||
|
store.timer = null;
|
||||||
|
}
|
||||||
|
if (store.visibilityHandler && typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', store.visibilityHandler);
|
||||||
|
store.visibilityHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(store) {
|
||||||
|
store.current = nowMs();
|
||||||
|
store.listeners.forEach((listener) => listener(store.current));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVisibilityHandler(store) {
|
||||||
|
if (store.visibilityHandler || typeof document === 'undefined') return;
|
||||||
|
store.visibilityHandler = () => {
|
||||||
|
if (!store.listeners.size) return;
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
/*
|
||||||
|
Hidden tabs do not need display-only countdown work. When the tab comes
|
||||||
|
back, publish immediately so labels catch up before the next interval.
|
||||||
|
*/
|
||||||
|
publish(store);
|
||||||
|
startStore(store);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (store.timer) {
|
||||||
|
clearInterval(store.timer);
|
||||||
|
store.timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', store.visibilityHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStore(store) {
|
||||||
|
if (store.timer || !store.listeners.size) return;
|
||||||
|
ensureVisibilityHandler(store);
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||||
|
/*
|
||||||
|
One interval fans out to every mounted consumer using the same cadence. The
|
||||||
|
previous pattern created independent intervals in each countdown component,
|
||||||
|
which meant several panels could wake the main thread separately even though
|
||||||
|
they only needed the same "current time" value for human-readable labels.
|
||||||
|
*/
|
||||||
|
store.timer = setInterval(() => {
|
||||||
|
publish(store);
|
||||||
|
}, store.delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(store, listener) {
|
||||||
|
store.listeners.add(listener);
|
||||||
|
startStore(store);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
store.listeners.delete(listener);
|
||||||
|
/*
|
||||||
|
Stop the interval as soon as the last consumer leaves. This matters for
|
||||||
|
tab panels and overlays because hidden or unmounted UI should not keep a
|
||||||
|
display clock alive just because another render path created it earlier.
|
||||||
|
*/
|
||||||
|
if (!store.listeners.size) {
|
||||||
|
stopStore(store);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSharedClock(delayMs = 1000, enabled = true) {
|
||||||
|
const [now, setNow] = useState(() => nowMs());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
const store = getStore(delayMs);
|
||||||
|
/*
|
||||||
|
Subscribing starts the shared interval but does not synchronously push
|
||||||
|
state from inside the effect. React's newer lint rules flag synchronous
|
||||||
|
effect-time state writes because they can cascade renders; the hook's
|
||||||
|
initial Date.now state is already fresh for the first paint, and later
|
||||||
|
updates arrive through the interval or visibility callback.
|
||||||
|
*/
|
||||||
|
return subscribe(store, setNow);
|
||||||
|
}, [delayMs, enabled]);
|
||||||
|
|
||||||
|
return now;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user