mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
buncha stuff, charging complete sounds, room display button feedback, etc
This commit is contained in:
+1
-5
@@ -41,11 +41,10 @@ import useUserIdentitySync from './hooks/useUserIdentitySync.js';
|
||||
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
|
||||
import VipPanel from './components/VipPanel/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import ReplayReadyPopup from './components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
|
||||
function useLayoutMode() {
|
||||
@@ -279,8 +278,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns);
|
||||
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
||||
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
||||
const latestRequestedReplay = useSessionSelector((state) => state.latestRequestedReplay);
|
||||
const { clearReplayModal } = useSessionActions();
|
||||
const [helpVisible, setHelpVisible] = useState(false);
|
||||
const [quickstartVisible, setQuickstartVisible] = useState(false);
|
||||
|
||||
@@ -363,7 +360,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
onOpenHelp={openHelpFromQuickstart}
|
||||
onClose={closeQuickstart}
|
||||
/>
|
||||
<ReplayReadyPopup replay={latestRequestedReplay} onClose={clearReplayModal} />
|
||||
{showFloatingFullscreenButton ? (
|
||||
<FloatingFullscreenButton
|
||||
side={fullscreenButtonSide}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
|
||||
const isPanel = variant === 'panel';
|
||||
const isFloatingPanel = variant === 'floating-panel';
|
||||
const isModal = !isPanel && !isFloatingPanel;
|
||||
const title = String(replay?.title || 'Replay').trim() || 'Replay';
|
||||
const messageUrl = normalizeUrl(replay?.messageUrl);
|
||||
const meta = formatBytes(replay?.size) || null;
|
||||
@@ -136,11 +137,12 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
title={title}
|
||||
meta={meta}
|
||||
actions={actions}
|
||||
fillHeight={isPanel}
|
||||
fillHeight={isPanel || isModal}
|
||||
clipOverflow={false}
|
||||
bodyClassName={`${isPanel ? 'flex min-h-0 flex-1 flex-col' : ''} space-y-0.5 p-0.5 text-sm text-slate-200`}
|
||||
className={isModal ? 'h-full w-full' : ''}
|
||||
bodyClassName={`${isPanel || isModal ? 'flex min-h-0 flex-1 flex-col' : ''} space-y-0.5 p-0.5 text-sm text-slate-200`}
|
||||
>
|
||||
<div className={`${isPanel ? 'min-h-0 flex-1' : ''} overflow-hidden rounded bg-black`}>
|
||||
<div className={`${isPanel || isModal ? 'min-h-0 flex-1' : ''} overflow-hidden rounded bg-black`}>
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
@@ -149,7 +151,11 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
preload="auto"
|
||||
playsInline
|
||||
{...videoLifecycleProps}
|
||||
className={`${isPanel ? 'h-full min-h-[10rem]' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
|
||||
// Spectator pages use the modal variant as the primary replay viewer, so
|
||||
// that video should fill the available viewport instead of behaving like
|
||||
// a centered dialog preview. object-contain preserves the replay frame
|
||||
// without cropping if the browser viewport is not the same aspect ratio.
|
||||
className={`${isPanel ? 'h-full min-h-[10rem]' : isModal ? 'h-full object-contain' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
@@ -160,11 +166,12 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 p-1" onClick={onClose} role="presentation">
|
||||
<div className="fixed inset-0 z-[110] flex items-stretch justify-stretch bg-black" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="pointer-events-auto w-full max-w-4xl"
|
||||
className="pointer-events-auto h-full w-full"
|
||||
onClick={(event) => {
|
||||
// The backdrop closes the popup, but clicks inside the card must leave video controls usable.
|
||||
// The backdrop closes the popup, but clicks inside the fullscreen card
|
||||
// must leave video controls and header actions usable.
|
||||
event.stopPropagation();
|
||||
}}
|
||||
role="presentation"
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
@@ -48,17 +47,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
||||
));
|
||||
const latestReplayJobId = latestReplay?.jobId || null;
|
||||
const latestReplayRequesterSocketId = latestReplay?.requestedBy?.socketId || null;
|
||||
const latestReplayRequestedBySelf = Boolean(
|
||||
latestReplayJobId &&
|
||||
selfSocketId &&
|
||||
latestReplayRequesterSocketId &&
|
||||
String(latestReplayRequesterSocketId) === String(selfSocketId),
|
||||
);
|
||||
const showPanelReplay = Boolean(
|
||||
latestReplay?.url &&
|
||||
latestReplayJobId &&
|
||||
!latestReplayRequestedBySelf &&
|
||||
dismissedPanelReplayId !== latestReplayJobId,
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
import DisplayNoticeOverlay from './components/DisplayNoticeOverlay.jsx';
|
||||
|
||||
export default function ServerDisplayContent() {
|
||||
const { session } = useSession();
|
||||
@@ -43,6 +44,7 @@ export default function ServerDisplayContent() {
|
||||
<div className="min-h-0 flex-[1.28]">
|
||||
<DisplayChatFeed />
|
||||
</div>
|
||||
<DisplayNoticeOverlay />
|
||||
<RewardRunOverlay />
|
||||
{/* Display is spectator-like: every Discord-hosted replay should take over
|
||||
this physical-room board, not only replays requested by this browser. */}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Display Notice Overlay
|
||||
// Purpose: Shows server-side room-control feedback as huge temporary display text.
|
||||
// Scope: Listens only on the /display page so normal driver and spectator pages are not interrupted.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../../../context/SocketContext.jsx';
|
||||
|
||||
const DEFAULT_DURATION_MS = 4500;
|
||||
|
||||
function normalizeNotice(payload = {}) {
|
||||
const text = String(payload?.text || '').trim();
|
||||
if (!text) return null;
|
||||
const durationMs = Number.isFinite(Number(payload?.durationMs))
|
||||
? Math.max(1200, Math.min(15000, Number(payload.durationMs)))
|
||||
: DEFAULT_DURATION_MS;
|
||||
return {
|
||||
id: payload?.id || `display-notice-${Date.now()}`,
|
||||
text,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DisplayNoticeOverlay() {
|
||||
const socket = useSocket();
|
||||
const [notice, setNotice] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function clearNoticeTimer() {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotice(payload = {}) {
|
||||
const next = normalizeNotice(payload);
|
||||
if (!next) return;
|
||||
// Home Assistant buttons can be pressed rapidly. The room display should
|
||||
// reflect the most recent confirmed action instead of queueing stale room
|
||||
// state messages after the action has already changed again.
|
||||
clearNoticeTimer();
|
||||
setNotice(next);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setNotice((current) => (current?.id === next.id ? null : current));
|
||||
timerRef.current = null;
|
||||
}, next.durationMs);
|
||||
}
|
||||
|
||||
socket.on('display:notice', handleNotice);
|
||||
return () => {
|
||||
clearNoticeTimer();
|
||||
socket.off('display:notice', handleNotice);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
if (!notice) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[130] flex items-center justify-center bg-black px-[4vw] py-[4vh]">
|
||||
<div className="max-w-[92vw] border-4 border-cyan-200 bg-slate-950 px-[3vw] py-[2.5vh] text-center">
|
||||
<div className="whitespace-pre-wrap text-[clamp(3.2rem,11vh,11rem)] font-black leading-[0.95] text-white">
|
||||
{notice.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user