mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
240 lines
8.6 KiB
React
240 lines
8.6 KiB
React
import { useEffect, useMemo, useState } from 'react';
|
|
import { SettingsProvider } from '../settings/index.js';
|
|
import { useSession } from '../context/SessionContext.jsx';
|
|
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
|
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
|
import { useRoverSnapshots } from '../hooks/useRoverSnapshots.js';
|
|
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
|
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
|
|
import VideoTile from '../components/VideoTile.jsx';
|
|
import ChatPanel from '../components/ChatPanel.jsx';
|
|
import AlertFeed from '../components/AlertFeed.jsx';
|
|
import useDefaultNickname from '../hooks/useDefaultNickname.js';
|
|
import RoomCameraFeed from '../components/RoomCameraFeed.jsx';
|
|
import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
|
|
|
|
const ROTATE_MS = 20000;
|
|
|
|
function formatDriverLabel({ roverId, session }) {
|
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
|
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
|
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
|
|
const mode = session?.mode;
|
|
const turnInfo = session?.turnQueues?.[roverId];
|
|
return mode === 'turns' && turnInfo?.current ? `${label} (turns)` : label;
|
|
}
|
|
|
|
function MiniSummaryContent() {
|
|
const { session } = useSession();
|
|
const spectatorReady = useSpectatorMode();
|
|
useDefaultNickname();
|
|
const inLockdown = session?.mode === 'lockdown';
|
|
const frames = useTelemetryFrames();
|
|
const roster = session?.roster ?? [];
|
|
const roomCameras = session?.roomCameras || [];
|
|
const feeds = useRoomCameraSnapshots(roomCameras.map((camera) => ({ id: camera.id })), {
|
|
enabled: !inLockdown,
|
|
version: session?.mode,
|
|
});
|
|
const [index, setIndex] = useState(0);
|
|
|
|
const snapshotFeeds = useRoverSnapshots(
|
|
roster.map((rover) => rover.id),
|
|
{ enabled: !inLockdown, version: session?.mode },
|
|
);
|
|
const av1Supported = supportsAv1WebRtc();
|
|
const previewEntries = roster.map((rover) => ({
|
|
type: 'rover',
|
|
id: rover.id,
|
|
key: `rover:${rover.id}:preview:av1`,
|
|
preview: true,
|
|
codec: 'av1',
|
|
}));
|
|
const roomPreviewEntries = roomCameras.map((camera) => ({
|
|
type: 'room',
|
|
id: camera.id,
|
|
key: `room:${camera.id}:preview:av1`,
|
|
preview: true,
|
|
codec: 'av1',
|
|
}));
|
|
const previewSources = useVideoRequests(previewEntries, { enabled: !inLockdown && av1Supported });
|
|
const roomPreviewSources = useVideoRequests(roomPreviewEntries, { enabled: !inLockdown && av1Supported });
|
|
const audioEntries = useMemo(
|
|
() =>
|
|
roster.flatMap((rover) => {
|
|
if (!rover?.id || !rover.media?.audioPublishUrl) return [];
|
|
const id = String(rover.id);
|
|
return [{ type: 'rover', id: `${id}-audio`, key: `${id}-audio` }];
|
|
}),
|
|
[roster],
|
|
);
|
|
const audioSources = useVideoRequests(audioEntries, { enabled: !inLockdown, version: session?.mode });
|
|
|
|
const roverPool = useMemo(() => {
|
|
if (!roster.length) return [];
|
|
const withSnapshot = roster.filter((rover) => snapshotFeeds[rover.id]?.objectUrl);
|
|
return withSnapshot.length ? withSnapshot : roster;
|
|
}, [roster, snapshotFeeds]);
|
|
|
|
const rotationPool = useMemo(() => {
|
|
const items = [];
|
|
roverPool.forEach((rover) => items.push({ type: 'rover', rover }));
|
|
roomCameras.forEach((camera) => items.push({ type: 'room', camera }));
|
|
return items;
|
|
}, [roverPool, roomCameras]);
|
|
|
|
const rotationKey = useMemo(
|
|
() =>
|
|
rotationPool
|
|
.map((entry) =>
|
|
entry.type === 'rover' ? `r:${entry.rover.id}` : `room:${entry.camera.id}`,
|
|
)
|
|
.join('|'),
|
|
[rotationPool],
|
|
);
|
|
|
|
useEffect(() => {
|
|
setIndex(0);
|
|
}, [rotationKey]);
|
|
|
|
useEffect(() => {
|
|
if (!rotationPool.length) return undefined;
|
|
const timer = setInterval(() => {
|
|
setIndex((prev) => (prev + 1) % rotationPool.length);
|
|
}, ROTATE_MS);
|
|
return () => clearInterval(timer);
|
|
}, [rotationPool.length, rotationKey]);
|
|
|
|
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
|
|
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
|
|
const activeCamera = activeEntry?.type === 'room' ? activeEntry.camera : null;
|
|
|
|
const activeSnapshot = activeRover ? snapshotFeeds[activeRover.id] || null : null;
|
|
const activePreview =
|
|
activeRover && previewSources[`rover:${activeRover.id}:preview:av1`]
|
|
? previewSources[`rover:${activeRover.id}:preview:av1`]
|
|
: null;
|
|
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
|
|
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
|
|
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
|
|
const activeFeed = activeCamera ? feeds[activeCamera.id] || null : null;
|
|
const activeRoomPreview =
|
|
activeCamera && roomPreviewSources[`room:${activeCamera.id}:preview:av1`]
|
|
? roomPreviewSources[`room:${activeCamera.id}:preview:av1`]
|
|
: 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 (
|
|
<div className="relative h-screen w-screen overflow-hidden bg-black p-0.5 text-slate-100 flex flex-col gap-0.5">
|
|
<ChatOverlay />
|
|
<section className="panel relative flex min-h-0 flex-1 overflow-hidden">
|
|
{!spectatorReady ? (
|
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
|
Switching to spectator…
|
|
</div>
|
|
) : activeRover ? (
|
|
<FitViewportFrame>
|
|
<VideoTile
|
|
sessionInfo={activePreview?.url ? activePreview : null}
|
|
videoMode={activePreview?.url ? 'whep' : 'snapshot'}
|
|
snapshotFeed={activeSnapshot}
|
|
audioSessionInfo={activeAudio}
|
|
label={activeRover.name || activeRover.id}
|
|
telemetryFrame={activeFrame}
|
|
batteryConfig={activeRover.battery}
|
|
layoutFormat="mobile"
|
|
hudVariant="spectator"
|
|
driverLabel={driverLabel}
|
|
hudForceMap
|
|
hudMapPosition="bottom-left"
|
|
fitParent
|
|
/>
|
|
</FitViewportFrame>
|
|
) : activeCamera ? (
|
|
<FitViewportFrame>
|
|
<RoomCameraFrame
|
|
camera={activeCamera}
|
|
feed={activeFeed}
|
|
videoSession={activeRoomPreview}
|
|
preferVideo={Boolean(activeRoomPreview?.url)}
|
|
/>
|
|
</FitViewportFrame>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
|
|
No sources available.
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function MiniSummaryApp() {
|
|
return (
|
|
<SettingsProvider>
|
|
<>
|
|
<MiniSummaryContent />
|
|
<AlertFeed />
|
|
</>
|
|
</SettingsProvider>
|
|
);
|
|
}
|
|
|
|
function RoomCameraFrame({ camera, feed, videoSession, preferVideo }) {
|
|
return (
|
|
<div className="relative h-full w-full bg-zinc-950">
|
|
<RoomCameraFeed
|
|
feed={feed}
|
|
label={camera.name || camera.id}
|
|
videoSession={videoSession}
|
|
preferVideo={preferVideo}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatOverlay() {
|
|
return (
|
|
<div
|
|
className="pointer-events-none absolute left-1/2 top-1 z-30"
|
|
style={{ transform: 'translate(-50%, 0) scale(0.7)', transformOrigin: 'top center' }}
|
|
>
|
|
<div
|
|
className="pointer-events-none overflow-hidden rounded-md"
|
|
style={{ width: '50vw', minWidth: '16rem', maxWidth: '24rem', opacity: 0.55, maxHeight: '12rem' }}
|
|
>
|
|
<ChatPanel hideInput hideSpectatorNotice />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FitViewportFrame({ children }) {
|
|
return (
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden bg-black">
|
|
<div
|
|
className="relative flex items-center justify-center overflow-hidden bg-black"
|
|
style={{
|
|
width: 'min(100%, calc(100vh * 16 / 9))',
|
|
height: 'min(100%, calc(100vw * 9 / 16))',
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
aspectRatio: '16 / 9',
|
|
}}
|
|
>
|
|
<div className="flex h-full w-full items-center justify-center overflow-hidden">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|