diff --git a/src/viewer/viewer.js b/src/viewer/viewer.js index ffbadb3..7a8ac4e 100644 --- a/src/viewer/viewer.js +++ b/src/viewer/viewer.js @@ -340,17 +340,32 @@ function wireHlsEvents(hls, video, panel) { }); // --- Segment / fragment tracking --- - // FRAG_LOADING fires continuously during playback for each fragment. - // Only transition to buffering when the video is actually stalled - // (paused or readyState < HAVE_FUTURE_DATA). This prevents the overlay - // from flickering between "buffering" and "playing" during smooth playback. + // FRAG_LOADING fires continuously during playback as hls.js proactively + // loads future segments. During smooth playback the browser's MSE buffer + // keeps the video playing without interruption — readyState may briefly + // 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) => { - 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.lastEvent = 'FRAG_LOADING'; + state.lastActivity = Date.now(); + renderPanelState(video, panel); } - state.lastEvent = 'FRAG_LOADING'; - state.lastActivity = Date.now(); - 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) => { @@ -367,6 +382,7 @@ function wireHlsEvents(hls, video, panel) { hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => { state.lastEvent = 'FRAG_BUFFERED'; state.lastActivity = Date.now(); + state.bufferedAhead = getBufferedAhead(video); if (state.phase !== 'error' && video.readyState >= 3 && !video.paused) { state.phase = 'playing'; } @@ -414,7 +430,12 @@ function wireHlsEvents(hls, video, panel) { state.lastEvent = 'BUFFER_RESET'; state.lastActivity = Date.now(); 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); }); @@ -422,13 +443,18 @@ function wireHlsEvents(hls, video, panel) { hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => { state.lastEvent = 'LEVEL_SWITCHED'; state.lastActivity = Date.now(); - state.phase = 'switching'; state.level = data.level; const levels = hls.levels; if (levels && levels[data.level]) { const lvl = levels[data.level]; 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); }); @@ -542,6 +568,11 @@ function wireVideoEvents(video) { onVideoEvent('waiting', s => { s.phase = 'buffering'; s.lastEvent = 'video.waiting'; }); 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 // the browser's timeupdate fires as they are played back sequentially). video.addEventListener('timeupdate', () => { @@ -572,11 +603,26 @@ function wireVideoEvents(video) { * Returns a cleanup function. */ function startBufferMonitor(video, panel) { + let prevPhase = ''; + let prevSegLoaded = -1; + let prevSegFailed = -1; + let prevLabel = ''; const timer = setInterval(() => { const s = panelStates.get(video); if (!s) { clearInterval(timer); return; } s.bufferedAhead = getBufferedAhead(video); - renderPanelState(video, panel); + // 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); + prevPhase = s.phase; + prevSegLoaded = s.segmentsLoaded; + prevSegFailed = s.segmentsFailed; + prevLabel = s.levelLabel; + } // Refresh aggregate status bar if any panel changed state. renderAggregateState(); }, 1000);