mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
better replays panels
This commit is contained in:
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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