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
|
// Patch immediately if HLS is already defined
|
||||||
patchHlsPrototype();
|
patchHlsPrototype();
|
||||||
|
|
||||||
// Watch for HLS.js to be dynamically defined later — use MutationObserver
|
// Watch for HLS.js to be dynamically defined later.
|
||||||
// on the <head> for script tags that load hls.js, as a more reliable fallback
|
// Use a combination of:
|
||||||
// than Object.defineProperty (which conflicts with page JS).
|
// 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;
|
var hlsPatched = false;
|
||||||
if (!hlsPatched) {
|
if (!hlsPatched) {
|
||||||
var pollCount = 0;
|
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 () {
|
var pollHls = setInterval(function () {
|
||||||
pollCount++;
|
pollCount++;
|
||||||
@@ -137,5 +138,32 @@
|
|||||||
}
|
}
|
||||||
if (pollCount >= maxPolls) clearInterval(pollHls);
|
if (pollCount >= maxPolls) clearInterval(pollHls);
|
||||||
}, 50);
|
}, 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.
|
* 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.
|
* Returns an array of { url, player } objects.
|
||||||
*/
|
*/
|
||||||
function m3u8DeepProbeFn() {
|
function m3u8DeepProbeFn() {
|
||||||
@@ -193,6 +194,20 @@ function m3u8DeepProbeFn() {
|
|||||||
if (obj && typeof obj === 'object') walk(obj, name, 0);
|
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;
|
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).
|
* Returns at most one video entry (the largest visible one).
|
||||||
* @param {Array} hlsManifests - manifests found by the deep probe
|
* @param {Array} hlsManifests - manifests found by the deep probe
|
||||||
* @returns {{ videos: Array, videoCount: number, hlsManifest: string|null }}
|
* @returns {{ videos: Array, videoCount: number, hlsManifest: string|null }}
|
||||||
@@ -248,8 +263,15 @@ function scrapeFn(hlsManifests) {
|
|||||||
mediaType = 'video';
|
mediaType = 'video';
|
||||||
}
|
}
|
||||||
// Priority 5: Deep probe fallback — use manifests found by scanning page globals/scripts
|
// 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) {
|
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';
|
mediaType = 'hls';
|
||||||
src = hlsManifest;
|
src = hlsManifest;
|
||||||
}
|
}
|
||||||
@@ -318,7 +340,7 @@ async function probeHlsManifests(tabId) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate and deduplicate HLS manifests across frames for one tab.
|
* 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
|
* @param {Array} allManifests
|
||||||
* @returns {Array}
|
* @returns {Array}
|
||||||
*/
|
*/
|
||||||
@@ -329,16 +351,39 @@ function dedupManifests(allManifests) {
|
|||||||
seenUrls.add(m.url);
|
seenUrls.add(m.url);
|
||||||
return true;
|
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) => {
|
unique.sort((a, b) => {
|
||||||
const aInline = a.player === 'inline-script' ? 1 : 0;
|
const aRank = playerRank[a.player] ?? 50;
|
||||||
const bInline = b.player === 'inline-script' ? 1 : 0;
|
const bRank = playerRank[b.player] ?? 50;
|
||||||
return aInline - bInline;
|
return aRank - bRank;
|
||||||
});
|
});
|
||||||
return unique;
|
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 {number} tabId
|
||||||
* @param {Array} manifests - HLS manifests found by probing
|
* @param {Array} manifests - HLS manifests found by probing
|
||||||
* @returns {Promise<Object|null>} scrape result or null on error
|
* @returns {Promise<Object|null>} scrape result or null on error
|
||||||
@@ -346,11 +391,21 @@ function dedupManifests(allManifests) {
|
|||||||
async function scrapeTab(tabId, manifests) {
|
async function scrapeTab(tabId, manifests) {
|
||||||
try {
|
try {
|
||||||
const results = await chrome.scripting.executeScript({
|
const results = await chrome.scripting.executeScript({
|
||||||
target: { tabId },
|
target: { tabId, allFrames: true },
|
||||||
func: scrapeFn,
|
func: scrapeFn,
|
||||||
args: [manifests],
|
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 {
|
} catch {
|
||||||
warn('skipping tab', tabId);
|
warn('skipping tab', tabId);
|
||||||
return null;
|
return null;
|
||||||
@@ -361,12 +416,13 @@ async function scrapeTab(tabId, manifests) {
|
|||||||
* Build a normalized video entry from a scraped video object.
|
* Build a normalized video entry from a scraped video object.
|
||||||
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
|
* @param {{ src: string, poster: string|null, mediaType: string, hlsManifest: string|null }} video
|
||||||
* @param {string} tabTitle
|
* @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 }}
|
* @returns {{ src: string, poster: string|null, tab: string, mediaType: string, hlsManifest?: string }}
|
||||||
*/
|
*/
|
||||||
function makeVideoEntry(video, tabTitle) {
|
function makeVideoEntry(video, tabTitle, posterFromContent) {
|
||||||
const entry = {
|
const entry = {
|
||||||
src: video.hlsManifest || video.src || '',
|
src: video.hlsManifest || video.src || '',
|
||||||
poster: video.poster || null,
|
poster: video.poster || posterFromContent || null,
|
||||||
tab: tabTitle,
|
tab: tabTitle,
|
||||||
mediaType: video.mediaType || 'video',
|
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
|
* @param {Array} tabsToScan - tabs returned by chrome.tabs.query
|
||||||
* @returns {Promise<{ items: Array, scanned: number, skipped: number }>}
|
* @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 =>
|
const scrapePromises = tabsToScan.map(t =>
|
||||||
scrapeTab(t.id, tabManifestMap.get(t.id) || [])
|
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
|
// Step 3: Merge content-script data into tab results
|
||||||
const tabResults = tabsToScan.map((tab, i) => ({
|
// - Enrich video entries with poster URLs from the content script
|
||||||
tabTitle: tab.title || 'Unknown',
|
// - Merge content-script intercepted manifests into the deep probe results
|
||||||
result: scrapeResults[i],
|
const tabResults = tabsToScan.map((tab, i) => {
|
||||||
})).filter(g => g.result && g.result.videos?.length > 0);
|
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
|
// Step 4: Dedup across tabs
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
|
|||||||
+385
-15
@@ -2,6 +2,13 @@
|
|||||||
* viewer.js - Goon Viewer page logic.
|
* viewer.js - Goon Viewer page logic.
|
||||||
* Reads video URLs from chrome.storage and renders them in a dynamic NxN grid.
|
* 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.
|
* 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';
|
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. */
|
/** Track all Hls instances so we can destroy them on unload. */
|
||||||
const hlsInstances = new Map();
|
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 */
|
/* HLS helpers */
|
||||||
/* ======================================================================== */
|
/* ======================================================================== */
|
||||||
@@ -32,10 +89,336 @@ function destroyHls(video) {
|
|||||||
inst.destroy();
|
inst.destroy();
|
||||||
hlsInstances.delete(video);
|
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.
|
* 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) {
|
function playHls(video, manifestUrl, panel) {
|
||||||
if (!window.Hls || !window.Hls.isSupported()) {
|
if (!window.Hls || !window.Hls.isSupported()) {
|
||||||
@@ -55,21 +438,8 @@ function playHls(video, manifestUrl, panel) {
|
|||||||
if (p) p.catch(() => {});
|
if (p) p.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
// Wire all fragmented playback tracking events
|
||||||
if (!data.fatal) return;
|
wirePlaybackTracking(hls, video, panel);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
hls.loadSource(manifestUrl);
|
hls.loadSource(manifestUrl);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|||||||
Reference in new issue
Block a user