This commit is contained in:
legop3
2025-11-15 21:30:18 -05:00
parent 6b52c84b86
commit 392e2f661b
21 changed files with 782 additions and 203 deletions
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
+2 -2
View File
@@ -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-BqgbVRB7.js"></script> <script type="module" crossorigin src="/assets/index-BrPsNUkx.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8Jiquik.css"> <link rel="stylesheet" crossorigin href="/assets/index-DZL3Zaf5.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+11
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6" "react-router-dom": "^7.9.6"
}, },
"devDependencies": { "devDependencies": {
@@ -3390,6 +3391,16 @@
"react": "^19.2.0" "react": "^19.2.0"
} }
}, },
"node_modules/react-joystick-component": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/react-joystick-component/-/react-joystick-component-6.2.1.tgz",
"integrity": "sha512-0G5Y5aX4hNuXB3xJCwz6Q+nYQOtC6kprNGKmZxmfoPvhepNYUiid0DbLEGZxmr/UKip3S/LUbcQUobtRCuB8IQ==",
"license": "MIT",
"peerDependencies": {
"react": ">=17.0.2",
"react-dom": ">=17.0.2"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+1
View File
@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-joystick-component": "^6.2.1",
"react-router-dom": "^7.9.6" "react-router-dom": "^7.9.6"
}, },
"devDependencies": { "devDependencies": {
+142 -21
View File
@@ -1,29 +1,71 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from './context/SessionContext.jsx'; import { useSession } from './context/SessionContext.jsx';
import { useTelemetryFrame } from './context/TelemetryContext.jsx';
import TelemetryPanel from './components/TelemetryPanel.jsx'; import TelemetryPanel from './components/TelemetryPanel.jsx';
import VideoTile from './components/VideoTile.jsx'; import VideoTile from './components/VideoTile.jsx';
import DrivePanel from './components/DrivePanel.jsx'; import DrivePanel from './components/DrivePanel.jsx';
import AlertFeed from './components/AlertFeed.jsx'; import AlertFeed from './components/AlertFeed.jsx';
import AdminPanel from './components/AdminPanel.jsx'; import AdminPanel from './components/AdminPanel.jsx';
import MobileControls, { MobileJoystick, AuxMotorControls } from './components/MobileControls.jsx';
import { DriveControlProvider } from './context/DriveControlContext.jsx';
import { useVideoRequests } from './hooks/useVideoRequests.js'; 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';
return ( return (
<div className="flex flex-wrap items-center gap-3 text-sm"> <div className="flex flex-wrap items-center gap-1 text-[0.65rem] uppercase tracking-[0.2em]">
<span className={`rounded-full px-3 py-1 font-medium ${color}`}> <span className={`rounded-full px-2 py-0.5 font-medium ${color}`}>
{connected ? 'Connected' : 'Disconnected'} {connected ? 'Connected' : 'Disconnected'}
</span> </span>
<span className="rounded-full bg-slate-800/80 px-3 py-1 text-slate-200"> <span className="rounded-full bg-slate-800/80 px-2 py-0.5 text-slate-200">
Role: {role || 'unknown'} Role {role || 'unknown'}
</span> </span>
<span className="rounded-full bg-slate-800/80 px-3 py-1 text-slate-200"> <span className="rounded-full bg-slate-800/80 px-2 py-0.5 text-slate-200">
Mode: {mode || '--'} Mode {mode || '--'}
</span> </span>
</div> </div>
); );
} }
function RoomCameraPanel() {
return (
<div className="min-h-[8rem] rounded-lg border border-slate-800 bg-slate-900/70 p-2 text-[0.75rem] text-slate-400">
<p className="text-center text-xs uppercase tracking-[0.3em] text-slate-500">Room camera</p>
<p className="mt-2 text-center text-sm text-slate-300">Feed placeholder. Wire upcoming room cam here.</p>
</div>
);
}
function useLayoutMode() {
const [mode, setMode] = useState(() => {
if (typeof window === 'undefined') return 'desktop';
return window.innerWidth >= 1024
? 'desktop'
: window.innerWidth > window.innerHeight
? 'mobile-landscape'
: 'mobile-portrait';
});
useEffect(() => {
function updateMode() {
if (typeof window === 'undefined') return;
const { innerWidth, innerHeight } = window;
if (innerWidth >= 1024) {
setMode('desktop');
} else if (innerWidth > innerHeight) {
setMode('mobile-landscape');
} else {
setMode('mobile-portrait');
}
}
updateMode();
window.addEventListener('resize', updateMode);
return () => window.removeEventListener('resize', updateMode);
}, []);
return mode;
}
function LogPanel() { function LogPanel() {
const { logs } = useSession(); const { logs } = useSession();
return ( return (
@@ -167,6 +209,12 @@ function DriverVideoPanel() {
const roverId = session?.assignment?.roverId; const roverId = session?.assignment?.roverId;
const sources = useVideoRequests(roverId ? [roverId] : []); const sources = useVideoRequests(roverId ? [roverId] : []);
const info = roverId ? sources[roverId] : null; const info = roverId ? sources[roverId] : null;
const frame = useTelemetryFrame(roverId);
const batteryConfig = useMemo(() => {
if (!roverId) return null;
const record = session?.roster?.find((item) => item.id === roverId);
return record?.battery ?? null;
}, [session?.roster, roverId]);
return ( return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4"> <section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
@@ -179,7 +227,13 @@ function DriverVideoPanel() {
</div> </div>
</header> </header>
{roverId ? ( {roverId ? (
<VideoTile sessionInfo={info} label={roverId} muted={false} /> <VideoTile
sessionInfo={info}
label={roverId}
muted={false}
telemetryFrame={frame}
batteryConfig={batteryConfig}
/>
) : ( ) : (
<p className="text-sm text-slate-400">Assignment required to initialize video.</p> <p className="text-sm text-slate-400">Assignment required to initialize video.</p>
)} )}
@@ -252,28 +306,95 @@ function AuthPanel() {
); );
} }
function App() { function DesktopLayout() {
const { connected, session } = useSession();
return ( return (
<div className="min-h-screen bg-slate-950 text-slate-50"> <div className="flex flex-col gap-2">
<main className="mx-auto flex max-w-6xl flex-col gap-6 px-6 py-10"> <section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)_minmax(0,1fr)] gap-2">
<header className="space-y-4"> <TelemetryPanel />
<h1 className="text-4xl font-semibold text-white">Multi Roomba Rover Console</h1>
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
</header>
<DriverVideoPanel /> <DriverVideoPanel />
<section className="grid gap-6 lg:grid-cols-2"> <DrivePanel />
</section>
<section className="grid grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_minmax(0,1fr)] gap-2">
<div className="flex flex-col gap-2">
<AuthPanel />
<AdminPanel />
<RosterPanel /> <RosterPanel />
</div>
<RoomCameraPanel />
<div className="flex flex-col gap-2">
<AssignmentCard /> <AssignmentCard />
<LogPanel />
<SessionInspector />
</div>
</section>
</div>
);
}
function MobilePortraitLayout() {
return (
<div className="flex flex-col gap-2">
<DriverVideoPanel />
<MobileControls />
<DrivePanel /> <DrivePanel />
<TelemetryPanel /> <TelemetryPanel />
<RosterPanel />
<AssignmentCard />
<AuthPanel /> <AuthPanel />
<SessionInspector />
<AdminPanel /> <AdminPanel />
<AlertFeed /> <RoomCameraPanel />
</section>
<LogPanel /> <LogPanel />
<SessionInspector />
</div>
);
}
function MobileLandscapeLayout() {
return (
<div className="flex flex-col gap-2">
<section className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.2fr)_minmax(0,0.9fr)] gap-2">
<div className="flex flex-col gap-2">
<DrivePanel />
<AuxMotorControls />
</div>
<DriverVideoPanel />
<div className="flex flex-col gap-2">
<MobileJoystick />
<TelemetryPanel />
</div>
</section>
<section className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2">
<div className="flex flex-col gap-2">
<RosterPanel />
<AssignmentCard />
<AuthPanel />
<AdminPanel />
</div>
<div className="flex flex-col gap-2">
<RoomCameraPanel />
<LogPanel />
<SessionInspector />
</div>
</section>
</div>
);
}
function App() {
const { connected, session } = useSession();
const layout = useLayoutMode();
const renderedLayout = layout === 'desktop' ? <DesktopLayout /> : layout === 'mobile-landscape' ? <MobileLandscapeLayout /> : <MobilePortraitLayout />;
return (
<div className="min-h-screen bg-black text-slate-50">
<main className="mx-auto flex w-full max-w-screen-2xl flex-col gap-2 px-1 py-1">
<DriveControlProvider>
<div className="flex justify-end">
<StatusBadge connected={connected} role={session?.role} mode={session?.mode} />
</div>
{renderedLayout}
<AlertFeed />
</DriveControlProvider>
</main> </main>
</div> </div>
); );
+1 -1
View File
@@ -10,7 +10,7 @@ const MODES = [
export default function AdminPanel() { export default function AdminPanel() {
const { session, lockRover, setMode, requestControl } = useSession(); const { session, lockRover, setMode, requestControl } = useSession();
const roster = session?.roster ?? []; const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({}); const [lockStates, setLockStates] = useState({});
const isAdmin = const isAdmin =
+44 -20
View File
@@ -1,30 +1,54 @@
import { useMemo } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
function AlertCard({ alert }) { const LIFETIME_MS = 5000;
const color =
alert.color === 'error'
? 'bg-red-500/10 border-red-500/40 text-red-100'
: alert.color === 'warn'
? 'bg-amber-500/10 border-amber-500/40 text-amber-100'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100';
return ( function buildKey(alert) {
<div className={`rounded-2xl border px-4 py-3 text-sm ${color}`}> if (alert.id) return alert.id;
<p className="text-xs uppercase tracking-[0.3em] opacity-60">{alert.title || 'Alert'}</p> if (alert.timestamp) return `${alert.timestamp}-${alert.message}`;
<p className="mt-1 text-base font-semibold">{alert.message}</p> return `${alert.title || 'alert'}-${alert.message}`;
</div>
);
} }
export default function AlertFeed() { export default function AlertFeed() {
const { alerts } = useSession(); const { alerts } = useSession();
const recent = useMemo(() => alerts.slice(-5).reverse(), [alerts]); const [now, setNow] = useState(() => Date.now());
if (recent.length === 0) return null; const latest = useMemo(() => alerts.slice(-5).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
useEffect(() => {
if (!latest.length) return undefined;
const interval = setInterval(() => setNow(Date.now()), 200);
return () => clearInterval(interval);
}, [latest.length]);
const visible = latest
.map((item) => ({
...item,
age: now - (item.alert.receivedAt ?? item.alert.timestamp ?? 0),
}))
.filter((item) => item.age <= LIFETIME_MS);
if (!visible.length) return null;
return ( return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4"> <div className="pointer-events-none fixed top-2 left-1/2 z-50 flex -translate-x-1/2 flex-col gap-2">
<h2 className="text-lg font-semibold text-white">Recent Alerts</h2> {visible.map((toast) => (
<div className="mt-4 space-y-3">{recent.map((alert) => <AlertCard key={alert.id || alert.message} alert={alert} />)}</div> <AlertToast key={toast.key} alert={toast.alert} />
</section> ))}
</div>
);
}
function AlertToast({ alert }) {
const colorClasses =
alert.color === 'error'
? 'border-red-500/50 bg-red-500/20 text-red-100'
: alert.color === 'warn'
? 'border-amber-500/50 bg-amber-500/20 text-amber-100'
: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100';
return (
<div className={`pointer-events-auto rounded-md border px-3 py-2 text-[0.75rem] shadow-lg shadow-black/60 ${colorClasses}`}>
<p className="text-[0.6rem] uppercase tracking-[0.4em] opacity-70">{alert.title || 'Alert'}</p>
<p className="text-sm font-semibold">{alert.message}</p>
</div>
); );
} }
+91 -50
View File
@@ -1,79 +1,120 @@
import { useDriveControls } from '../hooks/useDriveControls.js'; import { useDriveControl } from '../context/DriveControlContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
const oiButtons = [ const manualOiButtons = [
{ key: 'start', label: 'Start OI' },
{ key: 'safe', label: 'Safe' }, { key: 'safe', label: 'Safe' },
{ key: 'full', label: 'Full' },
{ key: 'passive', label: 'Passive' }, { key: 'passive', label: 'Passive' },
{ key: 'dock', label: 'Dock' }, { key: 'full', label: 'Full' },
]; ];
function SpeedMeter({ left, right }) {
return (
<div className="grid grid-cols-2 gap-4 rounded-2xl border border-slate-800/60 bg-slate-950/40 p-4 text-sm">
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Left</p>
<p className="text-2xl font-semibold text-white">{left}</p>
</div>
<div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Right</p>
<p className="text-2xl font-semibold text-white">{right}</p>
</div>
</div>
);
}
export default function DrivePanel() { export default function DrivePanel() {
const { roverId, speeds, stopMotors, sendOiCommand } = useDriveControls(); const { roverId, speeds, stopMotors, sendOiCommand, runStartDockFull, seekDock } = useDriveControl();
const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const drivingMode = (sensors.oiMode?.label || '').toLowerCase() === 'full';
const docked = Boolean(sensors.chargingSources?.homeBase);
const charging = Boolean(
sensors.chargingState?.label && sensors.chargingState.label.toLowerCase() !== 'not charging',
);
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
return ( return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4"> <section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
<header className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
<span>Drive Control</span>
<span>{roverId ? `Rover ${roverId}` : 'unassigned'}</span>
</div>
<div className="mt-2 space-y-2">
<div> <div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Drive Controls</p>
<h2 className="text-2xl font-semibold text-white">
{roverId ? `Driving rover ${roverId}` : 'No rover assigned'}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
<span>W/A/S/D: move</span>
<span>Shift: boost</span>
</div>
</header>
<SpeedMeter left={speeds.left} right={speeds.right} />
<div className="mt-4 flex flex-wrap gap-3">
<button <button
type="button" type="button"
onClick={stopMotors} onClick={runStartDockFull}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
disabled={!roverId} disabled={!roverId}
className="w-full rounded border border-emerald-500/40 bg-emerald-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-emerald-200 disabled:opacity-40"
> >
Stop Motors Enable Driving Mode
</button> </button>
<p className="mt-1 text-[0.65rem] text-slate-400">Runs Start Dock Full commands to ready the rover.</p>
<div className="mt-1 flex flex-wrap items-center gap-1 text-[0.6rem]">
<StatusPill label="Driving mode" active={drivingMode} />
{updated && <span className="text-slate-500">Updated {updated}</span>}
</div> </div>
</div>
<div className="mt-6 space-y-2"> <div>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">OI Modes</p> <button
<div className="flex flex-wrap gap-2"> type="button"
{oiButtons.map((btn) => ( onClick={seekDock}
disabled={!roverId}
className="w-full rounded border border-cyan-500/40 bg-cyan-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-cyan-200 disabled:opacity-40"
>
Seek Dock
</button>
<p className="mt-1 text-[0.65rem] text-slate-400">
Point the rover straight at the dock, about one foot away, before triggering.
</p>
<div className="mt-1 flex flex-wrap gap-1">
<StatusPill label={docked ? 'Docked' : 'Not docked'} active={docked} />
<StatusPill label={charging ? 'Charging' : 'Not charging'} active={charging} />
</div>
</div>
<SpeedRow left={speeds.left} right={speeds.right} />
<div>
<p className="text-[0.6rem] uppercase tracking-[0.3em] text-slate-500">Manual OI</p>
<div className="mt-1 flex flex-wrap gap-1">
{manualOiButtons.map((btn) => (
<button <button
key={btn.key} key={btn.key}
type="button" type="button"
onClick={() => sendOiCommand(btn.key)} onClick={() => sendOiCommand(btn.key)}
disabled={!roverId} disabled={!roverId}
className="rounded-lg border border-slate-700 px-3 py-1 text-sm font-semibold text-slate-200 disabled:opacity-50" className="rounded border border-slate-800 px-2 py-0.5 text-[0.65rem] uppercase tracking-[0.2em] text-slate-200 disabled:opacity-30"
> >
{btn.label} {btn.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-1">
<p className="mt-4 text-xs text-slate-500"> <button
Sensor streaming starts automatically after each OI change. Keep this tab focused while driving. type="button"
</p> onClick={stopMotors}
disabled={!roverId}
className="flex-1 rounded border border-red-500/50 bg-red-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.3em] text-red-200 disabled:opacity-40"
>
Stop Motors
</button>
</div>
</div>
<p className="mt-2 text-[0.6rem] text-slate-500">Sensor streaming auto-starts after each OI change.</p>
</section> </section>
); );
} }
function SpeedRow({ left, right }) {
return (
<div className="grid grid-cols-2 gap-1 rounded border border-slate-900 bg-black/50 p-1 text-[0.7rem]">
<div>
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Left</p>
<p className="font-semibold text-slate-100">{left}</p>
</div>
<div>
<p className="text-[0.55rem] uppercase tracking-[0.3em] text-slate-500">Right</p>
<p className="font-semibold text-slate-100">{right}</p>
</div>
</div>
);
}
function StatusPill({ label, active }) {
return (
<span
className={`rounded-full border px-2 py-0.5 text-[0.55rem] uppercase tracking-[0.3em] ${
active
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
: 'border-slate-700 bg-slate-900 text-slate-500'
}`}
>
{label}
</span>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { useCallback } from 'react';
import { Joystick } from 'react-joystick-component';
import { useDriveControl } from '../context/DriveControlContext.jsx';
function clampUnit(value = 0) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function MobileJoystick() {
const { roverId, driveWithVector, stopMotors } = useDriveControl();
const disabled = !roverId;
const handleMove = useCallback(
(event = {}) => {
if (disabled) return;
const x = clampUnit(event.x ?? 0);
const y = clampUnit(event.y ?? 0);
driveWithVector({ x, y });
},
[disabled, driveWithVector],
);
const handleStop = useCallback(() => {
if (disabled) return;
driveWithVector({ x: 0, y: 0 });
}, [disabled, driveWithVector]);
return (
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Joystick</div>
<div className="mt-2 flex items-center justify-center">
<Joystick
size={120}
baseColor="#0f172a"
stickColor="#38bdf8"
throttle={75}
move={handleMove}
stop={handleStop}
disabled={disabled}
/>
</div>
<p className="mt-2 text-[0.65rem] text-slate-400">
Drag to drive. Release to stop sending drive commands.
</p>
<button
type="button"
onClick={stopMotors}
disabled={disabled}
className="mt-2 w-full rounded border border-slate-800 bg-black/40 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-slate-200 disabled:opacity-40"
>
Panic Stop
</button>
</section>
);
}
function AuxMotorControls() {
const { roverId, setAuxMotors } = useDriveControl();
const disabled = !roverId;
const runAllForward = () => {
if (disabled) return;
setAuxMotors({ main: 127, side: 127, vacuum: 127 });
};
const stopAll = () => {
if (disabled) return;
setAuxMotors({ main: 0, side: 0, vacuum: 0 });
};
const auxButtons = [
{ label: 'Main +', values: { main: 127 } },
{ label: 'Main -', values: { main: -127 } },
{ label: 'Side +', values: { side: 127 } },
{ label: 'Side -', values: { side: -127 } },
{ label: 'Vacuum Max', values: { vacuum: 127 } },
{ label: 'Vacuum Off', values: { vacuum: 0 } },
];
return (
<section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-100">
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">Aux Motors</div>
<div className="mt-2 flex flex-wrap gap-1">
<button
type="button"
onClick={runAllForward}
disabled={disabled}
className="flex-1 rounded border border-emerald-500/40 bg-emerald-500/10 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-emerald-200 disabled:opacity-40"
>
All forward
</button>
<button
type="button"
onClick={stopAll}
disabled={disabled}
className="flex-1 rounded border border-slate-800 bg-black/40 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.2em] text-slate-200 disabled:opacity-40"
>
Stop all
</button>
</div>
<div className="mt-2 grid grid-cols-2 gap-1 text-[0.7rem]">
{auxButtons.map((btn) => (
<button
key={btn.label}
type="button"
onClick={() => !disabled && setAuxMotors(btn.values)}
disabled={disabled}
className="rounded border border-slate-800 bg-black/30 py-1 uppercase tracking-[0.2em] text-slate-200 disabled:opacity-30"
>
{btn.label}
</button>
))}
</div>
</section>
);
}
export default function MobileControlsStack() {
return (
<div className="flex flex-col gap-2">
<MobileJoystick />
<AuxMotorControls />
</div>
);
}
export { MobileJoystick, AuxMotorControls };
+36 -36
View File
@@ -1,56 +1,56 @@
import { useMemo } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx'; import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
function formatEntries(sensors = {}) { function formatMetric(value, fallback = '--') {
return Object.entries(sensors).map(([key, value]) => ({ if (value == null || value === '') return fallback;
key, return value;
value: typeof value === 'object' ? JSON.stringify(value) : value,
}));
} }
export default function TelemetryPanel() { export default function TelemetryPanel() {
const { session } = useSession(); const { session } = useSession();
const roverId = session?.assignment?.roverId; const roverId = session?.assignment?.roverId;
const frame = useTelemetryFrame(roverId); const frame = useTelemetryFrame(roverId);
const sensors = frame?.sensors || {};
const entries = useMemo(() => formatEntries(frame?.sensors), [frame?.sensors]); const voltage = sensors.voltageMv != null ? (sensors.voltageMv / 1000).toFixed(2) : null;
const current = sensors.currentMa != null ? (sensors.currentMa / 1000).toFixed(2) : null;
const charge = sensors.batteryChargeMah;
const capacity = sensors.batteryCapacityMah;
const updated = frame?.receivedAt ? new Date(frame.receivedAt).toLocaleTimeString() : null;
const rawSnippet = frame?.raw ? (frame.raw.length > 80 ? `${frame.raw.slice(0, 80)}` : frame.raw) : null;
return ( return (
<section className="rounded-2xl border border-slate-800 bg-slate-900/60 p-4"> <section className="rounded-lg border border-slate-900 bg-slate-950/70 p-2 text-[0.8rem] text-slate-200">
<header className="flex flex-wrap items-center justify-between gap-3"> <div className="flex items-center justify-between text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
<div> <span>Telemetry</span>
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Telemetry</p> <span>{updated ? `Updated ${updated}` : 'waiting'}</span>
<h2 className="text-2xl font-semibold text-white">
{roverId ? `Rover ${roverId}` : 'No rover assigned'}
</h2>
</div> </div>
<p className="text-sm text-slate-400"> {!roverId ? (
{frame?.receivedAt ? `Updated ${new Date(frame.receivedAt).toLocaleTimeString()}` : 'Waiting…'} <p className="mt-2 text-[0.75rem] text-slate-400">Assignment required to view sensors.</p>
</p> ) : !frame ? (
</header> <p className="mt-2 text-[0.75rem] text-slate-400">No sensor frames yet.</p>
{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"> <div className="mt-2 space-y-2">
{entries.map(({ key, value }) => ( <Metric label="Charge" value={formatMetric(charge != null && capacity != null ? `${charge}/${capacity} mAh` : null)} />
<div key={key} className="flex justify-between gap-6"> <Metric label="Charging" value={formatMetric(sensors.chargingState?.label)} />
<dt className="text-slate-500">{key}</dt> <Metric label="OI Mode" value={formatMetric(sensors.oiMode?.label)} />
<dd className="text-right font-semibold text-white">{value}</dd> <Metric label="Voltage" value={formatMetric(voltage ? `${voltage} V` : null)} />
<Metric label="Current" value={formatMetric(current ? `${current} A` : null)} />
</div> </div>
))}
</dl>
)} )}
{frame?.raw && ( {rawSnippet && (
<div className="mt-6"> <pre className="mt-2 overflow-hidden rounded border border-slate-800 bg-black/60 p-1 text-[0.6rem] text-lime-300">
<p className="text-xs uppercase tracking-[0.3em] text-slate-500">Raw frame</p> {rawSnippet}
<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> </pre>
</div>
)} )}
</section> </section>
); );
} }
function Metric({ label, value }) {
return (
<div className="flex items-center justify-between text-[0.75rem]">
<span className="text-slate-500">{label}</span>
<span className="font-semibold text-slate-200">{value}</span>
</div>
);
}
+145 -15
View File
@@ -4,7 +4,7 @@ import { WhepPlayer } from '../lib/whepPlayer.js';
const RESTART_DELAY_MS = 2000; const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000; const UNMUTE_RETRY_MS = 3000;
export default function VideoTile({ sessionInfo, label, forceMute = false }) { export default function VideoTile({ sessionInfo, label, forceMute = false, telemetryFrame, batteryConfig }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const restartTimer = useRef(null); const restartTimer = useRef(null);
const unmuteTimer = useRef(null); const unmuteTimer = useRef(null);
@@ -12,6 +12,13 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
const [restartToken, setRestartToken] = useState(0); const [restartToken, setRestartToken] = useState(0);
const [muted, setMuted] = useState(true); const [muted, setMuted] = useState(true);
const sensors = telemetryFrame?.sensors;
const batteryCharge = sensors?.batteryChargeMah ?? null;
const batteryCapacity = sensors?.batteryCapacityMah ?? null;
const wheelOvercurrents = sensors?.wheelOvercurrents || null;
const overcurrentActive = Boolean(
wheelOvercurrents && Object.values(wheelOvercurrents).some((value) => Boolean(value)),
);
const scheduleRestart = useCallback(() => { const scheduleRestart = useCallback(() => {
clearTimeout(restartTimer.current); clearTimeout(restartTimer.current);
@@ -22,19 +29,31 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
(delay = 0) => { (delay = 0) => {
if (forceMute) return; if (forceMute) return;
clearTimeout(unmuteTimer.current); clearTimeout(unmuteTimer.current);
unmuteTimer.current = setTimeout(async () => {
const scheduleRetry = () => {
clearTimeout(unmuteTimer.current);
unmuteTimer.current = setTimeout(() => {
if (!forceMute) {
tryPlay();
}
}, UNMUTE_RETRY_MS);
};
const tryPlay = async () => {
const video = videoRef.current; const video = videoRef.current;
if (!video) return; if (!video) return;
try { try {
video.muted = false; video.muted = false;
await video.play(); await video.play();
setMuted(false); setMuted(false);
} catch (err) { } catch {
video.muted = true; video.muted = true;
setMuted(true); setMuted(true);
attemptUnmute(UNMUTE_RETRY_MS); scheduleRetry();
} }
}, delay); };
unmuteTimer.current = setTimeout(tryPlay, delay);
}, },
[forceMute], [forceMute],
); );
@@ -55,13 +74,11 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
useEffect(() => { useEffect(() => {
if (!sessionInfo?.url || !videoRef.current) { if (!sessionInfo?.url || !videoRef.current) {
setStatus('waiting');
setDetail(null);
return undefined; return undefined;
} }
let active = true; let active = true;
let player; let player;
setMuted(true); const resetMuteId = setTimeout(() => setMuted(true), 0);
const handleStatus = (nextStatus, info) => { const handleStatus = (nextStatus, info) => {
if (!active) return; if (!active) return;
setStatus(nextStatus); setStatus(nextStatus);
@@ -87,6 +104,7 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
return () => { return () => {
active = false; active = false;
clearTimeout(resetMuteId);
player?.stop(); player?.stop();
}; };
}, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]); }, [sessionInfo?.url, sessionInfo?.token, restartToken, scheduleRestart]);
@@ -97,16 +115,17 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
} }
}, [status, sessionInfo?.url, scheduleRestart]); }, [status, sessionInfo?.url, scheduleRestart]);
const renderedStatus = const renderedStatus = !sessionInfo?.url
status === 'error' ? 'waiting'
: status === 'error'
? `Error: ${detail || 'unknown'}` ? `Error: ${detail || 'unknown'}`
: detail : detail
? `${status} (${detail})` ? `${status} (${detail})`
: status; : status;
return ( return (
<div className="space-y-2"> <div className="space-y-1">
<div className="w-full overflow-hidden rounded-2xl border border-slate-800 bg-black"> <div className="relative w-full overflow-hidden rounded-lg border border-slate-900 bg-black">
<video <video
ref={videoRef} ref={videoRef}
muted={forceMute || muted} muted={forceMute || muted}
@@ -115,10 +134,121 @@ export default function VideoTile({ sessionInfo, label, forceMute = false }) {
controls={false} controls={false}
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
<HudOverlay frame={telemetryFrame} />
<OvercurrentOverlay active={overcurrentActive} />
</div> </div>
<div className="text-xs text-slate-400"> <BatteryBar
<span className="font-semibold text-slate-200">{label}</span>{' '} charge={batteryCharge}
<span>{renderedStatus}</span> capacity={batteryCapacity}
config={batteryConfig}
/>
<div className="text-[0.65rem] uppercase tracking-[0.3em] text-slate-500">
<span className="text-slate-200">{label}</span> · {renderedStatus}
</div>
</div>
);
}
function BatteryBar({ charge, capacity, config }) {
if (charge == null || !config?.full || config.warn == null) {
return null;
}
const span = config.full - config.warn;
if (span <= 0) return null;
const normalized = (charge - config.warn) / span;
const percent = Math.min(1, Math.max(0, normalized));
const percentDisplay = Math.round(percent * 100);
const depleted = normalized <= 0;
const urgent = config.urgent != null && charge <= config.urgent;
const barClass = depleted
? 'bg-red-500 animate-pulse'
: urgent
? 'bg-amber-400'
: 'bg-emerald-500';
const capText = capacity ? `${charge}/${capacity}` : `${charge}`;
return (
<div className="rounded border border-slate-900 bg-slate-950/80 p-1">
<div className="flex items-center justify-between text-[0.65rem] text-slate-300">
<span>Battery</span>
<span>{capText} mAh</span>
</div>
<div className="mt-1 h-2 w-full rounded-full bg-slate-800">
<div
className={`h-full rounded-full transition-[width] ${barClass}`}
style={{ width: `${percentDisplay}%` }}
/>
</div>
</div>
);
}
function HudOverlay({ frame }) {
const sensors = frame?.sensors;
const bumps = sensors?.bumpsAndWheelDrops || {};
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const interval = setInterval(() => setNow(Date.now()), 150);
return () => clearInterval(interval);
}, []);
const pulse = frame?.receivedAt ? now - frame.receivedAt < 200 : false;
const bumperBadges = [
{ label: 'B-L', active: bumps.bumpLeft },
{ label: 'B-R', active: bumps.bumpRight },
];
const wheelBadges = [
{ label: 'DROP L', active: bumps.wheelDropLeft },
{ label: 'DROP R', active: bumps.wheelDropRight },
];
return (
<div className="pointer-events-none absolute inset-0 flex flex-col justify-between p-2 text-[0.6rem] uppercase tracking-[0.3em] text-slate-200">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${
pulse ? 'bg-emerald-300 shadow-[0_0_6px_rgba(16,185,129,0.9)]' : 'bg-slate-700'
}`}
></span>
<span className="text-slate-400">SENSORS</span>
</div>
<div className="flex gap-1">
{bumperBadges.map((badge) => (
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
))}
</div>
</div>
<div className="flex justify-end gap-1">
{wheelBadges.map((badge) => (
<HudBadge key={badge.label} label={badge.label} active={badge.active} />
))}
</div>
</div>
);
}
function HudBadge({ label, active }) {
return (
<span
className={`rounded-sm border px-1 py-0.5 text-[0.55rem] ${
active
? 'border-emerald-400 bg-emerald-500/10 text-emerald-200'
: 'border-slate-700 bg-black/40 text-slate-500'
}`}
>
{label}
</span>
);
}
function OvercurrentOverlay({ active }) {
if (!active) return null;
return (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-900/60">
<div className="text-center text-lg font-black uppercase tracking-[0.6em] text-red-100 animate-pulse">
Overcurrent
</div> </div>
</div> </div>
); );
+19
View File
@@ -0,0 +1,19 @@
import { createContext, useContext } from 'react';
import { useDriveControls as useDriveControlsHook } from '../hooks/useDriveControls.js';
/* eslint-disable react-refresh/only-export-components */
const DriveControlContext = createContext(null);
export function DriveControlProvider({ children }) {
const controls = useDriveControlsHook();
return <DriveControlContext.Provider value={controls}>{children}</DriveControlContext.Provider>;
}
export function useDriveControl() {
const context = useContext(DriveControlContext);
if (!context) {
throw new Error('useDriveControl must be used within DriveControlProvider');
}
return context;
}
+9 -1
View File
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useSocket } from './SocketContext.jsx'; import { useSocket } from './SocketContext.jsx';
@@ -61,7 +63,13 @@ export function SessionProvider({ children }) {
socket.on('log:init', handleLogInit); socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry); socket.on('log:entry', handleLogEntry);
socket.on('alert:new', (payload = {}) => { socket.on('alert:new', (payload = {}) => {
setAlerts((prev) => [...prev.slice(-49), payload]); setAlerts((prev) => [
...prev.slice(-49),
{
...payload,
receivedAt: Date.now(),
},
]);
}); });
return () => { return () => {
socket.off('session:sync', handleSession); socket.off('session:sync', handleSession);
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
// src/context/SocketContext.jsx // src/context/SocketContext.jsx
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { socket } from "../lib/socket"; import { socket } from "../lib/socket";
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useMemo, useState, useEffect } from 'react'; import { createContext, useContext, useMemo, useState, useEffect } from 'react';
import { useSocket } from './SocketContext.jsx'; import { useSocket } from './SocketContext.jsx';
+84 -6
View File
@@ -1,3 +1,5 @@
/* global Buffer */
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
@@ -10,6 +12,16 @@ const OI_COMMANDS = {
dock: [143], dock: [143],
}; };
const AUX_LIMITS = {
main: [-127, 127],
side: [-127, 127],
vacuum: [0, 127],
};
const COMMAND_DELAY_MS = 200;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
@@ -51,6 +63,11 @@ function computeSpeeds(keys) {
}; };
} }
function clampUnit(value) {
if (typeof value !== 'number' || Number.isNaN(value)) return 0;
return Math.max(-1, Math.min(1, value));
}
function shouldIgnoreEvent(event) { function shouldIgnoreEvent(event) {
const target = event.target; const target = event.target;
if (!target) return false; if (!target) return false;
@@ -66,8 +83,11 @@ function shouldIgnoreEvent(event) {
function bytesToBase64(bytes) { function bytesToBase64(bytes) {
const binary = String.fromCharCode(...bytes); const binary = String.fromCharCode(...bytes);
if (typeof btoa === 'function') return btoa(binary); if (typeof btoa === 'function') return btoa(binary);
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64'); return Buffer.from(bytes).toString('base64');
} }
throw new Error('No base64 encoder available');
}
export function useDriveControls() { export function useDriveControls() {
const socket = useSocket(); const socket = useSocket();
@@ -75,7 +95,7 @@ export function useDriveControls() {
const roverId = session?.assignment?.roverId; const roverId = session?.assignment?.roverId;
const keysRef = useRef(new Set()); const keysRef = useRef(new Set());
const lastSpeedsRef = useRef({ left: 0, right: 0 }); const lastSpeedsRef = useRef({ left: 0, right: 0 });
const [currentSpeeds, setCurrentSpeeds] = useState(lastSpeedsRef.current); const [currentSpeeds, setCurrentSpeeds] = useState(() => ({ left: 0, right: 0 }));
const emitCommand = useCallback( const emitCommand = useCallback(
(payload, cb) => { (payload, cb) => {
@@ -106,16 +126,49 @@ export function useDriveControls() {
}); });
}, [emitCommand, roverId]); }, [emitCommand, roverId]);
const driveWithVector = useCallback(
({ x = 0, y = 0, boost = false } = {}) => {
if (!roverId) return;
const base = boost ? 400 : 250;
const forward = clampUnit(y) * base;
const turn = clampUnit(x) * base;
const speeds = {
left: clamp(Math.round(forward + turn), -500, 500),
right: clamp(Math.round(forward - turn), -500, 500),
};
lastSpeedsRef.current = speeds;
setCurrentSpeeds(speeds);
emitCommand({
type: 'drive',
data: { driveDirect: speeds },
});
},
[emitCommand, roverId],
);
const sendMotorPwm = useCallback(
({ main = 0, side = 0, vacuum = 0 } = {}) => {
if (!roverId) return;
const payload = {
main: clamp(main, AUX_LIMITS.main[0], AUX_LIMITS.main[1]),
side: clamp(side, AUX_LIMITS.side[0], AUX_LIMITS.side[1]),
vacuum: clamp(vacuum, AUX_LIMITS.vacuum[0], AUX_LIMITS.vacuum[1]),
};
emitCommand({
type: 'motors',
data: { motorPwm: payload },
});
},
[emitCommand, roverId],
);
const stopMotors = useCallback(() => { const stopMotors = useCallback(() => {
if (!roverId) return; if (!roverId) return;
keysRef.current.clear(); keysRef.current.clear();
lastSpeedsRef.current = { left: 0, right: 0 }; lastSpeedsRef.current = { left: 0, right: 0 };
setCurrentSpeeds(lastSpeedsRef.current); setCurrentSpeeds(lastSpeedsRef.current);
emitCommand({ sendMotorPwm({ main: 0, side: 0, vacuum: 0 });
type: 'motors', }, [roverId, sendMotorPwm]);
data: { motorPwm: { main: 0, side: 0, vacuum: 0 } },
});
}, [emitCommand, roverId]);
const sendOiCommand = useCallback( const sendOiCommand = useCallback(
(key) => { (key) => {
@@ -131,6 +184,27 @@ export function useDriveControls() {
[emitCommand, enableSensorStream, roverId], [emitCommand, enableSensorStream, roverId],
); );
const runStartDockFull = useCallback(async () => {
if (!roverId) return;
for (const key of ['start', 'dock', 'full']) {
sendOiCommand(key);
// brief pause so the commands aren't collapsed by the rover
await sleep(COMMAND_DELAY_MS);
}
}, [roverId, sendOiCommand]);
const seekDock = useCallback(() => {
sendOiCommand('dock');
}, [sendOiCommand]);
const setAuxMotors = useCallback(
(values) => {
if (!roverId) return;
sendMotorPwm(values);
},
[roverId, sendMotorPwm],
);
useEffect(() => { useEffect(() => {
if (!roverId) { if (!roverId) {
keysRef.current.clear(); keysRef.current.clear();
@@ -171,5 +245,9 @@ export function useDriveControls() {
speeds: currentSpeeds, speeds: currentSpeeds,
stopMotors, stopMotors,
sendOiCommand, sendOiCommand,
runStartDockFull,
seekDock,
setAuxMotors,
driveWithVector,
}; };
} }
+16 -13
View File
@@ -1,24 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
export function useVideoRequests(roverIds = []) { export function useVideoRequests(roverIds = []) {
const socket = useSocket(); const socket = useSocket();
const [sources, setSources] = useState({}); const [sources, setSources] = useState({});
const ids = useMemo(() => Array.from(new Set(roverIds.filter(Boolean))), [roverIds]);
const idsKey = ids.join('|');
useEffect(() => { useEffect(() => {
const ids = Array.from(new Set(roverIds.filter(Boolean)));
if (!ids.length) { if (!ids.length) {
setSources({}); return undefined;
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;
@@ -32,7 +25,17 @@ export function useVideoRequests(roverIds = []) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [socket, roverIds.join('|')]); }, [socket, idsKey, ids]);
const filtered = useMemo(() => {
if (!ids.length) return {};
const next = {};
ids.forEach((id) => {
if (sources[id]) {
next[id] = sources[id];
}
});
return next;
}, [ids, sources]);
return sources; return filtered;
} }
+15 -4
View File
@@ -1,3 +1,5 @@
/* global Buffer */
const RTC_CONFIG = { const RTC_CONFIG = {
iceServers: [ iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.l.google.com:19302' },
@@ -8,11 +10,20 @@ const RTC_CONFIG = {
}; };
function encodeBase64(value) {
if (typeof btoa === 'function') {
return btoa(value);
}
if (typeof Buffer !== 'undefined') {
return Buffer.from(value).toString('base64');
}
throw new Error('No base64 encoder available');
}
function buildAuthHeader(token) { function buildAuthHeader(token) {
if (!token) return {}; if (!token) return {};
const credential = `${token}:${token}`; const credential = `${token}:${token}`;
const encoded = const encoded = encodeBase64(credential);
typeof btoa === 'function' ? btoa(credential) : Buffer.from(credential).toString('base64');
return { Authorization: `Basic ${encoded}` }; return { Authorization: `Basic ${encoded}` };
} }
@@ -54,9 +65,9 @@ export class WhepPlayer {
this.pc = pc; this.pc = pc;
const stream = new MediaStream(); const stream = new MediaStream();
pc.ontrack = (event) => { pc.ontrack = (event) => {
event.streams[0]?.getTracks().forEach((track) => stream.addTrack(track)); event.streams[0]?.getTracks().forEach((mediaTrack) => stream.addTrack(mediaTrack));
this.video.srcObject = stream; this.video.srcObject = stream;
if (track.kind === 'video' && 'playoutDelayHint' in event.receiver) { if (event.track?.kind === 'video' && 'playoutDelayHint' in event.receiver) {
event.receiver.playoutDelayHint = 0; event.receiver.playoutDelayHint = 0;
} }
}; };