refactor(viewer): modularize HLS playback tracking with per-panel state machine
CI / test (pull_request) Successful in 27s
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:
1 parent
a128babde0
commit
bd093be652
4 files changed
+1327
-120
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+156
-119
@@ -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,12 +52,14 @@ const panelsEl = document.getElementById('panels');
|
||||
/** Track all Hls instances so we can destroy them on unload. */
|
||||
const hlsInstances = new Map();
|
||||
|
||||
/* ======================================================================== */
|
||||
/* PanelState — per-panel playback state */
|
||||
/* ======================================================================== */
|
||||
|
||||
/** Map<video, PanelState> — one state object per video panel. */
|
||||
const panelStates = 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.
|
||||
@@ -77,9 +87,6 @@ const hlsInstances = new Map();
|
||||
* Total media duration in seconds (from video.duration).
|
||||
*/
|
||||
|
||||
/** Map<video, PanelState> — one state object per video panel. */
|
||||
const panelStates = new Map();
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
Reference in new issue
Block a user