fix(player): buffer indicator + 416 instead of 500 on out-of-range S3

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 */<total> 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
This commit is contained in:
Zac Gaetano 2026-05-26 20:25:40 +00:00
parent f0f615688e
commit d257a19d9d
2 changed files with 118 additions and 10 deletions

View file

@ -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);

View file

@ -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'); }}
/>
) : (
<React.Fragment>
@ -401,6 +452,23 @@ function AssetDetail({ asset, onClose }) {
<span className="badge live">LIVE · REC</span>
</div>
)}
{/* Player health badge — shows when waiting/stalled so the freeze is visible */}
{streamUrl && (playerState === 'waiting' || playerState === 'stalled' || playerState === 'seeking' || playerState === 'error') && (
<div style={{ position: "absolute", top: 12, right: 12, display: "flex", gap: 6, alignItems: "center" }}>
<span className={'badge ' + (playerState === 'error' ? 'danger' : playerState === 'stalled' ? 'warning' : 'neutral')}>
{playerState === 'seeking' && 'SEEKING'}
{playerState === 'waiting' && 'BUFFERING'}
{playerState === 'stalled' && 'STALLED'}
{playerState === 'error' && 'ERROR'}
{stallElapsedMs > 500 && playerState !== 'error' && ` · ${(stallElapsedMs/1000).toFixed(1)}s`}
</span>
{playerError && (
<span style={{ fontSize: 10.5, color: 'var(--text-3)', background: 'rgba(0,0,0,0.6)', padding: '2px 6px', borderRadius: 3, fontFamily: 'var(--font-mono)' }}>
{playerError}
</span>
)}
</div>
)}
{totalMs > 0 && (
<div className="player-tc">
<span className="mono">{msToTimecode(currentMs)}</span>
@ -415,7 +483,7 @@ function AssetDetail({ asset, onClose }) {
<Icon name={playing ? "pause" : "play"} size={14} />
</button>
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
<PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
<PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} buffered={buffered} />
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 70, textAlign: "right" }}>{asset.duration}</span>
{streamUrl && (
<React.Fragment>
@ -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 (
<div className="playback-bar" ref={ref} onClick={handle}>
{/* 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 (
<div key={'buf-' + i} style={{
position: 'absolute', top: 0, bottom: 0,
left: left + '%', width: (right - left) + '%',
background: 'rgba(255,255,255,0.18)',
borderRadius: 'inherit',
pointerEvents: 'none',
}} />
);
})}
<div className="playback-fill" style={{ width: pct + '%' }} />
<div className="playback-handle" style={{ left: pct + '%' }} />
{comments.map(function(c) {