better bettter replayses

This commit is contained in:
legop3
2026-06-07 16:39:50 -04:00
parent a15f0697d5
commit 3cff250ba0
16 changed files with 888 additions and 310 deletions
@@ -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>
);