feat: full HLS.js support — detection via interceptors + deep probe, playback via hls.js in viewer #2

Merged
kfj001 merged 15 commits from hls-fix into master 2026-08-23 18:22:42 -07:00
Showing only changes of commit b19035ad29 - Show all commits
+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);