From d257a19d9d69ec6556c98d7a5cdf530e015ca961 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Tue, 26 May 2026 20:25:40 +0000 Subject: [PATCH] fix(player): buffer indicator + 416 instead of 500 on out-of-range S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mam-api /video endpoint: - S3 InvalidRange (httpStatusCode 416) was being caught and returned as 500 via next(err), which the video element treats as a fatal load error and freezes the player. Now we catch the specific 416 case, do a no-range HEAD-equivalent to learn the real file size, and return proper 416 with Content-Range: bytes */ so the browser can recover. screens-asset.jsx — player health + buffer visualization: - New states: playerState ('idle'|'loading'|'playing'|'paused'|'seeking'| 'waiting'|'stalled'|'error'), playerError, buffered (array of {start,end} ms from HTMLMediaElement.buffered), stallStart, stallElapsedMs - Wired video element events: onProgress, onWaiting, onStalled, onPlaying, onCanPlay, onCanPlayThrough, onSeeking, onSeeked, onError - onError captures MediaError code+message into a console.error and the on-screen badge so freeze causes are now visible - Status badge overlay (top-right of player): shows SEEKING / BUFFERING / STALLED / ERROR + elapsed seconds since the stall began - PlaybackBar renders buffered ranges as translucent grey segments so you can see what the browser has loaded vs. what's still pending — makes seek-related freezes immediately obvious --- services/mam-api/src/routes/assets.js | 30 +++++++- services/web-ui/public/screens-asset.jsx | 98 ++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/services/mam-api/src/routes/assets.js b/services/mam-api/src/routes/assets.js index 3f5a114..5ead4ad 100644 --- a/services/mam-api/src/routes/assets.js +++ b/services/mam-api/src/routes/assets.js @@ -580,13 +580,37 @@ router.get('/:id/video', async (req, res, next) => { const params = { Bucket: getS3Bucket(), Key: key }; const rangeHeader = req.headers.range; if (rangeHeader) params.Range = rangeHeader; - const s3Res = await s3Client.send(new GetObjectCommand(params)); + + let s3Res; + try { + s3Res = await s3Client.send(new GetObjectCommand(params)); + } catch (err) { + // S3 returns InvalidRange (416) when the requested range exceeds the file. + // Forward as a proper 416 with the actual file size so the browser can + // adjust instead of erroring out (which would freeze the player). + if (err.Code === 'InvalidRange' || err.$metadata?.httpStatusCode === 416) { + // Need to know the actual file size — do a HEAD request + try { + const headRes = await s3Client.send(new GetObjectCommand({ Bucket: getS3Bucket(), Key: key })); + const totalSize = headRes.ContentLength || 0; + headRes.Body?.destroy?.(); // close the body stream we don't need + res.writeHead(416, { + 'Content-Type': 'text/plain', + 'Content-Range': `bytes */${totalSize}`, + 'Accept-Ranges': 'bytes', + }); + return res.end('Requested range not satisfiable'); + } catch (_) { + return res.status(416).end('Requested range not satisfiable'); + } + } + throw err; + } + const status = rangeHeader ? 206 : 200; const headers = { 'Content-Type': 'video/mp4', 'Accept-Ranges': 'bytes', - // Cache for 1 hour so the browser reuses buffered segments on seek - // instead of re-fetching. 'private' keeps it out of shared caches. 'Cache-Control': 'private, max-age=3600', }; if (s3Res.ContentLength) headers['Content-Length'] = String(s3Res.ContentLength); diff --git a/services/web-ui/public/screens-asset.jsx b/services/web-ui/public/screens-asset.jsx index cc1ca08..2d1efd2 100644 --- a/services/web-ui/public/screens-asset.jsx +++ b/services/web-ui/public/screens-asset.jsx @@ -38,6 +38,14 @@ function AssetDetail({ asset, onClose }) { const [filmFrames, setFilmFrames] = React.useState([]); const [filmstripLoading, setFilmstripLoading] = React.useState(false); const [filmstripKey, setFilmstripKey] = React.useState(0); + // Player health: 'idle' | 'loading' | 'playing' | 'paused' | 'seeking' | 'waiting' | 'stalled' | 'error' + const [playerState, setPlayerState] = React.useState('idle'); + const [playerError, setPlayerError] = React.useState(null); + // Array of {start, end} in milliseconds — populated from HTMLMediaElement.buffered + const [buffered, setBuffered] = React.useState([]); + // Wall-clock when waiting/stalled began (so we can show how long it's been hung) + const [stallStart, setStallStart] = React.useState(null); + const [stallElapsedMs, setStallElapsedMs] = React.useState(0); const videoRef = React.useRef(null); const assetId = asset && asset.id; @@ -129,6 +137,28 @@ function AssetDetail({ asset, onClose }) { } }; + // Read HTMLMediaElement.buffered into a JSON-friendly array of {start, end} ms. + // Called from onProgress and onTimeUpdate so the UI tracks buffer growth in real time. + const updateBuffered = function() { + const v = videoRef.current; + if (!v || !v.buffered || v.buffered.length === 0) { setBuffered([]); return; } + const ranges = []; + for (let i = 0; i < v.buffered.length; i++) { + ranges.push({ + start: v.buffered.start(i) * 1000, + end: v.buffered.end(i) * 1000, + }); + } + setBuffered(ranges); + }; + + // Tick the stall elapsed counter once per second when we're hung + React.useEffect(() => { + if (!stallStart) { setStallElapsedMs(0); return; } + const i = setInterval(() => setStallElapsedMs(Date.now() - stallStart), 250); + return () => clearInterval(i); + }, [stallStart]); + const seek = function(ms) { const clamped = Math.max(0, Math.min(totalMs || 0, ms)); setCurrentMs(clamped); @@ -339,11 +369,32 @@ function AssetDetail({ asset, onClose }) { ref={videoRef} src={streamType !== 'hls' ? streamUrl : undefined} style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block', background: '#000' }} - onTimeUpdate={function() { if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); }} - onLoadedMetadata={function() { if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); }} - onPlay={function() { setPlaying(true); }} - onPause={function() { setPlaying(false); }} - onEnded={function() { setPlaying(false); }} + onTimeUpdate={function() { + if (videoRef.current) setCurrentMs(videoRef.current.currentTime * 1000); + updateBuffered(); + }} + onLoadedMetadata={function() { + if (videoRef.current) setVideoDuration(videoRef.current.duration * 1000); + setPlayerState('paused'); + }} + onProgress={updateBuffered} + onPlay={function() { setPlaying(true); setPlayerState('playing'); setStallStart(null); }} + onPlaying={function() { setPlayerState('playing'); setStallStart(null); setPlayerError(null); }} + onPause={function() { setPlaying(false); setPlayerState('paused'); setStallStart(null); }} + onSeeking={function() { setPlayerState('seeking'); setStallStart(Date.now()); }} + onSeeked={function() { setStallStart(null); if (videoRef.current?.paused) setPlayerState('paused'); }} + onWaiting={function() { setPlayerState('waiting'); if (!stallStart) setStallStart(Date.now()); }} + onStalled={function() { setPlayerState('stalled'); if (!stallStart) setStallStart(Date.now()); }} + onCanPlay={function() { setStallStart(null); }} + onCanPlayThrough={function() { setStallStart(null); }} + onError={function(e) { + const err = videoRef.current?.error; + const msg = err ? `MediaError code=${err.code} message=${err.message || '(none)'}` : 'unknown error'; + setPlayerState('error'); + setPlayerError(msg); + console.error('[player]', msg, e); + }} + onEnded={function() { setPlaying(false); setPlayerState('paused'); }} /> ) : ( @@ -401,6 +452,23 @@ function AssetDetail({ asset, onClose }) { LIVE · REC )} + {/* Player health badge — shows when waiting/stalled so the freeze is visible */} + {streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && ( +
+ + {playerState === 'seeking' && 'SEEKING'} + {playerState === 'waiting' && 'BUFFERING'} + {playerState === 'stalled' && 'STALLED'} + {playerState === 'error' && 'ERROR'} + {stallElapsedMs > 500 && playerState !== 'error' && ` · ${(stallElapsedMs/1000).toFixed(1)}s`} + + {playerError && ( + + {playerError} + + )} +
+ )} {totalMs > 0 && (
{msToTimecode(currentMs)} @@ -415,7 +483,7 @@ function AssetDetail({ asset, onClose }) { {msToTimecode(currentMs)} - + {asset.duration} {streamUrl && ( @@ -524,7 +592,7 @@ function AssetDetail({ asset, onClose }) { ); } -function PlaybackBar({ current, total, onSeek, comments }) { +function PlaybackBar({ current, total, onSeek, comments, buffered }) { const ref = React.useRef(null); const handle = function(e) { const r = ref.current.getBoundingClientRect(); @@ -532,8 +600,24 @@ function PlaybackBar({ current, total, onSeek, comments }) { onSeek(Math.max(0, Math.min(1, p)) * total); }; const pct = total > 0 ? (current / total) * 100 : 0; + const bufferedRanges = Array.isArray(buffered) ? buffered : []; return (
+ {/* Buffered byte ranges — translucent grey segments showing what the browser has loaded */} + {total > 0 && bufferedRanges.map((br, i) => { + const left = Math.max(0, (br.start / total) * 100); + const right = Math.min(100, (br.end / total) * 100); + if (right <= left) return null; + return ( +
+ ); + })}
{comments.map(function(c) {