better replays panels

This commit is contained in:
legop3
2026-08-03 03:48:30 -04:00
parent 208b89fd7f
commit 28fcbad902
11 changed files with 227 additions and 262 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -12,7 +12,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-gn8wwSnH.js"></script>
<script type="module" crossorigin src="/assets/index-C7V6I437.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
</head>
<body>
+4 -5
View File
@@ -1,8 +1,7 @@
1. assign rovers based on battery percentage, give people highest one
2. bandwidth savings option for videoplayer invisible disconnecting
3. setting to disable replay popups in spectator settings menu
4. add admin ui for VIP and private requests instead of only through discord
5. add flag in roverd for video aspect ratio
2. setting to disable replay popups in spectator settings menu
3. add admin ui for VIP and private requests instead of only through discord
4. add flag in roverd for video aspect ratio
1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
1. just removing the black bars, doesnt do anything practical for the driver page
2. would only actually help for keeping spectate page compact
@@ -11,7 +10,7 @@
3. default is 4:3
4. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
6. fix this:
5. fix this:
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
Jun 18 15:14:18 roombaserver.local node[216731]: ^
@@ -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:
+28 -58
View File
@@ -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>
);