mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
vieo
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>webui</title>
|
||||
<script type="module" crossorigin src="/assets/index-xwzFeHXC.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JJWJQzRI.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cn-MOZwV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cbe_I3mP.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user