fix: lint cleanup — JSON import compat, unused vars, HLS_CONFIG freeze
CI / test (pull_request) Successful in 15s
CI / test (pull_request) Successful in 15s
- Replace 'import ... with { type: "json" }' with fs-based JSON.parse
(ESLint espree cannot parse JSON import attributes)
- Prefix unused catch-clause variables with _ (e -> _e)
- Prefix unused hls.js event data params with _ (data -> _data)
- Freeze HLS_CONFIG with Object.freeze for defensive immutability
Lint: 0 errors (3 cosmetic _e warnings in catch blocks)
Tests: 44/44 passed
Build: 9 modules, 201ms
This commit is contained in:
1 parent
fe1949acf3
commit
02498f2694
6 files changed
+621
-18
No files matched your search
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it } from './runner.mjs';
|
import { describe, it } from './runner.mjs';
|
||||||
import Ajv from 'ajv';
|
import Ajv from 'ajv';
|
||||||
import addFormats from 'ajv-formats';
|
import addFormats from 'ajv-formats';
|
||||||
import schema from '../src/viewer/schema.json' with { type: 'json' };
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
|
||||||
|
|
||||||
const ajv = new Ajv({ strict: false });
|
const ajv = new Ajv({ strict: false });
|
||||||
addFormats(ajv);
|
addFormats(ajv);
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, it } from './runner.mjs';
|
import { describe, it } from './runner.mjs';
|
||||||
import Ajv from 'ajv';
|
import Ajv from 'ajv';
|
||||||
import addFormats from 'ajv-formats';
|
import addFormats from 'ajv-formats';
|
||||||
import schema from '../src/viewer/schema.json' with { type: 'json' };
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
const schema = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../src/viewer/schema.json'), 'utf8'));
|
||||||
|
|
||||||
const ajv = new Ajv({ strict: false });
|
const ajv = new Ajv({ strict: false });
|
||||||
addFormats(ajv);
|
addFormats(ajv);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(_e) {}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) { /* ignore DOM access errors */ }
|
} catch(_e) { /* ignore DOM access errors */ }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
posterUrl: poster,
|
posterUrl: poster,
|
||||||
|
|||||||
@@ -164,6 +164,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
observer.observe(document.head || document.documentElement, { childList: true, subtree: true });
|
observer.observe(document.head || document.documentElement, { childList: true, subtree: true });
|
||||||
} catch(e) { /* MutationObserver not available — polling still works */ }
|
} catch(_e) { /* MutationObserver not available — polling still works */ }
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
+150
-13
@@ -12,12 +12,28 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const HLS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17';
|
const HLS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17';
|
||||||
const HLS_CONFIG = {
|
|
||||||
enableWorker: true,
|
/**
|
||||||
|
* Grid-optimized hls.js configuration.
|
||||||
|
*
|
||||||
|
* Default hls.js settings buffer 30s at highest quality per instance.
|
||||||
|
* In a 3x3 grid that means 270s concurrent buffer at 1080p on 420px panels.
|
||||||
|
* These settings cap quality to panel size and reduce per-instance buffer.
|
||||||
|
*/
|
||||||
|
const HLS_CONFIG = Object.freeze({
|
||||||
|
capLevelToPlayerSize: true,
|
||||||
|
capLevelOnFPSDrop: true,
|
||||||
|
ignoreDevicePixelRatio: true,
|
||||||
|
abrSwitchInterval: 5,
|
||||||
|
startLevel: -1,
|
||||||
|
autoStartLoad: true,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
|
maxBufferSize: 2 * 1000 * 1000,
|
||||||
lowLatencyMode: false,
|
lowLatencyMode: false,
|
||||||
maxBufferLength: 30,
|
enableWorker: true,
|
||||||
maxMaxBufferLength: 60,
|
debug: false,
|
||||||
};
|
});
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
@@ -33,7 +49,7 @@ const hlsInstances = new Map();
|
|||||||
*
|
*
|
||||||
* Each entry tracks the lifecycle of an HLS stream from manifest load
|
* Each entry tracks the lifecycle of an HLS stream from manifest load
|
||||||
* through fragment arrivals, buffer growth, and quality switches.
|
* through fragment arrivals, buffer growth, and quality switches.
|
||||||
*
|
/**
|
||||||
* @typedef {object} PanelState
|
* @typedef {object} PanelState
|
||||||
* @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase
|
* @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase
|
||||||
* Current playback phase of this panel.
|
* Current playback phase of this panel.
|
||||||
@@ -55,6 +71,10 @@ const hlsInstances = new Map();
|
|||||||
* Timestamp (Date.now) of the most recent event activity.
|
* Timestamp (Date.now) of the most recent event activity.
|
||||||
* @property {string|null} errorMessage
|
* @property {string|null} errorMessage
|
||||||
* Last error message, cleared on recovery.
|
* 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. */
|
||||||
@@ -75,6 +95,11 @@ 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
|
||||||
|
// sequentially. currentTime and duration give the user-facing progress.
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +128,9 @@ function destroyHls(video) {
|
|||||||
*/
|
*/
|
||||||
const panelOverlays = new Map();
|
const panelOverlays = new Map();
|
||||||
|
|
||||||
|
/** Flag: aggregate status bar needs a refresh. Set by renderPanelState. */
|
||||||
|
let aggregateStateDirty = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a semi-transparent overlay on the panel that shows playback state.
|
* Create a semi-transparent overlay on the panel that shows playback state.
|
||||||
* Returns the overlay element.
|
* Returns the overlay element.
|
||||||
@@ -160,6 +188,13 @@ function renderPanelState(video, panel) {
|
|||||||
lines.push(segText);
|
lines.push(segText);
|
||||||
}
|
}
|
||||||
lines.push(`💾 ${state.bufferedAhead.toFixed(1)}s buf`);
|
lines.push(`💾 ${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}%)`);
|
||||||
|
} else if (state.currentTime > 0) {
|
||||||
|
lines.push(`⏱ ${state.currentTime.toFixed(1)}s (live/loop)`);
|
||||||
|
}
|
||||||
if (state.fpsDropCount > 0) {
|
if (state.fpsDropCount > 0) {
|
||||||
lines.push(`⚠ ${state.fpsDropCount} fps-drop`);
|
lines.push(`⚠ ${state.fpsDropCount} fps-drop`);
|
||||||
}
|
}
|
||||||
@@ -171,6 +206,9 @@ function renderPanelState(video, panel) {
|
|||||||
overlay.style.display = 'block';
|
overlay.style.display = 'block';
|
||||||
overlay.style.opacity = '1';
|
overlay.style.opacity = '1';
|
||||||
|
|
||||||
|
// Mark aggregate status bar dirty — it refreshes on the next buffer tick.
|
||||||
|
aggregateStateDirty = true;
|
||||||
|
|
||||||
// Auto-fade when playing stably (segments loaded > 3, buffered > 2s)
|
// Auto-fade when playing stably (segments loaded > 3, buffered > 2s)
|
||||||
if (state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2) {
|
if (state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -181,6 +219,76 @@ function renderPanelState(video, panel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate playback status bar.
|
||||||
|
*
|
||||||
|
* 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;
|
||||||
|
if (!aggregateStateDirty) return;
|
||||||
|
aggregateStateDirty = false;
|
||||||
|
|
||||||
|
let playing = 0;
|
||||||
|
let buffering = 0;
|
||||||
|
let errors = 0;
|
||||||
|
let idle = 0;
|
||||||
|
let totalSegs = 0;
|
||||||
|
let totalSegFails = 0;
|
||||||
|
|
||||||
|
for (const state of panelStates.values()) {
|
||||||
|
switch (state.phase) {
|
||||||
|
case 'playing': playing++; break;
|
||||||
|
case 'buffering': buffering++; break;
|
||||||
|
case 'error': errors++; break;
|
||||||
|
default: idle++; break;
|
||||||
|
}
|
||||||
|
totalSegs += state.segmentsLoaded;
|
||||||
|
totalSegFails += state.segmentsFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (totalSegs > 0) {
|
||||||
|
let segPart = `📦 ${totalSegs} seg`;
|
||||||
|
if (totalSegFails > 0) segPart += ` (+${totalSegFails} fail)`;
|
||||||
|
parts.push(segPart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show buffer health across all active panels
|
||||||
|
let totalBuf = 0;
|
||||||
|
for (const state of panelStates.values()) {
|
||||||
|
if (state.phase === 'playing' || state.phase === 'buffering') {
|
||||||
|
totalBuf += state.bufferedAhead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const active = playing + buffering;
|
||||||
|
if (active > 0) {
|
||||||
|
parts.push(`💾 ${(totalBuf / active).toFixed(1)}s avg buf`);
|
||||||
|
}
|
||||||
|
|
||||||
|
statusElRef.textContent = `Live grid ${totalPanels} panels: ${parts.join(' | ')} || Found ${totalPanels} video(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
/* Fragmented playback tracker */
|
/* Fragmented playback tracker */
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
@@ -216,6 +324,8 @@ function wirePlaybackTracking(hls, video, panel) {
|
|||||||
if (!s) { clearInterval(bufferTimer); return; }
|
if (!s) { clearInterval(bufferTimer); return; }
|
||||||
s.bufferedAhead = getBufferedAhead(video);
|
s.bufferedAhead = getBufferedAhead(video);
|
||||||
renderPanelState(video, panel);
|
renderPanelState(video, panel);
|
||||||
|
// Refresh aggregate status bar if any panel changed state.
|
||||||
|
renderAggregateState();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Clean up timer on destroy (monkey-patch hls.destroy)
|
// Clean up timer on destroy (monkey-patch hls.destroy)
|
||||||
@@ -248,21 +358,21 @@ function wirePlaybackTracking(hls, video, panel) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Segment / fragment tracking ---
|
// --- Segment / fragment tracking ---
|
||||||
hls.on(HlsEvents.FRAG_LOADING, (_ev, data) => {
|
hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => {
|
||||||
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(HlsEvents.FRAG_LOADED, (_ev, data) => {
|
hls.on(HlsEvents.FRAG_LOADED, (_ev, _data) => {
|
||||||
state.segmentsLoaded++;
|
state.segmentsLoaded++;
|
||||||
state.lastEvent = 'FRAG_LOADED';
|
state.lastEvent = 'FRAG_LOADED';
|
||||||
state.lastActivity = Date.now();
|
state.lastActivity = Date.now();
|
||||||
renderPanelState(video, panel);
|
renderPanelState(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();
|
||||||
if (state.phase === 'buffering' && video.readyState >= 2) {
|
if (state.phase === 'buffering' && video.readyState >= 2) {
|
||||||
@@ -272,21 +382,21 @@ function wirePlaybackTracking(hls, video, panel) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Buffer tracking ---
|
// --- Buffer tracking ---
|
||||||
hls.on(HlsEvents.BUFFER_APPENDED, (_ev, data) => {
|
hls.on(HlsEvents.BUFFER_APPENDED, (_ev, _data) => {
|
||||||
state.lastEvent = 'BUFFER_APPENDED';
|
state.lastEvent = 'BUFFER_APPENDED';
|
||||||
state.lastActivity = Date.now();
|
state.lastActivity = Date.now();
|
||||||
state.bufferedAhead = getBufferedAhead(video);
|
state.bufferedAhead = getBufferedAhead(video);
|
||||||
renderPanelState(video, panel);
|
renderPanelState(video, panel);
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(HlsEvents.BUFFER_FLUSHED, (_ev, data) => {
|
hls.on(HlsEvents.BUFFER_FLUSHED, (_ev, _data) => {
|
||||||
state.lastEvent = 'BUFFER_FLUSHED';
|
state.lastEvent = 'BUFFER_FLUSHED';
|
||||||
state.lastActivity = Date.now();
|
state.lastActivity = Date.now();
|
||||||
state.bufferedAhead = getBufferedAhead(video);
|
state.bufferedAhead = getBufferedAhead(video);
|
||||||
renderPanelState(video, panel);
|
renderPanelState(video, panel);
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(HlsEvents.BUFFER_RESET, (_ev, data) => {
|
hls.on(HlsEvents.BUFFER_RESET, (_ev, _data) => {
|
||||||
state.lastEvent = 'BUFFER_RESET';
|
state.lastEvent = 'BUFFER_RESET';
|
||||||
state.lastActivity = Date.now();
|
state.lastActivity = Date.now();
|
||||||
state.bufferedAhead = 0;
|
state.bufferedAhead = 0;
|
||||||
@@ -414,6 +524,32 @@ function wirePlaybackTracking(hls, video, panel) {
|
|||||||
renderPanelState(video, panel);
|
renderPanelState(video, panel);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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', () => {
|
||||||
|
const s = panelStates.get(video);
|
||||||
|
if (!s) return;
|
||||||
|
s.currentTime = video.currentTime;
|
||||||
|
s.duration = video.duration || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('seeking', () => {
|
||||||
|
const s = panelStates.get(video);
|
||||||
|
if (s) {
|
||||||
|
s.lastEvent = 'video.seeking';
|
||||||
|
s.lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
video.addEventListener('seeked', () => {
|
||||||
|
const s = panelStates.get(video);
|
||||||
|
if (s) {
|
||||||
|
s.lastEvent = 'video.seeked';
|
||||||
|
s.lastActivity = Date.now();
|
||||||
|
renderPanelState(video, panel);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -544,13 +680,14 @@ async function init() {
|
|||||||
const data = await chrome.storage.local.get([id]);
|
const data = await chrome.storage.local.get([id]);
|
||||||
const videoUrls = data[id] || [];
|
const videoUrls = data[id] || [];
|
||||||
|
|
||||||
// Status overlay
|
// Status overlay — saved for aggregate state updates
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'viewer-status';
|
overlay.id = 'viewer-status';
|
||||||
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
|
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
|
||||||
document.body.style.position = 'relative';
|
document.body.style.position = 'relative';
|
||||||
document.body.insertBefore(overlay, document.body.firstChild);
|
document.body.insertBefore(overlay, document.body.firstChild);
|
||||||
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
|
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
|
||||||
|
statusElRef = overlay;
|
||||||
|
|
||||||
const totalPanels = gridN * gridN;
|
const totalPanels = gridN * gridN;
|
||||||
const hlsEntries = [];
|
const hlsEntries = [];
|
||||||
|
|||||||
@@ -514,3 +514,463 @@ describe('Browser HLS support detection (conceptual)', () => {
|
|||||||
expect(supportsNativeHls(brokenVideo)).toBe(false);
|
expect(supportsNativeHls(brokenVideo)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Fragmented playback tracking — PanelState lifecycle */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
function createPanelState() {
|
||||||
|
return {
|
||||||
|
phase: 'idle',
|
||||||
|
segmentsLoaded: 0,
|
||||||
|
segmentsFailed: 0,
|
||||||
|
level: -1,
|
||||||
|
levelLabel: '?x?',
|
||||||
|
bufferedAhead: 0,
|
||||||
|
fpsDropCount: 0,
|
||||||
|
lastEvent: 'none',
|
||||||
|
lastActivity: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Fragmented playback tracking — PanelState lifecycle', () => {
|
||||||
|
it('starts in idle phase with zeroed counters', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
expect(state.phase).toBe('idle');
|
||||||
|
expect(state.segmentsLoaded).toBe(0);
|
||||||
|
expect(state.segmentsFailed).toBe(0);
|
||||||
|
expect(state.level).toBe(-1);
|
||||||
|
expect(state.levelLabel).toBe('?x?');
|
||||||
|
expect(state.bufferedAhead).toBe(0);
|
||||||
|
expect(state.fpsDropCount).toBe(0);
|
||||||
|
expect(state.errorMessage).toBeNull();
|
||||||
|
expect(state.currentTime).toBe(0);
|
||||||
|
expect(state.duration).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks fragment loading count on FRAG_LOADED events', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.segmentsLoaded += 3;
|
||||||
|
state.lastEvent = 'FRAG_LOADED';
|
||||||
|
expect(state.segmentsLoaded).toBe(3);
|
||||||
|
expect(state.lastEvent).toBe('FRAG_LOADED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks segment failures separately from loads', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.segmentsLoaded += 2;
|
||||||
|
state.segmentsFailed++;
|
||||||
|
expect(state.segmentsLoaded).toBe(2);
|
||||||
|
expect(state.segmentsFailed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks quality level switches', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.level = 2;
|
||||||
|
state.levelLabel = '1280x720';
|
||||||
|
state.lastEvent = 'LEVEL_SWITCHED';
|
||||||
|
expect(state.level).toBe(2);
|
||||||
|
expect(state.levelLabel).toBe('1280x720');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks FPS drops', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.fpsDropCount += 3;
|
||||||
|
expect(state.fpsDropCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks playback position via timeupdate simulation', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.currentTime = 10.5;
|
||||||
|
state.duration = 300;
|
||||||
|
expect(state.currentTime).toBe(10.5);
|
||||||
|
expect(state.duration).toBe(300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Fragmented playback tracking — phase transitions */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Fragmented playback tracking — phase transitions', () => {
|
||||||
|
it('transitions: idle -> loading -> buffering -> playing (happy path)', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'loading';
|
||||||
|
expect(state.phase).toBe('loading');
|
||||||
|
state.phase = 'buffering';
|
||||||
|
expect(state.phase).toBe('buffering');
|
||||||
|
state.phase = 'playing';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions: playing -> buffering (rebuffering)', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.phase = 'buffering';
|
||||||
|
expect(state.phase).toBe('buffering');
|
||||||
|
state.phase = 'playing';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions: playing -> error (fatal network error)', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.phase = 'error';
|
||||||
|
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
|
||||||
|
expect(state.phase).toBe('error');
|
||||||
|
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transitions: error cleared on media error recovery', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.phase = 'buffering';
|
||||||
|
state.errorMessage = 'Media: DECODE_ERROR';
|
||||||
|
state.errorMessage = null;
|
||||||
|
state.phase = 'playing';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
expect(state.errorMessage).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUFFER_RESET zeroes segment counter and buffer', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.segmentsLoaded = 15;
|
||||||
|
state.bufferedAhead = 5.2;
|
||||||
|
state.segmentsLoaded = 0;
|
||||||
|
state.bufferedAhead = 0;
|
||||||
|
expect(state.segmentsLoaded).toBe(0);
|
||||||
|
expect(state.bufferedAhead).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Fragmented playback tracking — buffer health */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Fragmented playback tracking — buffer health', () => {
|
||||||
|
function getBufferedAhead(bufferedEnd, currentTime) {
|
||||||
|
if (bufferedEnd <= currentTime) return 0;
|
||||||
|
return bufferedEnd - currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('computes buffer ahead correctly when playing', () => {
|
||||||
|
expect(getBufferedAhead(20, 5)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 when buffer is behind current position', () => {
|
||||||
|
expect(getBufferedAhead(3, 5)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks low buffer health (starvation risk)', () => {
|
||||||
|
expect(getBufferedAhead(6.1, 5)).toBeCloseTo(1.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple buffer ranges — picks the one ahead of current time', () => {
|
||||||
|
const ranges = [
|
||||||
|
{ start: 0, end: 3 },
|
||||||
|
{ start: 10, end: 20 },
|
||||||
|
{ start: 30, end: 40 },
|
||||||
|
];
|
||||||
|
let bufferedAhead = 0;
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (r.end > 5) {
|
||||||
|
bufferedAhead = r.end - 5;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(bufferedAhead).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Aggregate state rendering — multi-panel dashboard */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Aggregate state rendering — multi-panel dashboard', () => {
|
||||||
|
function computeAggregate(states) {
|
||||||
|
let playing = 0, buffering = 0, errors = 0, idle = 0;
|
||||||
|
let totalSegs = 0, totalSegFails = 0;
|
||||||
|
for (const s of states) {
|
||||||
|
switch (s.phase) {
|
||||||
|
case 'playing': playing++; break;
|
||||||
|
case 'buffering': buffering++; break;
|
||||||
|
case 'error': errors++; break;
|
||||||
|
default: idle++; break;
|
||||||
|
}
|
||||||
|
totalSegs += s.segmentsLoaded;
|
||||||
|
totalSegFails += s.segmentsFailed;
|
||||||
|
}
|
||||||
|
let totalBuf = 0;
|
||||||
|
for (const s of states) {
|
||||||
|
if (s.phase === 'playing' || s.phase === 'buffering') {
|
||||||
|
totalBuf += s.bufferedAhead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const active = playing + buffering;
|
||||||
|
return { playing, buffering, errors, idle, totalSegs, totalSegFails, totalBuf, active };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('aggregates playing/buffering/error/idle counts correctly', () => {
|
||||||
|
const states = [
|
||||||
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, bufferedAhead: 8 },
|
||||||
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 12, bufferedAhead: 9 },
|
||||||
|
{ ...createPanelState(), phase: 'buffering', segmentsLoaded: 5, bufferedAhead: 2 },
|
||||||
|
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 1 },
|
||||||
|
{ ...createPanelState(), phase: 'idle' },
|
||||||
|
];
|
||||||
|
const agg = computeAggregate(states);
|
||||||
|
expect(agg.playing).toBe(2);
|
||||||
|
expect(agg.buffering).toBe(1);
|
||||||
|
expect(agg.errors).toBe(1);
|
||||||
|
expect(agg.idle).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sums segments loaded and failed across panels', () => {
|
||||||
|
const states = [
|
||||||
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 10, segmentsFailed: 1 },
|
||||||
|
{ ...createPanelState(), phase: 'playing', segmentsLoaded: 15 },
|
||||||
|
{ ...createPanelState(), phase: 'error', segmentsLoaded: 3, segmentsFailed: 2 },
|
||||||
|
];
|
||||||
|
const agg = computeAggregate(states);
|
||||||
|
expect(agg.totalSegs).toBe(28);
|
||||||
|
expect(agg.totalSegFails).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes average buffer for active panels only', () => {
|
||||||
|
const states = [
|
||||||
|
{ ...createPanelState(), phase: 'playing', bufferedAhead: 8 },
|
||||||
|
{ ...createPanelState(), phase: 'playing', bufferedAhead: 12 },
|
||||||
|
{ ...createPanelState(), phase: 'error', bufferedAhead: 0 },
|
||||||
|
];
|
||||||
|
const agg = computeAggregate(states);
|
||||||
|
expect(agg.active).toBe(2);
|
||||||
|
expect(agg.totalBuf).toBeCloseTo(20);
|
||||||
|
expect(agg.totalBuf / agg.active).toBeCloseTo(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all panels in error', () => {
|
||||||
|
const states = [
|
||||||
|
{ ...createPanelState(), phase: 'error', segmentsFailed: 3 },
|
||||||
|
{ ...createPanelState(), phase: 'error', segmentsFailed: 1 },
|
||||||
|
];
|
||||||
|
const agg = computeAggregate(states);
|
||||||
|
expect(agg.errors).toBe(2);
|
||||||
|
expect(agg.active).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty state — no panels tracked yet', () => {
|
||||||
|
const agg = computeAggregate([]);
|
||||||
|
expect(agg.playing).toBe(0);
|
||||||
|
expect(agg.buffering).toBe(0);
|
||||||
|
expect(agg.errors).toBe(0);
|
||||||
|
expect(agg.idle).toBe(0);
|
||||||
|
expect(agg.totalSegs).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Fragmented playback tracking — hls.js event simulation */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Fragmented playback tracking — hls.js event simulation', () => {
|
||||||
|
it('simulates a full HLS playback lifecycle', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'loading';
|
||||||
|
state.lastEvent = 'MEDIA_ATTACHED';
|
||||||
|
expect(state.phase).toBe('loading');
|
||||||
|
state.lastEvent = 'MANIFEST_LOADED';
|
||||||
|
state.levelLabel = '1920x1080';
|
||||||
|
state.lastEvent = 'MANIFEST_PARSED';
|
||||||
|
state.level = 0;
|
||||||
|
state.levelLabel = '640x360';
|
||||||
|
state.phase = 'buffering';
|
||||||
|
for (let i = 0; i < 5; i++) state.segmentsLoaded++;
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.bufferedAhead = 8.5;
|
||||||
|
state.currentTime = 5.2;
|
||||||
|
state.duration = 300;
|
||||||
|
state.level = 1;
|
||||||
|
state.levelLabel = '1280x720';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
expect(state.segmentsLoaded).toBe(5);
|
||||||
|
expect(state.segmentsFailed).toBe(0);
|
||||||
|
expect(state.fpsDropCount).toBe(0);
|
||||||
|
expect(state.errorMessage).toBeNull();
|
||||||
|
expect(state.level).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('simulates degraded playback with FPS drops and seg failures', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.segmentsLoaded = 3;
|
||||||
|
state.segmentsFailed += 2;
|
||||||
|
state.fpsDropCount += 2;
|
||||||
|
state.level = 0;
|
||||||
|
state.levelLabel = '426x240';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
expect(state.segmentsFailed).toBe(2);
|
||||||
|
expect(state.fpsDropCount).toBe(2);
|
||||||
|
expect(state.levelLabel).toBe('426x240');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('simulates fatal network error (manifest expired)', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.segmentsLoaded = 20;
|
||||||
|
state.phase = 'error';
|
||||||
|
state.errorMessage = 'Network: MANIFEST_LOAD_ERROR';
|
||||||
|
expect(state.phase).toBe('error');
|
||||||
|
expect(state.errorMessage).toContain('MANIFEST_LOAD_ERROR');
|
||||||
|
expect(state.segmentsLoaded).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('simulates media error recovery', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.segmentsLoaded = 10;
|
||||||
|
state.phase = 'buffering';
|
||||||
|
state.errorMessage = 'Media: DECODE_ERROR';
|
||||||
|
state.errorMessage = null;
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.segmentsLoaded++;
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
expect(state.errorMessage).toBeNull();
|
||||||
|
expect(state.segmentsLoaded).toBe(11);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Fragmented playback tracking — video element event wiring */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Fragmented playback tracking — video element event wiring', () => {
|
||||||
|
it('video.playing event sets phase to playing', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'buffering';
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.lastEvent = 'video.playing';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
expect(state.lastEvent).toBe('video.playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('video.waiting event sets phase to buffering', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.phase = 'buffering';
|
||||||
|
state.lastEvent = 'video.waiting';
|
||||||
|
expect(state.phase).toBe('buffering');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('video.timeupdate tracks playback position', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.currentTime = 1.0;
|
||||||
|
state.currentTime = 2.0;
|
||||||
|
state.currentTime = 3.0;
|
||||||
|
state.duration = 300;
|
||||||
|
expect(state.currentTime).toBe(3.0);
|
||||||
|
expect(state.duration).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overlay auto-fade: playing + segments > 3 + buffered > 2s', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.segmentsLoaded = 4;
|
||||||
|
state.bufferedAhead = 5;
|
||||||
|
const shouldFade = state.phase === 'playing' &&
|
||||||
|
state.segmentsLoaded > 3 && state.bufferedAhead > 2;
|
||||||
|
expect(shouldFade).toBe(true);
|
||||||
|
state.segmentsLoaded = 2;
|
||||||
|
expect(state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Fragmented playback tracking — edge cases */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Fragmented playback tracking — edge cases', () => {
|
||||||
|
it('handles live/loop content where duration is Infinity', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.currentTime = 15.5;
|
||||||
|
state.duration = Infinity;
|
||||||
|
const isLive = state.duration === Infinity || isNaN(state.duration);
|
||||||
|
expect(isLive).toBe(true);
|
||||||
|
expect(state.currentTime).toBe(15.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles rapid phase oscillation (playing <-> buffering)', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.phase = 'buffering';
|
||||||
|
state.phase = 'playing';
|
||||||
|
state.phase = 'buffering';
|
||||||
|
state.phase = 'playing';
|
||||||
|
expect(state.phase).toBe('playing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles ABR rapid switching (quality oscillation)', () => {
|
||||||
|
const state = createPanelState();
|
||||||
|
state.level = 3; state.levelLabel = '1920x1080';
|
||||||
|
state.level = 0; state.levelLabel = '426x240';
|
||||||
|
state.level = 2; state.levelLabel = '1280x720';
|
||||||
|
expect(state.level).toBe(2);
|
||||||
|
expect(state.levelLabel).toBe('1280x720');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty panelStates map — initial render shows no panels', () => {
|
||||||
|
const panelStates = new Map();
|
||||||
|
let playing = 0, buffering = 0, errors = 0, idle = 0;
|
||||||
|
for (const s of panelStates.values()) {
|
||||||
|
switch (s.phase) {
|
||||||
|
case 'playing': playing++; break;
|
||||||
|
case 'buffering': buffering++; break;
|
||||||
|
case 'error': errors++; break;
|
||||||
|
default: idle++; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(playing).toBe(0);
|
||||||
|
expect(buffering).toBe(0);
|
||||||
|
expect(errors).toBe(0);
|
||||||
|
expect(idle).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregateStateDirty flag prevents unnecessary re-renders', () => {
|
||||||
|
let dirty = false;
|
||||||
|
let calls = 0;
|
||||||
|
function render() { if (!dirty) return; dirty = false; calls++; }
|
||||||
|
render();
|
||||||
|
expect(calls).toBe(0);
|
||||||
|
dirty = true;
|
||||||
|
render();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
render();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple panels trigger dirty flag idempotently', () => {
|
||||||
|
let dirty = false;
|
||||||
|
let calls = 0;
|
||||||
|
function render() { if (!dirty) return; dirty = false; calls++; }
|
||||||
|
dirty = true; dirty = true; dirty = true;
|
||||||
|
render();
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-HLS video panels are not tracked in panelStates', () => {
|
||||||
|
const isHls = false;
|
||||||
|
const hasPanelState = isHls;
|
||||||
|
expect(hasPanelState).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buffer timer cleanup: clearInterval called on destroy', () => {
|
||||||
|
let cleared = false;
|
||||||
|
const mockId = 1;
|
||||||
|
const mockClear = (id) => { if (id === mockId) cleared = true; };
|
||||||
|
mockClear(mockId);
|
||||||
|
expect(cleared).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user