/** * viewer.js - Goon Viewer page logic. * Reads video URLs from chrome.storage and renders them in a dynamic NxN grid. * Supports HLS playback via hls.js (dynamic CDN load) and direct MP4/WebM. * * Modular architecture: * * PanelState — per-panel playback state (phase, segments, buffer, quality) * EventWiring — attaches hls.js + video element event listeners * BufferMonitor — periodic buffer health check + timer lifecycle * PanelRenderer — per-panel overlay rendering (phase, quality, segments) * AggregateRenderer — global status bar aggregating all panel states * PanelFactory — DOM panel creation + HLS/direct playback routing * * HLS delivery is inherently segmented (fragments load one at a time into * the MediaSource buffer). This module tracks the broken-up nature of * playback by wiring hls.js events for fragment loading, buffer updates, * quality switches, and FPS drops — then reports state through per-panel * overlays and an aggregate status bar. */ // HLS is vendored locally to avoid CSP blocks on sites that restrict // script-src to 'self'. The hls.js bundle is copied (not minified) so it // loads from the extension origin via chrome.runtime.getURL(), bypassing // page CSP entirely. /** * 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, enableWorker: true, debug: false, }); const params = new URLSearchParams(location.search); const id = params.get('id'); const gridN = parseInt(params.get('grid'), 10) || 2; const tags = params.get('tags') ? JSON.parse(params.get('tags')) : []; const panelsEl = document.getElementById('panels'); /** Track all Hls instances so we can destroy them on unload. */ /** * Custom HLS loader that proxies URLs whose host blocks extension-origin * requests (via a learned proxy-host set) through the service worker * (bypassing CORS/Referer restrictions) and fetches everything else natively. * * hls.js creates one loader instance per request via `new HlsLoader(hls)`, then * calls `loader.load(context, config, callbacks)` — callbacks are the 3rd arg * in v9 (some wrapper paths put them 2nd, so we scan each argument). The stats * object passed to the callbacks is pre-allocated by us and mutated by hls.js * in place, so it must have the correct sub-shape (see createLoadedStats). */ function HlsLoader(config) { this._config = config; this._xhr = null; // hls.js's loadFragment does `t.stats = h.stats` BEFORE calling h.load(), // so the LoadedStats object must exist on the instance at construction. // Without it every fragment's `stats` becomes undefined and hls.js's // auto-level `_abandonRulesCheck` crashes on `o.loading.start`. this.stats = createLoadedStats(); } HlsLoader.prototype.abort = function () { this._aborted = true; if (this._xhr) { this._xhr.abort(); this._xhr = null; } }; /** * Build a hls.js-compatible LoadedStats object. hls.js's loader `load()` * pre-allocates one of these and passes it (by reference) as the 2nd arg to * onSuccess/onError/onTimeout; hls.js then mutates its sub-objects * (`stats.parsing.start`, `stats.loading.first`, ...). A flat object crashes * with "Cannot set properties of undefined (setting 'start')". */ function createLoadedStats() { return { aborted: false, loaded: 0, retry: 0, total: 0, chunkCount: 0, bwEstimate: 0, loading: { start: 0, first: 0, end: 0 }, parsing: { start: 0, end: 0 }, buffering: { start: 0, first: 0, end: 0 }, }; } HlsLoader.prototype.load = function (context, loaderConfig, callbacksArg) { const self = this; self._aborted = false; // hls.js invokes loaders as load(context, config, callbacks) in v9, but // some paths/wrappers pass callbacks as the 2nd arg. Scan every argument // for the object carrying {onSuccess,onError,onTimeout} so it works either // way. const args = [callbacksArg, loaderConfig, context].filter(Boolean); let cb = null; for (const c of args) { if (c && (typeof c.onSuccess === 'function' || typeof c.onError === 'function' || typeof c.onTimeout === 'function')) { cb = c; break; } } const onSuccess = cb && typeof cb.onSuccess === 'function' ? cb.onSuccess : null; const onError = cb && typeof cb.onError === 'function' ? cb.onError : null; const onTimeout = cb && typeof cb.onTimeout === 'function' ? cb.onTimeout : null; // `context` carries the request URL/type; `loaderConfig` may carry a // per-loader timeout. Fall back defensively. const url = (context && context.url) || (loaderConfig && loaderConfig.url); self._loaderContext = ((context && context.type) || '?') + ': ' + (url || '').substring(0, 100); self._timeout = (loaderConfig && loaderConfig.timeout) || (context && context.timeout) || 10000; const trequest = Date.now(); // Reuse this.stats: hls.js already copied this reference onto every // fragment via `t.stats = h.stats` before load(), so the object we // mutate here is the very one `_abandonRulesCheck` reads. const stats = this.stats; stats.loading.start = trequest; const isManifest = /\.m3u8($|\?)/i.test(url || ''); const needProxy = needsProxy(url); // hls.js loader callback contract (verified against the bundled loader): // onSuccess(response, stats, context, networkDetails) // onError(response, stats, context, networkDetails) // onTimeout(response, stats, context) // `response` is {url, data, code, headers}; `data` is a *string* for // .m3u8 manifests (hls.js runs indexOf/regex on it) and an ArrayBuffer // for .ts segments. hls.js mutates `stats` and `context` in place, so we // pass the SAME object references we received — passing a timestamp there // caused "Cannot create property 'levelDetails' on number". const fireSuccess = (data) => { if (!onSuccess) { console.warn('[vidplex-viewer] no onSuccess callback for', (url || '').substring(0, 100)); return; } const tLoad = Date.now(); stats.loading.first = tLoad; stats.loading.end = tLoad; stats.loaded = (data instanceof ArrayBuffer ? data.byteLength : (data && data.length) || 0); onSuccess({ url, data, code: 200, headers: {} }, stats, context, null); }; const fireError = (code, text) => { if (!onError) { console.error('[vidplex-viewer] no onError callback; error:', text || code, (url || '').substring(0, 100)); return; } stats.loading.end = Date.now(); console.warn('[vidplex-viewer] HlsLoader error:', code, text, (url || '').substring(0, 100)); onError({ code: code || 0, text: text || '', url, headers: {}, data: null }, stats, context, null); }; const fireTimeout = () => { if (!onTimeout) return; onTimeout({ url, code: 0, text: 'timeout', headers: {}, data: null }, stats, context); }; if (needProxy) { // Route through the service worker to bypass the CDN's CORS/Referer // restrictions. The SW adds the required Referer and returns base64. console.log('[vidplex-viewer] Proxying via SW:', (url || '').substring(0, 120)); chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer: refererForUrl(url) }, (resp) => { if (self._aborted) return; const lastErr = chrome.runtime.lastError; if (lastErr || !resp || !resp.ok) { const status = resp && resp.status ? resp.status : 0; const text = lastErr && lastErr.message ? lastErr.message : (resp && resp.error) || ('HTTP ' + status || 'unknown'); fireError(status || 0, text); return; } // resp.data is base64. Decode to a binary string, then to a UTF-8 // string (manifests) or an ArrayBuffer (segments). let binary = ''; try { binary = atob(resp.data || ''); } catch { fireError(0, 'Failed to decode proxy response base64'); return; } if (isManifest) { const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); fireSuccess(new TextDecoder().decode(bytes)); return; } const segBytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) segBytes[i] = binary.charCodeAt(i); // Pass an ArrayBuffer to match hls.js's default XHR loader. fireSuccess(segBytes.buffer); }); } else { const xhr = new XMLHttpRequest(); self._xhr = xhr; xhr.open('GET', url, true); xhr.withCredentials = false; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { const data = isManifest ? new TextDecoder().decode(new Uint8Array(xhr.response)) : xhr.response; fireSuccess(data); } else { fireError(xhr.status, 'HTTP ' + xhr.status); } }; xhr.onerror = () => fireError(0, 'XHR network error'); xhr.ontimeout = () => fireTimeout(); xhr.timeout = self._timeout; // hls.js requests ArrayBuffer for segments; manifests are decoded above. xhr.responseType = 'arraybuffer'; xhr.send(); } }; HlsLoader.prototype.destroy = function () { this.abort(); }; HlsLoader.canSupportLoader = function () { return true; }; // === Service-worker proxy helper (CDN-agnostic) === // Proxies a video URL through the service worker (no CORS restrictions). The // SW forces the correct provider-origin Referer for the host (captured by the // background worker), so a referer-protecting CDN accepts the request. // Returns an ArrayBuffer of the response content. async function proxyFetch(url, referer) { try { const resp = await chrome.runtime.sendMessage({ type: 'GOON_PROXY_FETCH', url, referer }); if (resp && resp.data && resp.ok) { // Decode base64 → ArrayBuffer const binary = atob(resp.data); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } return null; } catch(e) { console.warn('[vidplex-viewer] proxyFetch failed for:', url.substring(0, 120), e && e.message ? e.message : e); return null; } } // CDN hosts that block extension-origin requests are learned by a CORS probe // at viewer startup (hosts that fail the probe are recorded), then stored in // chrome.storage.local. The proxy is used only for those hosts, so we never // hardcode a particular CDN. const PROXY_HOSTS_KEY = 'goon_proxy_hosts'; const proxyHosts = new Set(); // Provider referers bound to a stored entry at scan time, keyed by the entry's // manifest registrable domain. A single provider referer covers every edge // subdomain, so a segment on a sibling subhost resolves to the same referer. const refererByDomain = new Map(); (async () => { try { const stored = await chrome.storage.local.get(PROXY_HOSTS_KEY); const hosts = stored[PROXY_HOSTS_KEY]; if (Array.isArray(hosts)) for (const h of hosts) proxyHosts.add(registrableDomainOf(String(h).toLowerCase())); } catch { /* ignore */ } })(); // Registrable domain of a CDN host — the label a single provider referer // applies to (a referer-protecting CDN serves from many edge subdomains that // share one referer). Last-two-labels heuristic; mirrors the SW's copy. function registrableDomainOf(host) { if (!host) return ''; const parts = host.split('.'); if (parts.length < 2) return host; return parts.slice(-2).join('.'); } // The provider referer to send for a proxied request: the referer bound to the // entry at scan time (domain-keyed), or '' when none was captured. No self-origin // fallback — a self-origin referer is guaranteed-wrong for hotlink protection. function refererForUrl(url) { const ref = refererByDomain.get(registrableDomainOf(hostOfUrl(url))); return ref || ''; } // Whether a given video URL should be proxied: its host is a known // referer-protected CDN host. function needsProxy(url) { if (!url || typeof url !== 'string') return false; const host = hostOfUrl(url); return host ? proxyHosts.has(registrableDomainOf(host)) : false; } // host of a URL (lowercased); '' when malformed. function hostOfUrl(url) { try { return new URL(url).host.toLowerCase(); } catch { return ''; } } // Whether any of the given URLs needs proxying. function urlsNeedProxy(urls) { return urls.some(u => needsProxy(u)); } /** Check whether fetching a URL from the extension page works (CORS test). */ async function corsCheck(url) { try { const resp = await fetch(url, { method: 'HEAD', mode: 'cors' }); return resp.ok; } catch { return false; } } /** Track all Hls instances so we can destroy them on unload. */ const hlsInstances = new Map(); /* ======================================================================== */ /* PanelState — per-panel playback state */ /* ======================================================================== */ /** Map — one state object per video panel. */ const panelStates = new Map(); /** * @typedef {object} PanelState * @property {'idle'|'loading'|'buffering'|'playing'|'error'} phase * Current playback phase of this panel. * @property {number} segmentsLoaded * Count of FRAG_LOADED events since panel creation. * @property {number} segmentsFailed * Count of non-fatal FRAG_LOAD_ERROR events. * @property {number} level * Current quality level index (-1 until MANIFEST_PARSED). * @property {string} levelLabel * Human-readable quality label (e.g. "1280x720"). * @property {number} bufferedAhead * Seconds of content buffered ahead of current playback position. * @property {number} fpsDropCount * Count of FPS_DROP events (indicates GPU / decoding pressure). * @property {string} lastEvent * Most recent hls.js event name (for debug). * @property {number} lastActivity * Timestamp (Date.now) of the most recent event activity. * @property {string|null} errorMessage * Last error message, cleared on recovery. * @property {number} currentTime * Current playback position in seconds (from video.currentTime). * @property {number} duration * Total media duration in seconds (from video.duration). */ /** * Initialize a fresh PanelState for a video element. */ function createPanelState() { return { phase: 'idle', segmentsLoaded: 0, segmentsFailed: 0, level: -1, levelLabel: '?x?', bufferedAhead: 0, fpsDropCount: 0, lastEvent: 'none', lastActivity: 0, errorMessage: null, // 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, }; } /* ======================================================================== */ /* Buffer helpers */ /* ======================================================================== */ /** * Compute buffered seconds ahead of current playback position. * Returns 0 if the video has no buffered ranges or is not playing. */ function getBufferedAhead(video) { if (!video.buffered || video.buffered.length === 0) return 0; const current = video.currentTime; for (let i = 0; i < video.buffered.length; i++) { if (video.buffered.end(i) > current) { return video.buffered.end(i) - current; } } return 0; } /* ======================================================================== */ /* PanelRenderer — per-panel overlay rendering */ /* ======================================================================== */ /** * Per-panel status overlay element, keyed by video. * 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. * Returns the overlay element. */ function createPanelOverlay(panel) { const overlay = document.createElement('div'); overlay.className = 'panel-overlay'; overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;padding:4px 6px;background:linear-gradient(180deg,rgba(0,0,0,0.75) 0%,transparent 100%);color:#e2e8f0;font-family:monospace;font-size:10px;line-height:1.4;pointer-events:none;z-index:20;opacity:0;transition:opacity 0.3s ease;display:none;'; panel.appendChild(overlay); return overlay; } /** Remove the overlay for a video element. */ function removePanelOverlay(video) { const el = panelOverlays.get(video); if (el && el.parentNode) { el.parentNode.removeChild(el); panelOverlays.delete(video); } } /** * Phase emoji indicators for overlay display. */ const PHASE_ICONS = { idle: '\u23F8', // pause loading: '\u2B07', // down arrow buffering: '\u27F7', // spinning playing: '\u25B6', // play switching: '\u21BB', // right arrow corner error: '\u2717', // X ended: '\u23F9', // stop }; /** * Render the current PanelState into the overlay. * Shows: phase indicator, quality, segments loaded/failed, buffer ahead. * * This function is intentionally rendering-only — it reads state, formats it, * and writes to the DOM. No side effects beyond setting aggregateStateDirty. */ function renderPanelState(video, panel) { const state = panelStates.get(video); if (!state) return; let overlay = panelOverlays.get(video); if (!overlay) { overlay = createPanelOverlay(panel); panelOverlays.set(video, overlay); } // Build status lines const lines = []; lines.push(`${PHASE_ICONS[state.phase] || '?'} ${state.phase}`); if (state.level >= 0) { lines.push(`\uD83D\uDD7A\uFE0F ${state.levelLabel}`); } if (state.segmentsLoaded > 0) { const segText = state.segmentsFailed > 0 ? `\uD83D\uDCE6 ${state.segmentsLoaded} seg (+${state.segmentsFailed} fail)` : `\uD83D\uDCE6 ${state.segmentsLoaded} seg`; lines.push(segText); } lines.push(`\uD83D\uDCBE ${state.bufferedAhead.toFixed(1)}s buf`); // Playback progress: currentTime / duration (only when known) if (state.duration > 0) { const pct = ((state.currentTime / state.duration) * 100).toFixed(0); lines.push(`\u23F1 ${state.currentTime.toFixed(1)}s / ${state.duration.toFixed(0)}s (${pct}%)`); } else if (state.currentTime > 0) { lines.push(`\u23F1 ${state.currentTime.toFixed(1)}s (live/loop)`); } if (state.fpsDropCount > 0) { lines.push(`\u26A0 ${state.fpsDropCount} fps-drop`); } if (state.errorMessage) { lines.push(`\u274C ${state.errorMessage}`); } overlay.textContent = lines.join(' '); 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(() => { if (panelOverlays.get(video) === overlay) { overlay.style.opacity = '0.3'; } }, 3000); } } /* ======================================================================== */ /* AggregateRenderer — global status bar */ /* ======================================================================== */ let statusElRef = null; /** * Render aggregate playback state into the top status bar. * Format: `2 playing | 1 buffering | 1 error | 47 seg total` * * HLS delivery is inherently segmented — fragments load one at a time into * the MediaSource buffer. Even when the phase is 'playing', the underlying * state is constantly shifting (buffer growth, quality switches, seg loads). * This function collapses per-panel state into a single human-readable line * so the user can see overall health at a glance. */ 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(`\u25B6 ${playing} playing`); if (buffering > 0) parts.push(`\u27F7 ${buffering} buffering`); if (errors > 0) parts.push(`\u2717 ${errors} error`); if (idle > 0) parts.push(`\u23F8 ${idle} idle`); if (totalSegs > 0) { let segPart = `\uD83D\uDCE6 ${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(`\uD83D\uDCBE ${(totalBuf / active).toFixed(1)}s avg buf`); } statusElRef.textContent = `Live grid ${totalPanels} panels: ${parts.join(' | ')} || Found ${totalPanels} video(s)`; } /* ======================================================================== */ /* EventWiring — hls.js + video element event listeners */ /* ======================================================================== */ /** * Attach hls.js event listeners for fragmented playback tracking. * Wires up every event that reveals the broken-up nature of HLS: * segment loading, buffer mutations, quality switches, and decode issues. * * Side effects: registers callbacks on `hls`, creates `PanelState`, * sets up buffer monitor timer, patches hls.destroy for cleanup. */ function wireHlsEvents(hls, video, panel) { const state = createPanelState(); panelStates.set(video, state); const HlsEvents = window.Hls.Events; // --- Manifest lifecycle --- hls.on(HlsEvents.MANIFEST_LOADED, () => { state.phase = 'loading'; state.lastEvent = 'MANIFEST_LOADED'; state.lastActivity = Date.now(); renderPanelState(video, panel); }); hls.on(HlsEvents.MANIFEST_PARSED, (_ev, data) => { state.lastEvent = 'MANIFEST_PARSED'; state.lastActivity = Date.now(); if (data.levels.length > 0) { const lvl = data.levels[0]; state.levelLabel = `${lvl.width}x${lvl.height}`; } renderPanelState(video, panel); }); // --- Segment / fragment tracking --- // FRAG_LOADING fires continuously during playback as hls.js proactively // loads future segments. During smooth playback the browser's MSE buffer // keeps the video playing without interruption — readyState may briefly // dip to HAVE_CURRENT_DATA (2) between fragments even though the user // sees seamless playback. If we set phase='buffering' on every // FRAG_LOADING, the overlay flickers between "buffering" and "playing". // // Fix: Only transition to 'buffering' from FRAG_LOADING during initial // load states (idle/loading). Once the video reaches 'playing', 'buffering', // or 'switching', defer to the video element's native events // (waiting/stalled/playing — wired in wireVideoEvents) as the authoritative // source for buffering state. Those native events only fire when actual // stalling occurs, not during normal proactive fragment fetching. hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => { // Only set buffering during initial load. During active playback the // native 'waiting'/'stalled' events (see wireVideoEvents) are the // source of truth for when the video is actually stalled. if (state.phase === 'idle' || state.phase === 'loading') { state.phase = 'buffering'; state.lastEvent = 'FRAG_LOADING'; state.lastActivity = Date.now(); renderPanelState(video, panel); } // During playing/buffering/switching: ignore FRAG_LOADING entirely — // it fires continuously as hls.js proactively fetches future segments. // Re-rendering here causes overlay flickering even during smooth playback. }); hls.on(HlsEvents.FRAG_LOADED, (_ev, _data) => { state.segmentsLoaded++; state.lastEvent = 'FRAG_LOADED'; state.lastActivity = Date.now(); renderPanelState(video, panel); }); // FRAG_BUFFERED: transition to playing only when there's meaningful // buffer ahead (readyState >= HAVE_FUTURE_DATA) and video is running. // Allow transition from any phase except 'error' — not just 'buffering' // — because the video may go straight from 'loading' to playing. hls.on(HlsEvents.FRAG_BUFFERED, (_ev, _data) => { state.lastEvent = 'FRAG_BUFFERED'; state.lastActivity = Date.now(); state.bufferedAhead = getBufferedAhead(video); if (state.phase !== 'error' && video.readyState >= 3 && !video.paused) { state.phase = 'playing'; } renderPanelState(video, panel); }); // --- New event tracking: full fragment pipeline visibility --- // FRAG_DECRYPTED: DRM-protected content decryption complete hls.on(HlsEvents.FRAG_DECRYPTED, (_ev, _data) => { state.lastEvent = 'FRAG_DECRYPTED'; state.lastActivity = Date.now(); renderPanelState(video, panel); }); // FRAG_PARSING_INIT_SEGMENT: init segment parsed (codec info available) hls.on(HlsEvents.FRAG_PARSING_INIT_SEGMENT, (_ev, _data) => { state.lastEvent = 'FRAG_PARSING_INIT_SEGMENT'; state.lastActivity = Date.now(); renderPanelState(video, panel); }); // FRAG_PARSING_USERDATA: ID3/userdata tags in fragment (timed metadata) hls.on(HlsEvents.FRAG_PARSING_USERDATA, (_ev, _data) => { state.lastEvent = 'FRAG_PARSING_USERDATA'; state.lastActivity = Date.now(); renderPanelState(video, panel); }); // --- Buffer tracking --- 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) => { state.lastEvent = 'BUFFER_FLUSHED'; state.lastActivity = Date.now(); state.bufferedAhead = getBufferedAhead(video); renderPanelState(video, panel); }); hls.on(HlsEvents.BUFFER_RESET, (_ev, _data) => { state.lastEvent = 'BUFFER_RESET'; state.lastActivity = Date.now(); state.bufferedAhead = 0; // Do NOT reset segmentsLoaded — it's a cumulative metric across the // entire session. BUFFER_RESET fires during quality switches (hls.js // flushes and rebuilds the MediaSource buffer) but the segments were // still successfully loaded and played. Clearing it here makes the // overlay show "0 seg" mid-playback, which is the exact "fragmented / // lost state" the user sees. renderPanelState(video, panel); }); // --- Quality / level switches --- hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => { state.lastEvent = 'LEVEL_SWITCHED'; state.lastActivity = Date.now(); state.level = data.level; const levels = hls.levels; if (levels && levels[data.level]) { const lvl = levels[data.level]; state.levelLabel = `${lvl.width}x${lvl.height}`; } // Briefly show 'switching' to indicate a quality transition is in progress. // However, if there's meaningful buffer ahead, the switch is likely instant // (switching between already-buffered segments) — skip the intermediate state // to avoid the panel getting stuck at 'switching' when FRAG_BUFFERED doesn't fire. const buf = getBufferedAhead(video); state.phase = buf > 2 ? 'playing' : 'switching'; renderPanelState(video, panel); }); hls.on(HlsEvents.LEVEL_UPDATED, (_ev, data) => { state.lastEvent = 'LEVEL_UPDATED'; state.lastActivity = Date.now(); if (data.details) { state.levelLabel = `${data.details.width}x${data.details.height}`; } }); // --- FPS drops (decode pressure) --- hls.on(HlsEvents.FPS_DROP, (_ev, data) => { state.fpsDropCount++; state.lastEvent = 'FPS_DROP'; state.lastActivity = Date.now(); console.warn(`[vidplex-viewer] FPS drop: current=${data.currentFPS}, threshold=${data.thresholdFPS}`); renderPanelState(video, panel); }); hls.on(HlsEvents.FPS_MAX_LEVEL_DROP, (_ev, data) => { state.lastEvent = 'FPS_MAX_LEVEL_DROP'; state.lastActivity = Date.now(); console.warn(`[vidplex-viewer] Max level dropped due to FPS: ${data.currentLevel}`); renderPanelState(video, panel); }); // --- Media lifecycle --- hls.on(HlsEvents.MEDIA_ATTACHED, () => { state.lastEvent = 'MEDIA_ATTACHED'; state.lastActivity = Date.now(); state.phase = 'loading'; renderPanelState(video, panel); }); hls.on(HlsEvents.MEDIA_DETACHED, () => { state.lastEvent = 'MEDIA_DETACHED'; state.lastActivity = Date.now(); state.phase = 'idle'; renderPanelState(video, panel); }); // --- Errors --- hls.on(window.Hls.Events.ERROR, (_event, data) => { state.lastEvent = `ERROR:${data.details}`; state.lastActivity = Date.now(); if (!data.fatal) { // Non-fatal errors (e.g., transient fragment load failure) if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) { state.segmentsFailed++; } renderPanelState(video, panel); return; } // Fatal errors — attempt recovery before giving up if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) { state.errorMessage = `Network: ${data.details}`; state.segmentsFailed++; state.phase = 'error'; renderPanelState(video, panel); hls.destroy(); hlsInstances.delete(video); // A fatal network error on a token-protected stream is almost always an // expired auth token. The live source tab keeps rotating tokens, so pull a // fresh manifest from it and retry — capped at a few attempts per panel so a // tab with no live source doesn't loop forever. if ((video.dataset.hlsRescans || '0') | 0 < 3) { video.dataset.hlsRescans = ((video.dataset.hlsRescans || '0') | 0) + 1; console.log('[vidplex-viewer] fatal NETWORK_ERROR → auto-rescan attempt #' + video.dataset.hlsRescans); showError(panel, 'Refreshing stream…'); rescanAndRetry(video, panel, video.dataset.hlsManifest || ''); } else { showError(panel, 'Stream expired - re-scan to get fresh URLs.'); video.dataset.hlsRescans = '0'; } } else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) { state.errorMessage = `Media: ${data.details}`; state.phase = 'buffering'; state.lastActivity = Date.now(); renderPanelState(video, panel); hls.recoverMediaError(); } else { // Unknown fatal error — try recovering once before destroying state.errorMessage = `Fatal: ${data.details}`; state.phase = 'error'; renderPanelState(video, panel); showError(panel, 'Playback error - unable to recover.'); hls.destroy(); hlsInstances.delete(video); } }); // --- Stream completion --- hls.on(HlsEvents.ENDLIST, () => { state.lastEvent = 'ENDLIST'; state.lastActivity = Date.now(); state.phase = 'ended'; renderPanelState(video, panel); }); } /** * Wire video element lifecycle events (playing, waiting, stalled, etc.). * These complement hls.js events by providing browser-level playback state. */ function wireVideoEvents(video) { // Helper: bind a video event that updates state and re-renders. // Skips the handler if the panel state has already been cleaned up. function onVideoEvent(eventName, updater) { video.addEventListener(eventName, () => { const s = panelStates.get(video); if (!s) return; updater(s); s.lastActivity = Date.now(); if (s._panel) renderPanelState(video, s._panel); }); } // Track playback phase transitions. onVideoEvent('playing', s => { s.phase = 'playing'; s.lastEvent = 'video.playing'; }); onVideoEvent('waiting', s => { s.phase = 'buffering'; s.lastEvent = 'video.waiting'; }); onVideoEvent('stalled', s => { s.phase = 'buffering'; s.lastEvent = 'video.stalled'; }); // Video ended — on looped content this rarely fires (loop re-starts instantly), // but handle it for completeness. The 'ended' phase appears in the overlay // and aggregate state so the user knows playback reached the end of a segment. onVideoEvent('ended', s => { s.phase = 'ended'; s.lastEvent = 'video.ended'; }); // 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; }); // Seek events — update activity marker, re-render. video.addEventListener('seeking', () => { const s = panelStates.get(video); if (s) { s.lastEvent = 'video.seeking'; s.lastActivity = Date.now(); } }); onVideoEvent('seeked', s => { s.lastEvent = 'video.seeked'; }); } /* ======================================================================== */ /* BufferMonitor — periodic buffer health check */ /* ======================================================================== */ /** * Start a periodic timer that refreshes buffer health and re-renders overlays. * Returns a cleanup function. */ function startBufferMonitor(video, panel) { let prevPhase = ''; let prevSegLoaded = -1; let prevSegFailed = -1; let prevLabel = ''; const timer = setInterval(() => { const s = panelStates.get(video); if (!s) { clearInterval(timer); return; } s.bufferedAhead = getBufferedAhead(video); // Only re-render if something actually changed — avoids overlay flickering // on stably playing panels where only lastActivity/lastEvent differ. if (s.phase !== prevPhase || s.segmentsLoaded !== prevSegLoaded || s.segmentsFailed !== prevSegFailed || s.levelLabel !== prevLabel) { renderPanelState(video, panel); prevPhase = s.phase; prevSegLoaded = s.segmentsLoaded; prevSegFailed = s.segmentsFailed; prevLabel = s.levelLabel; } // Refresh aggregate status bar if any panel changed state. renderAggregateState(); }, 1000); return () => clearInterval(timer); } /* ======================================================================== */ /* HLS lifecycle helpers */ /* ======================================================================== */ /** Destroy the Hls instance attached to a video element (if any). */ function destroyHls(video) { const inst = hlsInstances.get(video); if (inst) { inst.destroy(); hlsInstances.delete(video); } panelStates.delete(video); removePanelOverlay(video); } /* ======================================================================== */ /* PanelFactory — DOM panel creation */ /* ======================================================================== */ /** Show an error message inside a panel's video placeholder. */ function showError(panel, message) { removeErrorOverlay(panel); const err = document.createElement('div'); err.className = 'goon-error-overlay'; err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;'; err.textContent = message; panel.appendChild(err); } /** Remove the error overlay from a panel (used before a retry re-plays). */ function removeErrorOverlay(panel) { const existing = panel.querySelectorAll('.goon-error-overlay'); for (const el of existing) el.remove(); } /** Create a video element with standard autoplay attributes. */ function createVideoElement() { const video = document.createElement('video'); video.autoplay = true; video.muted = true; video.loop = true; video.playsInline = true; return video; } /** * Play an HLS manifest on a