mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 10:42:45 -05:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b48fee00f | ||
|
|
1284125fff | ||
|
|
28d0b1437d | ||
|
|
9d3b8df4bf | ||
|
|
9d42cb5581 | ||
|
|
e670226f8c | ||
|
|
0669f29a4a | ||
|
|
38d3465bf8 | ||
|
|
6bfbb8ad80 | ||
|
|
ba2f66ebc1 | ||
|
|
fde2fca0a0 | ||
|
|
d36ea4f672 | ||
|
|
4be6e6c9a2 | ||
|
|
c71267873a |
@@ -5,6 +5,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
**/node_modules
|
||||
webui/.cache/mediamtx/
|
||||
# Neither the legacy state directory nor the Compose-mounted replacement may
|
||||
# enter an image build. This prevents credentials and backups from becoming
|
||||
# image layers after an operator has started using either deployment layout.
|
||||
|
||||
@@ -5,6 +5,7 @@ create_2_Open_Interface_Spec.txt
|
||||
|
||||
logs
|
||||
node_modules/
|
||||
webui/.cache/mediamtx/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pio
|
||||
|
||||
+5
-1
@@ -23,9 +23,13 @@ services:
|
||||
# Required for Bluetooth hardware such as the Balance Board.
|
||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
|
||||
|
||||
# Required for Kinect USB access.
|
||||
# Kinect USB access.
|
||||
devices:
|
||||
- /dev/bus/usb:/dev/bus/usb
|
||||
# For a server-local microphone, uncomment this device and group_add below.
|
||||
# - /dev/snd:/dev/snd
|
||||
# group_add:
|
||||
# - "${AUDIO_GID:?Set AUDIO_GID to the host sound device group ID}"
|
||||
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
|
||||
@@ -26,6 +26,8 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
||||
|
||||
## Decision log
|
||||
|
||||
- 2026-10-02: The admin container controls accept a tag within the repository supplied by Compose through `MULTIROVER_TARGET_IMAGE`. A successful healthy replacement saves the tag in the lifecycle status volume; failed replacements retain the previous selection. Checks and later updates follow that saved tag. Changing repositories in Compose resets the selection. Manual Compose deployments still use the Compose image and can replace a UI-selected image; the UI displays both references. The lifecycle helper itself is updated through Compose. Image rollback does not revert application data migrations. Deploy this feature to both the application and lifecycle services before using tag selection.
|
||||
|
||||
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
|
||||
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
|
||||
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
|
||||
|
||||
@@ -64,6 +64,7 @@ socket.on('connect', async () => {
|
||||
// previously stored session with this object instead of merging snapshots.
|
||||
socket.on('session:sync', (session) => {
|
||||
console.log('Session:', session);
|
||||
console.log('Server mode:', session.mode);
|
||||
});
|
||||
|
||||
// chat:init contains the recent chat history available when the bot connects.
|
||||
@@ -105,6 +106,21 @@ Run it with:
|
||||
node bot.js
|
||||
```
|
||||
|
||||
## Reading server mode
|
||||
|
||||
Read `session.mode` from the `session:sync` snapshot to get the current server
|
||||
mode. Its possible values are `open`, `turns`, `admin`, and `lockdown`.
|
||||
|
||||
The server sends a fresh session snapshot whenever the mode changes, so the
|
||||
example's handler receives both the initial mode and subsequent updates. No
|
||||
polling or separate mode listener is needed. If your bot stores session data for
|
||||
its chat-handling logic, replace the stored session on every `session:sync` and
|
||||
read the mode from that latest snapshot.
|
||||
|
||||
`session.mode` is the server-wide mode; `session.role` is this connection's role
|
||||
(such as `spectator`), and `session.operatingMode` is a separate connection field.
|
||||
Use `session.mode` when answering chat questions about the server's current mode.
|
||||
|
||||
## Events used
|
||||
|
||||
- `nickname:set` sets the bot's visible nickname.
|
||||
|
||||
@@ -13,6 +13,7 @@ const audioForward = require('../services/audioForwardService/configuration');
|
||||
const audioLevels = require('../services/audioLevelsService/configuration');
|
||||
const homeAssistant = require('../services/homeAssistantService/configuration');
|
||||
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
|
||||
const roomAudio = require('../services/roomAudioService/configuration');
|
||||
const roomCameras = require('../services/roomCameraService/configuration');
|
||||
const ptzCamera = require('../services/ptzCameraService/configuration');
|
||||
const kinect = require('../services/kinectService/configuration');
|
||||
@@ -46,6 +47,7 @@ const definitions = [
|
||||
// Activities own a separate catalog even though the HA connection is shared.
|
||||
homeAssistantActivities,
|
||||
roomCameras,
|
||||
roomAudio,
|
||||
ptzCamera,
|
||||
kinect,
|
||||
balanceBoard,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Configuration Database Migrations
|
||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
||||
// Scope: Upgrades database structure and stored configuration documents before current-schema validation.
|
||||
const migrations = [
|
||||
{
|
||||
version: 1,
|
||||
@@ -84,6 +84,34 @@ const migrations = [
|
||||
`).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
version: 4,
|
||||
run(db) {
|
||||
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
|
||||
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
|
||||
// Upgrade history as well as the active revision so a settings rollback
|
||||
// cannot reintroduce fields rejected by the new Automations schema.
|
||||
for (const row of rows) {
|
||||
const config = JSON.parse(row.config_json);
|
||||
let changed = false;
|
||||
for (const item of config.homeAssistantActivities?.items || []) {
|
||||
const hasAction = Object.hasOwn(item, 'idleAction');
|
||||
const hasValue = Object.hasOwn(item, 'idleValue');
|
||||
if (!hasAction && !hasValue) continue;
|
||||
item.automations ??= {};
|
||||
item.automations.idle ??= {};
|
||||
// Preserve any explicitly saved new-format setting when both formats
|
||||
// exist, including an empty text value used to clear an entity.
|
||||
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
|
||||
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
|
||||
delete item.idleAction;
|
||||
delete item.idleValue;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) update.run(JSON.stringify(config), row.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function applySchemaMigrations(db) {
|
||||
|
||||
@@ -124,7 +124,7 @@ function grantExternalSpectatorAccessAfterAdminLogin(socket) {
|
||||
}
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const requestedRole = socket.handshake?.query?.role;
|
||||
const requestedRole = socket.handshake?.auth?.role || socket.handshake?.query?.role;
|
||||
/*
|
||||
Role is assigned before the browser's full identity heartbeat has completed.
|
||||
For admin-gated external spectators, fail closed here; the spectator page can
|
||||
@@ -188,4 +188,5 @@ module.exports = {
|
||||
isAdmin,
|
||||
isLockdownAdmin,
|
||||
authenticate,
|
||||
canBecomeSpectator,
|
||||
};
|
||||
|
||||
@@ -109,6 +109,7 @@ async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
||||
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
|
||||
if (relativePath === path.join('rover-learning', 'recordings')) continue;
|
||||
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
|
||||
if (entry.isDirectory()) {
|
||||
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
|
||||
|
||||
@@ -91,9 +91,9 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
||||
|
||||
const requester = buildRequesterLabel(socket);
|
||||
const jobId = buildReplayJobId('web-chat');
|
||||
const attempt = replayApi.tryTriggerReplay({ by: { source: 'web-chat', requester } });
|
||||
const attempt = replayApi.tryTriggerReplay({ jobId, by: { source: 'web-chat', requester } });
|
||||
if (!attempt.ok) {
|
||||
await message.reply({ content: `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.` });
|
||||
await message.reply({ content: `Replay denied: ${attempt.error}` });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Discord Replay Command
|
||||
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
|
||||
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
|
||||
// Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs.
|
||||
const { AttachmentBuilder } = require('discord.js');
|
||||
const io = require('../../../globals/io');
|
||||
const { hostReplay } = require('../../replayMediaService');
|
||||
@@ -60,12 +60,6 @@ function createReplayCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
|
||||
if (!attempt.ok) {
|
||||
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||
const job = createReplayJob({
|
||||
id: buildReplayJobId('discord'),
|
||||
@@ -74,17 +68,23 @@ function createReplayCommand({
|
||||
sources: resolved.sources || [],
|
||||
includeSidebar: true,
|
||||
});
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
|
||||
const progressMessage = await message.reply({
|
||||
content: sanitizeMentions(buildAcceptedMessage(job)),
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||
const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } });
|
||||
if (!attempt.ok) {
|
||||
await replyDenied(message, `Replay denied: ${attempt.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let progressMessage = null;
|
||||
let stopTyping = () => {};
|
||||
let builtReplay = null;
|
||||
let deliveredMedia = null;
|
||||
try {
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
progressMessage = await message.reply({
|
||||
content: sanitizeMentions(buildAcceptedMessage(job)),
|
||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||
});
|
||||
stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||
if (progressMessage?.edit) {
|
||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||
|
||||
@@ -16,7 +16,7 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
|
||||
a direct browser load. Keeping the setup and admin routes in this explicit
|
||||
allowlist prevents them from working only after client-side navigation.
|
||||
*/
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin'], async (req, res) => {
|
||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin', '/mic'], async (req, res) => {
|
||||
try {
|
||||
const html = await renderIndexHtml(req);
|
||||
res.type('html').send(html);
|
||||
|
||||
@@ -20,6 +20,7 @@ function normalizeAdditionalHosts(rawHosts) {
|
||||
|
||||
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
const media = config?.media || {};
|
||||
const webrtcPort = media.webrtcPort ?? 8189;
|
||||
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||
let publicHostname = '';
|
||||
try {
|
||||
@@ -55,10 +56,10 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
|
||||
webrtc: true,
|
||||
// WHEP and WHIP signaling is public only through the Node /video proxy.
|
||||
// ICE transport on 8189 remains directly reachable by browsers.
|
||||
// The configured ICE transport port remains directly reachable by browsers.
|
||||
webrtcAddress: '127.0.0.1:8889',
|
||||
webrtcLocalUDPAddress: ':8189',
|
||||
webrtcLocalTCPAddress: ':8189',
|
||||
webrtcLocalUDPAddress: `:${webrtcPort}`,
|
||||
webrtcLocalTCPAddress: `:${webrtcPort}`,
|
||||
webrtcAdditionalHosts: additionalHosts,
|
||||
webrtcICEServers2: [
|
||||
{ url: 'stun:stun.l.google.com:19302' },
|
||||
@@ -81,6 +82,7 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||
{ action: 'pprof' },
|
||||
],
|
||||
paths: {
|
||||
'~^room-audio/': { source: 'publisher' },
|
||||
all: {
|
||||
source: 'publisher',
|
||||
sourceOnDemand: false,
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
// Media Transport Configuration
|
||||
// Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL.
|
||||
// Purpose: Defines the WebRTC media port and additional ICE hosts.
|
||||
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
||||
const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
|
||||
const { strictObject, stringArray, integer } = require('../../configuration/schemaHelpers');
|
||||
|
||||
module.exports = {
|
||||
key: 'media',
|
||||
defaultValue: {
|
||||
webrtcPort: 8189,
|
||||
additionalHosts: [],
|
||||
},
|
||||
schema: strictObject({
|
||||
webrtcPort: integer({
|
||||
title: 'WebRTC port',
|
||||
description: 'UDP and TCP port for live media. Forward the same external port to this server; HTTPS signaling continues through the web UI.',
|
||||
minimum: 1, maximum: 65535,
|
||||
}),
|
||||
additionalHosts: stringArray({
|
||||
title: 'Additional ICE hosts',
|
||||
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
|
||||
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
|
||||
}),
|
||||
}, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }),
|
||||
}, { title: 'Media', description: 'Configures the WebRTC media port and additional network candidates.', required: ['webrtcPort', 'additionalHosts'] }),
|
||||
};
|
||||
|
||||
@@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) {
|
||||
requestedBy: payload.requestedBy,
|
||||
});
|
||||
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
let providerContext = null;
|
||||
try {
|
||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||
let providerError = null;
|
||||
if (preferredDeliveryProvider?.begin) {
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
|
||||
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
|
||||
const Fuse = require('fuse.js');
|
||||
const { updateReplayStatus } = require('../replayEngineV2/cooldown');
|
||||
|
||||
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
||||
const FUZZY_THRESHOLD = 0.42;
|
||||
@@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
|
||||
|
||||
function emit(job, status, extra = {}) {
|
||||
const payload = buildPayload(job, status, extra);
|
||||
updateReplayStatus(payload);
|
||||
if (status === 'accepted') {
|
||||
// Sound once per participating rover; a disconnected rover must not abort video creation.
|
||||
const { issueCommand } = require('../commandService');
|
||||
const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id));
|
||||
for (const roverId of roverIds) {
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'song',
|
||||
song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] },
|
||||
});
|
||||
} catch (err) {
|
||||
logger?.warn?.('Unable to play replay tune', { roverId, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
io.emit('replay:status', payload);
|
||||
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
|
||||
if (status === 'failed') io.emit('replay:failed', payload);
|
||||
|
||||
@@ -4,40 +4,61 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const COOLDOWN_MS = 10 * 1000;
|
||||
const MAX_CONCURRENT_JOBS = 3;
|
||||
const replayEvents = new EventEmitter();
|
||||
const activeJobs = new Map();
|
||||
let status = null;
|
||||
let lastTriggeredAt = null;
|
||||
let lastTriggeredBy = null;
|
||||
|
||||
function getRemainingMs(now = Date.now()) {
|
||||
if (!lastTriggeredAt) return 0;
|
||||
return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt));
|
||||
}
|
||||
|
||||
function getReplayState() {
|
||||
const remainingMs = getRemainingMs();
|
||||
const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt));
|
||||
return {
|
||||
cooldownMs: COOLDOWN_MS,
|
||||
lastTriggeredAt,
|
||||
lastTriggeredBy,
|
||||
remainingMs,
|
||||
available: remainingMs === 0,
|
||||
atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS,
|
||||
activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) =>
|
||||
sources.map((source) => `${source.type}:${source.id}`),
|
||||
))],
|
||||
status,
|
||||
available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS,
|
||||
};
|
||||
}
|
||||
|
||||
function tryTriggerReplay(by = null) {
|
||||
const remainingMs = getRemainingMs();
|
||||
if (remainingMs > 0) {
|
||||
return { ok: false, remainingMs, state: getReplayState() };
|
||||
}
|
||||
lastTriggeredAt = Date.now();
|
||||
lastTriggeredBy = by || null;
|
||||
const state = getReplayState();
|
||||
replayEvents.emit('update', { state, by: lastTriggeredBy });
|
||||
return { ok: true, state };
|
||||
function emitUpdate() {
|
||||
replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tryTriggerReplay,
|
||||
getReplayState,
|
||||
replayEvents,
|
||||
};
|
||||
function tryTriggerReplay({ jobId, by = null } = {}) {
|
||||
const state = getReplayState();
|
||||
if (state.atJobLimit) {
|
||||
return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state };
|
||||
}
|
||||
if (state.remainingMs > 0) {
|
||||
return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state };
|
||||
}
|
||||
if (!jobId) throw new Error('Replay job id required');
|
||||
activeJobs.set(jobId, []);
|
||||
status = { jobId, status: 'accepted' };
|
||||
lastTriggeredAt = Date.now();
|
||||
lastTriggeredBy = by;
|
||||
emitUpdate();
|
||||
return { ok: true, state: getReplayState() };
|
||||
}
|
||||
|
||||
function updateReplayStatus(payload) {
|
||||
if (!activeJobs.has(payload.jobId)) return;
|
||||
if (payload.jobId === status?.jobId) {
|
||||
status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message };
|
||||
}
|
||||
if (payload.status === 'ready' || payload.status === 'failed') {
|
||||
activeJobs.delete(payload.jobId);
|
||||
} else {
|
||||
activeJobs.set(payload.jobId, payload.sources || []);
|
||||
}
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus };
|
||||
|
||||
@@ -6,6 +6,7 @@ const { promisify } = require('util');
|
||||
const fsp = require('fs/promises');
|
||||
const logger = require('../../globals/logger').child('replayEngineV2');
|
||||
const roverManager = require('../roverManager');
|
||||
const { roomAudioEvents } = require('../roomAudioService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
|
||||
const {
|
||||
@@ -40,8 +41,8 @@ function getActiveSegmentRoot() {
|
||||
return runtime.activeSegmentRoot;
|
||||
}
|
||||
|
||||
const workerManager = createWorkerManager({ getActiveSegmentRoot });
|
||||
const segmentStore = createSegmentStore({ getActiveSegmentRoot });
|
||||
const workerManager = createWorkerManager({ getActiveSegmentRoot, onWorkerClosed: segmentStore.refreshIndexForWorker });
|
||||
const sidebarRenderer = createSidebarRenderer({ execFileAsync, ensureDir });
|
||||
const replayBuilder = createReplayBuilder({
|
||||
execFileAsync,
|
||||
@@ -50,6 +51,7 @@ const replayBuilder = createReplayBuilder({
|
||||
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
||||
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
||||
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
||||
getRoomAudioEntries: segmentStore.getRoomAudioEntries,
|
||||
overlapping: segmentStore.overlapping,
|
||||
});
|
||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||
@@ -88,6 +90,10 @@ async function start() {
|
||||
}, CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
roomAudioEvents.on('update', () => {
|
||||
tick().catch((err) => logger.warn('room audio update tick failed', err.message));
|
||||
});
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
tick().catch((err) => logger.warn('room camera update tick failed', err.message));
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
|
||||
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
|
||||
}
|
||||
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) {
|
||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, getRoomAudioEntries, overlapping }) {
|
||||
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||
const tentativeEnd = nowMs - guardMs;
|
||||
const sourceEnds = [];
|
||||
@@ -186,6 +186,28 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
||||
|
||||
if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources');
|
||||
|
||||
const roomAudioEntries = await pinSegmentFiles(
|
||||
overlapping(getRoomAudioEntries(), tStart, tEnd), tmpDir, 'room-audio-seg',
|
||||
);
|
||||
if (roomAudioEntries.length) {
|
||||
// Place each segment at its recorded time. A mic joining late or
|
||||
// reconnecting must leave silence in the gap, not shift its speech earlier.
|
||||
const audioInputs = roomAudioEntries.flatMap((entry) => ['-i', entry.filePath]);
|
||||
const audioFilters = roomAudioEntries.map((entry, index) => {
|
||||
const start = Math.max(0, (tStart - entry.startMs) / 1000);
|
||||
const duration = (Math.min(tEnd, entry.endMs) - Math.max(tStart, entry.startMs)) / 1000;
|
||||
const delay = Math.max(0, Math.round(entry.startMs - tStart));
|
||||
return `[${index}:a]atrim=start=${start.toFixed(3)}:duration=${duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delay}:all=1[room${index}]`;
|
||||
});
|
||||
const refs = roomAudioEntries.map((_, index) => `[room${index}]`).join('');
|
||||
audioFilters.push(`${refs}amix=inputs=${roomAudioEntries.length}:normalize=0[roomout]`);
|
||||
const roomAudioPath = path.join(tmpDir, 'room-audio.m4a');
|
||||
await execFileAsync(FFMPEG_BIN, ['-y', '-hide_banner', '-loglevel', 'error', ...audioInputs,
|
||||
'-filter_complex', audioFilters.join(';'), '-map', '[roomout]', '-vn', '-ac', '1', '-ar', '48000',
|
||||
'-c:a', 'aac', '-b:a', '96k', roomAudioPath]);
|
||||
normalizedAudios.push(roomAudioPath);
|
||||
}
|
||||
|
||||
const layout = buildGridLayout(normalizedVideos.length);
|
||||
const { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path));
|
||||
let tileWidth = maxWidth || 640;
|
||||
|
||||
@@ -6,9 +6,10 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('../../globals/logger').child('replayEngineV2');
|
||||
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
||||
const { workers, segmentIndex } = require('./state');
|
||||
const { workers, pendingWorkerStarts, segmentIndex } = require('./state');
|
||||
const { sourceKey, sourceDirForKey } = require('./sources');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const SEGMENT_FILENAME_PATTERN = /^seg-[a-f0-9-]+-\d{6}\.mp4$/;
|
||||
|
||||
async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
@@ -25,7 +26,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentFiles = files.filter((entry) => entry.isFile() && /^seg-\d{6}\.mp4$/.test(entry.name)).map((entry) => entry.name).sort();
|
||||
const segmentFiles = files.filter((entry) => entry.isFile() && SEGMENT_FILENAME_PATTERN.test(entry.name)).map((entry) => entry.name).sort();
|
||||
if (!segmentFiles.length) {
|
||||
segmentIndex.set(key, []);
|
||||
return;
|
||||
@@ -64,6 +65,13 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
|
||||
async function cleanupOldFiles() {
|
||||
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
||||
// Retention applies to stopped workers too; dynamic microphone sessions
|
||||
// must not leave permanent index entries after their audio expires.
|
||||
for (const [key, entries] of segmentIndex) {
|
||||
const retained = entries.filter((entry) => entry.endMs >= cutoff);
|
||||
if (!retained.length && !workers.has(key) && !pendingWorkerStarts.has(key)) segmentIndex.delete(key);
|
||||
else segmentIndex.set(key, retained);
|
||||
}
|
||||
const root = getActiveSegmentRoot();
|
||||
try {
|
||||
await ensureDir(root);
|
||||
@@ -81,6 +89,10 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
if (stat.mtimeMs < cutoff) await fsp.unlink(filePath);
|
||||
} catch {}
|
||||
}
|
||||
if (!workers.has(dirent.name) && !pendingWorkerStarts.has(dirent.name)) {
|
||||
// rmdir only removes empty directories, preserving buffered or newly written files.
|
||||
try { await fsp.rmdir(dirPath); } catch {}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('cleanup failed', err.message);
|
||||
@@ -115,6 +127,14 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function getRoomAudioEntries() {
|
||||
// Include retained buffers from microphones that disconnected during the
|
||||
// replay window, rather than limiting history to currently live streams.
|
||||
return [...segmentIndex.entries()]
|
||||
.filter(([key]) => key.startsWith('roomAudio__audio__'))
|
||||
.flatMap(([, entries]) => entries);
|
||||
}
|
||||
|
||||
function overlapping(entries, startMs, endMs) {
|
||||
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
||||
}
|
||||
@@ -153,7 +173,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const name of files) {
|
||||
if (!/^seg-\d{6}\.mp4$/.test(name)) continue;
|
||||
if (!SEGMENT_FILENAME_PATTERN.test(name)) continue;
|
||||
const full = path.join(dir, name);
|
||||
let stat;
|
||||
try { stat = fs.statSync(full); } catch { continue; }
|
||||
@@ -172,10 +192,12 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
||||
|
||||
return {
|
||||
refreshSegmentIndex,
|
||||
refreshIndexForWorker,
|
||||
cleanupOldFiles,
|
||||
getVideoEntriesForSource,
|
||||
getAudioEntriesForRover,
|
||||
getAudioEntriesForSource,
|
||||
getRoomAudioEntries,
|
||||
overlapping,
|
||||
bootstrapIndexFromDisk,
|
||||
getReplayHealthSnapshot,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Replay Socket Hooks
|
||||
// Purpose: Registers web socket replay-trigger handler that publishes replay requests.
|
||||
// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests.
|
||||
// Scope: Applies replay mode/availability/source validation for socket-triggered replay requests.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('replaySocket');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
@@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
||||
const title = normalizeReplayTitle(payload?.title);
|
||||
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
|
||||
const jobId = buildReplayJobId('web');
|
||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
||||
const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } });
|
||||
if (!attempt.ok) {
|
||||
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
||||
cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
// Purpose: Resolves replay-capable media sources and ffmpeg worker arguments.
|
||||
// Scope: Converts live rover/room state into stable source descriptors and stream worker config.
|
||||
const path = require('path');
|
||||
const { randomUUID } = require('crypto');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getRoomCameras } = require('../roomCameraService');
|
||||
const { getActiveRoomAudioStreams } = require('../roomAudioService');
|
||||
const ptzCameraService = require('../ptzCameraService');
|
||||
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
|
||||
|
||||
@@ -51,12 +53,17 @@ function listDesiredSources() {
|
||||
// both come from the same live MediaMTX path, unlike rovers where audio is a
|
||||
// separate published stream.
|
||||
sources.push(...ptzCameraService.getReplayWorkerSources());
|
||||
for (const stream of getActiveRoomAudioStreams()) {
|
||||
sources.push({ id: stream.id, sourceType: 'roomAudio', kind: 'audio', label: stream.name,
|
||||
inputUrl: `rtsp://127.0.0.1:8554/room-audio/${encodeURIComponent(stream.id)}` });
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function buildWorkerArgs(activeSegmentRoot, source) {
|
||||
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
|
||||
const pattern = path.join(dir, 'seg-%06d.mp4');
|
||||
// Each capture process owns its filenames so restarts preserve buffered segments.
|
||||
const pattern = path.join(dir, `seg-${randomUUID()}-%06d.mp4`);
|
||||
// MediaMTX and RTSP cameras use TCP so replay capture has one reliable
|
||||
// transport and never falls back to separate RTP/RTCP UDP listeners.
|
||||
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
|
||||
|
||||
@@ -12,7 +12,7 @@ async function ensureDir(dir) {
|
||||
await fsp.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
function createWorkerManager({ getActiveSegmentRoot, onWorkerClosed }) {
|
||||
function startWorker(source) {
|
||||
const key = sourceKey(source);
|
||||
if (workers.has(key) || pendingWorkerStarts.has(key)) return;
|
||||
@@ -21,26 +21,36 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
const dir = sourceDirForKey(getActiveSegmentRoot(), key);
|
||||
ensureDir(dir)
|
||||
.then(() => {
|
||||
if (workers.has(key)) {
|
||||
// Directory creation can finish after a microphone has disconnected.
|
||||
if (workers.has(key) || !listDesiredSources().some((entry) => sourceKey(entry) === key)) {
|
||||
pendingWorkerStarts.delete(key);
|
||||
return;
|
||||
}
|
||||
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
workers.set(key, { key, source, proc });
|
||||
const worker = { key, source, proc, stopping: false };
|
||||
workers.set(key, worker);
|
||||
pendingWorkerStarts.delete(key);
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
const text = String(chunk || '').trim();
|
||||
if (!text) return;
|
||||
logger.warn('worker stderr', { key, text: text.slice(0, 500) });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(key);
|
||||
if (current?.proc === proc) workers.delete(key);
|
||||
logger.warn('worker exited', { key, code, signal });
|
||||
setTimeout(() => {
|
||||
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key);
|
||||
if (desired && !workers.has(key)) startWorker(desired);
|
||||
}, 1500);
|
||||
proc.on('error', (err) => logger.warn('worker process failed', { key, error: err.message }));
|
||||
worker.finished = new Promise((resolve) => {
|
||||
proc.once('close', async (code, signal) => {
|
||||
clearTimeout(worker.killTimer);
|
||||
// FFmpeg writes the final MP4 trailer before close. Index it before
|
||||
// releasing this worker so a disconnect cannot drop its last audio.
|
||||
try { await onWorkerClosed(worker); }
|
||||
catch (err) { logger.warn('final worker indexing failed', { key, error: err.message }); }
|
||||
if (workers.get(key) === worker) workers.delete(key);
|
||||
resolve();
|
||||
logger.warn('worker exited', { key, code, signal });
|
||||
if (!worker.stopping) setTimeout(() => {
|
||||
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key);
|
||||
if (desired && !workers.has(key)) startWorker(desired);
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -52,8 +62,14 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
function stopWorker(key) {
|
||||
const worker = workers.get(key);
|
||||
if (!worker) return;
|
||||
try { worker.proc.kill('SIGTERM'); } catch {}
|
||||
workers.delete(key);
|
||||
if (!worker.stopping) {
|
||||
worker.stopping = true;
|
||||
worker.proc.kill('SIGTERM');
|
||||
// Bound shutdown if a capture is blocked waiting on its network input.
|
||||
worker.killTimer = setTimeout(() => worker.proc.kill('SIGKILL'), 3000);
|
||||
worker.killTimer.unref();
|
||||
}
|
||||
return worker.finished;
|
||||
}
|
||||
|
||||
async function syncWorkers() {
|
||||
@@ -63,9 +79,11 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
||||
const key = sourceKey(source);
|
||||
if (!workers.has(key)) startWorker(source);
|
||||
}
|
||||
const stops = [];
|
||||
for (const key of Array.from(workers.keys())) {
|
||||
if (!desiredKeys.has(key)) stopWorker(key);
|
||||
if (!desiredKeys.has(key)) stops.push(stopWorker(key));
|
||||
}
|
||||
await Promise.all(stops);
|
||||
}
|
||||
|
||||
return { startWorker, stopWorker, syncWorkers };
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||
module.exports = {
|
||||
key: 'roomAudio', feature: true,
|
||||
defaultValue: { enabled: false, sources: [] },
|
||||
schema: strictObject({
|
||||
enabled: boolean({ description: 'Enable room audio and local browser microphone publishing.' }),
|
||||
sources: { type: 'array', items: strictObject({
|
||||
id: string({ minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||
name: string({ minLength: 1, maxLength: 120 }),
|
||||
type: { type: 'string', enum: ['device', 'stream'] },
|
||||
input: string({ minLength: 1, maxLength: 2048, description: 'ALSA device (for example default or plughw:CARD=USB,DEV=0), or a network stream URL.' }),
|
||||
}, { required: ['id', 'name', 'type', 'input'] }) },
|
||||
}, { title: 'Room audio', required: ['enabled', 'sources'] }),
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
const { spawn } = require('child_process');
|
||||
const EventEmitter = require('events');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('roomAudio');
|
||||
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||
const { canBecomeSpectator } = require('../authService');
|
||||
const { isDeterred } = require('../verificationService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const roomAudioEvents = new EventEmitter();
|
||||
const sources = new Map();
|
||||
const mediaSessions = new Map();
|
||||
let enabled = false;
|
||||
let active = [];
|
||||
let polling = false;
|
||||
let generation = 0;
|
||||
|
||||
function canListen(socket) {
|
||||
if (!enabled || !socket) return false;
|
||||
const mode = getMode();
|
||||
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
|
||||
if (isAdmin(socket)) return true;
|
||||
if (getRole(socket) === 'spectator') return canBecomeSpectator(socket);
|
||||
// Room audio follows general driving eligibility, without tying listening
|
||||
// to rover assignment, private-rover access, or the current driving turn.
|
||||
return getRole(socket) === 'user' && mode !== MODES.ADMIN && !isDeterred(socket);
|
||||
}
|
||||
function canPublish(socket, id) {
|
||||
return enabled && isLocalNetwork(getSocketIp(socket)) && sources.get(id)?.socketId === socket.id;
|
||||
}
|
||||
function getRoomAudioStreams(socket) { return canListen(socket) ? active : []; }
|
||||
function getActiveRoomAudioStreams() { return active; }
|
||||
function trackMediaSession(id, token) { if (id) mediaSessions.set(id, token); }
|
||||
function hasStream(id) { return active.some((source) => source.id === id); }
|
||||
function stopSource(id) {
|
||||
const source = sources.get(id);
|
||||
if (!source) return;
|
||||
sources.delete(id);
|
||||
clearTimeout(source.timer);
|
||||
source.child?.kill('SIGTERM');
|
||||
videoSessions.revokeWhere((info) => ['roomAudio', 'roomMic'].includes(info.sourceType) && info.sourceId === id);
|
||||
active = active.filter((entry) => entry.id !== id);
|
||||
roomAudioEvents.emit('update');
|
||||
}
|
||||
function startWorker(source) {
|
||||
if (sources.get(source.id) !== source) return;
|
||||
const args = ['-nostdin', '-hide_banner', '-loglevel', 'warning'];
|
||||
if (source.type === 'device') args.push('-f', 'alsa');
|
||||
else if (/^rtsps?:/i.test(source.input)) args.push('-rtsp_transport', 'tcp', '-timeout', '10000000');
|
||||
else args.push('-rw_timeout', '10000000');
|
||||
args.push('-i', source.input, '-map', '0:a:0', '-vn', '-c:a', 'libopus', '-ar', '48000', '-ac', '1', '-b:a', '64k', '-f', 'rtsp', '-rtsp_transport', 'tcp', `rtsp://127.0.0.1:8554/room-audio/${source.id}`);
|
||||
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
source.child = child;
|
||||
child.stderr.on('data', (data) => logger.warn('Room audio %s: %s', source.name, String(data).trim()));
|
||||
child.on('error', (error) => logger.warn('Room audio %s: %s', source.name, error.message));
|
||||
child.on('close', () => {
|
||||
if (sources.get(source.id) !== source) return;
|
||||
source.child = null;
|
||||
source.timer = setTimeout(() => startWorker(source), 2000);
|
||||
});
|
||||
}
|
||||
function applyConfig(config = {}) {
|
||||
const ids = (config.sources || []).map((source) => source.id);
|
||||
if (new Set(ids).size !== ids.length) throw new Error('Room audio source IDs must be unique');
|
||||
generation += 1;
|
||||
enabled = Boolean(config.enabled);
|
||||
const configured = new Map((config.sources || []).map((entry) => {
|
||||
const source = { ...entry, id: `static-${entry.id}` };
|
||||
return [source.id, source];
|
||||
}));
|
||||
for (const [id, source] of sources) {
|
||||
const next = configured.get(id);
|
||||
// Static-source edits should not interrupt browser microphones or restart
|
||||
// unchanged captures. Disabling the feature still stops every source.
|
||||
if (!enabled || (!source.socketId && (!next || next.type !== source.type || next.input !== source.input))) stopSource(id);
|
||||
}
|
||||
if (enabled) for (const [id, source] of configured) {
|
||||
const existing = sources.get(id);
|
||||
if (existing) existing.name = source.name;
|
||||
else {
|
||||
sources.set(id, source);
|
||||
startWorker(source);
|
||||
}
|
||||
}
|
||||
roomAudioEvents.emit('update');
|
||||
}
|
||||
async function listMediaMtxItems(collection) {
|
||||
const items = [];
|
||||
let page = 0;
|
||||
let pageCount = 1;
|
||||
while (page < pageCount) {
|
||||
const response = await fetch(`http://127.0.0.1:9997/v3/${collection}/list?page=${page}`, { signal: AbortSignal.timeout(1500) });
|
||||
if (!response.ok) throw new Error('MediaMTX unavailable');
|
||||
const data = await response.json();
|
||||
items.push(...(data.items || []));
|
||||
pageCount = data.pageCount || 1;
|
||||
page += 1;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Advertise actual ready MediaMTX paths, rather than a browser reservation or
|
||||
// an ffmpeg process that may still be trying to connect to its input.
|
||||
async function refreshStreams() {
|
||||
if ((!enabled && !mediaSessions.size) || polling) return;
|
||||
polling = true;
|
||||
const currentGeneration = generation;
|
||||
let ready = new Set();
|
||||
try {
|
||||
// HTTP authorization is checked at connection time. Recheck established
|
||||
// browser sessions too so lockdown, disable, and disconnect stop live audio.
|
||||
const previousSessionIds = [...mediaSessions.keys()];
|
||||
const sessions = await listMediaMtxItems('webrtcsessions');
|
||||
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||
for (const session of sessions) {
|
||||
if (!session.path?.startsWith('room-audio/')) continue;
|
||||
const info = videoSessions.getSession(mediaSessions.get(session.id));
|
||||
const socket = info && io.sockets.sockets.get(info.socketId);
|
||||
const allowed = socket && (session.state === 'publish'
|
||||
? info.sourceType === 'roomMic' && canPublish(socket, info.sourceId)
|
||||
: info.sourceType === 'roomAudio' && canListen(socket));
|
||||
if (!allowed) await fetch(`http://127.0.0.1:9997/v3/webrtcsessions/kick/${session.id}`, { method: 'POST', signal: AbortSignal.timeout(1500) });
|
||||
}
|
||||
for (const id of previousSessionIds) if (!liveSessionIds.has(id)) mediaSessions.delete(id);
|
||||
} catch (error) {
|
||||
logger.warn('Room audio permission refresh failed: %s', error.message);
|
||||
}
|
||||
// Permission API failures must not make ready streams disappear. Only path
|
||||
// discovery determines the catalog used by playback and replay capture.
|
||||
try {
|
||||
const paths = await listMediaMtxItems('paths');
|
||||
for (const path of paths) if (path.ready) ready.add(path.name);
|
||||
} catch { ready = new Set(); }
|
||||
finally { polling = false; }
|
||||
if (generation !== currentGeneration) return;
|
||||
const next = [...sources.values()].filter((source) => ready.has(`room-audio/${source.id}`)).map(({ id, name }) => ({ id, name }));
|
||||
if (JSON.stringify(next) !== JSON.stringify(active)) {
|
||||
active = next;
|
||||
roomAudioEvents.emit('update');
|
||||
}
|
||||
}
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('roomAudio:start', (_payload, cb = () => {}) => {
|
||||
if (!enabled || !isLocalNetwork(getSocketIp(socket))) return cb({ error: 'Room microphone publishing requires a local visitor and enabled room audio.' });
|
||||
// One owned path per socket; retries replace credentials without accumulating streams.
|
||||
const id = `mic-${socket.id}`;
|
||||
videoSessions.revokeWhere((info) => info.sourceType === 'roomMic' && info.sourceId === id);
|
||||
sources.set(id, { id, name: 'Room microphone', socketId: socket.id });
|
||||
const token = videoSessions.createSession(socket, { type: 'roomMic', id });
|
||||
cb({ id, token, url: `/video/room-audio/${id}/whip` });
|
||||
});
|
||||
socket.on('roomAudio:release', ({ token } = {}) => {
|
||||
const info = videoSessions.getSession(token);
|
||||
if (info?.socketId === socket.id && info.sourceType === 'roomAudio') videoSessions.revokeSession(token);
|
||||
});
|
||||
socket.on('roomAudio:stop', (_payload, cb = () => {}) => {
|
||||
stopSource(`mic-${socket.id}`);
|
||||
cb({ success: true });
|
||||
});
|
||||
socket.on('disconnect', () => {
|
||||
stopSource(`mic-${socket.id}`);
|
||||
});
|
||||
});
|
||||
applyConfig(loadConfig().roomAudio);
|
||||
registerConfigurationHandler('roomAudio', applyConfig);
|
||||
const pollTimer = setInterval(refreshStreams, 2000);
|
||||
pollTimer.unref();
|
||||
process.once('exit', () => { for (const source of sources.values()) source.child?.kill('SIGTERM'); });
|
||||
module.exports = { getActiveRoomAudioStreams, trackMediaSession, roomAudioEvents, getRoomAudioStreams, canListen, canPublish, hasStream };
|
||||
@@ -1,6 +1,6 @@
|
||||
// Restricted Container Lifecycle Controller
|
||||
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
|
||||
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts no caller-selected targets.
|
||||
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts tags only within the deployment-configured repository.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
@@ -9,6 +9,12 @@ const Docker = require('dockerode');
|
||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||
const TARGET_CONTAINER_NAME = 'multirover';
|
||||
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
|
||||
// A registry port is not a tag separator. Strip a digest before splitting the tag.
|
||||
const configuredName = TARGET_IMAGE.split('@')[0];
|
||||
const tagSeparator = configuredName.lastIndexOf(':');
|
||||
const hasConfiguredTag = tagSeparator > configuredName.lastIndexOf('/');
|
||||
const IMAGE_REPOSITORY = hasConfiguredTag ? configuredName.slice(0, tagSeparator) : configuredName;
|
||||
const DEFAULT_TAG = TARGET_IMAGE.includes('@') ? null : (hasConfiguredTag ? configuredName.slice(tagSeparator + 1) : 'latest');
|
||||
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||
// Status belongs beside the controller's private socket instead of in the
|
||||
// application's data volume. The controller runs as root for Docker access;
|
||||
@@ -20,6 +26,32 @@ const HEALTH_POLL_MS = 1000;
|
||||
|
||||
let operationRunning = false;
|
||||
let status = readStatus();
|
||||
// A repository change in Compose invalidates the previous repository's selection.
|
||||
if (status.selectedRepository !== IMAGE_REPOSITORY) {
|
||||
status.selectedRepository = IMAGE_REPOSITORY;
|
||||
status.selectedTag = DEFAULT_TAG;
|
||||
}
|
||||
|
||||
function selectedImage() {
|
||||
return status.selectedTag ? `${IMAGE_REPOSITORY}:${status.selectedTag}` : TARGET_IMAGE;
|
||||
}
|
||||
|
||||
function publicStatus() {
|
||||
return {
|
||||
...status,
|
||||
configuredImage: TARGET_IMAGE,
|
||||
repository: IMAGE_REPOSITORY,
|
||||
targetImage: selectedImage(),
|
||||
};
|
||||
}
|
||||
|
||||
function requestedImage(tag) {
|
||||
if (tag === null) return { imageReference: selectedImage(), tag: status.selectedTag };
|
||||
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
|
||||
throw new Error('Enter a valid container tag (1–128 letters, numbers, underscores, periods, or hyphens).');
|
||||
}
|
||||
return { imageReference: `${IMAGE_REPOSITORY}:${tag}`, tag };
|
||||
}
|
||||
|
||||
function readStatus() {
|
||||
try {
|
||||
@@ -52,11 +84,12 @@ function readStatus() {
|
||||
}
|
||||
|
||||
function writeStatus(patch) {
|
||||
status = { ...status, ...patch };
|
||||
const nextStatus = { ...status, ...patch };
|
||||
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
|
||||
const temporaryPath = `${STATUS_PATH}.tmp`;
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(nextStatus, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||
fs.renameSync(temporaryPath, STATUS_PATH);
|
||||
status = nextStatus;
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -68,23 +101,23 @@ async function inspectTargetContainer() {
|
||||
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
|
||||
}
|
||||
|
||||
async function inspectLocalTargetImage() {
|
||||
async function inspectLocalTargetImage(imageReference = selectedImage()) {
|
||||
try {
|
||||
return await docker.getImage(TARGET_IMAGE).inspect();
|
||||
return await docker.getImage(imageReference).inspect();
|
||||
} catch (error) {
|
||||
if (error.statusCode === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function pullTargetImage() {
|
||||
async function pullTargetImage(imageReference = selectedImage()) {
|
||||
const stream = await new Promise((resolve, reject) => {
|
||||
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
||||
docker.pull(imageReference, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
const image = await inspectLocalTargetImage();
|
||||
const image = await inspectLocalTargetImage(imageReference);
|
||||
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
|
||||
return image;
|
||||
}
|
||||
@@ -193,13 +226,14 @@ async function refreshImageStatus() {
|
||||
]);
|
||||
return writeStatus({
|
||||
runningImage: container.Image,
|
||||
runningImageReference: container.Config?.Image || null,
|
||||
availableImage: image?.Id || null,
|
||||
updateAvailable: image ? container.Image !== image.Id : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function runCheck() {
|
||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` });
|
||||
writeStatus({ message: `Pulling ${selectedImage()}…` });
|
||||
await pullTargetImage();
|
||||
const current = await refreshImageStatus();
|
||||
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
|
||||
@@ -213,9 +247,9 @@ async function runRestart() {
|
||||
return 'The application container restarted successfully.';
|
||||
}
|
||||
|
||||
async function runUpdate() {
|
||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null });
|
||||
const targetImage = await pullTargetImage();
|
||||
async function runUpdate({ imageReference, tag }) {
|
||||
writeStatus({ message: `Pulling ${imageReference}…`, rollback: null });
|
||||
const targetImage = await pullTargetImage(imageReference);
|
||||
const previousContainer = await inspectTargetContainer();
|
||||
const previousImage = previousContainer.Image;
|
||||
let replacementStarted = false;
|
||||
@@ -228,11 +262,15 @@ async function runUpdate() {
|
||||
updateAvailable: previousImage !== targetImage.Id,
|
||||
});
|
||||
replacementStarted = true;
|
||||
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE });
|
||||
await replaceContainer({ template: previousContainer, imageReference: targetImage.Id });
|
||||
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
|
||||
const healthy = await waitForHealthyContainer();
|
||||
writeStatus({
|
||||
// Commit the tag only once the replacement passes its health check.
|
||||
selectedRepository: IMAGE_REPOSITORY,
|
||||
selectedTag: tag,
|
||||
runningImage: healthy.Image,
|
||||
runningImageReference: healthy.Config?.Image || null,
|
||||
availableImage: targetImage.Id,
|
||||
updateAvailable: false,
|
||||
});
|
||||
@@ -284,7 +322,7 @@ function queueOperation(operation, runner) {
|
||||
operationRunning = false;
|
||||
}
|
||||
}, 500);
|
||||
return status;
|
||||
return publicStatus();
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, value) {
|
||||
@@ -294,10 +332,11 @@ function sendJson(response, statusCode, value) {
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url, 'http://localhost');
|
||||
if (request.method === 'GET' && request.url === '/status') {
|
||||
refreshImageStatus()
|
||||
.catch(() => status)
|
||||
.then((current) => sendJson(response, 200, { available: true, ...current }));
|
||||
.then(() => sendJson(response, 200, { available: true, ...publicStatus() }));
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/check') {
|
||||
@@ -308,8 +347,10 @@ const server = http.createServer((request, response) => {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && request.url === '/update') {
|
||||
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) });
|
||||
if (request.method === 'POST' && url.pathname === '/update') {
|
||||
const target = requestedImage(url.searchParams.get('tag'));
|
||||
queueOperation('update', () => runUpdate(target));
|
||||
sendJson(response, 202, { available: true, ...publicStatus() });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
|
||||
|
||||
@@ -88,14 +88,14 @@ io.on('connection', (socket) => {
|
||||
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||
});
|
||||
|
||||
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => {
|
||||
socket.on('server:updateApplication', (payload = {}, cb = () => {}) => {
|
||||
Promise.resolve()
|
||||
.then(() => requireRecentPassword(socket))
|
||||
.then(() => lifecycleClient.updateApplication())
|
||||
.then(() => lifecycleClient.updateApplication(payload?.tag))
|
||||
.then((lifecycle) => {
|
||||
// Wait for controller acceptance so rejected updates do not kick users.
|
||||
notifyDriversOfRestart(actorFor(socket));
|
||||
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
|
||||
database.recordAuditEvent(actorFor(socket), 'application.update-requested', { tag: payload?.tag ?? lifecycle.selectedTag });
|
||||
io.emit('server:restarting', { reason: 'application-update' });
|
||||
cb({ success: true, lifecycle });
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Container Lifecycle Client
|
||||
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
|
||||
// Scope: This client cannot select images, containers, or Docker arguments; the controller owns those constants.
|
||||
// Scope: This client can request a tag; the controller owns the repository, container, and Docker arguments.
|
||||
const http = require('http');
|
||||
|
||||
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||
@@ -68,5 +68,11 @@ module.exports = {
|
||||
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
|
||||
getLifecycleStatus,
|
||||
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
|
||||
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }),
|
||||
updateApplication: (tag) => {
|
||||
if (tag !== undefined && typeof tag !== 'string') throw new Error('Container tag must be a string.');
|
||||
return requestLifecycleController({
|
||||
method: 'POST',
|
||||
path: tag === undefined ? '/update' : `/update?tag=${encodeURIComponent(tag)}`,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ const { managerEvents } = roverManager;
|
||||
const assignmentService = require('../assignmentService');
|
||||
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||
const { buildRoverTurn } = require('../turnService/display');
|
||||
const { getRoomAudioStreams, roomAudioEvents } = require('../roomAudioService');
|
||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||
const {
|
||||
getPublicState: getPtzCameraState,
|
||||
@@ -214,6 +215,7 @@ function buildSession(socket) {
|
||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||
},
|
||||
roomCameras: getRoomCameras(),
|
||||
roomAudioStreams: getRoomAudioStreams(socket),
|
||||
ptzCamera: getPtzCameraState(socket),
|
||||
homeAssistant: getHomeAssistantState(),
|
||||
homeAssistantActivities: getActivityState(),
|
||||
@@ -407,6 +409,8 @@ turnEvents.on('queue', (event = {}) => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
roomAudioEvents.on('update', () => syncAll());
|
||||
|
||||
roomCameraEvents.on('update', () => {
|
||||
logger.info('Room camera change detected; syncing all clients');
|
||||
syncAll();
|
||||
@@ -459,7 +463,7 @@ balanceBoardEvents.on('change', () => {
|
||||
});
|
||||
|
||||
replayEvents.on('update', () => {
|
||||
logger.info('Replay cooldown updated; syncing all clients');
|
||||
logger.info('Replay state updated; syncing all clients');
|
||||
syncAll();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ function registerVideoAuthRoute(deps) {
|
||||
logAdminEvent,
|
||||
extractStreamInfoFromBody,
|
||||
canAccessStream,
|
||||
trackRoomAudioSession,
|
||||
} = deps;
|
||||
|
||||
app.post('/mediamtx/auth', (req, res) => {
|
||||
@@ -36,7 +37,8 @@ function registerVideoAuthRoute(deps) {
|
||||
const isLoopback = ip === '127.0.0.1' || ip === '::1';
|
||||
const isRoverForwardAudioRead = action === 'read'
|
||||
&& isRtspProtocol
|
||||
&& streamInfo?.id?.endsWith('-fwd');
|
||||
&& streamInfo?.type === 'rover'
|
||||
&& streamInfo.id.endsWith('-fwd');
|
||||
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
|
||||
// not represent a browser session. Rovers likewise read their dedicated
|
||||
// -fwd speaker feed without browser credentials; every other non-loopback
|
||||
@@ -50,7 +52,7 @@ function registerVideoAuthRoute(deps) {
|
||||
callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads
|
||||
under the existing session and role checks below.
|
||||
*/
|
||||
if (action === 'publish' && isRtspProtocol) {
|
||||
if (action === 'publish' && isRtspProtocol && (streamInfo?.type !== 'roomAudio' || isLoopback)) {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
@@ -62,7 +64,9 @@ function registerVideoAuthRoute(deps) {
|
||||
const info = videoSessions.getSession(sessionId);
|
||||
const streamTypeMatches =
|
||||
info &&
|
||||
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
||||
(info.sourceType === streamInfo.type
|
||||
|| (info.sourceType === 'roomMic' && streamInfo.type === 'roomAudio')
|
||||
|| (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
||||
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
|
||||
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
||||
return res.status(401).end();
|
||||
@@ -78,6 +82,7 @@ function registerVideoAuthRoute(deps) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
|
||||
if (streamInfo.type === 'roomAudio' && protocol === 'webrtc') trackRoomAudioSession(body.id, sessionId);
|
||||
return res.status(200).end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const { app } = require('../../globals/http');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('videoAuth');
|
||||
const roomAudioService = require('../roomAudioService');
|
||||
const videoSessions = require('../videoSessions');
|
||||
const { getMode, MODES } = require('../modeManager');
|
||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
@@ -28,6 +29,7 @@ const { canAccessStream } = createVideoAuthPolicy({
|
||||
turnService,
|
||||
roverManager,
|
||||
ptzCameraService,
|
||||
roomAudioService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
io,
|
||||
@@ -42,6 +44,7 @@ registerVideoAuthRoute({
|
||||
logAdminEvent,
|
||||
extractStreamInfoFromBody,
|
||||
canAccessStream,
|
||||
trackRoomAudioSession: roomAudioService.trackMediaSession,
|
||||
});
|
||||
|
||||
module.exports = {};
|
||||
|
||||
@@ -17,6 +17,7 @@ function createVideoAuthPolicy(deps) {
|
||||
turnService,
|
||||
roverManager,
|
||||
ptzCameraService,
|
||||
roomAudioService,
|
||||
getSocketIp,
|
||||
isLocalNetwork,
|
||||
io,
|
||||
@@ -58,6 +59,11 @@ function createVideoAuthPolicy(deps) {
|
||||
}
|
||||
|
||||
function canAccessStream({ socket, streamInfo, action, sourceType }) {
|
||||
if (streamInfo.type === 'roomAudio') {
|
||||
return action === 'publish'
|
||||
? sourceType === 'roomMic' && roomAudioService.canPublish(socket, streamInfo.id)
|
||||
: action === 'read' && sourceType === 'roomAudio' && roomAudioService.canListen(socket) && roomAudioService.hasStream(streamInfo.id);
|
||||
}
|
||||
if (!canView(socket)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,8 @@ function extractStreamInfo(path) {
|
||||
return { type: 'rover', id: rawId, baseId };
|
||||
}
|
||||
|
||||
if (remaining.length === 2 && remaining[0] === 'room-audio') return { type: 'roomAudio', id: remaining[1] };
|
||||
|
||||
if (remaining.length === 2 && remaining[0] === 'room') {
|
||||
return { type: 'room', id: remaining[1] || '' };
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ const {
|
||||
|
||||
function buildWhepUrlForSource(source) {
|
||||
const segments = [];
|
||||
if (source.type === 'room') {
|
||||
if (source.type === 'roomAudio') {
|
||||
segments.push('room-audio', encodeURIComponent(source.id));
|
||||
} else if (source.type === 'room') {
|
||||
segments.push('room', encodeURIComponent(source.id));
|
||||
} else if (source.type === 'ptz') {
|
||||
/*
|
||||
@@ -145,6 +147,9 @@ io.on('connection', (socket) => {
|
||||
*/
|
||||
throw new Error('Live video is limited to this rover queue');
|
||||
}
|
||||
} else if (target.type === 'roomAudio') {
|
||||
const roomAudio = require('../roomAudioService');
|
||||
if (!roomAudio.canListen(socket) || !roomAudio.hasStream(target.id)) throw new Error('Room audio unavailable');
|
||||
} else if (target.type === 'room') {
|
||||
throw new Error('Room cameras now use the snapshot feed');
|
||||
} else if (target.type === 'ptz') {
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||
"predev": "node scripts/prepareMediaMtx.js",
|
||||
"dev": "VITE_ROVERD_URL=https://rovertest.otter.land vite",
|
||||
"prebuild": "node scripts/prepareMediaMtx.js",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
|
||||
const version = '1.15.3'; // Matches the Dockerfile's default MediaMTX version.
|
||||
const baseUrl = `https://raw.githubusercontent.com/bluenviron/mediamtx/v${version}/`;
|
||||
const output = new URL('../.cache/mediamtx/publisher.js', import.meta.url);
|
||||
const moduleHash = 'e903555223f196aca86d7adce5cf4437824435291bf9b47f284aa767fffabaaa';
|
||||
const hash = (content) => createHash('sha256').update(content).digest('hex');
|
||||
|
||||
async function download(path, checksum) {
|
||||
const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(30000) });
|
||||
if (!response.ok) throw new Error(`MediaMTX download failed: ${path} (${response.status})`);
|
||||
const content = await response.text();
|
||||
if (hash(content) !== checksum) throw new Error(`MediaMTX checksum mismatch: ${path}`);
|
||||
return content;
|
||||
}
|
||||
|
||||
let cached;
|
||||
try {
|
||||
cached = await readFile(output, 'utf8');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
if (cached && hash(cached) === moduleHash) {
|
||||
console.info(`Using cached MediaMTX publisher v${version}`);
|
||||
} else {
|
||||
const [source, license] = await Promise.all([
|
||||
download('internal/servers/webrtc/publisher.js', '6b4448939b45c30507bce59acaf434e3133e6efac3bd77d7e20fb01462958509'),
|
||||
download('LICENSE', 'ecae73b0a23185a35a1222edc0aae5245bb9739420bad0125b0c42e935301a80'),
|
||||
]);
|
||||
// Keep upstream behavior and its license; only adapt the browser-global export for Vite.
|
||||
const module = `// MediaMTX v${version} publisher client, matching the Dockerfile default.\n`
|
||||
+ `// Source: ${baseUrl}internal/servers/webrtc/publisher.js\n`
|
||||
+ '// Only modification: export the class as an ES module for Vite.\n'
|
||||
+ `/*\n${license}\n*/\n`
|
||||
+ source.replace('window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;', 'export default MediaMTXWebRTCPublisher;');
|
||||
if (hash(module) !== moduleHash) throw new Error('Generated MediaMTX module checksum mismatch');
|
||||
await mkdir(new URL('.', output), { recursive: true });
|
||||
// Publish the verified module atomically so an interrupted download cannot poison the cache.
|
||||
const temporary = new URL(`${output.href}.${process.pid}.tmp`);
|
||||
await writeFile(temporary, module);
|
||||
await rename(temporary, output);
|
||||
console.info(`Downloaded and verified MediaMTX publisher v${version}`);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adm
|
||||
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
|
||||
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
|
||||
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
|
||||
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication');
|
||||
export const updateApplication = (socket, tag) => emitAdminRequest(socket, 'server:updateApplication', { tag });
|
||||
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
||||
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
||||
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
const [restartRequested, setRestartRequested] = useState(false);
|
||||
const [lifecycle, setLifecycle] = useState(null);
|
||||
const [lifecycleError, setLifecycleError] = useState('');
|
||||
const [containerTag, setContainerTag] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
@@ -90,10 +91,13 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return;
|
||||
const tag = containerTag === null ? undefined : containerTag.trim();
|
||||
const target = tag === undefined ? lifecycle.targetImage : `${lifecycle.repository}:${tag}`;
|
||||
if (!window.confirm(`Pull ${target} and replace the application container now? The page will reconnect automatically.`)) return;
|
||||
try {
|
||||
const response = await runSensitive(() => updateApplication(socket));
|
||||
const response = await runSensitive(() => updateApplication(socket, tag));
|
||||
setLifecycle(response.lifecycle);
|
||||
setContainerTag(null);
|
||||
} catch (error) {
|
||||
setLifecycleError(error.message);
|
||||
}
|
||||
@@ -121,15 +125,36 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
||||
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
|
||||
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
|
||||
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
|
||||
{lifecycleAvailable ? (
|
||||
<div className="space-y-0.5 break-all">
|
||||
<p>Repository: {lifecycle.repository}</p>
|
||||
<p>Selected image: {lifecycle.targetImage}</p>
|
||||
<p>Running image: {lifecycle.runningImage}</p>
|
||||
<p>Compose image: {lifecycle.configuredImage}</p>
|
||||
{lifecycle.targetImage !== lifecycle.configuredImage ? <p className="text-amber-300">A manual Compose deployment may restore the Compose image.</p> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
|
||||
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null}
|
||||
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
|
||||
</div>
|
||||
<label className="flex max-w-md flex-col gap-0.5">
|
||||
<span>Container tag</span>
|
||||
<input
|
||||
className="field-input w-full"
|
||||
value={containerTag ?? lifecycle?.selectedTag ?? ''}
|
||||
placeholder="Enter a tag"
|
||||
maxLength={128}
|
||||
disabled={!lifecycleAvailable || lifecycleBusy}
|
||||
onChange={(event) => setContainerTag(event.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Update and restart applies this tag. Update checks use the saved selected image.</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
|
||||
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
|
||||
</button>
|
||||
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}>
|
||||
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy || (containerTag !== null && !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(containerTag.trim()))} onClick={update}>
|
||||
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
|
||||
</button>
|
||||
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Component } from 'react';
|
||||
|
||||
export default class AppErrorBoundary extends Component {
|
||||
state = { crashed: false, error: null, componentStack: '', copyStatus: '' };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { crashed: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({ componentStack: info.componentStack || '' });
|
||||
}
|
||||
|
||||
getDetails() {
|
||||
const { error, componentStack } = this.state;
|
||||
return [
|
||||
String(error?.message ?? error ?? 'Unknown error'),
|
||||
'JavaScript stack:',
|
||||
error?.stack || 'Unavailable',
|
||||
'React component stack:',
|
||||
componentStack || 'Unavailable',
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
copyDetails = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.getDetails());
|
||||
this.setState({ copyStatus: 'Copied.' });
|
||||
} catch {
|
||||
// Clipboard access may be unavailable on the display device; the text
|
||||
// remains selectable so the error can still be copied manually.
|
||||
this.setState({ copyStatus: 'Copy unavailable. Select the details below to copy manually.' });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.crashed) return this.props.children;
|
||||
|
||||
// Keep recovery independent of providers and shared UI components: one of
|
||||
// those dependencies may be the reason the application failed.
|
||||
return (
|
||||
<main style={{ backgroundColor: '#b2339f', minHeight: '100vh' }}>
|
||||
<h1>Application crashed</h1>
|
||||
<button type="button" onClick={this.copyDetails}>Copy details</button>
|
||||
<button type="button" onClick={() => window.location.reload()}>Reload</button>
|
||||
{this.state.copyStatus ? <p role="status">{this.state.copyStatus}</p> : null}
|
||||
<pre>{this.getDetails()}</pre>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
import useServerUrl from '../../hooks/useServerUrl.js';
|
||||
|
||||
export const PTZ_CAMERA_ID = 'ptz-camera';
|
||||
|
||||
@@ -23,6 +24,7 @@ export default function PtzLiveVideo({
|
||||
statusClassName = 'pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]',
|
||||
fallback = null,
|
||||
}) {
|
||||
const serverUrl = useServerUrl();
|
||||
const videoRef = useRef(null);
|
||||
const playTimerRef = useRef(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
@@ -44,7 +46,7 @@ export default function PtzLiveVideo({
|
||||
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
||||
const video = videoRef.current;
|
||||
const stop = startWhepPlayback({
|
||||
url: source.url,
|
||||
url: serverUrl(source.url),
|
||||
token: source.token,
|
||||
video: videoRef.current,
|
||||
startMuted,
|
||||
@@ -76,7 +78,7 @@ export default function PtzLiveVideo({
|
||||
video.removeEventListener('error', handleError);
|
||||
stop();
|
||||
};
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted, serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ReplaySourcesPanel({
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const replayStatus = useSessionSelector((state) => state.replayStatus);
|
||||
const replayStatus = replayState?.status;
|
||||
const socket = useSocket();
|
||||
const { triggerReplay } = useSessionActions();
|
||||
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
||||
@@ -161,48 +161,25 @@ export default function ReplaySourcesPanel({
|
||||
};
|
||||
}, [sources]);
|
||||
|
||||
const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs);
|
||||
const replayCooldownEndsAt = hasReplayCooldown
|
||||
? replayState.lastTriggeredAt + replayState.cooldownMs
|
||||
const cooldownNow = useSharedClock(1000, Boolean(replayState?.lastTriggeredAt));
|
||||
const remainingMs = replayState?.lastTriggeredAt
|
||||
? Math.max(0, replayState.lastTriggeredAt + replayState.cooldownMs - cooldownNow)
|
||||
: 0;
|
||||
const cooldownNow = useSharedClock(1000, hasReplayCooldown);
|
||||
const remainingMs = useMemo(() => {
|
||||
if (!hasReplayCooldown) return 0;
|
||||
/*
|
||||
The button only shows whole seconds, so a shared one-second clock gives the
|
||||
same useful information without each mounted replay panel owning a 250ms
|
||||
interval. The exact server cooldown still decides whether the action is
|
||||
accepted; this value is only the local disabled-state/display estimate.
|
||||
*/
|
||||
const next = replayCooldownEndsAt - cooldownNow;
|
||||
return Math.max(0, next);
|
||||
}, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]);
|
||||
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
const atJobLimit = replayState?.atJobLimit === true;
|
||||
const replaySourceKeys = replayState?.activeSourceKeys;
|
||||
const activeSourceKeys = useMemo(() => new Set(replaySourceKeys || []), [replaySourceKeys]);
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || atJobLimit || !selected.length;
|
||||
const selectedSet = useMemo(() => {
|
||||
// Checkbox rendering asks the same membership question for every source.
|
||||
// A Set avoids repeated linear scans and, more importantly, gives memoized
|
||||
// child lists a stable value while the selected keys have not changed.
|
||||
return new Set(selected);
|
||||
}, [selected]);
|
||||
const replayStatusText = useMemo(() => {
|
||||
if (!replayStatus?.status) return null;
|
||||
const titleText = replayStatus.title ? `: ${replayStatus.title}` : '';
|
||||
switch (replayStatus.status) {
|
||||
case 'accepted':
|
||||
return `Replay accepted${titleText}`;
|
||||
case 'building':
|
||||
return `Replay building${titleText}`;
|
||||
case 'uploading':
|
||||
return `Replay uploading${titleText}`;
|
||||
case 'ready':
|
||||
return `Replay ready${titleText}`;
|
||||
case 'failed':
|
||||
return replayStatus.message || `Replay failed${titleText}`;
|
||||
default:
|
||||
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
|
||||
}
|
||||
}, [replayStatus]);
|
||||
const replayStatusText = replayStatus?.status
|
||||
? replayStatus.status === 'failed' && replayStatus.message
|
||||
? replayStatus.message
|
||||
: `Replay ${replayStatus.status}${replayStatus.title ? `: ${replayStatus.title}` : ''}`
|
||||
: null;
|
||||
const toggleKey = useCallback((key) => {
|
||||
setSelected((prev) => {
|
||||
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||
@@ -250,13 +227,13 @@ export default function ReplaySourcesPanel({
|
||||
current desktop sidebar. They only collapse when the card becomes
|
||||
narrower than the supported new-drive desktop column. */}
|
||||
<div className={`grid grid-cols-2 gap-0.5 @max-[15rem]:grid-cols-1 ${listWrapClass}`}>
|
||||
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} onToggle={toggleKey} />
|
||||
<GroupList title="Rovers" items={grouped.rovers} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
|
||||
<GroupList title="Room Cams" items={grouped.rooms} selectedSet={selectedSet} activeSourceKeys={activeSourceKeys} onToggle={toggleKey} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
{replayStatusText ? (
|
||||
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
<div className={`text-xs ${replayStatus.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
{replayStatusText}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -279,11 +256,6 @@ export default function ReplaySourcesPanel({
|
||||
saveSettings((current) => ({ ...(current || {}), [titleSettingKey]: next }));
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
// Enter is the keyboard equivalent of clicking Replay. Ignore
|
||||
// composition events so confirming an IME candidate cannot
|
||||
// accidentally submit a replay before the title is complete.
|
||||
// handleReplay remains the single authority for cooldown,
|
||||
// lockdown, busy, and empty-source checks.
|
||||
if (event.key !== 'Enter' || event.nativeEvent?.isComposing) return;
|
||||
event.preventDefault();
|
||||
handleReplay();
|
||||
@@ -314,7 +286,7 @@ export default function ReplaySourcesPanel({
|
||||
onClick={handleReplay}
|
||||
disabled={replayDisabled}
|
||||
>
|
||||
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
{atJobLimit ? 'Replay (limit reached)' : remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,14 +295,14 @@ export default function ReplaySourcesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const GroupList = React.memo(function GroupList({ title, items, selectedSet, onToggle }) {
|
||||
const GroupList = React.memo(function GroupList({ title, items, selectedSet, activeSourceKeys, onToggle }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs">{title}</div>
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<label key={item.key} className="surface flex items-center gap-0.5 text-xs">
|
||||
<label key={item.key} className={`surface flex items-center gap-0.5 text-xs ${activeSourceKeys.has(item.key) ? 'animate-pulse' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSet.has(item.key)}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { RESTART_DELAY_MS, startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
import useServerUrl from '../../hooks/useServerUrl.js';
|
||||
|
||||
function StreamPlayer({ id, volume }) {
|
||||
const socket = useSocket();
|
||||
const serverUrl = useServerUrl();
|
||||
const audio = useRef(null);
|
||||
const { restartToken, scheduleRestart } = useWhepRestart();
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let stop;
|
||||
let token;
|
||||
socket.timeout(5000).emit('video:request', { type: 'roomAudio', id }, (error, response = {}) => {
|
||||
if (!active) {
|
||||
if (response.token && socket.connected) socket.emit('roomAudio:release', { token: response.token });
|
||||
return;
|
||||
}
|
||||
token = response.token;
|
||||
if (error || response.error || !response.url) { scheduleRestart(); return; }
|
||||
stop = startWhepPlayback({
|
||||
...response, url: serverUrl(response.url), video: audio.current, audioOnly: true,
|
||||
onStatus: () => {}, onError: () => {}, scheduleRestart,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
active = false; stop?.();
|
||||
if (token && socket.connected) socket.emit('roomAudio:release', { token });
|
||||
};
|
||||
}, [id, socket, restartToken, scheduleRestart, serverUrl]);
|
||||
useEffect(() => { if (audio.current) audio.current.volume = Math.max(0, Math.min(1, volume)); }, [volume]);
|
||||
return <audio ref={audio} autoPlay />;
|
||||
}
|
||||
|
||||
export default function RoomAudioPlayer() {
|
||||
const container = useRef(null);
|
||||
const streams = useSessionSelector((state) => state.session?.roomAudioStreams);
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const { value } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const volume = (value?.masterVolume ?? 1) * (value?.roomVolume ?? 1);
|
||||
const play = useCallback(() => {
|
||||
const root = container.current;
|
||||
if (!root) return;
|
||||
const elements = [...root.querySelectorAll('audio')].filter((audio) => audio.srcObject);
|
||||
if (!elements.length) return;
|
||||
// Call play during the gesture; blocked playback retries on the interval.
|
||||
elements.forEach((audio) => { audio.play().catch(() => {}); });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!connected) return undefined;
|
||||
const timer = setInterval(play, RESTART_DELAY_MS);
|
||||
window.addEventListener('pointerdown', play);
|
||||
window.addEventListener('keydown', play);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('pointerdown', play);
|
||||
window.removeEventListener('keydown', play);
|
||||
};
|
||||
}, [connected, play]);
|
||||
if (!connected) return null;
|
||||
return (
|
||||
<div ref={container} className="contents">
|
||||
{(streams || []).map((stream) => <StreamPlayer key={stream.id} id={stream.id} volume={volume} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
import useServerUrl from '../../hooks/useServerUrl.js';
|
||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import {
|
||||
mainBrushAudioTelemetryEqual,
|
||||
@@ -130,6 +131,7 @@ export default function RoverMediaPlayer({
|
||||
pauseVideoWhenHidden = null,
|
||||
}) {
|
||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const serverUrl = useServerUrl();
|
||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const configuredPauseHiddenRoverVideo = useSessionSelector(
|
||||
@@ -415,7 +417,7 @@ export default function RoverMediaPlayer({
|
||||
}
|
||||
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
||||
const stop = startWhepPlayback({
|
||||
url: resolvedSessionInfo.url,
|
||||
url: serverUrl(resolvedSessionInfo.url),
|
||||
token: resolvedSessionInfo.token,
|
||||
video: videoRef.current,
|
||||
receiveAudio: !hasDedicatedAudio,
|
||||
@@ -446,6 +448,7 @@ export default function RoverMediaPlayer({
|
||||
hasDedicatedAudio,
|
||||
logAudio,
|
||||
effectiveRoverId,
|
||||
serverUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -510,7 +513,7 @@ export default function RoverMediaPlayer({
|
||||
return undefined;
|
||||
}
|
||||
return startWhepPlayback({
|
||||
url: resolvedAudioSessionInfo.url,
|
||||
url: serverUrl(resolvedAudioSessionInfo.url),
|
||||
token: resolvedAudioSessionInfo.token,
|
||||
video: audioRef.current,
|
||||
audioOnly: true,
|
||||
@@ -533,6 +536,7 @@ export default function RoverMediaPlayer({
|
||||
audioRestartToken,
|
||||
scheduleAudioRestart,
|
||||
logAudio,
|
||||
serverUrl,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -159,6 +159,7 @@ export default function SettingsPanel() {
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
|
||||
const roomVolume = Number.isFinite(audioSettings?.roomVolume) ? audioSettings.roomVolume : AUDIO_SETTINGS_DEFAULTS.roomVolume;
|
||||
const mainBrushDuckEnabled =
|
||||
typeof audioSettings?.mainBrushDuckEnabled === 'boolean'
|
||||
? audioSettings.mainBrushDuckEnabled
|
||||
@@ -440,6 +441,11 @@ export default function SettingsPanel() {
|
||||
value={roverVolume}
|
||||
onChange={handleAudioRange('roverVolume')}
|
||||
/>
|
||||
<RangeSetting
|
||||
label="Room audio"
|
||||
value={roomVolume}
|
||||
onChange={handleAudioRange('roomVolume')}
|
||||
/>
|
||||
<SettingRow>
|
||||
<span className="font-semibold text-white">Main brush ducking</span>
|
||||
<input
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
// Vip Audio Upload Card Content
|
||||
// Purpose: Defines the Vip Audio Upload Card Content module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { fieldClass } from '../constants.js';
|
||||
import { useControlSelector } from '../../../controls/index.js';
|
||||
import ControlHint from '../../ControlHint/index.jsx';
|
||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE, RTC_CONFIG } from './constants.js';
|
||||
import { bytesToBase64, buildAuthHeader } from './base64.js';
|
||||
import {
|
||||
waitForIceGatheringComplete,
|
||||
waitForPeerConnected,
|
||||
configureSenderForLowLatency,
|
||||
waitForOutboundAudioFlow,
|
||||
} from './whipTransport.js';
|
||||
import { MAX_UPLOAD_BYTES, TARGET_SAMPLE_RATE } from './constants.js';
|
||||
import { bytesToBase64 } from './base64.js';
|
||||
import { waitForOutboundAudioFlow } from './whipTransport.js';
|
||||
import MediaMTXWebRTCPublisher from '../../../../.cache/mediamtx/publisher.js';
|
||||
import useServerUrl from '../../../hooks/useServerUrl.js';
|
||||
import { mergeFloatChunks, encodeWavMono16 } from './audioCodec.js';
|
||||
import StatusIndicator from './StatusIndicator.jsx';
|
||||
import KeyPill from './KeyPill.jsx';
|
||||
@@ -28,6 +25,7 @@ export default function VipAudioUploadCard({
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
}) {
|
||||
const serverUrl = useServerUrl();
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const { value: vipAudio, save: saveVipAudio } = useSettingsNamespace('vipAudio', {
|
||||
openMicEnabled: false,
|
||||
@@ -47,7 +45,8 @@ export default function VipAudioUploadCard({
|
||||
|
||||
const streamRef = useRef(null);
|
||||
const audioTrackRef = useRef(null);
|
||||
const whipPcRef = useRef(null);
|
||||
const micAttemptRef = useRef(null);
|
||||
const micEnabledRef = useRef(false);
|
||||
const micActiveRef = useRef(false);
|
||||
const activeRoverRef = useRef('');
|
||||
|
||||
@@ -146,19 +145,12 @@ export default function VipAudioUploadCard({
|
||||
const target = String(targetRoverId || activeRoverRef.current || '').trim();
|
||||
micActiveRef.current = false;
|
||||
setMicState('idle');
|
||||
if (whipPcRef.current) {
|
||||
try {
|
||||
whipPcRef.current.getSenders().forEach((sender) => sender.track?.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
whipPcRef.current.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
whipPcRef.current = null;
|
||||
const publisher = micAttemptRef.current?.publisher;
|
||||
micAttemptRef.current = null;
|
||||
activeRoverRef.current = '';
|
||||
publisher?.close();
|
||||
// The upstream client stops transport retries; release its HTTP session separately.
|
||||
if (publisher?.sessionUrl) fetch(publisher.sessionUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||
if (streamRef.current) {
|
||||
try {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
@@ -175,14 +167,15 @@ export default function VipAudioUploadCard({
|
||||
// noop
|
||||
}
|
||||
}
|
||||
activeRoverRef.current = '';
|
||||
},
|
||||
[stopMicWhip],
|
||||
);
|
||||
|
||||
const startWhipMic = useCallback(
|
||||
async (target) => {
|
||||
async (target, attempt) => {
|
||||
const isCurrent = () => micAttemptRef.current === attempt && activeRoverRef.current === target;
|
||||
const startPayload = await startMicWhip?.(target);
|
||||
if (!isCurrent()) return;
|
||||
const whipUrl = String(startPayload?.whipUrl || '').trim();
|
||||
const token = String(startPayload?.token || '').trim();
|
||||
if (!whipUrl || !token) {
|
||||
@@ -198,12 +191,16 @@ export default function VipAudioUploadCard({
|
||||
autoGainControl: false,
|
||||
},
|
||||
});
|
||||
if (!isCurrent()) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
|
||||
const track = stream.getAudioTracks()?.[0];
|
||||
audioTrackRef.current = track || null;
|
||||
if (track) {
|
||||
track.enabled = Boolean(openMicEnabled || pttActive);
|
||||
track.enabled = micEnabledRef.current;
|
||||
}
|
||||
if (track?.applyConstraints) {
|
||||
try {
|
||||
@@ -219,47 +216,42 @@ export default function VipAudioUploadCard({
|
||||
}
|
||||
}
|
||||
|
||||
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||
whipPcRef.current = pc;
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (!micActiveRef.current) return;
|
||||
const state = pc.connectionState;
|
||||
if (state === 'connected') {
|
||||
setMicState('live');
|
||||
return;
|
||||
}
|
||||
if (state === 'failed' || state === 'disconnected' || state === 'closed') {
|
||||
setMicState('error');
|
||||
setMessage(`WHIP transport ${state}.`);
|
||||
}
|
||||
};
|
||||
stream.getAudioTracks().forEach((audioTrack) => {
|
||||
const sender = pc.addTrack(audioTrack, stream);
|
||||
configureSenderForLowLatency(sender);
|
||||
});
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 1800);
|
||||
|
||||
const response = await fetch(whipUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
...buildAuthHeader(token),
|
||||
if (!isCurrent()) return;
|
||||
attempt.publisher = new MediaMTXWebRTCPublisher({
|
||||
url: serverUrl(whipUrl),
|
||||
user: token,
|
||||
pass: token,
|
||||
stream,
|
||||
audioCodec: 'opus',
|
||||
audioBitrate: 64,
|
||||
audioVoice: true,
|
||||
onConnected: async () => {
|
||||
if (!isCurrent()) return;
|
||||
const pc = attempt.publisher.pc;
|
||||
try {
|
||||
// Report ready only after audio is flowing, including on transport reconnection.
|
||||
await waitForOutboundAudioFlow(pc);
|
||||
if (!isCurrent() || attempt.publisher.pc !== pc) return;
|
||||
await readyMicWhip?.(target);
|
||||
if (!isCurrent() || attempt.publisher.pc !== pc) return;
|
||||
setMicState('live');
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
if (!isCurrent() || attempt.publisher.pc !== pc) return;
|
||||
await stopMicCapture(target);
|
||||
if (micAttemptRef.current) return;
|
||||
setMicState('error');
|
||||
setMessage(error.message || 'Failed to start mic forwarding.');
|
||||
}
|
||||
},
|
||||
onError: (message) => {
|
||||
if (!isCurrent()) return;
|
||||
setMicState('starting');
|
||||
setMessage(message);
|
||||
},
|
||||
body: pc.localDescription?.sdp || offer.sdp,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`WHIP request failed: ${response.status}`);
|
||||
}
|
||||
const answerSdp = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
await waitForPeerConnected(pc, 10000);
|
||||
await waitForOutboundAudioFlow(pc, 6000);
|
||||
await readyMicWhip?.(target);
|
||||
},
|
||||
[openMicEnabled, pttActive, readyMicWhip, startMicWhip],
|
||||
[readyMicWhip, startMicWhip, stopMicCapture, serverUrl],
|
||||
);
|
||||
|
||||
const teardownClipPipeline = useCallback(async () => {
|
||||
@@ -410,8 +402,6 @@ export default function VipAudioUploadCard({
|
||||
}, [ensureClipPipeline, roverId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function syncLiveMic() {
|
||||
if (clipMode) {
|
||||
await stopMicCapture(activeRoverRef.current || roverId);
|
||||
@@ -425,34 +415,31 @@ export default function VipAudioUploadCard({
|
||||
if (micActiveRef.current && activeRoverRef.current === roverId) return;
|
||||
if (!openMicEnabled && !pttActive) return;
|
||||
|
||||
const attempt = {};
|
||||
micAttemptRef.current = attempt;
|
||||
try {
|
||||
setMicState('starting');
|
||||
setMessage('');
|
||||
micActiveRef.current = true;
|
||||
activeRoverRef.current = roverId;
|
||||
await startWhipMic(roverId);
|
||||
if (!cancelled) {
|
||||
setMicState('live');
|
||||
}
|
||||
await startWhipMic(roverId, attempt);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
if (micAttemptRef.current === attempt) {
|
||||
await stopMicCapture(roverId);
|
||||
if (micAttemptRef.current) return;
|
||||
setMicState('error');
|
||||
setMessage(err?.message || 'Failed to start mic forwarding.');
|
||||
}
|
||||
await stopMicCapture(roverId);
|
||||
}
|
||||
}
|
||||
|
||||
syncLiveMic();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clipMode, openMicEnabled, pttActive, roverId, startWhipMic, stopMicCapture]);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
micEnabledRef.current = Boolean(!clipMode && (openMicEnabled || pttActive));
|
||||
const track = audioTrackRef.current;
|
||||
if (!track) return;
|
||||
track.enabled = Boolean(!clipMode && (openMicEnabled || pttActive));
|
||||
if (track) track.enabled = micEnabledRef.current;
|
||||
}, [clipMode, openMicEnabled, pttActive]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,14 +10,3 @@ export function bytesToBase64(bytes) {
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof btoa === 'function') return btoa(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
export function buildAuthHeader(token) {
|
||||
if (!token) return {};
|
||||
const encoded = encodeBase64(`${token}:${token}`);
|
||||
return encoded ? { Authorization: `Basic ${encoded}` } : {};
|
||||
}
|
||||
|
||||
@@ -3,8 +3,3 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
export const TARGET_SAMPLE_RATE = 16000;
|
||||
export const RTC_CONFIG = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
bundlePolicy: 'max-bundle',
|
||||
rtcpMuxPolicy: 'require',
|
||||
};
|
||||
|
||||
@@ -1,91 +1,6 @@
|
||||
// whip Transport
|
||||
// Purpose: Defines the whip Transport module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
export function waitForIceGatheringComplete(pc, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!pc || pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
function onChange() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
function isPeerTransportReady(pc) {
|
||||
if (!pc) return false;
|
||||
const conn = pc.connectionState;
|
||||
const ice = pc.iceConnectionState;
|
||||
if (conn === 'connected') return true;
|
||||
if (ice === 'connected' || ice === 'completed') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function waitForPeerConnected(pc, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
if (isPeerTransportReady(pc)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Peer connection timeout'));
|
||||
}, timeoutMs);
|
||||
const onState = () => {
|
||||
if (isPeerTransportReady(pc)) {
|
||||
cleanup();
|
||||
resolve();
|
||||
} else if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
cleanup();
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
}
|
||||
};
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('connectionstatechange', onState);
|
||||
pc.removeEventListener('iceconnectionstatechange', onState);
|
||||
}
|
||||
pc.addEventListener('connectionstatechange', onState);
|
||||
pc.addEventListener('iceconnectionstatechange', onState);
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureSenderForLowLatency(sender) {
|
||||
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||
const params = sender.getParameters() || {};
|
||||
const first = (params.encodings && params.encodings[0]) || {};
|
||||
params.encodings = [
|
||||
{
|
||||
...first,
|
||||
maxBitrate: 64000,
|
||||
dtx: 'disabled',
|
||||
},
|
||||
];
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
} catch {
|
||||
// Browser support varies; keep defaults if rejected.
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
|
||||
@@ -16,7 +16,6 @@ const INITIAL_STATE = {
|
||||
overseerMemory: null,
|
||||
alerts: [],
|
||||
latestReplay: null,
|
||||
replayStatus: null,
|
||||
duplicateIdentityBlock: null,
|
||||
roverRemovalNotice: null,
|
||||
};
|
||||
@@ -199,23 +198,6 @@ export function SessionProvider({ children }) {
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleReplayStatus(payload = {}) {
|
||||
if (!payload?.status) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
/*
|
||||
Replay creation is serialized by the server's cooldown and workflow,
|
||||
so the browser needs one current progress value rather than a map of
|
||||
jobs. The server remains authoritative for job identity and execution;
|
||||
this scalar exists only to present accepted/building/uploading/ready/
|
||||
failed progress in the web UI.
|
||||
*/
|
||||
replayStatus: {
|
||||
...payload,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
function handleReplayReady(payload = {}) {
|
||||
if (!payload?.jobId || !payload?.url) return;
|
||||
setState((prev) => ({
|
||||
@@ -316,7 +298,6 @@ export function SessionProvider({ children }) {
|
||||
socket.on('overseer:state', handleOverseerState);
|
||||
socket.on('overseer:memory', handleOverseerMemory);
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
socket.on('replay:status', handleReplayStatus);
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
socket.on('replay:failed', handleReplayFailed);
|
||||
socket.on('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
@@ -331,7 +312,6 @@ export function SessionProvider({ children }) {
|
||||
socket.off('overseer:state', handleOverseerState);
|
||||
socket.off('overseer:memory', handleOverseerMemory);
|
||||
socket.off('alert:new', handleAlertNew);
|
||||
socket.off('replay:status', handleReplayStatus);
|
||||
socket.off('replay:ready', handleReplayReady);
|
||||
socket.off('replay:failed', handleReplayFailed);
|
||||
socket.off('session:duplicateIdentity', handleDuplicateIdentity);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Server Display App Root
|
||||
// Purpose: Defines the route root for the physical-room display page.
|
||||
// Scope: Keeps this passive spectator-style surface separate from the main driver UI.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import ServerDisplayContent from './ServerDisplayContent.jsx';
|
||||
|
||||
export default function ServerDisplayAppRoot() {
|
||||
return <ServerDisplayContent />;
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<ServerDisplayContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Main Application Shell
|
||||
// Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route.
|
||||
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import {
|
||||
@@ -90,11 +91,7 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
);
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
{/*
|
||||
Keep document-exit protection inside the control provider so it follows
|
||||
the same assigned-rover identity as every driving command. Mounting it
|
||||
only on the driver page leaves spectator and utility routes unchanged.
|
||||
*/}
|
||||
<RoomAudioPlayer />
|
||||
<UndockedPageExitGuard />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
|
||||
export default function useServerUrl() {
|
||||
const publicUrl = useSessionSelector((state) => state.session?.interInstances?.profile?.publicUrl);
|
||||
// The UI may be served by Vite while session data comes from the actual server.
|
||||
return useCallback((url) => new URL(url, publicUrl || window.location.origin).href, [publicUrl]);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ async function buildSocketIdentity() {
|
||||
audioAdjustments: currentSettings?.audioAdjustments || {},
|
||||
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||
identitySurface: getIdentitySurface(),
|
||||
role: window.location.pathname === '/mic' ? 'spectator' : 'user',
|
||||
operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover',
|
||||
};
|
||||
}
|
||||
|
||||
+6
-1
@@ -20,8 +20,10 @@ import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
import MicApp from './mic/MicApp.jsx'
|
||||
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
|
||||
|
||||
// Reporting and administration carry substantial route-specific libraries.
|
||||
// Loading each only on its own route keeps charting and JSON Schema tooling out
|
||||
@@ -40,6 +42,7 @@ function lazyRoute(element, label) {
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<AppErrorBoundary>
|
||||
<StrictMode>
|
||||
<SocketProvider>
|
||||
<SessionProvider>
|
||||
@@ -63,6 +66,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/mic" element={<MicApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
@@ -84,5 +88,6 @@ createRoot(document.getElementById('root')).render(
|
||||
</TelemetryProvider>
|
||||
</SessionProvider>
|
||||
</SocketProvider>
|
||||
</StrictMode>,
|
||||
</StrictMode>
|
||||
</AppErrorBoundary>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
||||
import { RESTART_DELAY_MS } from '../lib/whepPlayback.js';
|
||||
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager, useControlSelector } from '../controls/index.js';
|
||||
import ControlHint from '../components/ControlHint/index.jsx';
|
||||
import MediaMTXWebRTCPublisher from '../../.cache/mediamtx/publisher.js';
|
||||
import useServerUrl from '../hooks/useServerUrl.js';
|
||||
|
||||
export default function MicApp() {
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<MicPage />
|
||||
</ControlSystemProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MicPage() {
|
||||
const socket = useSocket();
|
||||
const serverUrl = useServerUrl();
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const local = useSessionSelector((state) => state.session?.isLocalNetwork);
|
||||
const enabled = useSessionSelector((state) => state.session?.features?.roomAudio);
|
||||
const [status, setStatus] = useState('Connecting…');
|
||||
const [pttMode, setPttMode] = useState(false);
|
||||
const available = Boolean(connected && local && enabled);
|
||||
const pttActive = useControlSelector((control) => Boolean(control.state.mic?.pttActive));
|
||||
const [pointerHeld, setPointerHeld] = useState(false);
|
||||
const [buttonKeyHeld, setButtonKeyHeld] = useState(false);
|
||||
const held = available && pttMode && (pttActive || pointerHeld || buttonKeyHeld);
|
||||
const transmitting = available && (!pttMode || held);
|
||||
const mediaRef = useRef(null);
|
||||
const transmittingRef = useRef(transmitting);
|
||||
useLayoutEffect(() => {
|
||||
transmittingRef.current = transmitting;
|
||||
mediaRef.current?.getAudioTracks().forEach((track) => { track.enabled = transmitting; });
|
||||
}, [transmitting]);
|
||||
useEffect(() => {
|
||||
// Pointer capture can outlive focus; release the page button when leaving the page.
|
||||
function releaseButton() {
|
||||
setPointerHeld(false);
|
||||
setButtonKeyHeld(false);
|
||||
}
|
||||
function visibilityChange() {
|
||||
if (document.hidden) releaseButton();
|
||||
}
|
||||
window.addEventListener('blur', releaseButton);
|
||||
document.addEventListener('visibilitychange', visibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('blur', releaseButton);
|
||||
document.removeEventListener('visibilitychange', visibilityChange);
|
||||
};
|
||||
}, []);
|
||||
useUserIdentitySync();
|
||||
useEffect(() => {
|
||||
if (!connected || !local || !enabled) return undefined;
|
||||
let disposed = false;
|
||||
let attempt;
|
||||
let media;
|
||||
let retryTimer;
|
||||
const ack = (event, payload = {}) => new Promise((resolve, reject) => {
|
||||
socket.timeout(5000).emit(event, payload, (error, result = {}) => {
|
||||
if (error || result.error) reject(error || new Error(result.error));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
const isCurrent = (current) => !disposed && Boolean(current) && attempt === current;
|
||||
function closeAttempt() {
|
||||
const current = attempt;
|
||||
attempt = null;
|
||||
if (!current) return;
|
||||
const publisher = current.publisher;
|
||||
publisher?.close();
|
||||
// Upstream close stops the peer and retry timer but leaves HTTP session cleanup to us.
|
||||
if (publisher?.sessionUrl) fetch(publisher.sessionUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function retry(current, message) {
|
||||
if (!isCurrent(current)) return;
|
||||
closeAttempt();
|
||||
clearTimeout(retryTimer);
|
||||
setStatus(message);
|
||||
retryTimer = setTimeout(start, RESTART_DELAY_MS);
|
||||
}
|
||||
async function start() {
|
||||
if (disposed) return;
|
||||
// Invalidate the previous attempt before closing it. Late async results
|
||||
// and peer events must never change the new attempt's state or timers.
|
||||
closeAttempt();
|
||||
const current = {};
|
||||
attempt = current;
|
||||
setStatus('Starting microphone…');
|
||||
try {
|
||||
await ack('session:setRole', { role: 'spectator' });
|
||||
if (!isCurrent(current)) return;
|
||||
if (!media || media.getAudioTracks().every((track) => track.readyState === 'ended')) {
|
||||
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
|
||||
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Microphone access requires HTTPS or localhost');
|
||||
const captured = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (!isCurrent(current)) { captured.getTracks().forEach((track) => track.stop()); return; }
|
||||
media = captured;
|
||||
mediaRef.current = media;
|
||||
// New capture and reconnect attempts must respect the current PTT state immediately.
|
||||
media.getAudioTracks().forEach((track) => { track.enabled = transmittingRef.current; });
|
||||
media.getAudioTracks().forEach((track) => { track.onended = () => retry(attempt, 'Microphone disconnected. Retrying…'); });
|
||||
}
|
||||
const source = await ack('roomAudio:start');
|
||||
if (!isCurrent(current)) return;
|
||||
setStatus('Connecting audio…');
|
||||
current.publisher = new MediaMTXWebRTCPublisher({
|
||||
url: serverUrl(source.url),
|
||||
user: source.token,
|
||||
pass: source.token,
|
||||
stream: media,
|
||||
audioCodec: 'opus',
|
||||
audioBitrate: 64,
|
||||
audioVoice: true,
|
||||
onConnected: () => {
|
||||
if (isCurrent(current)) setStatus('Microphone live');
|
||||
},
|
||||
// The publisher owns transport retries; restarting it here would create competing loops.
|
||||
onError: (message) => {
|
||||
if (isCurrent(current)) setStatus(message);
|
||||
},
|
||||
});
|
||||
} catch (error) { retry(current, `${error.message}. Retrying…`); }
|
||||
}
|
||||
start();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(retryTimer);
|
||||
closeAttempt();
|
||||
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
|
||||
mediaRef.current = null;
|
||||
if (socket.connected) socket.emit('roomAudio:stop', {});
|
||||
};
|
||||
}, [socket, connected, local, enabled, serverUrl]);
|
||||
const message = !connected ? 'Connecting…' : local === false ? 'This page is available to local visitors only.' : !enabled ? 'Room audio is disabled.' : status;
|
||||
return (
|
||||
<main className="min-h-screen bg-neutral-950 p-4 text-slate-200">
|
||||
<div className="panel mx-auto max-w-md p-4">
|
||||
<h1 className="text-lg font-semibold">Room microphone</h1>
|
||||
<p className="mt-2 text-sm" role="status">{message}</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2" role="group" aria-label="Microphone mode">
|
||||
{[false, true].map((ptt) => (
|
||||
<button key={String(ptt)} type="button" aria-pressed={pttMode === ptt}
|
||||
className={`button-dark w-full ${pttMode === ptt ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
|
||||
onClick={() => {
|
||||
setPointerHeld(false);
|
||||
setButtonKeyHeld(false);
|
||||
setPttMode(ptt);
|
||||
}}>
|
||||
{ptt ? 'Push to talk' : 'Open mic'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{pttMode && (
|
||||
<>
|
||||
<button type="button" disabled={!available} aria-pressed={held}
|
||||
className={`button-dark mt-4 min-h-40 w-full touch-none select-none text-2xl font-semibold ${held ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}`}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0 || !event.isPrimary) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setPointerHeld(true);
|
||||
}}
|
||||
onPointerUp={() => setPointerHeld(false)}
|
||||
onPointerCancel={() => setPointerHeld(false)}
|
||||
onLostPointerCapture={() => setPointerHeld(false)}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== ' ' && event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
setButtonKeyHeld(true);
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key !== ' ' && event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
setButtonKeyHeld(false);
|
||||
}}
|
||||
onBlur={() => setButtonKeyHeld(false)}>
|
||||
{held ? 'Talking…' : 'Hold to talk'}
|
||||
</button>
|
||||
<p className="mt-2 text-center text-sm text-slate-400">Hold the button or <ControlHint actionId="micPtt" /> to talk.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Mini Summary App Root
|
||||
// Purpose: Defines the Mini Summary App Root module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import { useEffect } from 'react';
|
||||
import AlertFeed from '../../components/AlertFeed/index.jsx';
|
||||
import MiniSummaryContent from './MiniSummaryContent.jsx';
|
||||
@@ -19,6 +20,7 @@ export default function MiniSummaryAppRoot() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<MiniSummaryContent />
|
||||
<AlertFeed scale={3} opacity={1}/>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Mounts the PTZ controller as a real page with the same shared input
|
||||
// and identity systems used by the driver page.
|
||||
// Scope: Mounts input/identity providers; the page owns session entry and layouts.
|
||||
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
@@ -25,6 +26,7 @@ function PtzRouteContent() {
|
||||
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<RoomAudioPlayer />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<LayoutProvider layout={layout}>
|
||||
|
||||
@@ -176,6 +176,7 @@ export const AUDIO_SETTINGS_DEFAULTS = {
|
||||
masterVolume: 1,
|
||||
alertVolume: 0.5,
|
||||
roverVolume: 1,
|
||||
roomVolume: 1,
|
||||
mainBrushDuckEnabled: true,
|
||||
mainBrushDuckAmount: 0.75,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Spectator App Root
|
||||
// Purpose: Defines the Spectator App Root module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import SpectatorContent from './SpectatorContent.jsx';
|
||||
|
||||
export default function SpectatorAppRoot() {
|
||||
return <SpectatorContent />;
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<SpectatorContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user