ptz in mini and better mini

This commit is contained in:
legop3
2026-07-13 18:52:37 -04:00
parent 8ee680ce9f
commit e2e94da656
6 changed files with 226 additions and 51 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<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>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-eqv2XD2p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B_3-jvHM.css">
<script type="module" crossorigin src="/assets/index-BOymxzTs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-tRV3nWgE.css">
</head>
<body>
<div id="root"></div>
@@ -1,7 +1,7 @@
// Mini Summary Content
// Purpose: Defines the Mini Summary 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 { useEffect, useMemo, useState } from 'react';
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import { useVisualTelemetryFrames } from '../../context/TelemetryContext.jsx';
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
@@ -9,6 +9,7 @@ import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
import PtzLiveVideo from '../../components/PtzLiveVideo/index.jsx';
import RoverMediaPlayer from '../../components/RoverMediaPlayer/index.jsx';
import FitViewportFrame from './components/FitViewportFrame.jsx';
import InfoColumn from './components/InfoColumn.jsx';
@@ -21,6 +22,133 @@ function hasRoverAudioCapture(rover) {
return Boolean(rover?.media?.audioCapture?.enabled && rover?.media?.audioCapture?.publishUrl);
}
function AutoFitBoxText({ children, className = '', maxSize = 1000, minSize = 14 }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
useLayoutEffect(() => {
const container = containerRef.current;
const textEl = textRef.current;
if (!container || !textEl) return undefined;
let raf = null;
const fit = () => {
const width = container.clientWidth;
const height = container.clientHeight;
if (!width || !height) {
scheduleFit();
return;
}
/*
Unlike the shared AutoFitText helper, the PTZ operator label has a real
second constraint: before rotation, text length must fit the screen
height and text thickness must fit the blue column width. Binary search
lets the name use as much of both dimensions as possible without
clipping either after the 90-degree transform.
*/
let low = minSize;
let high = maxSize;
let best = minSize;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
textEl.style.fontSize = `${mid}px`;
const fits = textEl.scrollWidth <= width && textEl.scrollHeight <= height;
if (fits) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
setFontSize(best);
};
const scheduleFit = () => {
if (raf) cancelAnimationFrame(raf);
raf = requestAnimationFrame(fit);
};
scheduleFit();
const ro = new ResizeObserver(scheduleFit);
ro.observe(container);
return () => {
if (raf) cancelAnimationFrame(raf);
ro.disconnect();
};
}, [children, maxSize, minSize]);
return (
<div ref={containerRef} className="flex h-full w-full min-w-0 items-center justify-center overflow-hidden">
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1 }}
>
{children}
</div>
</div>
);
}
function MiniPtzUserColumn({ label }) {
const columnRef = useRef(null);
const [columnSize, setColumnSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const column = columnRef.current;
if (!column) return undefined;
const updateSize = () => {
/*
The label box is measured before rotation, so the blue column's screen
height becomes the label's unrotated width, and the blue column's screen
width becomes the label's unrotated height. That swapped geometry is the
key difference from normal horizontal auto-fit text.
*/
setColumnSize({
width: column.clientWidth,
height: column.clientHeight,
});
};
updateSize();
const ro = new ResizeObserver(updateSize);
ro.observe(column);
return () => ro.disconnect();
}, []);
return (
<div ref={columnRef} className="relative flex h-full w-full min-w-0 items-center justify-center overflow-hidden bg-sky-600 text-white">
{/*
The mini page is intended for glanceable room displays, so the PTZ user
gets the same right-side identity treatment as rover metadata while
letting the blue column absorb any extra horizontal room after the
full-height PTZ video takes the camera-shaped part of the viewport.
*/}
{columnSize.width > 16 && columnSize.height > 16 ? (
<div
className="absolute left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2 rotate-90 px-2 text-center leading-none"
style={{
width: `${columnSize.height - 16}px`,
height: `${columnSize.width - 16}px`,
}}
>
{/*
This fitter checks both text width and text height before rotation.
After rotation, that means the username fits both the vertical
reading length and the visible thickness of the blue column.
*/}
<AutoFitBoxText className="font-black leading-none text-white" maxSize={1000} minSize={16}>
{label}
</AutoFitBoxText>
</div>
) : null}
</div>
);
}
export default function MiniSummaryContent() {
const { session } = useSession();
const spectatorReady = useSpectatorMode();
@@ -33,6 +161,9 @@ export default function MiniSummaryContent() {
const canSpectateVideo = Boolean(session?.isLocalNetwork);
const frames = useVisualTelemetryFrames();
const roster = session?.roster ?? [];
const ptz = session?.ptzCamera || null;
const ptzOperatorLabel = String(ptz?.operatorLabel || '').trim();
const hasActivePtzOperator = Boolean(ptz?.enabled && ptzOperatorLabel);
const [index, setIndex] = useState(0);
const activeDrivers = session?.activeDrivers || {};
const driverRoster = useMemo(
@@ -81,13 +212,21 @@ export default function MiniSummaryContent() {
}, [driverRoster, snapshotFeeds, videoSources, canSpectateVideo]);
const rotationPool = useMemo(() => {
return roverPool.map((rover) => ({ type: 'rover', rover }));
}, [roverPool]);
/*
PTZ is deliberately added only to the active-driver rotation. The branch
below that shows every rover side-by-side when nobody is driving remains
rover-only, because that view is a roster/status fallback rather than a
source carousel.
*/
const entries = roverPool.map((rover) => ({ type: 'rover', rover }));
if (hasActivePtzOperator) entries.push({ type: 'ptz' });
return entries;
}, [hasActivePtzOperator, roverPool]);
const rotationKey = useMemo(
() =>
rotationPool
.map((entry) => `r:${entry.rover.id}`)
.map((entry) => (entry.type === 'ptz' ? 'p:ptz-camera' : `r:${entry.rover.id}`))
.join('|'),
[rotationPool],
);
@@ -106,8 +245,8 @@ export default function MiniSummaryContent() {
const activeEntry = rotationPool.length ? rotationPool[index % rotationPool.length] : null;
const activeRover = activeEntry?.type === 'rover' ? activeEntry.rover : null;
const activePtz = activeEntry?.type === 'ptz';
const activeSnapshot = !canSpectateVideo && activeRover ? snapshotFeeds[activeRover.id] || null : null;
const activeVideo = canSpectateVideo && activeRover ? videoSources[activeRover.id] || null : null;
const activeAudio = activeRover ? audioSources[`${activeRover.id}-audio`] || null : null;
const activeFrame = activeRover ? frames[activeRover.id] || null : null;
@@ -124,7 +263,7 @@ export default function MiniSummaryContent() {
);
}
if (!driverRoster.length) {
if (!driverRoster.length && !hasActivePtzOperator) {
return (
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section className="flex h-full w-full gap-x-3 bg-slate-900 px-0">
@@ -156,17 +295,31 @@ export default function MiniSummaryContent() {
<div className="relative flex h-screen w-screen overflow-hidden bg-black text-slate-100">
<section
className="relative flex h-full shrink-0 items-center justify-start overflow-hidden bg-black"
style={{ width: 'min(100vw, calc(100vh * 4 / 3))' }}
style={{
/*
Rover cameras are framed by the existing 4:3 viewport helper below,
but the PTZ camera is a 16:9 source. Give PTZ a 16:9 full-height
media lane here so the actual camera image reaches the top and
bottom of the screen, then let the right-side blue column consume
whatever width remains.
*/
width: activePtz ? 'min(100vw, calc(100vh * 16 / 9))' : 'min(100vw, calc(100vh * 4 / 3))',
}}
>
{!spectatorReady ? (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
Switching to spectator
</div>
) : activeRover ? (
<FitViewportFrame>
{canSpectateVideo ? (
<div className="relative h-full w-full">
) : activeEntry ? (
<div className="relative h-full w-full overflow-hidden bg-black">
{/*
Keep every mini media source mounted while the carousel rotates.
WHEP negotiation is the expensive part that caused the visible
blank gap, so inactive sources are hidden with opacity instead of
being removed from React's tree and forced to reconnect later.
*/}
{rotationPool.map((entry) => {
if (entry.type !== 'rover') return null;
const rover = entry.rover;
const isActive = activeRover?.id === rover.id;
return (
@@ -174,6 +327,8 @@ export default function MiniSummaryContent() {
key={rover.id}
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<FitViewportFrame>
{canSpectateVideo ? (
<RoverMediaPlayer
sessionInfo={videoSources[rover.id] || null}
videoMode="whep"
@@ -183,21 +338,39 @@ export default function MiniSummaryContent() {
label={rover.name || rover.id}
sensors={frames[rover.id]?.sensors || null}
/>
</div>
);
})}
</div>
) : (
<RoverMediaPlayer
sessionInfo={null}
videoMode="snapshot"
snapshotFeed={activeSnapshot}
audioSessionInfo={activeAudio}
label={activeRover.name || activeRover.id}
sensors={activeFrame?.sensors || null}
snapshotFeed={snapshotFeeds[rover.id] || null}
audioSessionInfo={isActive ? activeAudio : null}
forceMute={!isActive}
label={rover.name || rover.id}
sensors={frames[rover.id]?.sensors || null}
/>
)}
</FitViewportFrame>
</div>
);
})}
{hasActivePtzOperator ? (
<div className={`absolute inset-0 ${activePtz ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
{/*
PTZ bypasses FitViewportFrame because that helper intentionally
enforces the rover camera's 4:3 shape. Keeping this component
mounted preserves the PTZ WHEP session across rover/PTZ
rotations while still letting the visible pane resize to 16:9
when PTZ becomes active.
*/}
<PtzLiveVideo
enabled
startMuted
className="relative h-full w-full bg-black"
videoClassName="h-full w-full object-cover"
/>
</div>
) : null}
</div>
) : (
<div className="flex h-full w-full items-center justify-center text-sm text-slate-500">
{driverRoster.length ? 'No sources available.' : 'No active drivers.'}
@@ -205,7 +378,9 @@ export default function MiniSummaryContent() {
)}
</section>
<aside className="flex h-full min-w-0 flex-1 flex-col border-l border-slate-800/60 bg-black">
{activeRover ? (
{activePtz ? (
<MiniPtzUserColumn label={ptzOperatorLabel} />
) : activeRover ? (
<InfoColumn
rover={activeRover}
frame={activeFrame}