refactor(viewer): modularize HLS playback tracking with per-panel state machine
CI / test (pull_request) Successful in 27s

Decompose monolithic wirePlaybackTracking() into single-responsibility modules:

- PanelState: per-panel playback state (phase, segments, buffer, quality)
- EventWiring: attaches hls.js + video element event listeners
- BufferMonitor: periodic buffer health check with timer lifecycle
- PanelRenderer: per-panel overlay rendering (phase, quality, segments)
- AggregateRenderer: global status bar aggregating all panel states
- PanelFactory: DOM panel creation + HLS/direct playback routing

Each module handles one concern: state tracking, event registration,
buffer monitoring, overlay rendering, aggregate rendering, or DOM creation.

Also fix: walk() dead code bug in m3u8DeepProbeFn where the string check
was unreachable behind the object-type guard.
This commit is contained in:
robot committed 2026-08-12 13:43:42 +00:00
1 parent a128babde0
commit bd093be652
4 files changed
+1351 -144

No files matched your search

File diff suppressed because it is too large. Load diff
+1
View File
@@ -6,6 +6,7 @@ import './viewer_units.test.mjs';
import './data_flow.test.mjs'; import './data_flow.test.mjs';
import './viewer_integration.test.mjs'; import './viewer_integration.test.mjs';
import './config_validation.test.mjs'; import './config_validation.test.mjs';
import './hls_qa.test.mjs';
import { run } from './runner.mjs'; import { run } from './runner.mjs';
const result = await run(); const result = await run();
+2 -1
View File
@@ -105,7 +105,7 @@ function m3u8DeepProbeFn() {
const seen = new Set(); const seen = new Set();
function walk(obj, path, depth) { function walk(obj, path, depth) {
if (!obj || typeof obj !== 'object' || depth > 5) return; if (depth > 5) return;
if (typeof obj === 'string') { if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi); const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi);
if (match) { if (match) {
@@ -115,6 +115,7 @@ function m3u8DeepProbeFn() {
} }
return; return;
} }
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) { for (const k of Object.keys(obj)) {
walk(obj[k], `${path}.${k}`, depth + 1); walk(obj[k], `${path}.${k}`, depth + 1);
} }
+180 -143
View File
@@ -3,7 +3,15 @@
* Reads video URLs from chrome.storage and renders them in a dynamic NxN grid. * Reads video URLs from chrome.storage and renders them in a dynamic NxN grid.
* Supports HLS playback via hls.js (dynamic CDN load) and direct MP4/WebM. * Supports HLS playback via hls.js (dynamic CDN load) and direct MP4/WebM.
* *
* Fragmented playback tracking: * Modular architecture:
*
* PanelState — per-panel playback state (phase, segments, buffer, quality)
* EventWiring — attaches hls.js + video element event listeners
* BufferMonitor — periodic buffer health check + timer lifecycle
* PanelRenderer — per-panel overlay rendering (phase, quality, segments)
* AggregateRenderer — global status bar aggregating all panel states
* PanelFactory — DOM panel creation + HLS/direct playback routing
*
* HLS delivery is inherently segmented (fragments load one at a time into * HLS delivery is inherently segmented (fragments load one at a time into
* the MediaSource buffer). This module tracks the broken-up nature of * the MediaSource buffer). This module tracks the broken-up nature of
* playback by wiring hls.js events for fragment loading, buffer updates, * playback by wiring hls.js events for fragment loading, buffer updates,
@@ -44,42 +52,41 @@ const panelsEl = document.getElementById('panels');
/** Track all Hls instances so we can destroy them on unload. */ /** Track all Hls instances so we can destroy them on unload. */
const hlsInstances = new Map(); const hlsInstances = new Map();
/** /* ======================================================================== */
* Per-panel playback state for fragmented HLS tracking. /* PanelState — per-panel playback state */
* /* ======================================================================== */
* Each entry tracks the lifecycle of an HLS stream from manifest load
* through fragment arrivals, buffer growth, and quality switches.
/**
* @typedef {object} PanelState
* @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase
* Current playback phase of this panel.
* @property {number} segmentsLoaded
* Count of FRAG_LOADED events since panel creation.
* @property {number} segmentsFailed
* Count of non-fatal FRAG_LOAD_ERROR events.
* @property {number} level
* Current quality level index (-1 until MANIFEST_PARSED).
* @property {string} levelLabel
* Human-readable quality label (e.g. "1280x720").
* @property {number} bufferedAhead
* Seconds of content buffered ahead of current playback position.
* @property {number} fpsDropCount
* Count of FPS_DROP events (indicates GPU / decoding pressure).
* @property {string} lastEvent
* Most recent hls.js event name (for debug).
* @property {number} lastActivity
* Timestamp (Date.now) of the most recent event activity.
* @property {string|null} errorMessage
* Last error message, cleared on recovery.
* @property {number} currentTime
* Current playback position in seconds (from video.currentTime).
* @property {number} duration
* Total media duration in seconds (from video.duration).
*/
/** Map<video, PanelState> — one state object per video panel. */ /** Map<video, PanelState> — one state object per video panel. */
const panelStates = new Map(); const panelStates = new Map();
/**
* @typedef {object} PanelState
* @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase
* Current playback phase of this panel.
* @property {number} segmentsLoaded
* Count of FRAG_LOADED events since panel creation.
* @property {number} segmentsFailed
* Count of non-fatal FRAG_LOAD_ERROR events.
* @property {number} level
* Current quality level index (-1 until MANIFEST_PARSED).
* @property {string} levelLabel
* Human-readable quality label (e.g. "1280x720").
* @property {number} bufferedAhead
* Seconds of content buffered ahead of current playback position.
* @property {number} fpsDropCount
* Count of FPS_DROP events (indicates GPU / decoding pressure).
* @property {string} lastEvent
* Most recent hls.js event name (for debug).
* @property {number} lastActivity
* Timestamp (Date.now) of the most recent event activity.
* @property {string|null} errorMessage
* Last error message, cleared on recovery.
* @property {number} currentTime
* Current playback position in seconds (from video.currentTime).
* @property {number} duration
* Total media duration in seconds (from video.duration).
*/
/** /**
* Initialize a fresh PanelState for a video element. * Initialize a fresh PanelState for a video element.
*/ */
@@ -95,7 +102,6 @@ function createPanelState() {
lastEvent: 'none', lastEvent: 'none',
lastActivity: 0, lastActivity: 0,
errorMessage: null, errorMessage: null,
// Playback position tracking — updated by timeupdate events.
// HLS delivers fragments out-of-order, but the browser plays them back // HLS delivers fragments out-of-order, but the browser plays them back
// sequentially. currentTime and duration give the user-facing progress. // sequentially. currentTime and duration give the user-facing progress.
currentTime: 0, currentTime: 0,
@@ -104,22 +110,26 @@ function createPanelState() {
} }
/* ======================================================================== */ /* ======================================================================== */
/* HLS helpers */ /* Buffer helpers */
/* ======================================================================== */ /* ======================================================================== */
/** Destroy the Hls instance attached to a video element (if any). */ /**
function destroyHls(video) { * Compute buffered seconds ahead of current playback position.
const inst = hlsInstances.get(video); * Returns 0 if the video has no buffered ranges or is not playing.
if (inst) { */
inst.destroy(); function getBufferedAhead(video) {
hlsInstances.delete(video); if (!video.buffered || video.buffered.length === 0) return 0;
const current = video.currentTime;
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.end(i) > current) {
return video.buffered.end(i) - current;
}
} }
panelStates.delete(video); return 0;
removePanelOverlay(video);
} }
/* ======================================================================== */ /* ======================================================================== */
/* Panel overlay helpers */ /* PanelRenderer — per-panel overlay rendering */
/* ======================================================================== */ /* ======================================================================== */
/** /**
@@ -152,9 +162,23 @@ function removePanelOverlay(video) {
} }
} }
/**
* Phase emoji indicators for overlay display.
*/
const PHASE_ICONS = {
idle: '\u23F8', // pause
loading: '\u2B07', // down arrow
buffering: '\u27F7', // spinning
playing: '\u25B6', // play
error: '\u2717', // X
};
/** /**
* Render the current PanelState into the overlay. * Render the current PanelState into the overlay.
* Shows: phase indicator, quality, segments loaded/failed, buffer ahead. * Shows: phase indicator, quality, segments loaded/failed, buffer ahead.
*
* This function is intentionally rendering-only — it reads state, formats it,
* and writes to the DOM. No side effects beyond setting aggregateStateDirty.
*/ */
function renderPanelState(video, panel) { function renderPanelState(video, panel) {
const state = panelStates.get(video); const state = panelStates.get(video);
@@ -166,40 +190,31 @@ function renderPanelState(video, panel) {
panelOverlays.set(video, overlay); panelOverlays.set(video, overlay);
} }
// Phase emoji/indicator
const phaseIcons = {
idle: '⏸',
loading: '⬇',
buffering: '◷',
playing: '▶',
error: '✗',
};
// Build status lines // Build status lines
const lines = []; const lines = [];
lines.push(`${phaseIcons[state.phase] || '?'} ${state.phase}`); lines.push(`${PHASE_ICONS[state.phase] || '?'} ${state.phase}`);
if (state.level >= 0) { if (state.level >= 0) {
lines.push(`📺 ${state.levelLabel}`); lines.push(`\uD83D\uDD7A\uFE0F ${state.levelLabel}`);
} }
if (state.segmentsLoaded > 0) { if (state.segmentsLoaded > 0) {
const segText = state.segmentsFailed > 0 const segText = state.segmentsFailed > 0
? `📦 ${state.segmentsLoaded} seg (+${state.segmentsFailed} fail)` ? `\uD83D\uDCE6 ${state.segmentsLoaded} seg (+${state.segmentsFailed} fail)`
: `📦 ${state.segmentsLoaded} seg`; : `\uD83D\uDCE6 ${state.segmentsLoaded} seg`;
lines.push(segText); lines.push(segText);
} }
lines.push(`💾 ${state.bufferedAhead.toFixed(1)}s buf`); lines.push(`\uD83D\uDCBE ${state.bufferedAhead.toFixed(1)}s buf`);
// Playback progress: currentTime / duration (only when known) // Playback progress: currentTime / duration (only when known)
if (state.duration > 0) { if (state.duration > 0) {
const pct = ((state.currentTime / state.duration) * 100).toFixed(0); const pct = ((state.currentTime / state.duration) * 100).toFixed(0);
lines.push(`⏱ ${state.currentTime.toFixed(1)}s / ${state.duration.toFixed(0)}s (${pct}%)`); lines.push(`\u23F1 ${state.currentTime.toFixed(1)}s / ${state.duration.toFixed(0)}s (${pct}%)`);
} else if (state.currentTime > 0) { } else if (state.currentTime > 0) {
lines.push(`⏱ ${state.currentTime.toFixed(1)}s (live/loop)`); lines.push(`\u23F1 ${state.currentTime.toFixed(1)}s (live/loop)`);
} }
if (state.fpsDropCount > 0) { if (state.fpsDropCount > 0) {
lines.push(`⚠ ${state.fpsDropCount} fps-drop`); lines.push(`\u26A0 ${state.fpsDropCount} fps-drop`);
} }
if (state.errorMessage) { if (state.errorMessage) {
lines.push(`❌ ${state.errorMessage}`); lines.push(`\u274C ${state.errorMessage}`);
} }
overlay.textContent = lines.join(' '); overlay.textContent = lines.join(' ');
@@ -219,24 +234,21 @@ function renderPanelState(video, panel) {
} }
} }
/* ======================================================================== */
/* AggregateRenderer — global status bar */
/* ======================================================================== */
let statusElRef = null;
/** /**
* Aggregate playback status bar. * Render aggregate playback state into the top status bar.
* Format: `2 playing | 1 buffering | 1 error | 47 seg total`
* *
* HLS delivery is inherently segmented — fragments load one at a time into * HLS delivery is inherently segmented — fragments load one at a time into
* the MediaSource buffer. Even when the phase is 'playing', the underlying * the MediaSource buffer. Even when the phase is 'playing', the underlying
* state is constantly shifting (buffer growth, quality switches, seg loads). * state is constantly shifting (buffer growth, quality switches, seg loads).
* This function collapses per-panel state into a single human-readable line * This function collapses per-panel state into a single human-readable line
* so the user can see overall health at a glance. * so the user can see overall health at a glance.
*
* Wires up to `aggregateStateDirty` — set by `renderPanelState` whenever
* a panel's state changes. The buffer-refresh tick checks the flag and
* re-renders only when necessary to keep CPU usage low.
*/
let statusElRef = null;
/**
* Render aggregate playback state into the top status bar.
* Format: `▶ 2 playing | ◷ 1 buffering | ✗ 1 error | 📦 47 seg total`
*/ */
function renderAggregateState() { function renderAggregateState() {
if (!statusElRef) return; if (!statusElRef) return;
@@ -263,13 +275,13 @@ function renderAggregateState() {
const totalPanels = panelStates.size; const totalPanels = panelStates.size;
const parts = []; const parts = [];
if (playing > 0) parts.push(`▶ ${playing} playing`); if (playing > 0) parts.push(`\u25B6 ${playing} playing`);
if (buffering > 0) parts.push(`◷ ${buffering} buffering`); if (buffering > 0) parts.push(`\u27F7 ${buffering} buffering`);
if (errors > 0) parts.push(`✗ ${errors} error`); if (errors > 0) parts.push(`\u2717 ${errors} error`);
if (idle > 0) parts.push(`⏸ ${idle} idle`); if (idle > 0) parts.push(`\u23F8 ${idle} idle`);
if (totalSegs > 0) { if (totalSegs > 0) {
let segPart = `📦 ${totalSegs} seg`; let segPart = `\uD83D\uDCE6 ${totalSegs} seg`;
if (totalSegFails > 0) segPart += ` (+${totalSegFails} fail)`; if (totalSegFails > 0) segPart += ` (+${totalSegFails} fail)`;
parts.push(segPart); parts.push(segPart);
} }
@@ -283,58 +295,28 @@ function renderAggregateState() {
} }
const active = playing + buffering; const active = playing + buffering;
if (active > 0) { if (active > 0) {
parts.push(`💾 ${(totalBuf / active).toFixed(1)}s avg buf`); parts.push(`\uD83D\uDCBE ${(totalBuf / active).toFixed(1)}s avg buf`);
} }
statusElRef.textContent = `Live grid ${totalPanels} panels: ${parts.join(' | ')} || Found ${totalPanels} video(s)`; statusElRef.textContent = `Live grid ${totalPanels} panels: ${parts.join(' | ')} || Found ${totalPanels} video(s)`;
} }
/* ======================================================================== */ /* ======================================================================== */
/* Fragmented playback tracker */ /* EventWiring — hls.js + video element event listeners */
/* ======================================================================== */ /* ======================================================================== */
/**
* Compute buffered seconds ahead of current playback position.
* Returns 0 if the video has no buffered ranges or is not playing.
*/
function getBufferedAhead(video) {
if (!video.buffered || video.buffered.length === 0) return 0;
const current = video.currentTime;
// Find the buffer range that contains or is ahead of current time
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.end(i) > current) {
return video.buffered.end(i) - current;
}
}
return 0;
}
/** /**
* Attach hls.js event listeners for fragmented playback tracking. * Attach hls.js event listeners for fragmented playback tracking.
* This wires up every event that reveals the broken-up nature of HLS: * Wires up every event that reveals the broken-up nature of HLS:
* segment loading, buffer mutations, quality switches, and decode issues. * segment loading, buffer mutations, quality switches, and decode issues.
*
* Side effects: registers callbacks on `hls`, creates `PanelState`,
* sets up buffer monitor timer, patches hls.destroy for cleanup.
*/ */
function wirePlaybackTracking(hls, video, panel) { function wireHlsEvents(hls, video, panel) {
const state = createPanelState(); const state = createPanelState();
panelStates.set(video, state); panelStates.set(video, state);
// Periodically update buffer health and re-render overlay
const bufferTimer = setInterval(() => {
const s = panelStates.get(video);
if (!s) { clearInterval(bufferTimer); return; }
s.bufferedAhead = getBufferedAhead(video);
renderPanelState(video, panel);
// Refresh aggregate status bar if any panel changed state.
renderAggregateState();
}, 1000);
// Clean up timer on destroy (monkey-patch hls.destroy)
const origDestroy = hls.destroy.bind(hls);
hls.destroy = function () {
clearInterval(bufferTimer);
origDestroy();
};
const HlsEvents = window.Hls.Events; const HlsEvents = window.Hls.Events;
// --- Manifest lifecycle --- // --- Manifest lifecycle ---
@@ -493,15 +475,20 @@ function wirePlaybackTracking(hls, video, panel) {
hlsInstances.delete(video); hlsInstances.delete(video);
} }
}); });
}
// --- Video element events (complement hls.js) --- /**
* Wire video element lifecycle events (playing, waiting, stalled, etc.).
* These complement hls.js events by providing browser-level playback state.
*/
function wireVideoEvents(video) {
video.addEventListener('playing', () => { video.addEventListener('playing', () => {
const s = panelStates.get(video); const s = panelStates.get(video);
if (s) { if (s) {
s.phase = 'playing'; s.phase = 'playing';
s.lastEvent = 'video.playing'; s.lastEvent = 'video.playing';
s.lastActivity = Date.now(); s.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, s._panel);
} }
}); });
@@ -511,7 +498,7 @@ function wirePlaybackTracking(hls, video, panel) {
s.phase = 'buffering'; s.phase = 'buffering';
s.lastEvent = 'video.waiting'; s.lastEvent = 'video.waiting';
s.lastActivity = Date.now(); s.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, s._panel);
} }
}); });
@@ -521,7 +508,7 @@ function wirePlaybackTracking(hls, video, panel) {
s.phase = 'buffering'; s.phase = 'buffering';
s.lastEvent = 'video.stalled'; s.lastEvent = 'video.stalled';
s.lastActivity = Date.now(); s.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, s._panel);
} }
}); });
@@ -547,11 +534,69 @@ function wirePlaybackTracking(hls, video, panel) {
if (s) { if (s) {
s.lastEvent = 'video.seeked'; s.lastEvent = 'video.seeked';
s.lastActivity = Date.now(); s.lastActivity = Date.now();
renderPanelState(video, panel); renderPanelState(video, s._panel);
} }
}); });
} }
/* ======================================================================== */
/* BufferMonitor — periodic buffer health check */
/* ======================================================================== */
/**
* Start a periodic timer that refreshes buffer health and re-renders overlays.
* Returns a cleanup function.
*/
function startBufferMonitor(video, panel) {
const timer = setInterval(() => {
const s = panelStates.get(video);
if (!s) { clearInterval(timer); return; }
s.bufferedAhead = getBufferedAhead(video);
renderPanelState(video, panel);
// Refresh aggregate status bar if any panel changed state.
renderAggregateState();
}, 1000);
return () => clearInterval(timer);
}
/* ======================================================================== */
/* HLS lifecycle helpers */
/* ======================================================================== */
/** Destroy the Hls instance attached to a video element (if any). */
function destroyHls(video) {
const inst = hlsInstances.get(video);
if (inst) {
inst.destroy();
hlsInstances.delete(video);
}
panelStates.delete(video);
removePanelOverlay(video);
}
/* ======================================================================== */
/* PanelFactory — DOM panel creation */
/* ======================================================================== */
/** Show an error message inside a panel's video placeholder. */
function showError(panel, message) {
const err = document.createElement('div');
err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;';
err.textContent = message;
panel.appendChild(err);
}
/** Create a video element with standard autoplay attributes. */
function createVideoElement() {
const video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
return video;
}
/** /**
* Play an HLS manifest on a <video> element using hls.js or native Safari. * Play an HLS manifest on a <video> element using hls.js or native Safari.
* Wires up fragmented playback tracking for segment/buffer/quality visibility. * Wires up fragmented playback tracking for segment/buffer/quality visibility.
@@ -575,7 +620,21 @@ function playHls(video, manifestUrl, panel) {
}); });
// Wire all fragmented playback tracking events // Wire all fragmented playback tracking events
wirePlaybackTracking(hls, video, panel); wireHlsEvents(hls, video, panel);
// Wire video element events (playing, waiting, stalled, timeupdate, etc.)
// Store panel ref on state so video event handlers can access it.
const state = panelStates.get(video);
if (state) state._panel = panel;
wireVideoEvents(video);
// Start periodic buffer health monitor
const stopMonitor = startBufferMonitor(video, panel);
const origDestroy = hls.destroy.bind(hls);
hls.destroy = function () {
stopMonitor();
origDestroy();
};
hls.loadSource(manifestUrl); hls.loadSource(manifestUrl);
hls.attachMedia(video); hls.attachMedia(video);
@@ -598,28 +657,6 @@ function loadHlsJs() {
}); });
} }
/* ======================================================================== */
/* UI helpers */
/* ======================================================================== */
/** Show an error message inside a panel's video placeholder. */
function showError(panel, message) {
const err = document.createElement('div');
err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;';
err.textContent = message;
panel.appendChild(err);
}
/** Create a video element with standard autoplay attributes. */
function createVideoElement() {
const video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
return video;
}
/** /**
* Create a single panel (video or empty). * Create a single panel (video or empty).
* Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback. * Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback.