/** * 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. */ const HLS_CDN = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.17'; /** * 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. */ 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 for each fragment. // Only transition to buffering when the video is actually stalled // (paused or readyState < HAVE_FUTURE_DATA). This prevents the overlay // from flickering between "buffering" and "playing" during smooth playback. hls.on(HlsEvents.FRAG_LOADING, (_ev, _data) => { if (video.paused || video.readyState < 3) { state.phase = 'buffering'; } state.lastEvent = 'FRAG_LOADING'; state.lastActivity = Date.now(); renderPanelState(video, panel); }); 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(); 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; state.segmentsLoaded = 0; renderPanelState(video, panel); }); // --- Quality / level switches --- hls.on(HlsEvents.LEVEL_SWITCHED, (_ev, data) => { state.lastEvent = 'LEVEL_SWITCHED'; state.lastActivity = Date.now(); state.phase = 'switching'; 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}`; } 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(`[goon-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(`[goon-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); showError(panel, 'Stream expired - re-scan to get fresh URLs.'); hls.destroy(); hlsInstances.delete(video); } 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'; }); // 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) { const timer = setInterval(() => { const s = panelStates.get(video); if (!s) { clearInterval(timer); return; } s.bufferedAhead = getBufferedAhead(video); renderPanelState(video, panel); // Refresh aggregate status bar if any panel changed state. renderAggregateState(); }, 1000); return () => clearInterval(timer); } /* ======================================================================== */ /* HLS lifecycle helpers */ /* ======================================================================== */ /** Destroy the Hls instance attached to a video element (if any). */ function destroyHls(video) { const inst = hlsInstances.get(video); if (inst) { inst.destroy(); hlsInstances.delete(video); } panelStates.delete(video); removePanelOverlay(video); } /* ======================================================================== */ /* PanelFactory — DOM panel creation */ /* ======================================================================== */ /** Show an error message inside a panel's video placeholder. */ function showError(panel, message) { const err = document.createElement('div'); err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;'; err.textContent = message; panel.appendChild(err); } /** Create a video element with standard autoplay attributes. */ function createVideoElement() { const video = document.createElement('video'); video.autoplay = true; video.muted = true; video.loop = true; video.playsInline = true; return video; } /** * Play an HLS manifest on a