mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
ptsoectate
This commit is contained in:
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Bf4Pfwl6.js"></script>
|
<script type="module" crossorigin src="/assets/index-Bi6EaYls.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-MAURNhur.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-MAURNhur.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const SNAPSHOT_DIR = process.env.ROVER_SNAPSHOT_DIR || '/var/lib/rover-snapshots
|
|||||||
const SNAPSHOT_POLL_MS = 300;
|
const SNAPSHOT_POLL_MS = 300;
|
||||||
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
|
const SNAPSHOT_STREAM_INTERVAL_MS = 2000;
|
||||||
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
|
const SPOTLIGHT_VERIFY_DELAY_MS = 1200;
|
||||||
|
const PUBLISHER_STDERR_SYNC_MS = 10000;
|
||||||
|
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -96,7 +97,7 @@ function schedulePublisherStateSync(reason = 'publisher') {
|
|||||||
publisherStderrSyncTimer = setTimeout(() => {
|
publisherStderrSyncTimer = setTimeout(() => {
|
||||||
publisherStderrSyncTimer = null;
|
publisherStderrSyncTimer = null;
|
||||||
emitChange(reason);
|
emitChange(reason);
|
||||||
}, 500);
|
}, PUBLISHER_STDERR_SYNC_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePublisherState(patch = {}, reason = 'publisher') {
|
function updatePublisherState(patch = {}, reason = 'publisher') {
|
||||||
@@ -397,7 +398,6 @@ function startPublisher() {
|
|||||||
lastStderr,
|
lastStderr,
|
||||||
lastEvent: 'stderr',
|
lastEvent: 'stderr',
|
||||||
};
|
};
|
||||||
logger.warn('publisher stderr', { text: text.slice(0, 500) });
|
|
||||||
schedulePublisherStateSync('publisher-stderr');
|
schedulePublisherStateSync('publisher-stderr');
|
||||||
});
|
});
|
||||||
proc.on('exit', (code, signal) => {
|
proc.on('exit', (code, signal) => {
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// PTZ Spectator Card
|
||||||
|
// Purpose: Adds the single room PTZ camera to the spectator rover grid.
|
||||||
|
// Scope: Uses live WHEP only when the server authorizes this socket; otherwise
|
||||||
|
// falls back to the PTZ snapshot feed that remote spectators are allowed to see.
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
|
import { usePtzCameraSnapshot } from '../../../hooks/usePtzCameraSnapshot.js';
|
||||||
|
import { useVideoRequests } from '../../../hooks/useVideoRequests.js';
|
||||||
|
import { WhepPlayer } from '../../../lib/whepPlayer.js';
|
||||||
|
|
||||||
|
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||||
|
|
||||||
|
function formatRemaining(deadline) {
|
||||||
|
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||||
|
if (!remaining) return '--';
|
||||||
|
const minutes = Math.floor(remaining / 60);
|
||||||
|
const seconds = remaining % 60;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSpotlightOn(light = {}) {
|
||||||
|
if (typeof light?.on === 'boolean') return light.on;
|
||||||
|
const raw = light?.state;
|
||||||
|
if (typeof raw === 'string') {
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
return !['', '0', 'off', 'false'].includes(normalized);
|
||||||
|
}
|
||||||
|
return Boolean(Number(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInfraredMode(mode) {
|
||||||
|
const normalized = String(mode || '').trim().toLowerCase();
|
||||||
|
if (normalized === 'on') return 'On';
|
||||||
|
if (normalized === 'off') return 'Off';
|
||||||
|
return 'Auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value, tone = '' }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="text-slate-400">{label}</span>
|
||||||
|
<span className={`min-w-0 truncate ${tone || 'text-slate-100'}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PtzLiveOrSnapshot({ label }) {
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const playerRef = useRef(null);
|
||||||
|
const [liveStatus, setLiveStatus] = useState('connecting');
|
||||||
|
const sources = useVideoRequests(
|
||||||
|
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
||||||
|
{ enabled: true },
|
||||||
|
);
|
||||||
|
const source = sources[PTZ_CAMERA_ID] || null;
|
||||||
|
const snapshot = usePtzCameraSnapshot({ enabled: Boolean(source?.error || !source?.url) });
|
||||||
|
const useSnapshot = Boolean(source?.error || !source?.url);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (useSnapshot || !source?.url || !videoRef.current) return undefined;
|
||||||
|
/*
|
||||||
|
Spectator live access is decided by the server's video session policy.
|
||||||
|
Local spectators get a WHEP URL, while remote spectators receive an error
|
||||||
|
here and automatically fall back to the low-bandwidth snapshot feed.
|
||||||
|
*/
|
||||||
|
const player = new WhepPlayer({
|
||||||
|
url: source.url,
|
||||||
|
token: source.token,
|
||||||
|
video: videoRef.current,
|
||||||
|
startMuted: true,
|
||||||
|
onStatus: setLiveStatus,
|
||||||
|
});
|
||||||
|
playerRef.current = player;
|
||||||
|
player.start().catch((err) => setLiveStatus(err.message || 'error'));
|
||||||
|
return () => {
|
||||||
|
player.stop();
|
||||||
|
playerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [source?.token, source?.url, useSnapshot]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative aspect-video w-full overflow-hidden rounded bg-black">
|
||||||
|
{useSnapshot ? (
|
||||||
|
snapshot?.objectUrl ? (
|
||||||
|
<img src={snapshot.objectUrl} alt={label} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">
|
||||||
|
{snapshot?.error || source?.error || 'Waiting for PTZ snapshot...'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<video ref={videoRef} className="h-full w-full object-contain" playsInline autoPlay muted />
|
||||||
|
)}
|
||||||
|
<div className="pointer-events-none absolute left-0 top-0 bg-black/70 px-1 py-0.5 text-xs font-semibold text-white">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute bottom-0 left-0 m-1 rounded bg-black/70 px-1 py-0.5 text-[0.7rem] text-slate-100">
|
||||||
|
{useSnapshot ? snapshot?.status || 'snapshot' : liveStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PtzSpectatorCard() {
|
||||||
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
|
if (!ptz?.enabled) return null;
|
||||||
|
|
||||||
|
const publisher = ptz?.publisher || {};
|
||||||
|
const publisherStatus = publisher.running
|
||||||
|
? 'running'
|
||||||
|
: publisher.restartAt
|
||||||
|
? 'restarting'
|
||||||
|
: publisher.lastEvent || 'stopped';
|
||||||
|
const queueCount = Array.isArray(ptz?.queue) ? ptz.queue.length : 0;
|
||||||
|
const label = ptz?.name || 'PTZ Camera';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="flex min-h-[16rem] flex-col rounded bg-zinc-900 p-0 sm:min-h-[18rem]">
|
||||||
|
<PtzLiveOrSnapshot label={label} />
|
||||||
|
<div className="min-h-0 flex-1 space-y-0.5 overflow-hidden p-1 text-xs">
|
||||||
|
<InfoRow label="Operator" value={ptz?.operatorLabel || 'none'} />
|
||||||
|
<InfoRow label="Remaining" value={formatRemaining(ptz?.deadline)} />
|
||||||
|
<InfoRow label="Queue" value={queueCount ? `${queueCount} waiting` : 'empty'} />
|
||||||
|
<InfoRow label="Spotlight" value={isSpotlightOn(ptz?.light) ? 'On' : 'Off'} />
|
||||||
|
<InfoRow label="Infrared" value={normalizeInfraredMode(ptz?.ir?.state)} />
|
||||||
|
<InfoRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
|
||||||
|
{publisher.lastStderr ? (
|
||||||
|
<div className="line-clamp-2 break-words font-mono text-[0.65rem] leading-tight text-slate-400">
|
||||||
|
{publisher.lastStderr}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
// Purpose: Defines the Rover Row module and the local helpers/components used in this file.
|
// Purpose: Defines the Rover Row module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
|
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
|
||||||
|
import PtzSpectatorCard from './PtzSpectatorCard.jsx';
|
||||||
|
|
||||||
export default function RoverRow({ roster }) {
|
export default function RoverRow({ roster }) {
|
||||||
if (roster.length === 0) {
|
|
||||||
return <p className="col-span-full text-slate-400">No rovers registered.</p>;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
|
<section className="grid grid-cols-1 gap-0.5 md:grid-cols-2">
|
||||||
|
{roster.length === 0 ? <p className="col-span-full text-slate-400">No rovers registered.</p> : null}
|
||||||
{roster.map((rover) => (
|
{roster.map((rover) => (
|
||||||
<RoverSpectatorCard key={rover.id} rover={rover} />
|
<RoverSpectatorCard key={rover.id} rover={rover} />
|
||||||
))}
|
))}
|
||||||
|
<PtzSpectatorCard />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user