fix: refactor HLS detection pipeline to restore dropped strategies
- Add Strategy E to m3u8DeepProbeFn: read __goon_plugin_data.manifests from main-world interceptors (was accidentally dropped during refactoring) - Re-add content script query in scanVideos: restores poster URL extraction (og:image, twitter:image) and secondary manifest source from content.js - Extend interceptor Hls.js polling from 3s to 30s + add MutationObserver fallback for dynamically-loaded players - Restore allFrames:true in scrapeTab: catches iframe-hosted video players (e.g. Pornhub embeds player in nested iframes) - Prefer interceptor-collected manifests over inline-script scraps in both dedupManifests sort order and scrapeFn Priority 5 fallback
This commit is contained in:
1 parent
88585a29bf
commit
fe1949acf3
3 files changed
+522
-39
No files matched your search
@@ -120,13 +120,14 @@
|
||||
// Patch immediately if HLS is already defined
|
||||
patchHlsPrototype();
|
||||
|
||||
// Watch for HLS.js to be dynamically defined later — use MutationObserver
|
||||
// on the <head> for script tags that load hls.js, as a more reliable fallback
|
||||
// than Object.defineProperty (which conflicts with page JS).
|
||||
// Watch for HLS.js to be dynamically defined later.
|
||||
// Use a combination of:
|
||||
// 1. Longer polling window (30s instead of 3s) to catch lazy-loaded players.
|
||||
// 2. MutationObserver on <head> for new <script> tags — catches late hls.js loads.
|
||||
var hlsPatched = false;
|
||||
if (!hlsPatched) {
|
||||
var pollCount = 0;
|
||||
var maxPolls = 60; // poll for up to ~3s (50ms interval)
|
||||
var maxPolls = 600; // poll for up to ~30s (50ms interval)
|
||||
|
||||
var pollHls = setInterval(function () {
|
||||
pollCount++;
|
||||
@@ -137,5 +138,32 @@
|
||||
}
|
||||
if (pollCount >= maxPolls) clearInterval(pollHls);
|
||||
}, 50);
|
||||
|
||||
// MutationObserver: watches for new <script> tags that may load hls.js late.
|
||||
// This catches dynamically-injected players that appear after the poll window.
|
||||
try {
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
if (hlsPatched) { observer.disconnect(); return; }
|
||||
for (var m = 0; m < mutations.length; m++) {
|
||||
var nodes = mutations[m].addedNodes;
|
||||
if (!nodes) continue;
|
||||
for (var n = 0; n < nodes.length; n++) {
|
||||
var node = nodes[n];
|
||||
if (node.tagName === 'SCRIPT' && node.src) {
|
||||
var src = node.src.toLowerCase();
|
||||
if (src.includes('hls') || src.includes('videojs') || src.includes('player')) {
|
||||
// Script tag loaded — check if Hls is now defined
|
||||
if (typeof Hls !== 'undefined' && !hlsPatched) {
|
||||
hlsPatched = true;
|
||||
patchHlsPrototype();
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.head || document.documentElement, { childList: true, subtree: true });
|
||||
} catch(e) { /* MutationObserver not available — polling still works */ }
|
||||
}
|
||||
})();
|
||||
+105
-20
@@ -96,7 +96,8 @@ function showActions() {
|
||||
|
||||
/**
|
||||
* Deep probe a page for HLS manifest URLs.
|
||||
* Walks known player objects, video elements, inline scripts, and config globals.
|
||||
* Walks known player objects, video elements, inline scripts, config globals,
|
||||
* and interceptor-collected data.
|
||||
* Returns an array of { url, player } objects.
|
||||
*/
|
||||
function m3u8DeepProbeFn() {
|
||||
@@ -193,6 +194,20 @@ function m3u8DeepProbeFn() {
|
||||
if (obj && typeof obj === 'object') walk(obj, name, 0);
|
||||
}
|
||||
|
||||
// Strategy E: Interceptor-collected manifests — the most reliable source.
|
||||
// The main-world interceptors (injected at document_start) capture .m3u8 URLs
|
||||
// from XHR, fetch, and Hls.prototype.loadSource/attachMedia calls, and store
|
||||
// them in window.__goon_plugin_data.manifests[]. This was the original
|
||||
// Strategy 3/6 on master that was accidentally dropped during refactoring.
|
||||
if (window.__goon_plugin_data && Array.isArray(window.__goon_plugin_data.manifests)) {
|
||||
for (const url of window.__goon_plugin_data.manifests) {
|
||||
if (url && typeof url === 'string' && !seen.has(url)) {
|
||||
seen.add(url);
|
||||
manifests.push({ url, player: '__goon_plugin_data' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
@@ -201,7 +216,7 @@ function m3u8DeepProbeFn() {
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Scrape visible video elements from the main frame.
|
||||
* Scrape visible video elements from the current frame.
|
||||
* Returns at most one video entry (the largest visible one).
|
||||
* @param {Array} hlsManifests - manifests found by the deep probe
|
||||
* @returns {{ videos: Array, videoCount: number, hlsManifest: string|null }}
|
||||
@@ -248,8 +263,15 @@ function scrapeFn(hlsManifests) {
|
||||
mediaType = 'video';
|
||||
}
|
||||
// Priority 5: Deep probe fallback — use manifests found by scanning page globals/scripts
|
||||
// and interceptor-collected manifests. This is the most reliable fallback because
|
||||
// interceptors catch HLS activity at the network layer regardless of player type.
|
||||
if (!hlsManifest && hlsManifests && hlsManifests.length > 0) {
|
||||
hlsManifest = hlsManifests[0].url || hlsManifests[0];
|
||||
// Prefer interceptor-collected manifests over script-scrapped ones
|
||||
const interceptManifest = hlsManifests.find(m => m.player === '__goon_plugin_data') ||
|
||||
hlsManifests.find(m => m.player === 'content-script-intercept') ||
|
||||
hlsManifests.find(m => m.player !== 'inline-script') ||
|
||||
hlsManifests[0];
|
||||
hlsManifest = interceptManifest.url || interceptManifest;
|
||||
mediaType = 'hls';
|
||||
src = hlsManifest;
|
||||
}
|
||||
@@ -318,7 +340,7 @@ async function probeHlsManifests(tabId) {
|
||||
|
||||
/**
|
||||
* Aggregate and deduplicate HLS manifests across frames for one tab.
|
||||
* Prefers manifests from known players over inline-script scraping.
|
||||
* Prefers manifests from known players/interceptors over inline-script scraping.
|
||||
* @param {Array} allManifests
|
||||
* @returns {Array}
|
||||
*/
|
||||
@@ -329,16 +351,39 @@ function dedupManifests(allManifests) {
|
||||
seenUrls.add(m.url);
|
||||
return true;
|
||||
});
|
||||
// Sort: prefer interceptor-collected > player globals > config > inline-script
|
||||
const playerRank = {
|
||||
'__goon_plugin_data': 0,
|
||||
'content-script-intercept': 0,
|
||||
'video._goon_hls_manifest': 1,
|
||||
'Hls.cast': 1,
|
||||
'inline-script': 99,
|
||||
};
|
||||
unique.sort((a, b) => {
|
||||
const aInline = a.player === 'inline-script' ? 1 : 0;
|
||||
const bInline = b.player === 'inline-script' ? 1 : 0;
|
||||
return aInline - bInline;
|
||||
const aRank = playerRank[a.player] ?? 50;
|
||||
const bRank = playerRank[b.player] ?? 50;
|
||||
return aRank - bRank;
|
||||
});
|
||||
return unique;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape video elements from the main frame of a tab.
|
||||
* Query the content script for poster URLs, intercepted manifests, and video metadata.
|
||||
* @param {number} tabId
|
||||
* @returns {Promise<Object|null>} content script response or null on error/timeout
|
||||
*/
|
||||
async function queryContentScript(tabId) {
|
||||
try {
|
||||
return await chrome.tabs.sendMessage(tabId, { type: 'GOON_GET_VIDEO_DATA' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape video elements from ALL frames in a tab.
|
||||
* On sites like Pornhub, the video player lives inside an iframe — main-frame-only
|
||||
* scraping sees zero <video> elements. Running allFrames catches them.
|
||||
* @param {number} tabId
|
||||
* @param {Array} manifests - HLS manifests found by probing
|
||||
* @returns {Promise<Object|null>} scrape result or null on error
|
||||
@@ -346,11 +391,21 @@ function dedupManifests(allManifests) {
|
||||
async function scrapeTab(tabId, manifests) {
|
||||
try {
|
||||
const results = await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
target: { tabId, allFrames: true },
|
||||
func: scrapeFn,
|
||||
args: [manifests],
|
||||
});
|
||||
return results[0]?.result || null;
|
||||
// Prefer the result with the largest video (may come from an iframe)
|
||||
const valid = results
|
||||
.map(r => r.result)
|
||||
.filter(r => r && r.videos && r.videos.length > 0);
|
||||
if (valid.length === 0) return results[0]?.result || null;
|
||||
valid.sort((a, b) => {
|
||||
const aArea = a.videos[0]?.area || 0;
|
||||
const bArea = b.videos[0]?.area || 0;
|
||||
return bArea - aArea;
|
||||
});
|
||||
return valid[0];
|
||||
} catch {
|
||||
warn('skipping tab', tabId);
|
||||
return null;
|
||||
@@ -361,12 +416,13 @@ async function scrapeTab(tabId, manifests) {
|
||||
* Build a normalized video entry from a scraped video object.
|
||||
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
|
||||
* @param {string} tabTitle
|
||||
* @param {string|null} posterFromContent - poster URL from content script (OG image, etc.)
|
||||
* @returns {{ src: string, poster: string|null, tab: string, mediaType: string, hlsManifest?: string }}
|
||||
*/
|
||||
function makeVideoEntry(video, tabTitle) {
|
||||
function makeVideoEntry(video, tabTitle, posterFromContent) {
|
||||
const entry = {
|
||||
src: video.hlsManifest || video.src || '',
|
||||
poster: video.poster || null,
|
||||
poster: video.poster || posterFromContent || null,
|
||||
tab: tabTitle,
|
||||
mediaType: video.mediaType || 'video',
|
||||
};
|
||||
@@ -375,7 +431,7 @@ function makeVideoEntry(video, tabTitle) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full scan: probe HLS manifests, scrape video elements, dedup, store.
|
||||
* Run the full scan: probe HLS manifests, scrape video elements, query content scripts, dedup, store.
|
||||
* @param {Array} tabsToScan - tabs returned by chrome.tabs.query
|
||||
* @returns {Promise<{ items: Array, scanned: number, skipped: number }>}
|
||||
*/
|
||||
@@ -393,17 +449,46 @@ async function scanVideos(tabsToScan) {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Scrape video elements (main frame only)
|
||||
// Step 2: Scrape video elements (all frames) AND query content scripts (in parallel)
|
||||
const scrapePromises = tabsToScan.map(t =>
|
||||
scrapeTab(t.id, tabManifestMap.get(t.id) || [])
|
||||
);
|
||||
const scrapeResults = await Promise.all(scrapePromises);
|
||||
const contentPromises = tabsToScan.map(t => queryContentScript(t.id));
|
||||
const [scrapeResults, contentResults] = await Promise.all([
|
||||
Promise.all(scrapePromises),
|
||||
Promise.all(contentPromises),
|
||||
]);
|
||||
|
||||
// Step 3: Collect results from tabs that found videos
|
||||
const tabResults = tabsToScan.map((tab, i) => ({
|
||||
tabTitle: tab.title || 'Unknown',
|
||||
result: scrapeResults[i],
|
||||
})).filter(g => g.result && g.result.videos?.length > 0);
|
||||
// Step 3: Merge content-script data into tab results
|
||||
// - Enrich video entries with poster URLs from the content script
|
||||
// - Merge content-script intercepted manifests into the deep probe results
|
||||
const tabResults = tabsToScan.map((tab, i) => {
|
||||
const result = scrapeResults[i];
|
||||
const contentData = contentResults[i];
|
||||
|
||||
// Enrich videos with poster from content script (og:image, twitter:image, etc.)
|
||||
if (result && result.videos?.length > 0 && contentData?.posterUrl) {
|
||||
for (const v of result.videos) {
|
||||
if (!v.poster) v.poster = contentData.posterUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Merge content-script intercepted manifests into the tab's manifest list
|
||||
if (contentData && contentData.interceptedManifests) {
|
||||
const existing = tabManifestMap.get(tab.id) || [];
|
||||
for (const m of contentData.interceptedManifests) {
|
||||
if (m.url && !existing.some(em => em.url === m.url)) {
|
||||
existing.push({ url: m.url, player: 'content-script-intercept' });
|
||||
}
|
||||
}
|
||||
tabManifestMap.set(tab.id, existing);
|
||||
}
|
||||
|
||||
return {
|
||||
tabTitle: tab.title || 'Unknown',
|
||||
result,
|
||||
};
|
||||
}).filter(g => g.result && g.result.videos?.length > 0);
|
||||
|
||||
// Step 4: Dedup across tabs
|
||||
const seen = new Map();
|
||||
|
||||
+385
-15
@@ -2,6 +2,13 @@
|
||||
* 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.
|
||||
*
|
||||
* Fragmented playback tracking:
|
||||
* 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';
|
||||
@@ -21,6 +28,56 @@ const panelsEl = document.getElementById('panels');
|
||||
/** Track all Hls instances so we can destroy them on unload. */
|
||||
const hlsInstances = new Map();
|
||||
|
||||
/**
|
||||
* Per-panel playback state for fragmented HLS tracking.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/** Map<video, PanelState> — one state object per video panel. */
|
||||
const panelStates = new Map();
|
||||
|
||||
/**
|
||||
* 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 helpers */
|
||||
/* ======================================================================== */
|
||||
@@ -32,10 +89,336 @@ function destroyHls(video) {
|
||||
inst.destroy();
|
||||
hlsInstances.delete(video);
|
||||
}
|
||||
panelStates.delete(video);
|
||||
removePanelOverlay(video);
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Panel overlay helpers */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* Per-panel status overlay element, keyed by video.
|
||||
* Map<video, HTMLElement>
|
||||
*/
|
||||
const panelOverlays = new Map();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current PanelState into the overlay.
|
||||
* Shows: phase indicator, quality, segments loaded/failed, buffer ahead.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// Phase emoji/indicator
|
||||
const phaseIcons = {
|
||||
idle: '⏸',
|
||||
loading: '⬇',
|
||||
buffering: '◷',
|
||||
playing: '▶',
|
||||
error: '✗',
|
||||
};
|
||||
|
||||
// Build status lines
|
||||
const lines = [];
|
||||
lines.push(`${phaseIcons[state.phase] || '?'} ${state.phase}`);
|
||||
if (state.level >= 0) {
|
||||
lines.push(`📺 ${state.levelLabel}`);
|
||||
}
|
||||
if (state.segmentsLoaded > 0) {
|
||||
const segText = state.segmentsFailed > 0
|
||||
? `📦 ${state.segmentsLoaded} seg (+${state.segmentsFailed} fail)`
|
||||
: `📦 ${state.segmentsLoaded} seg`;
|
||||
lines.push(segText);
|
||||
}
|
||||
lines.push(`💾 ${state.bufferedAhead.toFixed(1)}s buf`);
|
||||
if (state.fpsDropCount > 0) {
|
||||
lines.push(`⚠ ${state.fpsDropCount} fps-drop`);
|
||||
}
|
||||
if (state.errorMessage) {
|
||||
lines.push(`❌ ${state.errorMessage}`);
|
||||
}
|
||||
|
||||
overlay.textContent = lines.join(' ');
|
||||
overlay.style.display = 'block';
|
||||
overlay.style.opacity = '1';
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================================== */
|
||||
/* Fragmented playback tracker */
|
||||
/* ======================================================================== */
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// Find the buffer range that contains or is ahead of current time
|
||||
for (let i = 0; i < video.buffered.length; i++) {
|
||||
if (video.buffered.end(i) > current) {
|
||||
return video.buffered.end(i) - current;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach hls.js event listeners for fragmented playback tracking.
|
||||
* This wires up every event that reveals the broken-up nature of HLS:
|
||||
* segment loading, buffer mutations, quality switches, and decode issues.
|
||||
*/
|
||||
function wirePlaybackTracking(hls, video, panel) {
|
||||
const state = createPanelState();
|
||||
panelStates.set(video, state);
|
||||
|
||||
// Periodically update buffer health and re-render overlay
|
||||
const bufferTimer = setInterval(() => {
|
||||
const s = panelStates.get(video);
|
||||
if (!s) { clearInterval(bufferTimer); return; }
|
||||
s.bufferedAhead = getBufferedAhead(video);
|
||||
renderPanelState(video, panel);
|
||||
}, 1000);
|
||||
|
||||
// Clean up timer on destroy (monkey-patch hls.destroy)
|
||||
const origDestroy = hls.destroy.bind(hls);
|
||||
hls.destroy = function () {
|
||||
clearInterval(bufferTimer);
|
||||
origDestroy();
|
||||
};
|
||||
|
||||
const HlsEvents = window.Hls.Events;
|
||||
|
||||
// --- Manifest lifecycle ---
|
||||
hls.on(HlsEvents.MANIFEST_LOADED, (_ev, data) => {
|
||||
state.phase = 'loading';
|
||||
state.lastEvent = 'MANIFEST_LOADED';
|
||||
state.lastActivity = Date.now();
|
||||
console.log(`[goon-viewer] Manifest loaded: ${data.levels.length} levels, type=${data.type}`);
|
||||
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}`;
|
||||
}
|
||||
console.log(`[goon-viewer] Manifest parsed: ${data.levels.length} levels, audio=${data.audioTracks?.length || 0} tracks`);
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
// --- Segment / fragment tracking ---
|
||||
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) => {
|
||||
state.segmentsLoaded++;
|
||||
state.lastEvent = 'FRAG_LOADED';
|
||||
state.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
});
|
||||
|
||||
hls.on(HlsEvents.FRAG_BUFFERED, (_ev, data) => {
|
||||
state.lastEvent = 'FRAG_BUFFERED';
|
||||
state.lastActivity = Date.now();
|
||||
if (state.phase === 'buffering' && video.readyState >= 2) {
|
||||
state.phase = 'playing';
|
||||
}
|
||||
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.level = data.level;
|
||||
const levels = hls.levels;
|
||||
if (levels && levels[data.level]) {
|
||||
const lvl = levels[data.level];
|
||||
state.levelLabel = `${lvl.width}x${lvl.height}`;
|
||||
}
|
||||
console.log(`[goon-viewer] Level switched to ${data.level} (${state.levelLabel})`);
|
||||
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
|
||||
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
state.errorMessage = `Network: ${data.details}`;
|
||||
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';
|
||||
renderPanelState(video, panel);
|
||||
hls.recoverMediaError();
|
||||
} else {
|
||||
state.errorMessage = `Fatal: ${data.details}`;
|
||||
state.phase = 'error';
|
||||
renderPanelState(video, panel);
|
||||
showError(panel, 'Playback error - unable to recover.');
|
||||
hls.destroy();
|
||||
hlsInstances.delete(video);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Video element events (complement hls.js) ---
|
||||
video.addEventListener('playing', () => {
|
||||
const s = panelStates.get(video);
|
||||
if (s) {
|
||||
s.phase = 'playing';
|
||||
s.lastEvent = 'video.playing';
|
||||
s.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('waiting', () => {
|
||||
const s = panelStates.get(video);
|
||||
if (s) {
|
||||
s.phase = 'buffering';
|
||||
s.lastEvent = 'video.waiting';
|
||||
s.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('stalled', () => {
|
||||
const s = panelStates.get(video);
|
||||
if (s) {
|
||||
s.phase = 'buffering';
|
||||
s.lastEvent = 'video.stalled';
|
||||
s.lastActivity = Date.now();
|
||||
renderPanelState(video, panel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
@@ -55,21 +438,8 @@ function playHls(video, manifestUrl, panel) {
|
||||
if (p) p.catch(() => {});
|
||||
});
|
||||
|
||||
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||
if (!data.fatal) return;
|
||||
|
||||
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
showError(panel, 'Stream expired - re-scan to get fresh URLs.');
|
||||
hls.destroy();
|
||||
hlsInstances.delete(video);
|
||||
} else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
hls.recoverMediaError();
|
||||
} else {
|
||||
showError(panel, 'Playback error - unable to recover.');
|
||||
hls.destroy();
|
||||
hlsInstances.delete(video);
|
||||
}
|
||||
});
|
||||
// Wire all fragmented playback tracking events
|
||||
wirePlaybackTracking(hls, video, panel);
|
||||
|
||||
hls.loadSource(manifestUrl);
|
||||
hls.attachMedia(video);
|
||||
|
||||
Reference in new issue
Block a user