mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better replays panels
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
@@ -45,18 +46,18 @@ export default function ReplaySourcesPanel({
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const replayStatus = useSessionSelector((state) => state.replayStatus);
|
||||
const socket = useSocket();
|
||||
const { triggerReplay } = useSessionActions();
|
||||
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [titleDirty, setTitleDirty] = useState(false);
|
||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||
const [activeJobId, setActiveJobId] = useState(null);
|
||||
const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null);
|
||||
const [panelReplay, setPanelReplay] = useState(null);
|
||||
// Settings keys include the panel id because the same replay source control is
|
||||
// mounted in desktop, portrait, and landscape layouts with independent saved UI
|
||||
// preferences. Pulling the values into named constants also gives hook
|
||||
@@ -65,21 +66,23 @@ export default function ReplaySourcesPanel({
|
||||
const titleSettingKey = `${panelId}:title`;
|
||||
const savedIncludeSidebar = settings?.[includeSidebarSettingKey];
|
||||
const savedTitle = settings?.[titleSettingKey];
|
||||
const activeReplayJob = useSessionSelector((state) => (
|
||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
||||
));
|
||||
// The job id is deliberately local to this mounted panel. Reading the global
|
||||
// latestReplay value here caused a newly mounted panel to resurrect the last
|
||||
// replay popup even though this panel did not request it. The job record can
|
||||
// remain in shared session state for asynchronous socket updates; selecting
|
||||
// it through this panel-owned id keeps popup ownership and lifetime local.
|
||||
const panelReplay = activeReplayJob?.media || null;
|
||||
const panelReplayJobId = panelReplay?.jobId || null;
|
||||
const showPanelReplay = Boolean(
|
||||
panelReplay?.url &&
|
||||
panelReplayJobId &&
|
||||
dismissedPanelReplayId !== panelReplayJobId,
|
||||
);
|
||||
const showPanelReplay = Boolean(panelReplay?.url);
|
||||
|
||||
useEffect(() => {
|
||||
const handleReplayReady = (replay = {}) => {
|
||||
if (!replay?.url) return;
|
||||
/*
|
||||
A replay popup is an event owned by the lifetime of this mounted panel,
|
||||
not retained application history. Listening to the live socket event
|
||||
means a ready replay opens immediately, while switching tabs away and
|
||||
back cannot replay an event that happened before the new mount.
|
||||
*/
|
||||
setPanelReplay(replay);
|
||||
};
|
||||
|
||||
socket.on('replay:ready', handleReplayReady);
|
||||
return () => socket.off('replay:ready', handleReplayReady);
|
||||
}, [socket]);
|
||||
|
||||
const availableDefaultKey = useMemo(() => {
|
||||
// PTZ layouts provide their camera key explicitly so entering the dedicated
|
||||
@@ -182,10 +185,10 @@ export default function ReplaySourcesPanel({
|
||||
// child lists a stable value while the selected keys have not changed.
|
||||
return new Set(selected);
|
||||
}, [selected]);
|
||||
const activeJobStatusText = useMemo(() => {
|
||||
if (!activeReplayJob?.status) return null;
|
||||
const titleText = activeReplayJob.title ? `: ${activeReplayJob.title}` : '';
|
||||
switch (activeReplayJob.status) {
|
||||
const replayStatusText = useMemo(() => {
|
||||
if (!replayStatus?.status) return null;
|
||||
const titleText = replayStatus.title ? `: ${replayStatus.title}` : '';
|
||||
switch (replayStatus.status) {
|
||||
case 'accepted':
|
||||
return `Replay accepted${titleText}`;
|
||||
case 'building':
|
||||
@@ -195,40 +198,33 @@ export default function ReplaySourcesPanel({
|
||||
case 'ready':
|
||||
return `Replay ready${titleText}`;
|
||||
case 'failed':
|
||||
return activeReplayJob.message || `Replay failed${titleText}`;
|
||||
return replayStatus.message || `Replay failed${titleText}`;
|
||||
default:
|
||||
return activeReplayJob.message || `Replay ${activeReplayJob.status}${titleText}`;
|
||||
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
|
||||
}
|
||||
}, [activeReplayJob]);
|
||||
|
||||
}, [replayStatus]);
|
||||
const toggleKey = useCallback((key) => {
|
||||
setSelected((prev) => {
|
||||
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||
});
|
||||
setSuccess(null);
|
||||
}, []);
|
||||
|
||||
const handleReplay = useCallback(async () => {
|
||||
if (replayDisabled) return;
|
||||
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;
|
||||
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.');
|
||||
}
|
||||
await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar });
|
||||
/*
|
||||
Do not set panel-local success state after acknowledgement. The server
|
||||
broadcasts the authoritative accepted/building/uploading/ready stages,
|
||||
and every replay panel renders that one shared status progression.
|
||||
*/
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -245,7 +241,7 @@ export default function ReplaySourcesPanel({
|
||||
<ReplayReadyPopup
|
||||
replay={panelReplay}
|
||||
variant="floating-panel"
|
||||
onClose={() => setDismissedPanelReplayId(panelReplayJobId)}
|
||||
onClose={() => setPanelReplay(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -256,11 +252,11 @@ export default function ReplaySourcesPanel({
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||
{activeJobStatusText ? (
|
||||
<div className={`text-xs ${activeReplayJob?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
{activeJobStatusText}
|
||||
{replayStatusText ? (
|
||||
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||
{replayStatusText}
|
||||
</div>
|
||||
) : success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
||||
) : null}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<label className="surface shrink-0 text-xs" htmlFor={`${panelId}-title`}>
|
||||
Replay title:
|
||||
|
||||
@@ -15,9 +15,8 @@ const INITIAL_STATE = {
|
||||
overseerControlState: null,
|
||||
overseerMemory: null,
|
||||
alerts: [],
|
||||
replayJobs: {},
|
||||
latestReplay: null,
|
||||
latestRequestedReplay: null,
|
||||
replayStatus: null,
|
||||
duplicateIdentityBlock: null,
|
||||
roverRemovalNotice: null,
|
||||
};
|
||||
@@ -201,62 +200,40 @@ export function SessionProvider({ children }) {
|
||||
}));
|
||||
}
|
||||
function handleReplayStatus(payload = {}) {
|
||||
if (!payload?.jobId) return;
|
||||
setState((prev) => {
|
||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
||||
const nextJob = {
|
||||
...previous,
|
||||
if (!payload?.status) return;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
/*
|
||||
Replay creation is serialized by the server's cooldown and workflow,
|
||||
so the browser needs one current progress value rather than a map of
|
||||
jobs. The server remains authoritative for job identity and execution;
|
||||
this scalar exists only to present accepted/building/uploading/ready/
|
||||
failed progress in the web UI.
|
||||
*/
|
||||
replayStatus: {
|
||||
...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,
|
||||
},
|
||||
};
|
||||
});
|
||||
receivedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
function handleReplayReady(payload = {}) {
|
||||
if (!payload?.jobId || !payload?.url) return;
|
||||
setState((prev) => {
|
||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
||||
const selfSocketId = String(prev.session?.socketId || '').trim();
|
||||
const requesterSocketId = String(payload?.requestedBy?.socketId || '').trim();
|
||||
const requestedByThisBrowser = Boolean(selfSocketId && requesterSocketId && selfSocketId === requesterSocketId);
|
||||
const nextJob = {
|
||||
...previous,
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
/*
|
||||
The client needs only the newest completed replay for immediate
|
||||
presentation on spectator and server-display routes. Replay request
|
||||
panels listen to the live socket event instead, because retaining media
|
||||
for those panels would make an old popup return after a tab remount.
|
||||
Keeping one media slot here also avoids turning replays into a library.
|
||||
*/
|
||||
latestReplay: {
|
||||
...payload,
|
||||
status: 'ready',
|
||||
media: payload,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
replayJobs: {
|
||||
...(prev.replayJobs || {}),
|
||||
[payload.jobId]: nextJob,
|
||||
},
|
||||
// Only the latest replay media 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(),
|
||||
},
|
||||
latestRequestedReplay: requestedByThisBrowser
|
||||
? {
|
||||
...payload,
|
||||
receivedAt: Date.now(),
|
||||
}
|
||||
: prev.latestRequestedReplay,
|
||||
};
|
||||
});
|
||||
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.';
|
||||
@@ -453,13 +430,6 @@ export function SessionProvider({ children }) {
|
||||
})),
|
||||
clearLatestReplay: () =>
|
||||
setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)),
|
||||
showReplayModal: (replay) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
latestRequestedReplay: replay ? { ...replay, receivedAt: Date.now() } : null,
|
||||
})),
|
||||
clearReplayModal: () =>
|
||||
setState((prev) => (prev.latestRequestedReplay ? { ...prev, latestRequestedReplay: null } : prev)),
|
||||
}),
|
||||
[emitWithAck, setState],
|
||||
);
|
||||
|
||||
@@ -155,9 +155,9 @@ export default function SpectatorContent() {
|
||||
<AlertFeed />
|
||||
<RewardRunOverlay />
|
||||
{/* Spectators do not have the replay request panel that normal web users see, so
|
||||
the spectator route listens to every ready replay directly. This intentionally
|
||||
uses latestReplay instead of latestRequestedReplay because all spectators should
|
||||
get the fullscreen replay when a Discord-hosted replay becomes available. */}
|
||||
the spectator route presents the shared latestReplay value directly. Every
|
||||
spectator should receive the fullscreen replay when completed media becomes
|
||||
available, regardless of which browser or transport requested its creation. */}
|
||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user