mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
better bettter replayses
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user