This commit is contained in:
legop3
2025-11-15 05:20:22 -05:00
parent 50c56e7271
commit eb2e2b15d8
14 changed files with 296 additions and 66 deletions
+30
View File
@@ -1,5 +1,8 @@
import { useMemo, useState } from 'react';
import { useSession } from './context/SessionContext.jsx';
import TelemetryPanel from './components/TelemetryPanel.jsx';
import VideoTile from './components/VideoTile.jsx';
import { useVideoRequests } from './hooks/useVideoRequests.js';
function StatusBadge({ connected, role, mode }) {
const color = connected ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-200';
@@ -156,6 +159,31 @@ function SessionInspector() {
);
}
function DriverVideoPanel() {
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const sources = useVideoRequests(roverId ? [roverId] : []);
const info = roverId ? sources[roverId] : null;
return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<header className="mb-4 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Video Feed</p>
<h2 className="text-2xl font-semibold text-white">
{roverId ? `Rover ${roverId}` : 'No rover assigned'}
</h2>
</div>
</header>
{roverId ? (
<VideoTile sessionInfo={info} label={roverId} muted={false} />
) : (
<p className="text-sm text-slate-400">Assignment required to initialize video.</p>
)}
</section>
);
}
function AuthPanel() {
const { login, setRole } = useSession();
const [username, setUsername] = useState('');
@@ -231,9 +259,11 @@ function App() {
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
</header>
<DriverVideoPanel />
<section className="grid gap-6 lg:grid-cols-2">
<RosterPanel />
<AssignmentCard />
<TelemetryPanel />
<AuthPanel />
<SessionInspector />
</section>
+56
View File
@@ -0,0 +1,56 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
function formatEntries(sensors = {}) {
return Object.entries(sensors).map(([key, value]) => ({
key,
value: typeof value === 'object' ? JSON.stringify(value) : value,
}));
}
export default function TelemetryPanel() {
const { session } = useSession();
const roverId = session?.assignment?.roverId;
const frame = useTelemetryFrame(roverId);
const entries = useMemo(() => formatEntries(frame?.sensors), [frame?.sensors]);
return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
<h2 className="text-2xl font-semibold text-white">
{roverId ? `Rover ${roverId}` : 'No rover assigned'}
</h2>
</div>
<p className="text-sm text-slate-400">
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting…'}
</p>
</header>
{entries.length === 0 ? (
<p className="mt-4 text-sm text-slate-400">
{roverId ? 'No sensor frames yet.' : 'Assignment required to view sensors.'}
</p>
) : (
<dl className="mt-4 grid gap-3 text-sm text-slate-100">
{entries.map(({ key, value }) => (
<div key={key} className="flex justify-between gap-6">
<dt className="text-slate-500">{key}</dt>
<dd className="text-right font-semibold text-white">{value}</dd>
</div>
))}
</dl>
)}
{frame?.raw && (
<div className="mt-6">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Raw frame</p>
<pre className="mt-2 max-h-32 overflow-y-auto rounded-xl bg-slate-950/60 p-3 text-xs text-lime-300">
{frame.raw}
</pre>
</div>
)}
</section>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useRef, useState } from 'react';
import { WhepPlayer } from '../lib/whepPlayer.js';
export default function VideoTile({ sessionInfo, label, muted = true }) {
const videoRef = useRef(null);
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
useEffect(() => {
let player;
let cancelled = false;
async function start() {
if (!sessionInfo?.url || !videoRef.current) {
setStatus('waiting');
return;
}
setStatus('connecting');
setError(null);
player = new WhepPlayer({
url: sessionInfo.url,
token: sessionInfo.token,
video: videoRef.current,
});
try {
await player.start();
if (!cancelled) {
setStatus('playing');
}
} catch (err) {
if (!cancelled) {
setStatus('error');
setError(err.message);
}
}
}
start();
return () => {
cancelled = true;
player?.stop();
};
}, [sessionInfo?.url, sessionInfo?.token]);
return (
<div className="space-y-2">
<div className="aspect-video w-full overflow-hidden rounded-2xl border border-slate-800 bg-black">
<video
ref={videoRef}
muted={muted}
playsInline
autoPlay
controls={false}
className="h-full w-full object-contain"
/>
</div>
<div className="text-xs text-slate-400">
<span className="font-semibold text-slate-200">{label}</span>{' '}
{status === 'error' ? <span className="text-red-400">Error: {error}</span> : <span>{status}</span>}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { createContext, useContext, useMemo, useState, useEffect } from 'react';
import { useSocket } from './SocketContext.jsx';
const TelemetryContext = createContext({ frames: {} });
export function TelemetryProvider({ children }) {
const socket = useSocket();
const [frames, setFrames] = useState({});
useEffect(() => {
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
setFrames((prev) => ({
...prev,
[roverId]: {
roverId,
sensors,
raw: frame?.data || null,
receivedAt: Date.now(),
},
}));
}
socket.on('sensorFrame', handleSensorFrame);
return () => {
socket.off('sensorFrame', handleSensorFrame);
};
}, [socket]);
const value = useMemo(() => ({ frames }), [frames]);
return <TelemetryContext.Provider value={value}>{children}</TelemetryContext.Provider>;
}
export function useTelemetryFrames() {
return useContext(TelemetryContext).frames;
}
export function useTelemetryFrame(roverId) {
const frames = useTelemetryFrames();
if (!roverId) return null;
return frames[roverId] ?? null;
}
-29
View File
@@ -1,29 +0,0 @@
import { useEffect, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
export function useSensorFeed() {
const socket = useSocket();
const [frames, setFrames] = useState({});
useEffect(() => {
function handleFrame({ roverId, sensors = {}, frame = {} }) {
if (!roverId) return;
setFrames((prev) => ({
...prev,
[roverId]: {
roverId,
sensors,
raw: frame?.data || null,
receivedAt: Date.now(),
},
}));
}
socket.on('sensorFrame', handleFrame);
return () => {
socket.off('sensorFrame', handleFrame);
};
}, [socket]);
return frames;
}
+11 -2
View File
@@ -7,9 +7,18 @@ export function useVideoRequests(roverIds = []) {
useEffect(() => {
const ids = Array.from(new Set(roverIds.filter(Boolean)));
if (!ids.length) return;
if (!ids.length) {
setSources({});
return;
}
let cancelled = false;
setSources((prev) => {
const next = {};
ids.forEach((id) => {
if (prev[id]) next[id] = prev[id];
});
return next;
});
ids.forEach((roverId) => {
socket.emit('video:request', { roverId }, (resp = {}) => {
if (cancelled) return;
+67
View File
@@ -0,0 +1,67 @@
function buildAuthHeader(token) {
if (!token) return {};
const credential = `${token}:${token}`;
const encoded = typeof btoa === 'function' ? btoa(credential) : Buffer.from(credential).toString('base64');
return { Authorization: `Basic ${encoded}` };
}
export class WhepPlayer {
constructor({ url, token, video }) {
this.url = url;
this.token = token;
this.video = video;
this.pc = null;
this.abortController = null;
}
async start() {
if (!this.url || !this.video) {
throw new Error('Video target missing');
}
this.stop();
this.abortController = new AbortController();
const pc = new RTCPeerConnection();
this.pc = pc;
const stream = new MediaStream();
pc.ontrack = (event) => {
event.streams[0]?.getTracks().forEach((track) => stream.addTrack(track));
this.video.srcObject = stream;
};
pc.addTransceiver('video', { direction: 'recvonly' });
pc.addTransceiver('audio', { direction: 'recvonly' });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp',
...buildAuthHeader(this.token),
},
body: offer.sdp,
signal: this.abortController.signal,
});
if (!response.ok) {
throw new Error(`WHEP request failed: ${response.status}`);
}
const answerSdp = await response.text();
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
}
stop() {
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
if (this.pc) {
this.pc.getSenders().forEach((sender) => sender.track?.stop());
this.pc.getReceivers().forEach((receiver) => receiver.track?.stop());
this.pc.close();
this.pc = null;
}
if (this.video) {
this.video.srcObject = null;
}
}
}
+9 -6
View File
@@ -5,18 +5,21 @@ import './index.css'
import App from './App.jsx'
import { SocketProvider } from './context/SocketContext.jsx'
import { SessionProvider } from './context/SessionContext.jsx'
import { TelemetryProvider } from './context/TelemetryContext.jsx'
import SpectatorApp from './spectate/SpectatorApp.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<SocketProvider>
<SessionProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
</Routes>
</BrowserRouter>
<TelemetryProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
</Routes>
</BrowserRouter>
</TelemetryProvider>
</SessionProvider>
</SocketProvider>
</StrictMode>,
+4 -15
View File
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
import { useSensorFeed } from '../hooks/useSensorFeed.js';
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
import VideoTile from '../components/VideoTile.jsx';
function SpectatorStatus({ connected, ready, rosterCount }) {
return (
@@ -65,19 +66,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
</p>
</header>
<div className="aspect-video w-full rounded-2xl border border-slate-800 bg-black/60 flex items-center justify-center text-sm text-slate-400">
{videoInfo?.error ? (
<span>Video error: {videoInfo.error}</span>
) : videoInfo?.url ? (
<div className="text-center text-xs text-slate-300">
<p className="font-semibold text-white">Video feed placeholder</p>
<p>WHEP URL: {videoInfo.url}</p>
<p>Token: {videoInfo.token || 'retrieving…'}</p>
</div>
) : (
<span>Requesting video session</span>
)}
</div>
<VideoTile sessionInfo={videoInfo} label={rover.name} />
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
{sensorList.length === 0 ? (
@@ -100,7 +89,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
export default function SpectatorApp() {
const { connected, session, logs } = useSession();
const spectatorReady = useSpectatorMode();
const frames = useSensorFeed();
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
const videoSources = useVideoRequests(roster.map((rover) => rover.id));