mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
spectator reconnects and stuff
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
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user