mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
ptz in mini and better mini
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user