mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
better bettter replayses
This commit is contained in:
+5
-1
@@ -41,10 +41,11 @@ 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 { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useSessionActions, 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() {
|
||||
@@ -278,6 +279,8 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns);
|
||||
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
||||
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
const { clearLatestReplay } = useSessionActions();
|
||||
const [helpVisible, setHelpVisible] = useState(false);
|
||||
const [quickstartVisible, setQuickstartVisible] = useState(false);
|
||||
|
||||
@@ -360,6 +363,7 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
onOpenHelp={openHelpFromQuickstart}
|
||||
onClose={closeQuickstart}
|
||||
/>
|
||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||
{showFloatingFullscreenButton ? (
|
||||
<FloatingFullscreenButton
|
||||
side={fullscreenButtonSide}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Replay Ready Popup
|
||||
// Purpose: Presents the latest Discord-hosted replay video to web users as soon as upload completes.
|
||||
// Scope: Owns the ephemeral modal shell, immediate video loading, and click-outside close behavior.
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function normalizeUrl(value) {
|
||||
const text = String(value || '').trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value);
|
||||
if (!Number.isFinite(size) || size <= 0) return '';
|
||||
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export default function ReplayReadyPopup({ replay, onClose }) {
|
||||
const videoUrl = normalizeUrl(replay?.url);
|
||||
if (!videoUrl) return null;
|
||||
|
||||
const title = String(replay?.title || 'Replay').trim() || 'Replay';
|
||||
const messageUrl = normalizeUrl(replay?.messageUrl);
|
||||
const meta = formatBytes(replay?.size) || null;
|
||||
const actions = (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<a href={videoUrl} target="_blank" rel="noreferrer" className="button-dark px-1 py-0.25 text-[0.72rem]">
|
||||
Open video
|
||||
</a>
|
||||
{messageUrl ? (
|
||||
<a href={messageUrl} target="_blank" rel="noreferrer" className="button-dark px-1 py-0.25 text-[0.72rem]">
|
||||
Discord
|
||||
</a>
|
||||
) : null}
|
||||
<button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.72rem]">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 p-1"
|
||||
onClick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto w-full max-w-4xl"
|
||||
onClick={(event) => {
|
||||
// The backdrop closes the popup, but clicks inside the card must leave video controls usable.
|
||||
event.stopPropagation();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<CardFrame title={title} meta={meta} actions={actions} clipOverflow={false} bodyClassName="space-y-0.5 p-0.5 text-sm text-slate-200">
|
||||
<div className="overflow-hidden rounded bg-black">
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
preload="auto"
|
||||
playsInline
|
||||
className="aspect-video max-h-[72vh] w-full bg-black"
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,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 users = useSessionSelector((state) => state.session?.users ?? []);
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const { triggerReplay } = useSessionActions();
|
||||
@@ -41,6 +39,10 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const [titleDirty, setTitleDirty] = useState(false);
|
||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||
const [remainingMs, setRemainingMs] = useState(0);
|
||||
const [activeJobId, setActiveJobId] = useState(null);
|
||||
const activeReplayJob = useSessionSelector((state) => (
|
||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
||||
));
|
||||
|
||||
const defaults = useMemo(() => {
|
||||
const roverId = assignmentRoverId;
|
||||
@@ -51,17 +53,13 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
}, [assignmentRoverId]);
|
||||
|
||||
const defaultTitle = useMemo(() => {
|
||||
const self = Array.isArray(users)
|
||||
? users.find((entry) => entry?.socketId === selfSocketId)
|
||||
: null;
|
||||
const nickname = (self?.nickname || 'Someone').trim() || 'Someone';
|
||||
const roverId = assignmentRoverId || null;
|
||||
const roverName =
|
||||
roverId && Array.isArray(roster)
|
||||
? roster.find((entry) => String(entry?.id) === String(roverId))?.name || roverId
|
||||
: 'a rover';
|
||||
return `${nickname} driving ${roverName}`;
|
||||
}, [users, selfSocketId, assignmentRoverId, roster]);
|
||||
: '';
|
||||
return roverName ? `Replay: ${roverName}` : 'Replay';
|
||||
}, [assignmentRoverId, roster]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(defaults);
|
||||
@@ -119,6 +117,24 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
}, [replayState?.lastTriggeredAt, replayState?.cooldownMs, replayState?.remainingMs]);
|
||||
|
||||
const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length;
|
||||
const activeJobStatusText = useMemo(() => {
|
||||
if (!activeReplayJob?.status) return null;
|
||||
const titleText = activeReplayJob.title ? `: ${activeReplayJob.title}` : '';
|
||||
switch (activeReplayJob.status) {
|
||||
case 'accepted':
|
||||
return `Replay accepted${titleText}`;
|
||||
case 'building':
|
||||
return `Replay building${titleText}`;
|
||||
case 'uploading':
|
||||
return `Replay uploading${titleText}`;
|
||||
case 'ready':
|
||||
return `Replay ready${titleText}`;
|
||||
case 'failed':
|
||||
return activeReplayJob.message || `Replay failed${titleText}`;
|
||||
default:
|
||||
return activeReplayJob.message || `Replay ${activeReplayJob.status}${titleText}`;
|
||||
}
|
||||
}, [activeReplayJob]);
|
||||
|
||||
const toggleKey = (key) => {
|
||||
setSelected((prev) => {
|
||||
@@ -132,14 +148,22 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setActiveJobId(null);
|
||||
try {
|
||||
const payload = selected.map((key) => {
|
||||
const [type, id] = key.split(':');
|
||||
return { type, id };
|
||||
});
|
||||
const resolvedTitle = String(title || '').trim() || defaultTitle;
|
||||
await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar });
|
||||
setSuccess('Replay sent. Check the Discord replay channel.');
|
||||
const resp = await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar });
|
||||
if (resp?.jobId) {
|
||||
// The socket acknowledgement is only the start of the async job.
|
||||
// Later replay:status events update this same job id as Discord builds and uploads the video.
|
||||
setActiveJobId(resp.jobId);
|
||||
setSuccess('Replay accepted.');
|
||||
} else {
|
||||
setSuccess('Replay accepted.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -195,7 +219,11 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
{remainingMs > 0 ? `Replay (${Math.ceil(remainingMs / 1000)}s)` : busy ? 'Replay…' : 'Replay'}
|
||||
</button>
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
{success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||
{activeJobStatusText ? (
|
||||
<div className={`text-xs ${activeReplayJob?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
{activeJobStatusText}
|
||||
</div>
|
||||
) : success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||
</div>
|
||||
</CardFrame>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ const INITIAL_STATE = {
|
||||
overseerControlState: null,
|
||||
overseerMemory: null,
|
||||
alerts: [],
|
||||
replayJobs: {},
|
||||
latestReplay: null,
|
||||
};
|
||||
|
||||
const SessionContext = createContext(null);
|
||||
@@ -129,6 +131,72 @@ export function SessionProvider({ children }) {
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleReplayStatus(payload = {}) {
|
||||
if (!payload?.jobId) return;
|
||||
setState((prev) => {
|
||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
||||
const nextJob = {
|
||||
...previous,
|
||||
...payload,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
// Keep status by job id because the replay panel receives the job id synchronously
|
||||
// from the trigger acknowledgement, then later socket events update that same record.
|
||||
replayJobs: {
|
||||
...(prev.replayJobs || {}),
|
||||
[payload.jobId]: nextJob,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
function handleReplayReady(payload = {}) {
|
||||
if (!payload?.jobId || !payload?.url) return;
|
||||
setState((prev) => {
|
||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
||||
const nextJob = {
|
||||
...previous,
|
||||
...payload,
|
||||
status: 'ready',
|
||||
media: payload,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
replayJobs: {
|
||||
...(prev.replayJobs || {}),
|
||||
[payload.jobId]: nextJob,
|
||||
},
|
||||
// Only the current replay popup is retained. Discord is the media host, so
|
||||
// this state is intentionally short-lived and does not become a replay library.
|
||||
latestReplay: {
|
||||
...payload,
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
function handleReplayFailed(payload = {}) {
|
||||
if (payload?.jobId) handleReplayStatus({ ...payload, status: 'failed' });
|
||||
const message = typeof payload?.message === 'string' && payload.message.trim()
|
||||
? payload.message.trim()
|
||||
: 'Replay failed after being accepted.';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
alerts: [
|
||||
...prev.alerts.slice(-49),
|
||||
{
|
||||
id: payload?.jobId ? `replay-failed-${payload.jobId}` : `replay-failed-${Date.now()}`,
|
||||
title: 'Replay failed',
|
||||
message,
|
||||
color: '#f59e0b',
|
||||
receivedAt: Date.now(),
|
||||
lifetimeMs: 6000,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}
|
||||
function handleOverseerState(payload = null) {
|
||||
const state = payload && typeof payload === 'object' ? payload : null;
|
||||
setState((prev) => ({ ...prev, overseerControlState: state }));
|
||||
@@ -148,6 +216,9 @@ export function SessionProvider({ children }) {
|
||||
socket.on('overseer:state', handleOverseerState);
|
||||
socket.on('overseer:memory', handleOverseerMemory);
|
||||
socket.on('alert:new', handleAlertNew);
|
||||
socket.on('replay:status', handleReplayStatus);
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
socket.on('replay:failed', handleReplayFailed);
|
||||
return () => {
|
||||
socket.off('session:sync', handleSession);
|
||||
socket.off('log:init', handleLogInit);
|
||||
@@ -158,6 +229,9 @@ export function SessionProvider({ children }) {
|
||||
socket.off('overseer:state', handleOverseerState);
|
||||
socket.off('overseer:memory', handleOverseerMemory);
|
||||
socket.off('alert:new', handleAlertNew);
|
||||
socket.off('replay:status', handleReplayStatus);
|
||||
socket.off('replay:ready', handleReplayReady);
|
||||
socket.off('replay:failed', handleReplayFailed);
|
||||
};
|
||||
}, [setState, socket]);
|
||||
|
||||
@@ -231,6 +305,8 @@ export function SessionProvider({ children }) {
|
||||
{ ...alert, receivedAt: Date.now(), id: alert.id || Math.random().toString(36).slice(2) },
|
||||
],
|
||||
})),
|
||||
clearLatestReplay: () =>
|
||||
setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)),
|
||||
}),
|
||||
[emitWithAck, setState],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user