spectator reconnects and stuff

This commit is contained in:
legop3
2025-12-23 22:27:35 -05:00
parent 108de883a0
commit 195ecb7d61
9 changed files with 101 additions and 104 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,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="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-DkDG6KGq.js"></script> <script type="module" crossorigin src="/assets/index-DoVsLZ1w.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B88Ix9u6.css"> <link rel="stylesheet" crossorigin href="/assets/index-B88Ix9u6.css">
</head> </head>
<body> <body>
+1 -6
View File
@@ -24,12 +24,7 @@ function enforceLockdown() {
io.on('connection', (socket) => { io.on('connection', (socket) => {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) { if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
socket.data.lockdownTimer = setTimeout(() => { disconnectForLockdown(socket);
if (!isLockdownAdmin(socket)) {
disconnectForLockdown(socket);
}
}, 10000);
socket.once('disconnect', () => clearLockdownTimer(socket));
} }
}); });
+5 -2
View File
@@ -55,6 +55,7 @@ export default function VideoTile({
songNote = null, songNote = null,
hudForceMap = false, hudForceMap = false,
hudMapPosition = 'top-right', hudMapPosition = 'top-right',
fitParent = false,
}) { }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const audioRef = useRef(null); const audioRef = useRef(null);
@@ -347,8 +348,10 @@ export default function VideoTile({
const showVerticalBattery = hudVariant === 'spectator'; const showVerticalBattery = hudVariant === 'spectator';
return ( return (
<div className="flex flex-col gap-0.5"> <div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
<div className="relative w-full overflow-hidden bg-black aspect-video"> <div
className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-video'}`}
>
<video <video
ref={videoRef} ref={videoRef}
muted={forceMute || muted} muted={forceMute || muted}
+22 -34
View File
@@ -1,14 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
export function useRoomCameraSnapshots(sourceList = []) { export function useRoomCameraSnapshots(sourceList = [], options = {}) {
const socket = useSocket(); const socket = useSocket();
const { enabled = true, version = null } = options;
const [feeds, setFeeds] = useState({}); const [feeds, setFeeds] = useState({});
const objectUrls = useRef(new Map()); const objectUrls = useRef(new Map());
const ids = useMemo(() => sourceList.map((e) => (typeof e === 'string' ? e : e.id)), [sourceList]); const ids = useMemo(() => sourceList.map((e) => (typeof e === 'string' ? e : e.id)), [sourceList]);
const idsKey = useMemo(() => ids.join('|'), [ids]); const idsKey = useMemo(() => {
const base = ids.join('|');
return version ? `${base}|v:${version}` : base;
}, [ids, version]);
const idsRef = useRef([]); const idsRef = useRef([]);
const retryTimer = useRef(null); const [connectionNonce, setConnectionNonce] = useState(0);
useEffect(() => {
if (!socket) return undefined;
const handleConnect = () => setConnectionNonce((prev) => prev + 1);
socket.on('connect', handleConnect);
return () => socket.off('connect', handleConnect);
}, [socket]);
useEffect(() => { useEffect(() => {
idsRef.current = ids; idsRef.current = ids;
@@ -18,13 +29,15 @@ export function useRoomCameraSnapshots(sourceList = []) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear(); objectUrls.current.clear();
setFeeds({}); setFeeds({});
if (retryTimer.current) {
clearTimeout(retryTimer.current);
retryTimer.current = null;
}
}, [idsKey]); }, [idsKey]);
useEffect(() => { useEffect(() => {
if (!enabled) {
objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear();
setFeeds({});
return undefined;
}
if (!idsRef.current.length || !socket) { if (!idsRef.current.length || !socket) {
return undefined; return undefined;
} }
@@ -63,34 +76,13 @@ export function useRoomCameraSnapshots(sourceList = []) {
objectUrl: prev[meta.id]?.objectUrl || null, objectUrl: prev[meta.id]?.objectUrl || null,
}, },
})); }));
if (meta.error && !cancelled) {
scheduleRetry();
}
};
const scheduleRetry = (delay = 5000) => {
if (retryTimer.current) {
clearTimeout(retryTimer.current);
}
retryTimer.current = setTimeout(() => {
retryTimer.current = null;
if (!cancelled) {
socket.emit('roomCamera:subscribe', { ids: currentIds }, (resp = {}) => {
if (resp.error && !retryTimer.current) {
scheduleRetry();
}
});
}
}, delay);
}; };
socket.on('roomCamera:frame', handleFrame); socket.on('roomCamera:frame', handleFrame);
socket.on('roomCamera:status', handleStatus); socket.on('roomCamera:status', handleStatus);
socket.emit('roomCamera:subscribe', { ids: currentIds }, (resp = {}) => { socket.emit('roomCamera:subscribe', { ids: currentIds }, (resp = {}) => {
if (resp.error) { if (resp.error) return;
scheduleRetry();
}
}); });
return () => { return () => {
@@ -100,12 +92,8 @@ export function useRoomCameraSnapshots(sourceList = []) {
socket.off('roomCamera:status', handleStatus); socket.off('roomCamera:status', handleStatus);
objectUrls.current.forEach((url) => URL.revokeObjectURL(url)); objectUrls.current.forEach((url) => URL.revokeObjectURL(url));
objectUrls.current.clear(); objectUrls.current.clear();
if (retryTimer.current) {
clearTimeout(retryTimer.current);
retryTimer.current = null;
}
}; };
}, [socket, idsKey]); }, [socket, idsKey, enabled, connectionNonce]);
return feeds; return feeds;
} }
+20 -42
View File
@@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
const RETRY_DELAY_MS = 3000;
function normalizeEntry(entry) { function normalizeEntry(entry) {
if (!entry) return null; if (!entry) return null;
if (typeof entry === 'string') { if (typeof entry === 'string') {
@@ -48,8 +46,9 @@ function dedupeEntries(entries = []) {
return unique.sort((a, b) => a.key.localeCompare(b.key)); return unique.sort((a, b) => a.key.localeCompare(b.key));
} }
export function useVideoRequests(sourceList = []) { export function useVideoRequests(sourceList = [], options = {}) {
const socket = useSocket(); const socket = useSocket();
const { enabled = true, version = null } = options;
const [sources, setSources] = useState({}); const [sources, setSources] = useState({});
const normalizedEntries = useMemo(() => { const normalizedEntries = useMemo(() => {
if (!Array.isArray(sourceList)) { if (!Array.isArray(sourceList)) {
@@ -57,74 +56,53 @@ export function useVideoRequests(sourceList = []) {
} }
return dedupeEntries(sourceList.map(normalizeEntry).filter(Boolean)); return dedupeEntries(sourceList.map(normalizeEntry).filter(Boolean));
}, [sourceList]); }, [sourceList]);
const normalizedKey = useMemo( const normalizedKey = useMemo(() => {
() => normalizedEntries.map((entry) => `${entry.type}:${entry.id}:${entry.key}`).join('|'), const base = normalizedEntries.map((entry) => `${entry.type}:${entry.id}:${entry.key}`).join('|');
[normalizedEntries], return version ? `${base}|v:${version}` : base;
); }, [normalizedEntries, version]);
const entriesRef = useRef([]); const entriesRef = useRef([]);
const retryTimers = useRef(new Map()); const [connectionNonce, setConnectionNonce] = useState(0);
useEffect(() => {
if (!socket) return undefined;
const handleConnect = () => setConnectionNonce((prev) => prev + 1);
socket.on('connect', handleConnect);
return () => {
socket.off('connect', handleConnect);
};
}, [socket]);
useEffect(() => { useEffect(() => {
entriesRef.current = normalizedEntries; entriesRef.current = normalizedEntries;
}, [normalizedEntries]); }, [normalizedEntries]);
useEffect(() => { useEffect(() => {
if (!normalizedKey) { if (!normalizedKey || !enabled) {
setSources({});
return undefined; return undefined;
} }
const entries = entriesRef.current; const entries = entriesRef.current;
let cancelled = false; let cancelled = false;
const timers = retryTimers.current;
function clearRetry(key) {
const timer = timers.get(key);
if (timer) {
clearTimeout(timer);
timers.delete(key);
}
}
function scheduleRetry(entry) {
clearRetry(entry.key);
const timer = setTimeout(() => {
timers.delete(entry.key);
if (cancelled) return;
requestEntry(entry);
}, RETRY_DELAY_MS);
timers.set(entry.key, timer);
}
function requestEntry(entry) { function requestEntry(entry) {
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id }; const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
socket.emit('video:request', payload, (resp = {}) => { socket.emit('video:request', payload, (resp = {}) => {
if (cancelled) return; if (cancelled) return;
if (!resp || resp.error || !resp.url || !resp.token) {
scheduleRetry(entry);
setSources((prev) => ({
...prev,
[entry.key]: resp,
}));
return;
}
clearRetry(entry.key);
setSources((prev) => ({ ...prev, [entry.key]: resp })); setSources((prev) => ({ ...prev, [entry.key]: resp }));
}); });
} }
entries.forEach((entry) => { entries.forEach((entry) => {
clearRetry(entry.key);
requestEntry(entry); requestEntry(entry);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
}; };
}, [socket, normalizedKey]); }, [socket, normalizedKey, enabled, connectionNonce]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (!normalizedKey) return {}; if (!normalizedKey || !enabled) return {};
const next = {}; const next = {};
normalizedEntries.forEach((entry) => { normalizedEntries.forEach((entry) => {
if (sources[entry.key]) { if (sources[entry.key]) {
+25 -6
View File
@@ -22,10 +22,14 @@ function formatDriverLabel({ roverId, session }) {
function MiniSummaryContent() { function MiniSummaryContent() {
const { session } = useSession(); const { session } = useSession();
const spectatorReady = useSpectatorMode(); const spectatorReady = useSpectatorMode();
const inLockdown = session?.mode === 'lockdown';
const frames = useTelemetryFrames(); const frames = useTelemetryFrames();
const roster = session?.roster ?? []; const roster = session?.roster ?? [];
const roomCameras = session?.roomCameras || []; const roomCameras = session?.roomCameras || [];
const feeds = useRoomCameraSnapshots(roomCameras.map((camera) => ({ id: camera.id }))); const feeds = useRoomCameraSnapshots(roomCameras.map((camera) => ({ id: camera.id })), {
enabled: !inLockdown,
version: session?.mode,
});
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const entries = useMemo( const entries = useMemo(
@@ -42,13 +46,16 @@ function MiniSummaryContent() {
[roster], [roster],
); );
const videoSources = useVideoRequests(entries); const videoSourcesEnabled = useVideoRequests(entries, {
enabled: !inLockdown,
version: session?.mode,
});
const roverPool = useMemo(() => { const roverPool = useMemo(() => {
if (!roster.length) return []; if (!roster.length) return [];
const withVideo = roster.filter((rover) => videoSources[rover.id]?.url); const withVideo = roster.filter((rover) => videoSourcesEnabled[rover.id]?.url);
return withVideo.length ? withVideo : roster; return withVideo.length ? withVideo : roster;
}, [roster, videoSources]); }, [roster, videoSourcesEnabled]);
const rotationPool = useMemo(() => { const rotationPool = useMemo(() => {
const items = []; const items = [];
@@ -83,12 +90,23 @@ function MiniSummaryContent() {
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null; const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
const activeCamera = activeEntry?.type === 'room' ? activeEntry.camera : null; const activeCamera = activeEntry?.type === 'room' ? activeEntry.camera : null;
const activeVideo = activeRover ? videoSources[activeRover.id] || null : null; const activeVideo = activeRover ? videoSourcesEnabled[activeRover.id] || null : null;
const activeAudio = activeRover ? videoSources[`${activeRover.id}-audio`] || null : null; const activeAudio = activeRover ? videoSourcesEnabled[`${activeRover.id}-audio`] || null : null;
const activeFrame = activeRover ? frames[activeRover.id] || null : null; const activeFrame = activeRover ? frames[activeRover.id] || null : null;
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null; const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
const activeFeed = activeCamera ? feeds[activeCamera.id] || null : null; const activeFeed = activeCamera ? feeds[activeCamera.id] || null : null;
if (inLockdown) {
return (
<div className="relative flex h-screen w-screen items-center justify-center bg-black text-slate-200">
<div className="surface max-w-sm space-y-0.5 p-1 text-center text-sm">
<p className="text-lg font-semibold text-white">Mini spectator is disabled in lockdown.</p>
<p className="text-slate-300">It will automatically resume once lockdown ends.</p>
</div>
</div>
);
}
return ( return (
<div className="relative h-screen w-screen overflow-hidden bg-black p-0.5 text-slate-100 flex flex-col gap-0.5"> <div className="relative h-screen w-screen overflow-hidden bg-black p-0.5 text-slate-100 flex flex-col gap-0.5">
<ChatOverlay /> <ChatOverlay />
@@ -110,6 +128,7 @@ function MiniSummaryContent() {
driverLabel={driverLabel} driverLabel={driverLabel}
hudForceMap hudForceMap
hudMapPosition="bottom-left" hudMapPosition="bottom-left"
fitParent
/> />
</FitViewportFrame> </FitViewportFrame>
) : activeCamera ? ( ) : activeCamera ? (
+15 -1
View File
@@ -89,6 +89,7 @@ function LogsRow({ className = '' }) {
export default function SpectatorApp() { export default function SpectatorApp() {
const { session } = useSession(); const { session } = useSession();
const inLockdown = session?.mode === 'lockdown';
useSpectatorMode(); useSpectatorMode();
const frames = useTelemetryFrames(); const frames = useTelemetryFrames();
const roster = session?.roster ?? []; const roster = session?.roster ?? [];
@@ -99,7 +100,20 @@ export default function SpectatorApp() {
} }
return [base]; return [base];
}); });
const videoSources = useVideoRequests(entries); const videoSources = useVideoRequests(entries, { enabled: !inLockdown, version: session?.mode });
if (inLockdown) {
return (
<SettingsProvider>
<div className="flex min-h-screen items-center justify-center bg-black text-slate-200">
<div className="surface max-w-md space-y-0.5 p-1 text-center text-sm">
<p className="text-lg font-semibold text-white">Spectate disabled during lockdown.</p>
<p className="text-slate-300">Please wait until the server leaves lockdown to view streams.</p>
</div>
</div>
</SettingsProvider>
);
}
return ( return (
<SettingsProvider> <SettingsProvider>