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" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>webui</title>
|
<title>webui</title>
|
||||||
<script type="module" crossorigin src="/assets/index-xwzFeHXC.js"></script>
|
<script type="module" crossorigin src="/assets/index-Cn-MOZwV.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-JJWJQzRI.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Cbe_I3mP.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useSession } from './context/SessionContext.jsx';
|
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 }) {
|
function StatusBadge({ connected, role, mode }) {
|
||||||
const color = connected ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-200';
|
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() {
|
function AuthPanel() {
|
||||||
const { login, setRole } = useSession();
|
const { login, setRole } = useSession();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
@@ -231,9 +259,11 @@ function App() {
|
|||||||
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
|
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
|
||||||
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
|
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
|
||||||
</header>
|
</header>
|
||||||
|
<DriverVideoPanel />
|
||||||
<section className="grid gap-6 lg:grid-cols-2">
|
<section className="grid gap-6 lg:grid-cols-2">
|
||||||
<RosterPanel />
|
<RosterPanel />
|
||||||
<AssignmentCard />
|
<AssignmentCard />
|
||||||
|
<TelemetryPanel />
|
||||||
<AuthPanel />
|
<AuthPanel />
|
||||||
<SessionInspector />
|
<SessionInspector />
|
||||||
</section>
|
</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(() => {
|
useEffect(() => {
|
||||||
const ids = Array.from(new Set(roverIds.filter(Boolean)));
|
const ids = Array.from(new Set(roverIds.filter(Boolean)));
|
||||||
if (!ids.length) return;
|
if (!ids.length) {
|
||||||
|
setSources({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setSources((prev) => {
|
||||||
|
const next = {};
|
||||||
|
ids.forEach((id) => {
|
||||||
|
if (prev[id]) next[id] = prev[id];
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
ids.forEach((roverId) => {
|
ids.forEach((roverId) => {
|
||||||
socket.emit('video:request', { roverId }, (resp = {}) => {
|
socket.emit('video:request', { roverId }, (resp = {}) => {
|
||||||
if (cancelled) return;
|
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 App from './App.jsx'
|
||||||
import { SocketProvider } from './context/SocketContext.jsx'
|
import { SocketProvider } from './context/SocketContext.jsx'
|
||||||
import { SessionProvider } from './context/SessionContext.jsx'
|
import { SessionProvider } from './context/SessionContext.jsx'
|
||||||
|
import { TelemetryProvider } from './context/TelemetryContext.jsx'
|
||||||
import SpectatorApp from './spectate/SpectatorApp.jsx'
|
import SpectatorApp from './spectate/SpectatorApp.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<SocketProvider>
|
<SocketProvider>
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<BrowserRouter>
|
<TelemetryProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route path="/" element={<App />} />
|
<Routes>
|
||||||
<Route path="/spectate" element={<SpectatorApp />} />
|
<Route path="/" element={<App />} />
|
||||||
</Routes>
|
<Route path="/spectate" element={<SpectatorApp />} />
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</TelemetryProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
</SocketProvider>
|
</SocketProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
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 { useSensorFeed } from '../hooks/useSensorFeed.js';
|
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
|
||||||
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../hooks/useVideoRequests.js';
|
||||||
|
import VideoTile from '../components/VideoTile.jsx';
|
||||||
|
|
||||||
function SpectatorStatus({ connected, ready, rosterCount }) {
|
function SpectatorStatus({ connected, ready, rosterCount }) {
|
||||||
return (
|
return (
|
||||||
@@ -65,19 +66,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
|
|||||||
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
|
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting for telemetry…'}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</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">
|
<VideoTile sessionInfo={videoInfo} label={rover.name} />
|
||||||
{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>
|
|
||||||
<section className="rounded-2xl border border-slate-800/80 bg-slate-950/50 p-4">
|
<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>
|
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p>
|
||||||
{sensorList.length === 0 ? (
|
{sensorList.length === 0 ? (
|
||||||
@@ -100,7 +89,7 @@ function RoverSpectatorCard({ rover, frame, videoInfo }) {
|
|||||||
export default function SpectatorApp() {
|
export default function SpectatorApp() {
|
||||||
const { connected, session, logs } = useSession();
|
const { connected, session, logs } = useSession();
|
||||||
const spectatorReady = useSpectatorMode();
|
const spectatorReady = useSpectatorMode();
|
||||||
const frames = useSensorFeed();
|
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));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user