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 './viewer_integration.test.mjs';
import './config_validation.test.mjs';
import './hls_qa.test.mjs';
import { run } from './runner.mjs';
const result = await run();
+2 -1
View File
@@ -105,7 +105,7 @@ function m3u8DeepProbeFn() {
const seen = new Set();
function walk(obj, path, depth) {
if (!obj || typeof obj !== 'object' || depth > 5) return;
if (depth > 5) return;
if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi);
if (match) {
@@ -115,6 +115,7 @@ function m3u8DeepProbeFn() {
}
return;
}
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
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.
* 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
* the MediaSource buffer). This module tracks the broken-up nature of
* 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. */
const hlsInstances = new Map();
/**
* Per-panel playback state for fragmented HLS tracking.
*
* 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).
*/
/* ======================================================================== */
/* PanelState — per-panel playback state */
/* ======================================================================== */
/** Map<video, PanelState> — one state object per video panel. */
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.
*/
@@ -95,7 +102,6 @@ function createPanelState() {
lastEvent: 'none',
lastActivity: 0,
errorMessage: null,
// Playback position tracking — updated by timeupdate events.
// HLS delivers fragments out-of-order, but the browser plays them back
// sequentially. currentTime and duration give the user-facing progress.
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) {
const inst = hlsInstances.get(video);
if (inst) {
inst.destroy();
hlsInstances.delete(video);
/**
* 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;
for (let i = 0; i < video.buffered.length; i++) {
if (video.buffered.end(i) > current) {
return video.buffered.end(i) - current;
}
}
panelStates.delete(video);
removePanelOverlay(video);
return 0;
}
/* ======================================================================== */
/* 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.
* 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) {
const state = panelStates.get(video);
@@ -166,40 +190,31 @@ function renderPanelState(video, panel) {
panelOverlays.set(video, overlay);
}
// Phase emoji/indicator
const phaseIcons = {
idle: '⏸',
loading: '⬇',
buffering: '◷',
playing: '▶',
error: '✗',
};
// Build status lines
const lines = [];
lines.push(`${phaseIcons[state.phase] || '?'} ${state.phase}`);
lines.push(`${PHASE_ICONS[state.phase] || '?'} ${state.phase}`);
if (state.level >= 0) {
lines.push(`📺 ${state.levelLabel}`);
lines.push(`\uD83D\uDD7A\uFE0F ${state.levelLabel}`);
}
if (state.segmentsLoaded > 0) {
const segText = state.segmentsFailed > 0
? `📦 ${state.segmentsLoaded} seg (+${state.segmentsFailed} fail)`
: `📦 ${state.segmentsLoaded} seg`;
? `\uD83D\uDCE6 ${state.segmentsLoaded} seg (+${state.segmentsFailed} fail)`
: `\uD83D\uDCE6 ${state.segmentsLoaded} seg`;
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)
if (state.duration > 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) {
lines.push(`⏱ ${state.currentTime.toFixed(1)}s (live/loop)`);
lines.push(`\u23F1 ${state.currentTime.toFixed(1)}s (live/loop)`);
}
if (state.fpsDropCount > 0) {
lines.push(`⚠ ${state.fpsDropCount} fps-drop`);
lines.push(`\u26A0 ${state.fpsDropCount} fps-drop`);
}
if (state.errorMessage) {
lines.push(`❌ ${state.errorMessage}`);
lines.push(`\u274C ${state.errorMessage}`);
}
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
* the MediaSource buffer. Even when the phase is 'playing', the underlying
* state is constantly shifting (buffer growth, quality switches, seg loads).
* This function collapses per-panel state into a single human-readable line
* 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() {
if (!statusElRef) return;
@@ -263,13 +275,13 @@ function renderAggregateState() {
const totalPanels = panelStates.size;
const parts = [];
if (playing > 0) parts.push(`▶ ${playing} playing`);
if (buffering > 0) parts.push(`◷ ${buffering} buffering`);
if (errors > 0) parts.push(`✗ ${errors} error`);
if (idle > 0) parts.push(`⏸ ${idle} idle`);
if (playing > 0) parts.push(`\u25B6 ${playing} playing`);
if (buffering > 0) parts.push(`\u27F7 ${buffering} buffering`);
if (errors > 0) parts.push(`\u2717 ${errors} error`);
if (idle > 0) parts.push(`\u23F8 ${idle} idle`);
if (totalSegs > 0) {
let segPart = `📦 ${totalSegs} seg`;
let segPart = `\uD83D\uDCE6 ${totalSegs} seg`;
if (totalSegFails > 0) segPart += ` (+${totalSegFails} fail)`;
parts.push(segPart);
}
@@ -283,58 +295,28 @@ function renderAggregateState() {
}
const active = playing + buffering;
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)`;
}
/* ======================================================================== */
/* 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.
* 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.
*
* 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();
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;
// --- Manifest lifecycle ---
@@ -493,15 +475,20 @@ function wirePlaybackTracking(hls, video, panel) {
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', () => {
const s = panelStates.get(video);
if (s) {
s.phase = 'playing';
s.lastEvent = 'video.playing';
s.lastActivity = Date.now();
renderPanelState(video, panel);
renderPanelState(video, s._panel);
}
});
@@ -511,7 +498,7 @@ function wirePlaybackTracking(hls, video, panel) {
s.phase = 'buffering';
s.lastEvent = 'video.waiting';
s.lastActivity = Date.now();
renderPanelState(video, panel);
renderPanelState(video, s._panel);
}
});
@@ -521,7 +508,7 @@ function wirePlaybackTracking(hls, video, panel) {
s.phase = 'buffering';
s.lastEvent = 'video.stalled';
s.lastActivity = Date.now();
renderPanelState(video, panel);
renderPanelState(video, s._panel);
}
});
@@ -547,11 +534,69 @@ function wirePlaybackTracking(hls, video, panel) {
if (s) {
s.lastEvent = 'video.seeked';
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.
* 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
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.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).
* Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback.