The vite config outputs 'hls.js' but manifest.json and viewer.js both referenced 'hls.min.js', causing chrome.runtime.getURL() to return a 404 — triggering 'HLS player unavailable'. - Update web_accessible_resources entry in manifest.json - Update loadHlsJs() chrome.runtime.getURL reference - Add debug log of manifest URL being loaded for diagnostics
856 lines
30 KiB
JavaScript
856 lines
30 KiB
JavaScript
/**
|
|
* 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 like Pornhub that
|
|
// restrict script-src to 'self'. The hls.min.js bundle is built by Vite
|
|
// and exposed via chrome.runtime.getURL() so it loads from the extension
|
|
// origin, 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. */
|
|
const hlsInstances = new Map();
|
|
|
|
/* ======================================================================== */
|
|
/* PanelState — per-panel playback state */
|
|
/* ======================================================================== */
|
|
|
|
/** Map<video, PanelState> — 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<video, HTMLElement>
|
|
*/
|
|
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(`[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'; });
|
|
|
|
// 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) {
|
|
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 <video> element using hls.js or native Safari.
|
|
* Wires up fragmented playback tracking for segment/buffer/quality visibility.
|
|
*/
|
|
function playHls(video, manifestUrl, panel) {
|
|
if (!window.Hls || !window.Hls.isSupported()) {
|
|
video.src = manifestUrl;
|
|
const p = video.play();
|
|
if (p) p.catch(() => {});
|
|
return;
|
|
}
|
|
|
|
destroyHls(video);
|
|
|
|
const hls = new window.Hls(HLS_CONFIG);
|
|
hlsInstances.set(video, hls);
|
|
|
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
|
const p = video.play();
|
|
if (p) p.catch(() => {});
|
|
});
|
|
|
|
// Wire all fragmented playback tracking events
|
|
wireHlsEvents(hls, video, panel);
|
|
|
|
// Wire video element events (playing, waiting, stalled, timeupdate, etc.)
|
|
// Store panel ref on state so video event handlers can access it.
|
|
const state = panelStates.get(video);
|
|
if (state) state._panel = panel;
|
|
wireVideoEvents(video);
|
|
|
|
// Start periodic buffer health monitor
|
|
const stopMonitor = startBufferMonitor(video, panel);
|
|
const origDestroy = hls.destroy.bind(hls);
|
|
hls.destroy = function () {
|
|
stopMonitor();
|
|
origDestroy();
|
|
};
|
|
|
|
// Log the manifest URL being loaded for debugging (helps diagnose playback failures).
|
|
console.log(`[goon-viewer] Loading HLS manifest:`, manifestUrl.substring(0, 200));
|
|
|
|
hls.loadSource(manifestUrl);
|
|
hls.attachMedia(video);
|
|
}
|
|
|
|
/**
|
|
* Load hls.js from the extension bundle. Returns a Promise that resolves when ready.
|
|
* We load from chrome.runtime.getURL() (extension origin) instead of a CDN to
|
|
* avoid CSP blocks — sites like Pornhub restrict script-src to 'self', which
|
|
* would block any cross-origin <script src> injection in the viewer window.
|
|
*/
|
|
function loadHlsJs() {
|
|
return new Promise((resolve, reject) => {
|
|
if (window.Hls && window.Hls.isSupported) {
|
|
resolve();
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = chrome.runtime.getURL('hls.js');
|
|
script.onload = () => resolve();
|
|
script.onerror = () => reject(new Error('Hls.js bundle unavailable - rebuild the extension.'));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a single panel (video or empty).
|
|
* Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback.
|
|
*/
|
|
function createPanel(entry, tag) {
|
|
const panel = document.createElement('div');
|
|
panel.classList.add('panel');
|
|
|
|
if (!entry) {
|
|
panel.classList.add('empty');
|
|
return { panel, hlsEntry: null };
|
|
}
|
|
|
|
const video = createVideoElement();
|
|
|
|
const src = entry.src || entry.url || '';
|
|
const mediaType = entry.mediaType || 'video';
|
|
const poster = entry.poster || null;
|
|
const hlsManifest = entry.hlsManifest || null;
|
|
|
|
if (poster) video.poster = poster;
|
|
|
|
if (mediaType === 'hls') {
|
|
// Use explicit hlsManifest field, or fall back to src if it looks like a manifest URL.
|
|
// This handles the case where hlsManifest was lost in storage serialization but src
|
|
// still points to the .m3u8 playlist.
|
|
const manifestUrl = hlsManifest || (src.includes('.m3u8') && src.startsWith('http') ? src : null);
|
|
if (manifestUrl) {
|
|
video.dataset.hlsManifest = manifestUrl;
|
|
video.dataset.mediaType = 'hls';
|
|
panel.appendChild(video);
|
|
if (tag) {
|
|
const label = document.createElement('div');
|
|
label.className = 'panel-label';
|
|
label.textContent = tag;
|
|
panel.appendChild(label);
|
|
}
|
|
return { panel, hlsEntry: { video, manifest: manifestUrl, panel } };
|
|
}
|
|
// mediaType says HLS but we have no manifest URL — fall through to native playback.
|
|
// On Safari this may still work for .m3u8; on Chrome it will fail.
|
|
console.warn('[goon-viewer] HLS entry missing manifest URL, src:', src);
|
|
}
|
|
|
|
video.src = src;
|
|
panel.appendChild(video);
|
|
if (tag) {
|
|
const label = document.createElement('div');
|
|
label.className = 'panel-label';
|
|
label.textContent = tag;
|
|
panel.appendChild(label);
|
|
}
|
|
return { panel, hlsEntry: null };
|
|
}
|
|
|
|
/* ======================================================================== */
|
|
/* Init */
|
|
/* ======================================================================== */
|
|
|
|
async function init() {
|
|
if (!id) {
|
|
panelsEl.innerHTML = '<div style="padding:20px;color:#64748b">No viewer ID provided</div>';
|
|
return;
|
|
}
|
|
|
|
const data = await chrome.storage.local.get([id]);
|
|
const videoUrls = data[id] || [];
|
|
|
|
// 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 = [];
|
|
|
|
for (let i = 0; i < totalPanels; i++) {
|
|
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : (videoUrls[i] ? { src: videoUrls[i] } : null);
|
|
const { panel, hlsEntry } = createPanel(entry, tags[i]);
|
|
panelsEl.appendChild(panel);
|
|
if (hlsEntry) hlsEntries.push(hlsEntry);
|
|
}
|
|
|
|
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
|
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
|
|
|
// Load hls.js and start HLS playback
|
|
if (hlsEntries.length > 0) {
|
|
try {
|
|
await loadHlsJs();
|
|
} catch {
|
|
for (const { panel } of hlsEntries) {
|
|
showError(panel, 'HLS player unavailable - check network.');
|
|
}
|
|
return;
|
|
}
|
|
for (const { video, manifest, panel } of hlsEntries) {
|
|
playHls(video, manifest, panel);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clean up storage entry and HLS instances when the viewer is closed
|
|
window.addEventListener('unload', async () => {
|
|
for (const video of [...hlsInstances.keys()]) {
|
|
destroyHls(video);
|
|
}
|
|
if (id) {
|
|
await chrome.storage.local.remove([id]);
|
|
}
|
|
});
|
|
|
|
init();
|