mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
flip camera
This commit is contained in:
@@ -44,6 +44,7 @@ run_pipeline() {
|
|||||||
--timeout 0 \
|
--timeout 0 \
|
||||||
--width "${VIDEO_WIDTH}" \
|
--width "${VIDEO_WIDTH}" \
|
||||||
--height "${VIDEO_HEIGHT}" \
|
--height "${VIDEO_HEIGHT}" \
|
||||||
|
--rotation 180 \
|
||||||
--framerate "${VIDEO_FPS}" \
|
--framerate "${VIDEO_FPS}" \
|
||||||
--bitrate "${VIDEO_BITRATE}" \
|
--bitrate "${VIDEO_BITRATE}" \
|
||||||
--codec h264 \
|
--codec h264 \
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# spectate system overhaul
|
# spectate system overhaul
|
||||||
- rip out and remake the entire page
|
- rip out and remake the entire page
|
||||||
|
- basically keep nothing from it
|
||||||
- right now the spectator page is a mess, and hasn't been updated in a while
|
- right now the spectator page is a mess, and hasn't been updated in a while
|
||||||
- does not work properly, aside from the page being out of date
|
- does not work properly, aside from the page being out of date
|
||||||
- the spectate page should use modules from the driver page
|
- the spectate page should use modules from the driver page
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
|
|
||||||
## layout and styling of the spectator page
|
## layout and styling of the spectator page
|
||||||
- use same styling style as the driver page
|
- use same styling style as the driver page
|
||||||
|
- feel free to add spectator role checks to hide the features that spectators can't use in certain components.
|
||||||
- mainly designed for a 4:3 monitor
|
- mainly designed for a 4:3 monitor
|
||||||
- make use of the vertical space
|
- make use of the vertical space
|
||||||
- a row of columns, one for each rover at the top
|
- a row of columns, one for each rover at the top
|
||||||
|
|||||||
@@ -356,6 +356,10 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: 'Spectator role required' });
|
cb({ error: 'Spectator role required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (getMode() === MODES.LOCKDOWN) {
|
||||||
|
cb({ error: 'Spectating disabled in lockdown' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
logger.info('Spectator subscribing to all rovers', socket.id);
|
logger.info('Spectator subscribing to all rovers', socket.id);
|
||||||
for (const record of rovers.values()) {
|
for (const record of rovers.values()) {
|
||||||
socket.join(record.room);
|
socket.join(record.room);
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
export function useSpectatorMode() {
|
export function useSpectatorMode() {
|
||||||
const { session, setRole, subscribeAll } = useSession();
|
const { session, setRole, subscribeAll, connected } = useSession();
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function ensureSpectator() {
|
async function ensureSpectator() {
|
||||||
|
if (session?.mode === 'lockdown') {
|
||||||
|
setReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (session?.role !== 'spectator') {
|
if (session?.role !== 'spectator') {
|
||||||
await setRole('spectator');
|
await setRole('spectator');
|
||||||
@@ -18,13 +22,16 @@ export function useSpectatorMode() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to enter spectator mode', err);
|
console.error('Failed to enter spectator mode', err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setReady(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ensureSpectator();
|
ensureSpectator();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [session?.role, setRole, subscribeAll]);
|
}, [connected, session?.mode, session?.role, setRole, subscribeAll]);
|
||||||
|
|
||||||
return ready;
|
return ready;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { SettingsProvider } from '../settings/index.js';
|
||||||
import { useSession } from '../context/SessionContext.jsx';
|
import { useSession } from '../context/SessionContext.jsx';
|
||||||
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
|
||||||
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||||
import VideoTile from '../components/VideoTile.jsx';
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
|
import RoomCameraPanel from '../components/RoomCameraPanel.jsx';
|
||||||
|
import UserListPanel from '../components/UserListPanel.jsx';
|
||||||
|
import ChatPanel from '../components/ChatPanel.jsx';
|
||||||
|
import LogPanel from '../components/LogPanel.jsx';
|
||||||
|
|
||||||
function SpectatorStatus({ connected, ready, rosterCount }) {
|
function SpectatorStatus({ connected, ready, rosterCount, mode, blocked }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5 text-sm">
|
<div className="flex flex-wrap items-center gap-0.5 text-sm">
|
||||||
<span className={`px-0.5 py-0.5 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
<span className={`px-0.5 py-0.5 font-semibold ${connected ? 'bg-emerald-500/20 text-emerald-200' : 'bg-red-500/20 text-red-200'}`}>
|
||||||
@@ -15,111 +20,135 @@ function SpectatorStatus({ connected, ready, rosterCount }) {
|
|||||||
Spectator mode {ready ? 'active' : 'pending…'}
|
Spectator mode {ready ? 'active' : 'pending…'}
|
||||||
</span>
|
</span>
|
||||||
<span className="bg-slate-800 px-0.5 py-0.5 text-slate-200">Rovers: {rosterCount}</span>
|
<span className="bg-slate-800 px-0.5 py-0.5 text-slate-200">Rovers: {rosterCount}</span>
|
||||||
|
<span className="bg-slate-800 px-0.5 py-0.5 text-slate-200">Mode: {mode || 'unknown'}</span>
|
||||||
|
{blocked && <span className="bg-red-700/60 px-0.5 py-0.5 text-red-100">Spectate blocked</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LogStream({ logs }) {
|
function TelemetrySummary({ frame }) {
|
||||||
|
const sensors = frame?.sensors || {};
|
||||||
|
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
|
||||||
|
const entries = [
|
||||||
|
['Voltage', sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : '--'],
|
||||||
|
['Current', sensors.currentMa != null ? `${sensors.currentMa} mA` : '--'],
|
||||||
|
['Charge', sensors.batteryChargeMah != null ? `${sensors.batteryChargeMah}` : '--'],
|
||||||
|
['OI', sensors.oiMode?.label || '--'],
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel space-y-0.5 text-sm">
|
<div className="surface space-y-0.25 text-sm text-slate-200">
|
||||||
<header className="mb-0.5 flex items-center justify-between">
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||||
<h2 className="text-lg font-semibold text-white">Server logs</h2>
|
<span>Telemetry</span>
|
||||||
<span className="text-xs text-slate-500">latest {logs.length}</span>
|
<span>{updated ? `Updated ${updated}` : 'Waiting...'}</span>
|
||||||
</header>
|
|
||||||
<div className="surface h-60 overflow-y-auto space-y-0.5 text-xs font-mono text-slate-300">
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<p>No log entries yet.</p>
|
|
||||||
) : (
|
|
||||||
logs
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.map((entry) => (
|
|
||||||
<div key={entry.id} className="surface-muted">
|
|
||||||
<span className="text-slate-500">{entry.timestamp}</span>{' '}
|
|
||||||
<span className="text-cyan-300">[{entry.level}]</span>{' '}
|
|
||||||
{entry.label && <span className="text-pink-300">[{entry.label}]</span>}{' '}
|
|
||||||
<span>{entry.message}</span>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
<div className="grid grid-cols-2 gap-0.25">
|
||||||
|
{entries.map(([label, value]) => (
|
||||||
|
<div key={label} className="surface-muted flex items-center justify-between px-0.5 py-0.25 text-xs">
|
||||||
|
<span className="text-slate-400">{label}</span>
|
||||||
|
<span className="font-semibold text-white">{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSensors(sensors = {}) {
|
function CurrentDriverBadge({ roverId, session }) {
|
||||||
return Object.entries(sensors).map(([key, value]) => ({
|
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||||
key,
|
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||||
value: typeof value === 'object' ? JSON.stringify(value) : value,
|
const label = user?.nickname || (activeDriverId ? activeDriverId.slice(0, 6) : 'No driver');
|
||||||
}));
|
const mode = session?.mode;
|
||||||
|
const turnInfo = session?.turnQueues?.[roverId];
|
||||||
|
const driverText = mode === 'turns' && turnInfo?.current ? `Driver: ${label} (turns)` : `Driver: ${label}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="surface-muted text-xs text-slate-300">
|
||||||
|
{driverText}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoverSpectatorCard({ rover, frame, videoInfo }) {
|
function RoverSpectatorCard({ rover, frame, videoInfo, session }) {
|
||||||
const sensorList = formatSensors(frame?.sensors);
|
|
||||||
return (
|
return (
|
||||||
<article className="space-y-0.5 bg-zinc-900 p-0.5">
|
<article className="space-y-0.5 bg-zinc-900 p-0.5">
|
||||||
<header className="flex flex-col gap-0.5">
|
<header className="flex flex-col gap-0.25">
|
||||||
<h3 className="text-2xl font-semibold text-white">{rover.name}</h3>
|
<h3 className="text-2xl font-semibold text-white leading-tight">{rover.name}</h3>
|
||||||
<p className="text-sm text-slate-400">
|
<CurrentDriverBadge roverId={rover.id} session={session} />
|
||||||
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
<VideoTile sessionInfo={videoInfo} label={rover.name} forceMute />
|
<VideoTile sessionInfo={videoInfo} label={rover.name} telemetryFrame={frame} batteryConfig={rover.battery} />
|
||||||
<section className="surface">
|
<TelemetrySummary frame={frame} />
|
||||||
<p className="text-xs text-slate-500">Telemetry</p>
|
|
||||||
{sensorList.length === 0 ? (
|
|
||||||
<p className="mt-0.5 text-sm text-slate-400">No sensor data yet.</p>
|
|
||||||
) : (
|
|
||||||
<dl className="mt-0.5 grid gap-0.5 text-sm text-slate-200">
|
|
||||||
{sensorList.map(({ key, value }) => (
|
|
||||||
<div key={key} className="flex justify-between gap-0.5">
|
|
||||||
<dt className="text-slate-500">{key}</dt>
|
|
||||||
<dd className="text-right font-semibold text-white">{value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RoverRow({ roster, frames, videoSources, session }) {
|
||||||
|
if (roster.length === 0) {
|
||||||
|
return <p className="col-span-full text-slate-400">No rovers registered.</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{roster.map((rover) => (
|
||||||
|
<RoverSpectatorCard
|
||||||
|
key={rover.id}
|
||||||
|
rover={rover}
|
||||||
|
frame={frames[rover.id]}
|
||||||
|
videoInfo={videoSources[rover.id]}
|
||||||
|
session={session}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SecondaryRow() {
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-1 gap-0.5 lg:grid-cols-[2fr_1fr_1fr]">
|
||||||
|
<RoomCameraPanel defaultOrientation="horizontal" />
|
||||||
|
<UserListPanel />
|
||||||
|
<ChatPanel />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogsRow() {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<LogPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SpectatorApp() {
|
export default function SpectatorApp() {
|
||||||
const { connected, session, logs } = useSession();
|
const { connected, session } = useSession();
|
||||||
const spectatorReady = useSpectatorMode();
|
const spectatorReady = useSpectatorMode();
|
||||||
const frames = useTelemetryFrames();
|
const frames = useTelemetryFrames();
|
||||||
const roster = session?.roster ?? [];
|
const roster = session?.roster ?? [];
|
||||||
const videoSources = useVideoRequests(roster.map((rover) => rover.id));
|
const videoSources = useVideoRequests(roster.map((rover) => rover.id));
|
||||||
|
const blocked = session?.mode === 'lockdown';
|
||||||
|
|
||||||
const status = useMemo(
|
const status = useMemo(
|
||||||
() => ({ connected, ready: spectatorReady, rosterCount: roster.length }),
|
() => ({ connected, ready: spectatorReady, rosterCount: roster.length, mode: session?.mode, blocked }),
|
||||||
[connected, spectatorReady, roster.length],
|
[blocked, connected, roster.length, session?.mode, spectatorReady],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-slate-100">
|
<SettingsProvider>
|
||||||
<main className="mx-auto flex max-w-7xl flex-col gap-0.5 px-0.5 py-0.5">
|
<div className="min-h-screen bg-black text-slate-100">
|
||||||
<header className="space-y-0.5">
|
<main className="mx-auto flex max-w-7xl flex-col gap-0.5 px-0.5 py-0.5">
|
||||||
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
|
<header className="space-y-0.5">
|
||||||
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
|
<h1 className="text-4xl font-semibold text-white">Spectator Console</h1>
|
||||||
<SpectatorStatus {...status} />
|
<p className="text-slate-400">Live rover telemetry & logs. This view is read-only.</p>
|
||||||
</header>
|
<SpectatorStatus {...status} />
|
||||||
<section className="grid gap-0.5 lg:grid-cols-3">
|
{blocked && (
|
||||||
{roster.length === 0 ? (
|
<p className="text-sm text-amber-300">Lockdown active. Spectator view will resume automatically when access returns.</p>
|
||||||
<p className="col-span-full text-slate-400">No rovers registered.</p>
|
)}
|
||||||
) : (
|
</header>
|
||||||
roster.map((rover) => (
|
<RoverRow roster={roster} frames={frames} videoSources={videoSources} session={session} />
|
||||||
<RoverSpectatorCard
|
<SecondaryRow />
|
||||||
key={rover.id}
|
<LogsRow />
|
||||||
rover={rover}
|
</main>
|
||||||
frame={frames[rover.id]}
|
</div>
|
||||||
videoInfo={videoSources[rover.id]}
|
</SettingsProvider>
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
<LogStream logs={logs} />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user