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:
robot committed 2026-08-12 04:47:47 +00:00
1 parent 88585a29bf
commit fe1949acf3
3 files changed
+522 -39

No files matched your search

+32 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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);