mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
spectator page selectors
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useSession, useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||
import ChatPanel from '../../components/ChatPanel/index.jsx';
|
||||
@@ -17,11 +18,23 @@ import usePortraitLayout from './hooks/usePortraitLayout.js';
|
||||
import RoverRow from './components/RoverRow.jsx';
|
||||
import SecondaryRow from './components/SecondaryRow.jsx';
|
||||
import LogsRow from './components/LogsRow.jsx';
|
||||
import SpectatorViewControls from './components/SpectatorViewControls.jsx';
|
||||
|
||||
const SPECTATOR_VIEW_DEFAULTS = {
|
||||
showSidebar: true,
|
||||
showRovers: true,
|
||||
showPtz: true,
|
||||
showRoomCameras: true,
|
||||
};
|
||||
|
||||
export default function SpectatorContent() {
|
||||
const { session } = useSession();
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
const { value: viewPreferences, save: saveViewPreferences } = useSettingsNamespace(
|
||||
'spectatorPage',
|
||||
SPECTATOR_VIEW_DEFAULTS,
|
||||
);
|
||||
const inLockdown = session?.mode === 'lockdown';
|
||||
const canUseSpectatorAccess = session?.bandwidthSavings?.canUseExternalSpectatorAccess !== false;
|
||||
const spectatorAccessMode = session?.bandwidthSavings?.externalSpectatorAccess || 'on';
|
||||
@@ -34,6 +47,15 @@ export default function SpectatorContent() {
|
||||
useSpectatorMode();
|
||||
const isPortraitLayout = usePortraitLayout();
|
||||
const roster = session?.roster ?? [];
|
||||
// Treat absent keys as enabled so older settings cookies gain every new view
|
||||
// option by default instead of unexpectedly hiding parts of the page.
|
||||
const showSidebar = viewPreferences?.showSidebar !== false;
|
||||
const showRovers = viewPreferences?.showRovers !== false;
|
||||
const showPtz = viewPreferences?.showPtz !== false;
|
||||
const showRoomCameras = viewPreferences?.showRoomCameras !== false;
|
||||
const updateViewPreference = (key, enabled) => {
|
||||
saveViewPreferences((current) => ({ ...(current || {}), [key]: Boolean(enabled) }));
|
||||
};
|
||||
|
||||
if (inLockdown) {
|
||||
return (
|
||||
@@ -68,7 +90,9 @@ export default function SpectatorContent() {
|
||||
|
||||
const mainClass = isPortraitLayout
|
||||
? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden'
|
||||
: 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]';
|
||||
: showSidebar
|
||||
? 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]'
|
||||
: 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0';
|
||||
const contentClass = isPortraitLayout
|
||||
? 'order-2 flex min-h-0 min-w-0 flex-1 flex-col gap-0.5 overflow-y-auto'
|
||||
: 'order-1 flex min-h-0 min-w-0 flex-col gap-0.5 md:overflow-y-auto';
|
||||
@@ -81,7 +105,7 @@ export default function SpectatorContent() {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-slate-100 md:h-screen md:overflow-hidden">
|
||||
<main className={mainClass}>
|
||||
<section className={sidebarClass}>
|
||||
{showSidebar ? <section className={sidebarClass}>
|
||||
{isPortraitLayout ? (
|
||||
<div className={`${topBarItemClass} ${portraitItemHeight} flex flex-col gap-0.5`}>
|
||||
<GlobalObjectiveBanner layout="desktop" dismissable={false} className="text-sm" />
|
||||
@@ -112,12 +136,20 @@ export default function SpectatorContent() {
|
||||
<div className={`${topBarItemClass} ${isPortraitLayout ? portraitItemHeight : ''}`}>
|
||||
<LogsRow className={`${isPortraitLayout ? 'h-full' : 'h-40'} overflow-hidden`} />
|
||||
</div>
|
||||
</section>
|
||||
</section> : null}
|
||||
<section className={contentClass}>
|
||||
<RoverRow roster={roster} />
|
||||
<SecondaryRow />
|
||||
{/*
|
||||
Conditional rendering is deliberate: CSS-only hiding would leave
|
||||
WHEP peer connections and snapshot socket subscriptions active.
|
||||
Unmounting delegates cleanup to the media components that own them.
|
||||
*/}
|
||||
{showRovers || showPtz ? (
|
||||
<RoverRow roster={roster} showRovers={showRovers} showPtz={showPtz} />
|
||||
) : null}
|
||||
{showRoomCameras ? <SecondaryRow /> : null}
|
||||
</section>
|
||||
</main>
|
||||
<SpectatorViewControls preferences={viewPreferences} onToggle={updateViewPreference} />
|
||||
<AlertFeed />
|
||||
<RewardRunOverlay />
|
||||
{/* Spectators do not have the replay request panel that normal web users see, so
|
||||
|
||||
@@ -4,14 +4,21 @@
|
||||
import RoverSpectatorCard from './RoverSpectatorCard.jsx';
|
||||
import PtzSpectatorCard from './PtzSpectatorCard.jsx';
|
||||
|
||||
export default function RoverRow({ roster }) {
|
||||
export default function RoverRow({ roster, showRovers = true, showPtz = true }) {
|
||||
return (
|
||||
<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) => (
|
||||
<RoverSpectatorCard key={rover.id} rover={rover} />
|
||||
))}
|
||||
<PtzSpectatorCard />
|
||||
{showRovers && roster.length === 0 ? <p className="col-span-full text-slate-400">No rovers registered.</p> : null}
|
||||
{showRovers
|
||||
? roster.map((rover) => (
|
||||
<RoverSpectatorCard key={rover.id} rover={rover} />
|
||||
))
|
||||
: null}
|
||||
{/*
|
||||
PTZ intentionally remains the last grid item. With three rovers it
|
||||
therefore occupies the fourth tile, preserving the spectator layout's
|
||||
designed visual balance while still allowing independent unmounting.
|
||||
*/}
|
||||
{showPtz ? <PtzSpectatorCard /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Spectator View Controls
|
||||
// Purpose: Lets each spectator choose which major page regions consume space and media bandwidth.
|
||||
// Scope: Owns only the fixed gear menu UI; cookie persistence and layout decisions remain in SpectatorContent.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FaCog } from 'react-icons/fa';
|
||||
import CardFrame from '../../../components/CardFrame/index.jsx';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ key: 'showSidebar', label: 'Sidebar' },
|
||||
{ key: 'showRovers', label: 'Rovers' },
|
||||
{ key: 'showPtz', label: 'PTZ camera' },
|
||||
{ key: 'showRoomCameras', label: 'Room cameras' },
|
||||
];
|
||||
|
||||
export default function SpectatorViewControls({ preferences, onToggle }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const controlsRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
const handlePointerDown = (event) => {
|
||||
/*
|
||||
The popup floats above both the sidebar and camera grid, so clicking
|
||||
elsewhere should dismiss it without changing any saved preference.
|
||||
Keeping the gear and popup under one ref makes clicks on either safe.
|
||||
*/
|
||||
if (!controlsRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (event) => {
|
||||
// Escape mirrors normal dialog/menu behavior without trapping keyboard focus.
|
||||
if (event.key === 'Escape') setOpen(false);
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={controlsRef} className="fixed right-1 top-1 z-50 flex flex-col items-end gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="button-dark flex h-8 w-8 items-center justify-center p-0 text-slate-100 shadow-lg"
|
||||
aria-label="Spectator view settings"
|
||||
aria-expanded={open}
|
||||
aria-controls="spectator-view-controls"
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<FaCog aria-hidden="true" />
|
||||
</button>
|
||||
{open ? (
|
||||
<CardFrame
|
||||
title="Spectator view"
|
||||
className="w-48 shadow-xl"
|
||||
bodyClassName="space-y-0.5 p-0.5 text-sm"
|
||||
clipOverflow={false}
|
||||
>
|
||||
<div id="spectator-view-controls" className="space-y-0.5">
|
||||
{VIEW_OPTIONS.map((option) => (
|
||||
<label
|
||||
key={option.key}
|
||||
className="surface-muted flex cursor-pointer items-center gap-0.5 px-1 py-0.75 text-slate-100"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-sky-500"
|
||||
checked={preferences[option.key] !== false}
|
||||
onChange={(event) => onToggle(option.key, event.target.checked)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</CardFrame>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user