mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
/display page woooo
This commit is contained in:
@@ -22,7 +22,7 @@ export default function ChatPanel({
|
||||
fillHeight = false,
|
||||
allowSpectatorInput = false,
|
||||
title = 'Chat and TTS',
|
||||
nicknameLayout = 'inline',
|
||||
minimal = false,
|
||||
}) {
|
||||
const role = useSessionSelector((state) => state.session?.role || null);
|
||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||
@@ -67,6 +67,8 @@ export default function ChatPanel({
|
||||
[currentRoverId, roster],
|
||||
);
|
||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled);
|
||||
const effectiveHideInput = minimal || hideInput;
|
||||
const effectiveTitle = minimal ? '' : title;
|
||||
|
||||
const sorted = useMemo(() => messages.slice(-200), [messages]);
|
||||
const typingRows = useMemo(() => typing || [], [typing]);
|
||||
@@ -125,7 +127,7 @@ export default function ChatPanel({
|
||||
|
||||
async function handleSend(event) {
|
||||
event.preventDefault();
|
||||
if (!canChat || hideInput) return;
|
||||
if (!canChat || effectiveHideInput) return;
|
||||
// Allow users to type "\n" to represent a newline in messages
|
||||
const normalizedDraft = draft.replace(/\\n/g, '\n');
|
||||
const clean = normalizedDraft.trim();
|
||||
@@ -156,9 +158,9 @@ export default function ChatPanel({
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
title={title}
|
||||
title={effectiveTitle}
|
||||
|
||||
hideHeader={!title}
|
||||
hideHeader={minimal || !effectiveTitle}
|
||||
fillHeight={fillHeight}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
>
|
||||
@@ -172,7 +174,7 @@ export default function ChatPanel({
|
||||
<ChatTypingRow key={`typing-${entry.typingId || entry.id}`} message={entry} />
|
||||
))}
|
||||
</div>
|
||||
{!hideInput && (
|
||||
{!effectiveHideInput && (
|
||||
<form className={composerClass} onSubmit={handleSend}>
|
||||
<div className="chat-composer-nickname">
|
||||
<NicknameForm compact />
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// 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 ServerDisplayContent from './ServerDisplayContent.jsx';
|
||||
|
||||
export default function ServerDisplayAppRoot() {
|
||||
return <ServerDisplayContent />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Server Display Content
|
||||
// Purpose: Composes the passive room display route used as a third spectator-style page.
|
||||
// Scope: Shows online names, rover driver/battery status, chat, replay overlays, reward overlays, and hidden audio.
|
||||
import { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import ReplayReadyPopup from '../../components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
import RewardRunOverlay from '../../components/RewardRunOverlay/index.jsx';
|
||||
import OnlinePeopleStrip from './components/OnlinePeopleStrip.jsx';
|
||||
import DisplayRoverGrid from './components/DisplayRoverGrid.jsx';
|
||||
import DisplayChatFeed from './components/DisplayChatFeed.jsx';
|
||||
|
||||
export default function ServerDisplayContent() {
|
||||
const { session } = useSession();
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
const inLockdown = session?.mode === 'lockdown';
|
||||
|
||||
useDefaultNickname();
|
||||
// This page is a passive room endpoint, but it still needs the same identity
|
||||
// heartbeat as /spectate and /mini so nickname cookies and spectator role sync
|
||||
// stay current when this laptop is left running for a long session.
|
||||
useUserIdentitySync();
|
||||
useSpectatorMode();
|
||||
|
||||
if (inLockdown) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black text-[clamp(2.5rem,8vh,8rem)] font-black text-slate-200">
|
||||
Lockdown
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="display-page flex h-screen w-screen flex-col overflow-hidden bg-black text-slate-100">
|
||||
<div className="h-[8vh] min-h-[4rem] shrink-0">
|
||||
<OnlinePeopleStrip users={session?.users || []} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-[0.72]">
|
||||
<DisplayRoverGrid roster={session?.roster || []} session={session} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-[1.28]">
|
||||
<DisplayChatFeed />
|
||||
</div>
|
||||
<RewardRunOverlay />
|
||||
{/* Display is spectator-like: every Discord-hosted replay should take over
|
||||
this physical-room board, not only replays requested by this browser. */}
|
||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Display Chat Feed
|
||||
// Purpose: Shows the existing chat panel scaled for distance viewing.
|
||||
// Scope: Uses ChatPanel minimal mode so the display route reuses normal chat behavior without input chrome.
|
||||
import ChatPanel from '../../../components/ChatPanel/index.jsx';
|
||||
|
||||
export default function DisplayChatFeed() {
|
||||
const displayChatScale = 2.35;
|
||||
|
||||
return (
|
||||
<section className="h-full min-h-0 overflow-hidden border-t border-slate-800 bg-black p-[0.45vw]">
|
||||
<div
|
||||
className="origin-top-left"
|
||||
style={{
|
||||
// ChatPanel is shared with the normal UI and intentionally compact.
|
||||
// Scaling the whole panel keeps message rendering, scrolling, typing
|
||||
// rows, and future chat behavior in one place while making the display
|
||||
// readable. Width and height compensation reserve the unscaled layout
|
||||
// space that becomes exactly one full chat band after transform.
|
||||
width: `calc(100% / ${displayChatScale})`,
|
||||
height: `calc(100% / ${displayChatScale})`,
|
||||
transform: `scale(${displayChatScale})`,
|
||||
}}
|
||||
>
|
||||
<ChatPanel minimal fillHeight hideSpectatorNotice />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Display Rover Cell
|
||||
// Purpose: Shows one rover's room-readable driver and battery status.
|
||||
// Scope: Reuses shared rover/battery presentation primitives while avoiding controls, queues, and video.
|
||||
import { useTelemetryFrame } from '../../../context/TelemetryContext.jsx';
|
||||
import BatteryBar from '../../../components/BatteryBar/index.jsx';
|
||||
import RoverLabel from '../../../components/RoverLabel/index.jsx';
|
||||
import AutoFitText from '../../../mini/MiniSummaryApp/components/AutoFitText.jsx';
|
||||
import {
|
||||
buildRoverStateText,
|
||||
findDriverForRover,
|
||||
formatBatteryText,
|
||||
getDisplayBatteryVisual,
|
||||
} from '../utils.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function DisplayRoverCell({ rover, session }) {
|
||||
const frame = useTelemetryFrame(rover?.id);
|
||||
const visual = getDisplayBatteryVisual({ rover, frame });
|
||||
const driver = findDriverForRover({ roverId: rover?.id, session });
|
||||
const stateText = buildRoverStateText(rover, visual);
|
||||
const batteryText = formatBatteryText(visual);
|
||||
const active = Boolean(driver);
|
||||
const urgent = Boolean(visual?.urgentActive);
|
||||
const warn = Boolean(visual?.warnActive);
|
||||
const locked = Boolean(rover?.locked);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={classNames(
|
||||
'relative min-h-0 overflow-hidden border border-slate-800 bg-black',
|
||||
locked ? 'border-red-400 ring-4 ring-red-500/80' : '',
|
||||
!locked && active ? 'ring-2 ring-sky-400/80' : '',
|
||||
!locked && urgent ? 'ring-4 ring-red-500/90' : !locked && warn ? 'ring-2 ring-amber-300/80' : '',
|
||||
)}
|
||||
>
|
||||
<BatteryBar visual={visual} variant="background" orientation="vertical" />
|
||||
<div className="relative z-10 grid h-full min-h-0 grid-rows-[auto_minmax(0,1fr)_auto] gap-[0.55vh] p-[0.85vw] text-center">
|
||||
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-[0.8vw]">
|
||||
<div className="min-w-0">
|
||||
<RoverLabel
|
||||
rover={rover}
|
||||
fallback={rover?.id}
|
||||
as={AutoFitText}
|
||||
className="leading-none"
|
||||
maxSize={76}
|
||||
minSize={20}
|
||||
/>
|
||||
</div>
|
||||
{locked ? (
|
||||
// Keep the lock warning adjacent to the rover identity so the cell
|
||||
// remains readable while still making the locked state impossible
|
||||
// to miss at a glance.
|
||||
<div className="max-w-[36vw] border-4 border-red-100 bg-red-700 px-[0.9vw] py-[0.45vh] text-center text-[clamp(1.6rem,4.2vh,4.4rem)] font-black leading-none text-white">
|
||||
<div>LOCKED</div>
|
||||
{rover?.lockReason ? (
|
||||
<div className="mt-[0.3vh] text-[clamp(1rem,2.4vh,2.5rem)] leading-none text-red-50">
|
||||
{rover.lockReason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid min-h-0 min-w-0 grid-cols-[minmax(0,1.45fr)_minmax(0,0.85fr)] items-center gap-[1vw]">
|
||||
<div className="min-w-0">
|
||||
<AutoFitText
|
||||
className={classNames(
|
||||
'font-black leading-none',
|
||||
active ? 'text-white' : 'text-slate-400',
|
||||
)}
|
||||
maxSize={128}
|
||||
minSize={28}
|
||||
>
|
||||
{driver?.label || 'Idle'}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<AutoFitText
|
||||
className={classNames(
|
||||
'font-black leading-none',
|
||||
urgent ? 'text-red-100' : warn ? 'text-amber-100' : 'text-slate-100',
|
||||
)}
|
||||
maxSize={100}
|
||||
minSize={24}
|
||||
>
|
||||
{batteryText}
|
||||
</AutoFitText>
|
||||
</div>
|
||||
</div>
|
||||
{stateText ? (
|
||||
<div className="min-w-0 text-[clamp(0.95rem,1.8vh,1.8rem)] font-black leading-none text-amber-100">
|
||||
{stateText}
|
||||
</div>
|
||||
) : (
|
||||
// This empty line keeps cells with and without exceptional states the
|
||||
// same height. The grid should not jump just because one rover becomes
|
||||
// locked or low battery while people are reading the board.
|
||||
<div aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Display Rover Grid
|
||||
// Purpose: Fills the central 16:10 display band with rover driver/battery cells.
|
||||
// Scope: Keeps rover status presentation dense and label-free for room readability.
|
||||
import DisplayRoverCell from './DisplayRoverCell.jsx';
|
||||
import { gridClassForRoverCount } from '../utils.js';
|
||||
|
||||
export default function DisplayRoverGrid({ roster = [], session }) {
|
||||
if (!roster.length) {
|
||||
return (
|
||||
<section className="flex h-full min-h-0 items-center justify-center border-b border-slate-800 bg-black text-[clamp(2.5rem,7vh,7rem)] font-black text-slate-500">
|
||||
No rovers
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`grid h-full min-h-0 ${gridClassForRoverCount(roster.length)} auto-rows-fr gap-0.5 bg-slate-950 p-0.5`}>
|
||||
{roster.map((rover) => (
|
||||
<DisplayRoverCell key={rover.id} rover={rover} session={session} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Online People Strip
|
||||
// Purpose: Renders a one-line, label-free list of online people for the room display.
|
||||
// Scope: Owns overflow detection and marquee-style motion without changing shared user-list components.
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { formatUserName } from '../utils.js';
|
||||
|
||||
function classNames(...values) {
|
||||
return values.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
export default function OnlinePeopleStrip({ users = [] }) {
|
||||
const viewportRef = useRef(null);
|
||||
const trackRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
const names = useMemo(
|
||||
() =>
|
||||
users
|
||||
// The display page itself enters spectator mode, and other passive
|
||||
// spectators are usually not relevant to people physically in the room.
|
||||
// Filtering them keeps the top strip focused on active participants.
|
||||
.filter((user) => user?.role !== 'spectator')
|
||||
.map(formatUserName)
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
[users],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!viewport || !content) return undefined;
|
||||
|
||||
const updateOverflow = () => {
|
||||
// The strip only moves when it needs to. A still row is easier to read
|
||||
// when the current online set already fits on the 16:10 display.
|
||||
setOverflowing(content.scrollWidth > viewport.clientWidth + 4);
|
||||
};
|
||||
|
||||
updateOverflow();
|
||||
const resizeObserver = new ResizeObserver(updateOverflow);
|
||||
resizeObserver.observe(viewport);
|
||||
resizeObserver.observe(content);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [names]);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track || !overflowing) return undefined;
|
||||
|
||||
// This animation is local to the display route, so it is created directly
|
||||
// on the strip element instead of adding route-specific keyframes to the
|
||||
// global stylesheet. The duplicated name row means -50% lands exactly at
|
||||
// the start of the second copy, producing a continuous readable loop.
|
||||
const animation = track.animate(
|
||||
[{ transform: 'translateX(0)' }, { transform: 'translateX(-50%)' }],
|
||||
{ duration: 42000, iterations: Infinity, easing: 'linear' },
|
||||
);
|
||||
return () => animation.cancel();
|
||||
}, [overflowing, names]);
|
||||
|
||||
const renderedNames = names.length ? names : ['No one online'];
|
||||
const itemNodes = renderedNames.map((name, index) => (
|
||||
<span key={`${name}-${index}`} className="shrink-0 px-[1.6vw] font-black tracking-normal text-slate-100">
|
||||
{name}
|
||||
</span>
|
||||
));
|
||||
|
||||
return (
|
||||
<div ref={viewportRef} className="relative h-full min-w-0 overflow-hidden border-b border-slate-800/80 bg-black">
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={classNames(
|
||||
'flex h-full w-max items-center whitespace-nowrap text-[clamp(2rem,4.2vh,4.6rem)] leading-none',
|
||||
)}
|
||||
>
|
||||
<div ref={contentRef} className="flex h-full items-center">
|
||||
{itemNodes}
|
||||
</div>
|
||||
{overflowing ? (
|
||||
// The duplicate row makes the loop continuous. It is hidden from assistive
|
||||
// tech because it is purely mechanical animation, not extra information.
|
||||
<div className="flex h-full items-center" aria-hidden="true">
|
||||
{itemNodes}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Server Display Utilities
|
||||
// Purpose: Supplies small formatting helpers shared by the room display components.
|
||||
// Scope: Keeps the display route focused on presentation while still using the same
|
||||
// session, roster, and battery data shapes as the rest of the web UI.
|
||||
import { buildBatteryVisual } from '../../lib/battery.js';
|
||||
|
||||
export function formatUserName(user) {
|
||||
// The room display is read from a distance, so every user needs a short stable
|
||||
// label even when they have not set a nickname. Socket ids are noisy, but the
|
||||
// first six characters match existing UI behavior and are enough to distinguish
|
||||
// temporary visitors in the room.
|
||||
return String(user?.nickname || user?.socketId?.slice(0, 6) || 'unknown').trim();
|
||||
}
|
||||
|
||||
export function findDriverForRover({ roverId, session }) {
|
||||
const activeDriverId = session?.activeDrivers?.[roverId] || null;
|
||||
if (!activeDriverId) return null;
|
||||
const user = (session?.users || []).find((entry) => entry.socketId === activeDriverId);
|
||||
return {
|
||||
socketId: activeDriverId,
|
||||
label: formatUserName(user || { socketId: activeDriverId }),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDisplayBatteryVisual({ rover, frame }) {
|
||||
// Roster battery state is the normal fast path because the server already
|
||||
// classifies warning/urgent thresholds. Telemetry charge is included as a
|
||||
// fallback so the display still gets a useful percentage if the roster state
|
||||
// arrives before the richer battery snapshot is populated.
|
||||
return buildBatteryVisual({
|
||||
batteryState: rover?.batteryState ?? null,
|
||||
charge: frame?.sensors?.batteryChargeMah ?? null,
|
||||
config: rover?.battery ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBatteryText(visual) {
|
||||
if (!visual?.available) return '--';
|
||||
if (visual.urgentActive) return `${visual.percentDisplay}% Low`;
|
||||
if (visual.warnActive) return `${visual.percentDisplay}% Low`;
|
||||
return `${visual.percentDisplay}%`;
|
||||
}
|
||||
|
||||
export function buildRoverStateText(rover, visual) {
|
||||
const states = [];
|
||||
if (rover?.private?.enabled && rover?.private?.open) states.push('Private');
|
||||
if (visual?.urgentActive || visual?.warnActive) states.push('Low');
|
||||
// Only exceptional state belongs here. The normal driver/id/battery lines
|
||||
// already consume the big readable space, and extra routine labels make this
|
||||
// utility board harder to scan from across the room.
|
||||
return states.join(' · ');
|
||||
}
|
||||
|
||||
export function gridClassForRoverCount(count) {
|
||||
if (count <= 1) return 'grid-cols-1';
|
||||
if (count === 2) return 'grid-cols-2';
|
||||
if (count === 4) return 'grid-cols-2';
|
||||
return 'grid-cols-3';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { TelemetryProvider } from './context/TelemetryContext.jsx'
|
||||
import { ChatProvider } from './context/ChatContext.jsx'
|
||||
import SpectatorApp from './spectate/SpectatorApp/SpectatorAppRoot.jsx'
|
||||
import MiniSummaryApp from './mini/MiniSummaryApp/MiniSummaryAppRoot.jsx'
|
||||
import ServerDisplayApp from './display/ServerDisplayApp/ServerDisplayAppRoot.jsx'
|
||||
import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
|
||||
@@ -27,6 +28,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/" element={<App />} />
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</ChatProvider>
|
||||
|
||||
Reference in New Issue
Block a user