fix: capture HLS segment playback state — prevent fragmented / lost overlay states
CI / test (pull_request) Successful in 22s

Bug fixes:
- FRAG_LOADING: stop updating lastActivity and re-rendering during active
  playback. It fires continuously as hls.js proactively fetches future
  segments, causing overlay flickering between buffering/playing.
- BUFFER_RESET: stop clearing segmentsLoaded. The event fires during
  quality switches when hls.js flushes and rebuilds the MediaSource buffer,
  but segments were already played. Clearing the counter made the overlay
  show '0 seg' mid-playback.
- FRAG_BUFFERED: add bufferedAhead update immediately after a fragment is
  buffered, instead of waiting for the next 1s monitor tick.
- LEVEL_SWITCHED: only set phase='switching' when buffer is low (< 2s).
  With meaningful buffer the switch is instant (already-buffered segments),
  so skip the intermediate state to avoid panels getting stuck.
- Buffer monitor: add change-detection so renderPanelState only fires when
  phase, segments, or quality actually changed — not every second.
- wireVideoEvents: add 'ended' event handler for completeness.
This commit is contained in:
robot committed 2026-08-13 06:29:22 +00:00
1 parent 8e9f7f96f1
commit b19035ad29
1 file changed
+54 -8
+54 -8
View File
@@ -340,17 +340,32 @@ function wireHlsEvents(hls, video, panel) {
}); });
// --- Segment / fragment tracking --- // --- Segment / fragment tracking ---
// FRAG_LOADING fires continuously during playback for each fragment. // FRAG_LOADING fires continuously during playback as hls.js proactively
// Only transition to buffering when the video is actually stalled // loads future segments. During smooth playback the browser's MSE buffer
// (paused or readyState < HAVE_FUTURE_DATA). This prevents the overlay // keeps the video playing without interruption — readyState may briefly
// from flickering between "buffering" and "playing" during smooth playback. // dip to HAVE_CURRENT_DATA (2) between fragments even though the user
// sees seamless playback. If we set phase='buffering' on every
// FRAG_LOADING, the overlay flickers between "buffering" and "playing".
//
// Fix: Only transition to 'buffering' from FRAG_LOADING during initial
// load states (idle/loading). Once the video reaches 'playing', 'buffering',
// or 'switching', defer to the video element's native events
// (waiting/stalled/playing — wired in wireVideoEvents) as the authoritative
// source for buffering state. Those native events only fire when actual
// stalling occurs, not during normal proactive fragment fetching.
hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => { hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => {
if (video.paused || video.readyState < 3) { // Only set buffering during initial load. During active playback the
// native 'waiting'/'stalled' events (see wireVideoEvents) are the
// source of truth for when the video is actually stalled.
if (state.phase === 'idle' || state.phase === 'loading') {
state.phase = 'buffering'; state.phase = 'buffering';
}
state.lastEvent = 'FRAG_LOADING'; state.lastEvent = 'FRAG_LOADING';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, panel);
}
// During playing/buffering/switching: ignore FRAG_LOADING entirely —
// it fires continuously as hls.js proactively fetches future segments.
// Re-rendering here causes overlay flickering even during smooth playback.
}); });
hls.on(HlsEvents.FRAG_LOADED, (_ev, _data) => { hls.on(HlsEvents.FRAG_LOADED, (_ev, _data) => {
@@ -367,6 +382,7 @@ function wireHlsEvents(hls, video, panel) {
hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => { hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => {
state.lastEvent = 'FRAG_BUFFERED'; state.lastEvent = 'FRAG_BUFFERED';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
state.bufferedAhead = getBufferedAhead(video);
if (state.phase !== 'error' && video.readyState >= 3 && !video.paused) { if (state.phase !== 'error' && video.readyState >= 3 && !video.paused) {
state.phase = 'playing'; state.phase = 'playing';
} }
@@ -414,7 +430,12 @@ function wireHlsEvents(hls, video, panel) {
state.lastEvent = 'BUFFER_RESET'; state.lastEvent = 'BUFFER_RESET';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
state.bufferedAhead = 0; state.bufferedAhead = 0;
state.segmentsLoaded = 0; // Do NOT reset segmentsLoaded — it's a cumulative metric across the
// entire session. BUFFER_RESET fires during quality switches (hls.js
// flushes and rebuilds the MediaSource buffer) but the segments were
// still successfully loaded and played. Clearing it here makes the
// overlay show "0 seg" mid-playback, which is the exact "fragmented /
// lost state" the user sees.
renderPanelState(video, panel); renderPanelState(video, panel);
}); });
@@ -422,13 +443,18 @@ function wireHlsEvents(hls, video, panel) {
hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => { hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => {
state.lastEvent = 'LEVEL_SWITCHED'; state.lastEvent = 'LEVEL_SWITCHED';
state.lastActivity = Date.now(); state.lastActivity = Date.now();
state.phase = 'switching';
state.level = data.level; state.level = data.level;
const levels = hls.levels; const levels = hls.levels;
if (levels && levels[data.level]) { if (levels && levels[data.level]) {
const lvl = levels[data.level]; const lvl = levels[data.level];
state.levelLabel = `${lvl.width}x${lvl.height}`; state.levelLabel = `${lvl.width}x${lvl.height}`;
} }
// Briefly show 'switching' to indicate a quality transition is in progress.
// However, if there's meaningful buffer ahead, the switch is likely instant
// (switching between already-buffered segments) — skip the intermediate state
// to avoid the panel getting stuck at 'switching' when FRAG_BUFFERED doesn't fire.
const buf = getBufferedAhead(video);
state.phase = buf > 2 ? 'playing' : 'switching';
renderPanelState(video, panel); renderPanelState(video, panel);
}); });
@@ -542,6 +568,11 @@ function wireVideoEvents(video) {
onVideoEvent('waiting', s => { s.phase = 'buffering'; s.lastEvent = 'video.waiting'; }); onVideoEvent('waiting', s => { s.phase = 'buffering'; s.lastEvent = 'video.waiting'; });
onVideoEvent('stalled', s => { s.phase = 'buffering'; s.lastEvent = 'video.stalled'; }); onVideoEvent('stalled', s => { s.phase = 'buffering'; s.lastEvent = 'video.stalled'; });
// Video ended — on looped content this rarely fires (loop re-starts instantly),
// but handle it for completeness. The 'ended' phase appears in the overlay
// and aggregate state so the user knows playback reached the end of a segment.
onVideoEvent('ended', s => { s.phase = 'ended'; s.lastEvent = 'video.ended'; });
// Track actual playback position (fragments are delivered out of order but // Track actual playback position (fragments are delivered out of order but
// the browser's timeupdate fires as they are played back sequentially). // the browser's timeupdate fires as they are played back sequentially).
video.addEventListener('timeupdate', () => { video.addEventListener('timeupdate', () => {
@@ -572,11 +603,26 @@ function wireVideoEvents(video) {
* Returns a cleanup function. * Returns a cleanup function.
*/ */
function startBufferMonitor(video, panel) { function startBufferMonitor(video, panel) {
let prevPhase = '';
let prevSegLoaded = -1;
let prevSegFailed = -1;
let prevLabel = '';
const timer = setInterval(() => { const timer = setInterval(() => {
const s = panelStates.get(video); const s = panelStates.get(video);
if (!s) { clearInterval(timer); return; } if (!s) { clearInterval(timer); return; }
s.bufferedAhead = getBufferedAhead(video); s.bufferedAhead = getBufferedAhead(video);
// Only re-render if something actually changed — avoids overlay flickering
// on stably playing panels where only lastActivity/lastEvent differ.
if (s.phase !== prevPhase ||
s.segmentsLoaded !== prevSegLoaded ||
s.segmentsFailed !== prevSegFailed ||
s.levelLabel !== prevLabel) {
renderPanelState(video, panel); renderPanelState(video, panel);
prevPhase = s.phase;
prevSegLoaded = s.segmentsLoaded;
prevSegFailed = s.segmentsFailed;
prevLabel = s.levelLabel;
}
// Refresh aggregate status bar if any panel changed state. // Refresh aggregate status bar if any panel changed state.
renderAggregateState(); renderAggregateState();
}, 1000); }, 1000);