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" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- site-metadata:inject -->
|
<!-- site-metadata:inject -->
|
||||||
<!-- analytics: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">
|
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
1. assign rovers based on battery percentage, give people highest one
|
1. assign rovers based on battery percentage, give people highest one
|
||||||
2. bandwidth savings option for videoplayer invisible disconnecting
|
2. setting to disable replay popups in spectator settings menu
|
||||||
3. 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 admin ui for VIP and private requests instead of only through discord
|
4. add flag in roverd for video aspect ratio
|
||||||
5. 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. 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
|
1. just removing the black bars, doesnt do anything practical for the driver page
|
||||||
2. would only actually help for keeping spectate page compact
|
2. would only actually help for keeping spectate page compact
|
||||||
@@ -11,7 +10,7 @@
|
|||||||
3. default is 4:3
|
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
|
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
|
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]: /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]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
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.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
import { useSocket } from '../../context/SocketContext.jsx';
|
||||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
@@ -45,18 +46,18 @@ export default function ReplaySourcesPanel({
|
|||||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||||
|
const replayStatus = useSessionSelector((state) => state.replayStatus);
|
||||||
|
const socket = useSocket();
|
||||||
const { triggerReplay } = useSessionActions();
|
const { triggerReplay } = useSessionActions();
|
||||||
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]);
|
||||||
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
const { value: settings, save: saveSettings } = useSettingsNamespace('replaySources', {});
|
||||||
const [selected, setSelected] = useState([]);
|
const [selected, setSelected] = useState([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [success, setSuccess] = useState(null);
|
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [titleDirty, setTitleDirty] = useState(false);
|
const [titleDirty, setTitleDirty] = useState(false);
|
||||||
const [includeSidebar, setIncludeSidebar] = useState(true);
|
const [includeSidebar, setIncludeSidebar] = useState(true);
|
||||||
const [activeJobId, setActiveJobId] = useState(null);
|
const [panelReplay, setPanelReplay] = useState(null);
|
||||||
const [dismissedPanelReplayId, setDismissedPanelReplayId] = useState(null);
|
|
||||||
// Settings keys include the panel id because the same replay source control is
|
// Settings keys include the panel id because the same replay source control is
|
||||||
// mounted in desktop, portrait, and landscape layouts with independent saved UI
|
// mounted in desktop, portrait, and landscape layouts with independent saved UI
|
||||||
// preferences. Pulling the values into named constants also gives hook
|
// preferences. Pulling the values into named constants also gives hook
|
||||||
@@ -65,21 +66,23 @@ export default function ReplaySourcesPanel({
|
|||||||
const titleSettingKey = `${panelId}:title`;
|
const titleSettingKey = `${panelId}:title`;
|
||||||
const savedIncludeSidebar = settings?.[includeSidebarSettingKey];
|
const savedIncludeSidebar = settings?.[includeSidebarSettingKey];
|
||||||
const savedTitle = settings?.[titleSettingKey];
|
const savedTitle = settings?.[titleSettingKey];
|
||||||
const activeReplayJob = useSessionSelector((state) => (
|
const showPanelReplay = Boolean(panelReplay?.url);
|
||||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
|
||||||
));
|
useEffect(() => {
|
||||||
// The job id is deliberately local to this mounted panel. Reading the global
|
const handleReplayReady = (replay = {}) => {
|
||||||
// latestReplay value here caused a newly mounted panel to resurrect the last
|
if (!replay?.url) return;
|
||||||
// replay popup even though this panel did not request it. The job record can
|
/*
|
||||||
// remain in shared session state for asynchronous socket updates; selecting
|
A replay popup is an event owned by the lifetime of this mounted panel,
|
||||||
// it through this panel-owned id keeps popup ownership and lifetime local.
|
not retained application history. Listening to the live socket event
|
||||||
const panelReplay = activeReplayJob?.media || null;
|
means a ready replay opens immediately, while switching tabs away and
|
||||||
const panelReplayJobId = panelReplay?.jobId || null;
|
back cannot replay an event that happened before the new mount.
|
||||||
const showPanelReplay = Boolean(
|
*/
|
||||||
panelReplay?.url &&
|
setPanelReplay(replay);
|
||||||
panelReplayJobId &&
|
};
|
||||||
dismissedPanelReplayId !== panelReplayJobId,
|
|
||||||
);
|
socket.on('replay:ready', handleReplayReady);
|
||||||
|
return () => socket.off('replay:ready', handleReplayReady);
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
const availableDefaultKey = useMemo(() => {
|
const availableDefaultKey = useMemo(() => {
|
||||||
// PTZ layouts provide their camera key explicitly so entering the dedicated
|
// 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.
|
// child lists a stable value while the selected keys have not changed.
|
||||||
return new Set(selected);
|
return new Set(selected);
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
const activeJobStatusText = useMemo(() => {
|
const replayStatusText = useMemo(() => {
|
||||||
if (!activeReplayJob?.status) return null;
|
if (!replayStatus?.status) return null;
|
||||||
const titleText = activeReplayJob.title ? `: ${activeReplayJob.title}` : '';
|
const titleText = replayStatus.title ? `: ${replayStatus.title}` : '';
|
||||||
switch (activeReplayJob.status) {
|
switch (replayStatus.status) {
|
||||||
case 'accepted':
|
case 'accepted':
|
||||||
return `Replay accepted${titleText}`;
|
return `Replay accepted${titleText}`;
|
||||||
case 'building':
|
case 'building':
|
||||||
@@ -195,40 +198,33 @@ export default function ReplaySourcesPanel({
|
|||||||
case 'ready':
|
case 'ready':
|
||||||
return `Replay ready${titleText}`;
|
return `Replay ready${titleText}`;
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return activeReplayJob.message || `Replay failed${titleText}`;
|
return replayStatus.message || `Replay failed${titleText}`;
|
||||||
default:
|
default:
|
||||||
return activeReplayJob.message || `Replay ${activeReplayJob.status}${titleText}`;
|
return replayStatus.message || `Replay ${replayStatus.status}${titleText}`;
|
||||||
}
|
}
|
||||||
}, [activeReplayJob]);
|
}, [replayStatus]);
|
||||||
|
|
||||||
const toggleKey = useCallback((key) => {
|
const toggleKey = useCallback((key) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key];
|
||||||
});
|
});
|
||||||
setSuccess(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleReplay = useCallback(async () => {
|
const handleReplay = useCallback(async () => {
|
||||||
if (replayDisabled) return;
|
if (replayDisabled) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
|
||||||
setActiveJobId(null);
|
|
||||||
try {
|
try {
|
||||||
const payload = selected.map((key) => {
|
const payload = selected.map((key) => {
|
||||||
const [type, id] = key.split(':');
|
const [type, id] = key.split(':');
|
||||||
return { type, id };
|
return { type, id };
|
||||||
});
|
});
|
||||||
const resolvedTitle = String(title || '').trim() || defaultTitle;
|
const resolvedTitle = String(title || '').trim() || defaultTitle;
|
||||||
const resp = await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar });
|
await triggerReplay({ sources: payload, title: resolvedTitle, includeSidebar });
|
||||||
if (resp?.jobId) {
|
/*
|
||||||
// The socket acknowledgement is only the start of the async job.
|
Do not set panel-local success state after acknowledgement. The server
|
||||||
// Later replay:status events update this same job id as Discord builds and uploads the video.
|
broadcasts the authoritative accepted/building/uploading/ready stages,
|
||||||
setActiveJobId(resp.jobId);
|
and every replay panel renders that one shared status progression.
|
||||||
setSuccess('Replay accepted.');
|
*/
|
||||||
} else {
|
|
||||||
setSuccess('Replay accepted.');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -245,7 +241,7 @@ export default function ReplaySourcesPanel({
|
|||||||
<ReplayReadyPopup
|
<ReplayReadyPopup
|
||||||
replay={panelReplay}
|
replay={panelReplay}
|
||||||
variant="floating-panel"
|
variant="floating-panel"
|
||||||
onClose={() => setDismissedPanelReplayId(panelReplayJobId)}
|
onClose={() => setPanelReplay(null)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -256,11 +252,11 @@ export default function ReplaySourcesPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
{error ? <div className="text-xs text-amber-400">{error}</div> : null}
|
||||||
{activeJobStatusText ? (
|
{replayStatusText ? (
|
||||||
<div className={`text-xs ${activeReplayJob?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
<div className={`text-xs ${replayStatus?.status === 'failed' ? 'text-amber-400' : 'text-emerald-300'}`}>
|
||||||
{activeJobStatusText}
|
{replayStatusText}
|
||||||
</div>
|
</div>
|
||||||
) : success ? <div className="text-xs text-emerald-300">{success}</div> : null}
|
) : null}
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<label className="surface shrink-0 text-xs" htmlFor={`${panelId}-title`}>
|
<label className="surface shrink-0 text-xs" htmlFor={`${panelId}-title`}>
|
||||||
Replay title:
|
Replay title:
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ const INITIAL_STATE = {
|
|||||||
overseerControlState: null,
|
overseerControlState: null,
|
||||||
overseerMemory: null,
|
overseerMemory: null,
|
||||||
alerts: [],
|
alerts: [],
|
||||||
replayJobs: {},
|
|
||||||
latestReplay: null,
|
latestReplay: null,
|
||||||
latestRequestedReplay: null,
|
replayStatus: null,
|
||||||
duplicateIdentityBlock: null,
|
duplicateIdentityBlock: null,
|
||||||
roverRemovalNotice: null,
|
roverRemovalNotice: null,
|
||||||
};
|
};
|
||||||
@@ -201,62 +200,40 @@ export function SessionProvider({ children }) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
function handleReplayStatus(payload = {}) {
|
function handleReplayStatus(payload = {}) {
|
||||||
if (!payload?.jobId) return;
|
if (!payload?.status) return;
|
||||||
setState((prev) => {
|
setState((prev) => ({
|
||||||
const previous = prev.replayJobs?.[payload.jobId] || {};
|
|
||||||
const nextJob = {
|
|
||||||
...previous,
|
|
||||||
...payload,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...prev,
|
...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.
|
Replay creation is serialized by the server's cooldown and workflow,
|
||||||
replayJobs: {
|
so the browser needs one current progress value rather than a map of
|
||||||
...(prev.replayJobs || {}),
|
jobs. The server remains authoritative for job identity and execution;
|
||||||
[payload.jobId]: nextJob,
|
this scalar exists only to present accepted/building/uploading/ready/
|
||||||
|
failed progress in the web UI.
|
||||||
|
*/
|
||||||
|
replayStatus: {
|
||||||
|
...payload,
|
||||||
|
receivedAt: Date.now(),
|
||||||
},
|
},
|
||||||
};
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function handleReplayReady(payload = {}) {
|
function handleReplayReady(payload = {}) {
|
||||||
if (!payload?.jobId || !payload?.url) return;
|
if (!payload?.jobId || !payload?.url) return;
|
||||||
setState((prev) => {
|
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,
|
|
||||||
...payload,
|
|
||||||
status: 'ready',
|
|
||||||
media: payload,
|
|
||||||
updatedAt: Date.now(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...prev,
|
...prev,
|
||||||
replayJobs: {
|
/*
|
||||||
...(prev.replayJobs || {}),
|
The client needs only the newest completed replay for immediate
|
||||||
[payload.jobId]: nextJob,
|
presentation on spectator and server-display routes. Replay request
|
||||||
},
|
panels listen to the live socket event instead, because retaining media
|
||||||
// Only the latest replay media is retained. Discord is the media host, so
|
for those panels would make an old popup return after a tab remount.
|
||||||
// this state is intentionally short-lived and does not become a replay library.
|
Keeping one media slot here also avoids turning replays into a library.
|
||||||
|
*/
|
||||||
latestReplay: {
|
latestReplay: {
|
||||||
...payload,
|
...payload,
|
||||||
receivedAt: Date.now(),
|
receivedAt: Date.now(),
|
||||||
},
|
},
|
||||||
latestRequestedReplay: requestedByThisBrowser
|
}));
|
||||||
? {
|
|
||||||
...payload,
|
|
||||||
receivedAt: Date.now(),
|
|
||||||
}
|
|
||||||
: prev.latestRequestedReplay,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function handleReplayFailed(payload = {}) {
|
function handleReplayFailed(payload = {}) {
|
||||||
if (payload?.jobId) handleReplayStatus({ ...payload, status: 'failed' });
|
|
||||||
const message = typeof payload?.message === 'string' && payload.message.trim()
|
const message = typeof payload?.message === 'string' && payload.message.trim()
|
||||||
? payload.message.trim()
|
? payload.message.trim()
|
||||||
: 'Replay failed after being accepted.';
|
: 'Replay failed after being accepted.';
|
||||||
@@ -453,13 +430,6 @@ export function SessionProvider({ children }) {
|
|||||||
})),
|
})),
|
||||||
clearLatestReplay: () =>
|
clearLatestReplay: () =>
|
||||||
setState((prev) => (prev.latestReplay ? { ...prev, latestReplay: null } : prev)),
|
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],
|
[emitWithAck, setState],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -155,9 +155,9 @@ export default function SpectatorContent() {
|
|||||||
<AlertFeed />
|
<AlertFeed />
|
||||||
<RewardRunOverlay />
|
<RewardRunOverlay />
|
||||||
{/* Spectators do not have the replay request panel that normal web users see, so
|
{/* 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
|
the spectator route presents the shared latestReplay value directly. Every
|
||||||
uses latestReplay instead of latestRequestedReplay because all spectators should
|
spectator should receive the fullscreen replay when completed media becomes
|
||||||
get the fullscreen replay when a Discord-hosted replay becomes available. */}
|
available, regardless of which browser or transport requested its creation. */}
|
||||||
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
<ReplayReadyPopup replay={latestReplay} onClose={clearLatestReplay} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user