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:
parent
f0f615688e
commit
d257a19d9d
2 changed files with 118 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue