autoclose 10s after video over

This commit is contained in:
legop3
2026-06-07 18:51:25 -04:00
parent 503394f1f0
commit d659cbb522
5 changed files with 192 additions and 139 deletions
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
@@ -11,7 +11,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" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-ByArKJJA.js"></script> <script type="module" crossorigin src="/assets/index-C85Tgudp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcnYeuah.css"> <link rel="stylesheet" crossorigin href="/assets/index-BcnYeuah.css">
</head> </head>
<body> <body>
@@ -1,8 +1,12 @@
// Replay Ready Popup // Replay Ready Popup
// Purpose: Presents the latest Discord-hosted replay video to web users as soon as upload completes. // 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. // Scope: Owns the ephemeral modal shell, immediate video loading, and click-outside close behavior.
import { useCallback, useEffect, useRef } from 'react';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
const CLOSE_AFTER_VIDEO_END_MS = 10000;
const CLOSE_IF_VIDEO_NEVER_PLAYS_MS = 20000;
function normalizeUrl(value) { function normalizeUrl(value) {
const text = String(value || '').trim(); const text = String(value || '').trim();
return text || null; return text || null;
@@ -16,7 +20,66 @@ function formatBytes(value) {
} }
export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' }) { export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' }) {
const fallbackCloseTimerRef = useRef(null);
const endedCloseTimerRef = useRef(null);
const onCloseRef = useRef(onClose);
const videoUrl = normalizeUrl(replay?.url); const videoUrl = normalizeUrl(replay?.url);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const clearCloseTimers = useCallback(() => {
if (fallbackCloseTimerRef.current) {
clearTimeout(fallbackCloseTimerRef.current);
fallbackCloseTimerRef.current = null;
}
if (endedCloseTimerRef.current) {
clearTimeout(endedCloseTimerRef.current);
endedCloseTimerRef.current = null;
}
}, []);
const closePopup = useCallback(() => {
// Timers are always cleared before closing so a stale timeout from a previous
// replay cannot close the next replay popup after React reuses this component.
clearCloseTimers();
onCloseRef.current?.();
}, [clearCloseTimers]);
useEffect(() => {
clearCloseTimers();
if (!videoUrl) return undefined;
// A Discord media URL can fail because the attachment URL expired, the browser
// cannot load the remote media, or autoplay never reaches actual playback. This
// fallback keeps both popup variants from getting stuck forever in those cases.
fallbackCloseTimerRef.current = setTimeout(closePopup, CLOSE_IF_VIDEO_NEVER_PLAYS_MS);
return clearCloseTimers;
}, [clearCloseTimers, closePopup, videoUrl]);
const handleVideoPlaying = useCallback(() => {
if (!fallbackCloseTimerRef.current) return;
// Once playback really starts, the video has proven useful. From this point on,
// the popup should stay open until the viewer finishes the replay or closes it.
clearTimeout(fallbackCloseTimerRef.current);
fallbackCloseTimerRef.current = null;
}, []);
const handleVideoEnded = useCallback(() => {
if (endedCloseTimerRef.current) {
clearTimeout(endedCloseTimerRef.current);
}
// Leaving the finished replay visible briefly gives people time to notice the
// final frame and use the Discord/open-video links before the popup cleans itself up.
endedCloseTimerRef.current = setTimeout(closePopup, CLOSE_AFTER_VIDEO_END_MS);
}, [closePopup]);
const videoLifecycleProps = {
onPlaying: handleVideoPlaying,
onEnded: handleVideoEnded,
};
if (!videoUrl) return null; if (!videoUrl) return null;
const isPanel = variant === 'panel'; const isPanel = variant === 'panel';
@@ -57,6 +120,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
autoPlay autoPlay
preload="auto" preload="auto"
playsInline playsInline
{...videoLifecycleProps}
// The floating panel is intentionally compact because it appears for users who // The floating panel is intentionally compact because it appears for users who
// did not ask for the replay. It still loads the video immediately, but its // did not ask for the replay. It still loads the video immediately, but its
// bounded height prevents the floating card from covering too much of the UI. // bounded height prevents the floating card from covering too much of the UI.
@@ -84,6 +148,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
autoPlay autoPlay
preload="auto" preload="auto"
playsInline playsInline
{...videoLifecycleProps}
className={`${isPanel ? 'h-full min-h-[10rem]' : 'aspect-video max-h-[72vh]'} w-full bg-black`} className={`${isPanel ? 'h-full min-h-[10rem]' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
/> />
</div> </div>
@@ -23,8 +23,6 @@ function normalizeSources(list = []) {
.filter(Boolean); .filter(Boolean);
} }
const PANEL_REPLAY_AUTO_CLOSE_MS = 35000;
export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) { export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHeight = false }) {
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []); const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
const mode = useSessionSelector((state) => state.session?.mode || null); const mode = useSessionSelector((state) => state.session?.mode || null);
@@ -193,16 +191,6 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : ''; const listWrapClass = fillHeight ? 'flex-1 min-h-0 overflow-y-auto' : '';
useEffect(() => {
if (!showPanelReplay || !latestReplayJobId) return undefined;
// Non-requester replay previews are intentionally lightweight. They appear automatically
// so web-only users can catch the replay, then clear themselves before blocking the source controls.
const timeoutId = setTimeout(() => {
setDismissedPanelReplayId(latestReplayJobId);
}, PANEL_REPLAY_AUTO_CLOSE_MS);
return () => clearTimeout(timeoutId);
}, [showPanelReplay, latestReplayJobId]);
return ( return (
<div className={`relative ${fillHeight ? 'flex h-full min-h-0 flex-col' : ''}`}> <div className={`relative ${fillHeight ? 'flex h-full min-h-0 flex-col' : ''}`}>
{showPanelReplay ? ( {showPanelReplay ? (