{label}
diff --git a/webui/src/components/RoomCameraPanel.jsx b/webui/src/components/RoomCameraPanel.jsx
index 1e90c5c2..528a1a6b 100644
--- a/webui/src/components/RoomCameraPanel.jsx
+++ b/webui/src/components/RoomCameraPanel.jsx
@@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { useRoomCameraSnapshots } from '../hooks/useRoomCameraSnapshots.js';
+import { useVideoRequests } from '../hooks/useVideoRequests.js';
+import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
import RoomCameraFeed from './RoomCameraFeed.jsx';
function EmptyState() {
@@ -32,6 +34,15 @@ export default function RoomCameraPanel({
const { session } = useSession();
const cameras = session?.roomCameras || [];
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
+ const av1Supported = supportsAv1WebRtc();
+ const previewEntries = cameras.map((camera) => ({
+ type: 'room',
+ id: camera.id,
+ key: `room:${camera.id}:preview:av1`,
+ preview: true,
+ codec: 'av1',
+ }));
+ const previewSources = useVideoRequests(previewEntries, { enabled: av1Supported });
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
const [orientation, setOrientation] = useState(() =>
normalizeOrientation(
@@ -96,13 +107,19 @@ export default function RoomCameraPanel({
{cameras.map((camera) => {
const feed = feedMap[camera.id] || null;
+ const previewSession = previewSources[`room:${camera.id}:preview:av1`] || null;
return (
{/* */}
-
+
);
})}
diff --git a/webui/src/hooks/useVideoRequests.js b/webui/src/hooks/useVideoRequests.js
index a17b1d2d..ed1e94ab 100644
--- a/webui/src/hooks/useVideoRequests.js
+++ b/webui/src/hooks/useVideoRequests.js
@@ -11,23 +11,42 @@ function normalizeEntry(entry) {
if (typeof entry === 'object') {
if (entry.type && entry.id) {
const id = String(entry.id);
+ const preview = Boolean(entry.preview);
+ const codec = entry.codec ? String(entry.codec) : null;
let key = entry.key;
if (!key) {
key = entry.type === 'room' ? `room:${id}` : id;
+ if (preview) {
+ key = `${key}:preview${codec ? `:${codec}` : ''}`;
+ }
}
return {
type: entry.type,
id,
key,
+ preview,
+ codec,
};
}
if (entry.roverId) {
const id = String(entry.roverId);
- return { type: 'rover', id, key: entry.key || id };
+ return {
+ type: 'rover',
+ id,
+ key: entry.key || id,
+ preview: Boolean(entry.preview),
+ codec: entry.codec ? String(entry.codec) : null,
+ };
}
if (entry.roomCameraId) {
const id = String(entry.roomCameraId);
- return { type: 'room', id, key: entry.key || `room:${id}` };
+ return {
+ type: 'room',
+ id,
+ key: entry.key || `room:${id}`,
+ preview: Boolean(entry.preview),
+ codec: entry.codec ? String(entry.codec) : null,
+ };
}
}
return null;
@@ -86,6 +105,12 @@ export function useVideoRequests(sourceList = [], options = {}) {
function requestEntry(entry) {
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
+ if (entry.preview) {
+ payload.preview = true;
+ if (entry.codec) {
+ payload.codec = entry.codec;
+ }
+ }
socket.emit('video:request', payload, (resp = {}) => {
if (cancelled) return;
setSources((prev) => ({ ...prev, [entry.key]: resp }));
diff --git a/webui/src/lib/mediaSupport.js b/webui/src/lib/mediaSupport.js
new file mode 100644
index 00000000..f8682be4
--- /dev/null
+++ b/webui/src/lib/mediaSupport.js
@@ -0,0 +1,28 @@
+let cachedAv1Support = null;
+
+function hasAv1CodecCapability() {
+ if (typeof RTCRtpReceiver !== 'undefined' && RTCRtpReceiver.getCapabilities) {
+ const caps = RTCRtpReceiver.getCapabilities('video');
+ const codecs = caps?.codecs || [];
+ return codecs.some((codec) => {
+ const mime = (codec?.mimeType || '').toLowerCase();
+ return mime === 'video/av1' || mime === 'video/av01' || mime === 'video/av1x';
+ });
+ }
+ if (typeof document !== 'undefined') {
+ const video = document.createElement('video');
+ if (typeof video.canPlayType === 'function') {
+ const result = video.canPlayType('video/mp4; codecs="av01.0.05M.08"');
+ return result === 'probably' || result === 'maybe';
+ }
+ }
+ return false;
+}
+
+export function supportsAv1WebRtc() {
+ if (cachedAv1Support != null) {
+ return cachedAv1Support;
+ }
+ cachedAv1Support = hasAv1CodecCapability();
+ return cachedAv1Support;
+}
diff --git a/webui/src/mini/MiniSummaryApp.jsx b/webui/src/mini/MiniSummaryApp.jsx
index ab5985e2..4c31cce7 100644
--- a/webui/src/mini/MiniSummaryApp.jsx
+++ b/webui/src/mini/MiniSummaryApp.jsx
@@ -10,6 +10,8 @@ import VideoTile from '../components/VideoTile.jsx';
import ChatPanel from '../components/ChatPanel.jsx';
import AlertFeed from '../components/AlertFeed.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
+import RoomCameraFeed from '../components/RoomCameraFeed.jsx';
+import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
const ROTATE_MS = 20000;
@@ -34,12 +36,29 @@ function MiniSummaryContent() {
enabled: !inLockdown,
version: session?.mode,
});
+ const av1Supported = supportsAv1WebRtc();
const [index, setIndex] = useState(0);
const snapshotFeeds = useRoverSnapshots(
roster.map((rover) => rover.id),
{ enabled: !inLockdown, version: session?.mode },
);
+ const previewEntries = roster.map((rover) => ({
+ type: 'rover',
+ id: rover.id,
+ key: `rover:${rover.id}:preview:av1`,
+ preview: true,
+ codec: 'av1',
+ }));
+ const roomPreviewEntries = roomCameras.map((camera) => ({
+ type: 'room',
+ id: camera.id,
+ key: `room:${camera.id}:preview:av1`,
+ preview: true,
+ codec: 'av1',
+ }));
+ const previewSources = useVideoRequests(previewEntries, { enabled: !inLockdown && av1Supported });
+ const roomPreviewSources = useVideoRequests(roomPreviewEntries, { enabled: !inLockdown && av1Supported });
const audioEntries = useMemo(
() =>
roster.flatMap((rover) => {
@@ -91,10 +110,18 @@ function MiniSummaryContent() {
const activeCamera = activeEntry?.type === 'room' ? activeEntry.camera : null;
const activeSnapshot = activeRover ? snapshotFeeds[activeRover.id] || null : null;
+ const activePreview =
+ activeRover && previewSources[`rover:${activeRover.id}:preview:av1`]
+ ? previewSources[`rover:${activeRover.id}:preview:av1`]
+ : null;
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
const driverLabel = activeRover ? formatDriverLabel({ roverId: activeRover.id, session }) : null;
const activeFeed = activeCamera ? feeds[activeCamera.id] || null : null;
+ const activeRoomPreview =
+ activeCamera && roomPreviewSources[`room:${activeCamera.id}:preview:av1`]
+ ? roomPreviewSources[`room:${activeCamera.id}:preview:av1`]
+ : null;
if (inLockdown) {
return (
@@ -118,8 +145,8 @@ function MiniSummaryContent() {
) : activeRover ? (
) : activeCamera ? (
-
+
) : (
@@ -158,26 +185,15 @@ export default function MiniSummaryApp() {
);
}
-function RoomCameraFrame({ camera, feed }) {
- const hasImage = feed?.objectUrl;
- const connecting = feed && feed.status === 'connecting';
+function RoomCameraFrame({ camera, feed, videoSession, preferVideo }) {
return (
- {hasImage ? (
-

- ) : (
-
- {connecting ? `Connecting to ${camera.name || camera.id}…` : 'No frame yet'}
-
- )}
-
- {camera.name || camera.id}
-
+
);
}
diff --git a/webui/src/spectate/SpectatorApp.jsx b/webui/src/spectate/SpectatorApp.jsx
index 1e4b7884..b3ab3b7d 100644
--- a/webui/src/spectate/SpectatorApp.jsx
+++ b/webui/src/spectate/SpectatorApp.jsx
@@ -13,6 +13,7 @@ import RoverRoster from '../components/RoverRoster.jsx';
import AlertFeed from '../components/AlertFeed.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import CommunityGoalBanner from '../components/CommunityGoalBanner.jsx';
+import { supportsAv1WebRtc } from '../lib/mediaSupport.js';
function formatDriverLabel({ roverId, session }) {
const activeDriverId = session?.activeDrivers?.[roverId] || null;
@@ -25,14 +26,15 @@ function formatDriverLabel({ roverId, session }) {
return driverText;
}
-function RoverSpectatorCard({ rover, frame, snapshotFeed, audioInfo, session }) {
+function RoverSpectatorCard({ rover, frame, snapshotFeed, previewSession, audioInfo, session }) {
const driverLabel = formatDriverLabel({ roverId: rover.id, session });
+ const hasPreview = Boolean(previewSession?.url);
return (
No rovers registered.;
}
@@ -60,6 +62,7 @@ function RoverRow({ roster, frames, snapshotFeeds, audioSources, session }) {
rover={rover}
frame={frames[rover.id]}
snapshotFeed={snapshotFeeds[rover.id]}
+ previewSession={previewSources[`rover:${rover.id}:preview:av1`] || null}
audioInfo={audioSources[`${rover.id}-audio`]}
session={session}
showHudMap
@@ -100,10 +103,19 @@ function SpectatorContent() {
useSpectatorMode();
const frames = useTelemetryFrames();
const roster = session?.roster ?? [];
+ const av1Supported = supportsAv1WebRtc();
const snapshotFeeds = useRoverSnapshots(
roster.map((rover) => rover.id),
{ enabled: !inLockdown, version: session?.mode },
);
+ const previewEntries = roster.map((rover) => ({
+ type: 'rover',
+ id: rover.id,
+ key: `rover:${rover.id}:preview:av1`,
+ preview: true,
+ codec: 'av1',
+ }));
+ const previewSources = useVideoRequests(previewEntries, { enabled: !inLockdown && av1Supported, version: session?.mode });
const audioEntries = roster.flatMap((rover) =>
rover.media?.audioPublishUrl
? [{ type: 'rover', id: `${rover.id}-audio`, key: `${rover.id}-audio` }]
@@ -130,6 +142,7 @@ function SpectatorContent() {
roster={roster}
frames={frames}
snapshotFeeds={snapshotFeeds}
+ previewSources={previewSources}
audioSources={audioSources}
session={session}
/>