diff --git a/__tests__/data_flow.test.mjs b/__tests__/data_flow.test.mjs index eb88042..ef9e78a 100644 --- a/__tests__/data_flow.test.mjs +++ b/__tests__/data_flow.test.mjs @@ -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); diff --git a/__tests__/schema.test.mjs b/__tests__/schema.test.mjs index 5972686..07c0b80 100644 --- a/__tests__/schema.test.mjs +++ b/__tests__/schema.test.mjs @@ -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); diff --git a/src/content/content.js b/src/content/content.js index f16846f..fbfefc0 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -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, diff --git a/src/inject/interceptors.js b/src/inject/interceptors.js index 0702684..04462fe 100644 --- a/src/inject/interceptors.js +++ b/src/inject/interceptors.js @@ -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 */ } } })(); diff --git a/src/viewer/viewer.js b/src/viewer/viewer.js index b4c95ec..606c8b6 100644 --- a/src/viewer/viewer.js +++ b/src/viewer/viewer.js @@ -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,29 +49,33 @@ 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. - * @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. - */ + /** + * @typedef {object} PanelState + * @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase + * Current playback phase of this panel. + * @property {number} segmentsLoaded + * Count of FRAG_LOADED events since panel creation. + * @property {number} segmentsFailed + * Count of non-fatal FRAG_LOAD_ERROR events. + * @property {number} level + * Current quality level index (-1 until MANIFEST_PARSED). + * @property {string} levelLabel + * Human-readable quality label (e.g. "1280x720"). + * @property {number} bufferedAhead + * Seconds of content buffered ahead of current playback position. + * @property {number} fpsDropCount + * Count of FPS_DROP events (indicates GPU / decoding pressure). + * @property {string} lastEvent + * Most recent hls.js event name (for debug). + * @property {number} lastActivity + * Timestamp (Date.now) of the most recent event activity. + * @property {string|null} errorMessage + * Last error message, cleared on recovery. + * @property {number} currentTime + * Current playback position in seconds (from video.currentTime). + * @property {number} duration + * Total media duration in seconds (from video.duration). + */ /** Map — one state object per video panel. */ const panelStates = new Map(); @@ -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 = []; diff --git a/src/viewer/viewer.test.js b/src/viewer/viewer.test.js index 59dcafe..3080fbf 100644 --- a/src/viewer/viewer.test.js +++ b/src/viewer/viewer.test.js @@ -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); + }); +});