mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
ptz in mini and better mini
This commit is contained in:
@@ -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,24 +295,40 @@ 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">
|
||||
{rotationPool.map((entry) => {
|
||||
const rover = entry.rover;
|
||||
const isActive = activeRover?.id === rover.id;
|
||||
return (
|
||||
<div
|
||||
key={rover.id}
|
||||
className={`absolute inset-0 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||
>
|
||||
) : 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 (
|
||||
<div
|
||||
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>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
<RoverMediaPlayer
|
||||
sessionInfo={null}
|
||||
videoMode="snapshot"
|
||||
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>
|
||||
) : (
|
||||
<RoverMediaPlayer
|
||||
sessionInfo={null}
|
||||
videoMode="snapshot"
|
||||
snapshotFeed={activeSnapshot}
|
||||
audioSessionInfo={activeAudio}
|
||||
label={activeRover.name || activeRover.id}
|
||||
sensors={activeFrame?.sensors || null}
|
||||
/>
|
||||
)}
|
||||
</FitViewportFrame>
|
||||
) : 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