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 Ajv from 'ajv';
|
||||
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 });
|
||||
addFormats(ajv);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, it } from './runner.mjs';
|
||||
import Ajv from 'ajv';
|
||||
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 });
|
||||
addFormats(ajv);
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
return url;
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
} catch(_e) {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -122,7 +122,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch(e) { /* ignore DOM access errors */ }
|
||||
} catch(_e) { /* ignore DOM access errors */ }
|
||||
|
||||
return {
|
||||
posterUrl: poster,
|
||||
|
||||
@@ -164,6 +164,6 @@
|
||||
}
|
||||
});
|
||||
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_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,
|
||||
maxBufferLength: 30,
|
||||
maxMaxBufferLength: 60,
|
||||
};
|
||||
enableWorker: true,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
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
|
||||
* through fragment arrivals, buffer growth, and quality switches.
|
||||
*
|
||||
/**
|
||||
* @typedef {object} PanelState
|
||||
* @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase
|
||||
* Current playback phase of this panel.
|
||||
@@ -55,6 +71,10 @@ const hlsInstances = new Map();
|
||||
* 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. */
|
||||
@@ -75,6 +95,11 @@ 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,
|
||||
duration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +128,9 @@ function destroyHls(video) {
|
||||
*/
|
||||
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.
|
||||
* Returns the overlay element.
|
||||
@@ -160,6 +188,13 @@ function renderPanelState(video, panel) {
|
||||
lines.push(segText);
|
||||
}
|
||||
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) {
|
||||
lines.push(`⚠ ${state.fpsDropCount} fps-drop`);
|
||||
}
|
||||
@@ -171,6 +206,9 @@ function renderPanelState(video, panel) {
|
||||
overlay.style.display = 'block';
|
||||
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)
|
||||
if (state.phase === 'playing' && state.segmentsLoaded > 3 && state.bufferedAhead > 2) {
|
||||
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 */
|
||||
/* ======================================================================== */
|
||||
@@ -216,6 +324,8 @@ function wirePlaybackTracking(hls, video, panel) {
|
||||
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)
|
||||
@@ -248,21 +358,21 @@ function wirePlaybackTracking(hls, video, panel) {
|
||||
});
|
||||
|
||||
// --- Segment / fragment tracking ---
|
||||
hls.on(HlsEvents.FRAG_LOADING, (_ev, data) => {
|
||||
hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => {
|
||||
state.phase = 'buffering';
|
||||
state.lastEvent = 'FRAG_LOADING';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
hls.on(HlsEvents.FRAG_LOADED, (_ev, data) => {
|
||||
hls.on(HlsEvents.FRAG_LOADED, (_ev, _data) => {
|
||||
state.segmentsLoaded++;
|
||||
state.lastEvent = 'FRAG_LOADED';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
hls.on(HlsEvents.FRAG_BUFFERED, (_ev, data) => {
|
||||
hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => {
|
||||
state.lastEvent = 'FRAG_BUFFERED';
|
||||
state.lastActivity = Date.now();
|
||||
if (state.phase === 'buffering' && video.readyState >= 2) {
|
||||
@@ -272,21 +382,21 @@ function wirePlaybackTracking(hls, video, panel) {
|
||||
});
|
||||
|
||||
// --- Buffer tracking ---
|
||||
hls.on(HlsEvents.BUFFER_APPENDED, (_ev, data) => {
|
||||
hls.on(HlsEvents.BUFFER_APPENDED, (_ev, _data) => {
|
||||
state.lastEvent = 'BUFFER_APPENDED';
|
||||
state.lastActivity = Date.now();
|
||||
state.bufferedAhead = getBufferedAhead(video);
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
hls.on(HlsEvents.BUFFER_FLUSHED, (_ev, data) => {
|
||||
hls.on(HlsEvents.BUFFER_FLUSHED, (_ev, _data) => {
|
||||
state.lastEvent = 'BUFFER_FLUSHED';
|
||||
state.lastActivity = Date.now();
|
||||
state.bufferedAhead = getBufferedAhead(video);
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
hls.on(HlsEvents.BUFFER_RESET, (_ev, data) => {
|
||||
hls.on(HlsEvents.BUFFER_RESET, (_ev, _data) => {
|
||||
state.lastEvent = 'BUFFER_RESET';
|
||||
state.lastActivity = Date.now();
|
||||
state.bufferedAhead = 0;
|
||||
@@ -414,6 +524,32 @@ function wirePlaybackTracking(hls, 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 videoUrls = data[id] || [];
|
||||
|
||||
// Status overlay
|
||||
// Status overlay — saved for aggregate state updates
|
||||
const overlay = document.createElement('div');
|
||||
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;';
|
||||
document.body.style.position = 'relative';
|
||||
document.body.insertBefore(overlay, document.body.firstChild);
|
||||
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
|
||||
statusElRef = overlay;
|
||||
|
||||
const totalPanels = gridN * gridN;
|
||||
const hlsEntries = [];
|
||||
|
||||
@@ -514,3 +514,463 @@ describe('Browser HLS support detection (conceptual)', () => {
|
||||
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