Files
goon_plugin/src/viewer/index.js
T
robot bd093be652
CI / test (pull_request) Successful in 27s
refactor(viewer): modularize HLS playback tracking with per-panel state machine
Decompose monolithic wirePlaybackTracking() into single-responsibility modules:

- PanelState: per-panel playback state (phase, segments, buffer, quality)
- EventWiring: attaches hls.js + video element event listeners
- BufferMonitor: periodic buffer health check with 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

Each module handles one concern: state tracking, event registration,
buffer monitoring, overlay rendering, aggregate rendering, or DOM creation.

Also fix: walk() dead code bug in m3u8DeepProbeFn where the string check
was unreachable behind the object-type guard.
2026-08-12 13:43:42 +00:00

607 lines
21 KiB
JavaScript

/**
* index.js - Popup logic for the Goon Plugin.
* Runs inside the popup; scans the current window's tabs for videos
* and stores the results for the viewer to consume.
*/
const STORAGE_KEY = 'goon_videos';
const TAGGED_KEY = 'goon_tagged';
// Set to true to enable verbose console output in the popup.
const DEBUG = false;
function log(...args) { if (DEBUG) console.log('[goon]', ...args); }
function warn(...args) { if (DEBUG) console.warn('[goon]', ...args); }
// UI references
const findBtn = document.getElementById('find-btn');
const clearBtn = document.getElementById('clear-btn');
const openBtn = document.getElementById('open-btn');
const statusEl = document.getElementById('status');
const listEl = document.getElementById('video-list');
const btnRow = document.getElementById('btn-row');
listEl.style.wordBreak = 'break-word';
listEl.style.whiteSpace = 'pre-wrap';
/* ======================================================================== */
/* UI helpers */
/* ======================================================================== */
function setStatus(msg) {
statusEl.textContent = msg;
log(msg);
}
/** Load the current video list and tags from storage. */
function loadVideos() {
return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({
videos: data[STORAGE_KEY] || [],
tags: data[TAGGED_KEY] || [],
}));
}
/** Render the video list UI from stored video entries. */
function renderList(videos) {
listEl.innerHTML = '';
if (!videos || videos.length === 0) {
listEl.style.display = 'none';
return;
}
listEl.style.display = 'block';
for (const video of videos) {
const link = document.createElement('a');
link.className = 'video-item';
link.href = video.src || video.url || '';
link.textContent = video.label ? `${video.label}: ${link.href}` : link.href;
link.target = '_blank';
listEl.appendChild(link);
}
// Copy all URLs button
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy All URLs';
copyBtn.addEventListener('click', () => {
const urls = videos.map(v => v.src || v.url);
navigator.clipboard.writeText(urls.join('\n')).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy All URLs'; }, 1500);
});
});
listEl.appendChild(copyBtn);
}
/** Show the main button row and render saved videos. */
function showActions() {
btnRow.style.display = 'flex';
loadVideos().then(({ videos, tags }) => {
if (videos.length > 0) {
const items = videos.map((video, i) => ({
src: video.src || video.url || '',
label: tags[i],
}));
renderList(items);
setStatus(`Using ${videos.length} video(s) from last search.`);
} else {
setStatus('');
}
});
}
/* ======================================================================== */
/* HLS deep probe - runs in target page context via executeScript */
/* ======================================================================== */
/**
* Deep probe a page for HLS manifest URLs.
* Walks known player objects, video elements, inline scripts, config globals,
* and interceptor-collected data.
* Returns an array of { url, player } objects.
*/
function m3u8DeepProbeFn() {
const manifests = [];
const seen = new Set();
function walk(obj, path, depth) {
if (depth > 5) return;
if (typeof obj === 'string') {
const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi);
if (match) {
for (const u of match) {
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: path }); }
}
}
return;
}
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
walk(obj[k], `${path}.${k}`, depth + 1);
}
}
// Strategy A: Known player globals (shallow + deep)
const playerGlobals = [
'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer',
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
'fhplayer', '__pj', 'playerInstance',
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
];
for (const name of playerGlobals) {
const obj = window[name];
if (!obj || typeof obj === 'function') continue;
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) {
if (typeof obj[key] === 'string' && obj[key].includes('.m3u8')) {
if (!seen.has(obj[key])) { seen.add(obj[key]); manifests.push({ url: obj[key], player: `${name}.${key}` }); }
}
}
walk(obj, name, 0);
}
// Strategy B: Video element HLS instances
// B1: Use Hls.cast(video) — the official hls.js API to get the bound instance
if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') {
for (const v of document.querySelectorAll('video')) {
try {
const hlsInst = Hls.cast(v);
if (hlsInst && hlsInst.url) {
if (!seen.has(hlsInst.url)) { seen.add(hlsInst.url); manifests.push({ url: hlsInst.url, player: 'Hls.cast' }); }
}
} catch { /* ignore */ }
}
}
// B2: Check interceptor-set manifest annotation on video elements
for (const v of document.querySelectorAll('video')) {
if (v._goon_hls_manifest && !seen.has(v._goon_hls_manifest)) {
seen.add(v._goon_hls_manifest);
manifests.push({ url: v._goon_hls_manifest, player: 'video._goon_hls_manifest' });
}
}
// B3: Fallback — walk video element keys looking for HLS-related objects
for (const v of document.querySelectorAll('video')) {
for (const key of Object.keys(v)) {
if (key.toLowerCase().includes('hls') || key === 'player') {
const hlsObj = v[key];
if (hlsObj && typeof hlsObj === 'object') {
for (const subKey of Object.keys(hlsObj)) {
if (typeof hlsObj[subKey] === 'string' && hlsObj[subKey].includes('.m3u8')) {
if (!seen.has(hlsObj[subKey])) { seen.add(hlsObj[subKey]); manifests.push({ url: hlsObj[subKey], player: `video.${key}.${subKey}` }); }
}
}
walk(hlsObj, `video.${key}`, 0);
}
}
}
}
// Strategy C: Inline scripts
for (const script of document.querySelectorAll('script:not([src])')) {
const text = script.textContent || '';
const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi);
if (matches) {
for (const u of matches) {
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); }
}
}
}
// Strategy D: Config globals
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
for (const name of configKeys) {
const obj = window[name];
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;
}
/* ======================================================================== */
/* Video scraper - runs in target page context via executeScript */
/* ======================================================================== */
/**
* 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 }}
*/
function scrapeFn(hlsManifests) {
const allVideos = Array.from(document.querySelectorAll('video'))
.filter(v => {
const style = window.getComputedStyle(v);
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
})
.map(v => {
const area = v.clientWidth * v.clientHeight;
const rawSrc = v.currentSrc || v.src || '';
const poster = v.poster || v.getAttribute('data-poster') || null;
let hlsManifest = null;
let mediaType = 'video';
let src = rawSrc;
// Priority 1: Direct .m3u8 source (rare but possible)
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
hlsManifest = rawSrc;
mediaType = 'hls';
}
// Priority 2: Interceptor annotation — Hls.attachMedia() patched by interceptors
else if (v._goon_hls_manifest) {
hlsManifest = v._goon_hls_manifest;
mediaType = 'hls';
src = hlsManifest;
}
// Priority 3: Hls.cast(video) — official hls.js API (works when Hls is loaded)
else if (typeof Hls !== 'undefined' && typeof Hls.cast === 'function') {
try {
const hlsInst = Hls.cast(v);
if (hlsInst && hlsInst.url) {
hlsManifest = hlsInst.url;
mediaType = 'hls';
src = hlsManifest;
}
} catch { /* ignore */ }
}
// Priority 4: Direct HTTP source (non-blob)
if (!hlsManifest && rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
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) {
// 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;
}
return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest };
});
allVideos.sort((a, b) => b.area - a.area);
// Filter: only keep entries with a real playable source
const playable = allVideos.filter(v => {
const srcOk = v.src && (v.src.startsWith('http://') || v.src.startsWith('https://'));
const hlsOk = v.hlsManifest && (v.hlsManifest.startsWith('http://') || v.hlsManifest.startsWith('https://'));
return srcOk || hlsOk;
});
// Dedup: if multiple videos share the same HLS manifest, keep only the largest
const seen = new Map();
const unique = [];
for (const v of playable) {
const key = (v.hlsManifest || v.src || '') + '||' + (v.poster || '');
if (!seen.has(key)) {
seen.set(key, v);
unique.push(v);
}
}
const largest = unique.length > 0 ? unique[0] : null;
return {
videos: largest ? [largest] : [],
videoCount: largest ? 1 : 0,
hlsManifest: largest?.hlsManifest || null,
};
}
/* ======================================================================== */
/* Scan pipeline */
/* ======================================================================== */
/** URLs that should not be scanned. */
const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:'];
/** Filter out system/browser pages from the tab list. */
function filterValidTabs(tabs) {
return tabs.filter(t =>
t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix))
);
}
/**
* Probe all frames in a tab for HLS manifests.
* Runs in the MAIN world so strategies that depend on page globals work:
* - window.__goon_plugin_data.manifests (set by main-world interceptors)
* - window.Hls / Hls.cast (page's hls.js global)
* - player globals (PHPlayer, flowplayer, etc.)
* - config globals (__NEXT_DATA__, appData, etc.)
*
* The default isolated world cannot see these — only DOM properties like
* video._goon_hls_manifest cross the boundary.
* @param {number} tabId
* @returns {Promise<Array>} array of { url, player } or empty on error
*/
async function probeHlsManifests(tabId) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId, allFrames: true, world: 'MAIN' },
func: m3u8DeepProbeFn,
});
return results.flatMap(r => r.result || []);
} catch {
return [];
}
}
/**
* Aggregate and deduplicate HLS manifests across frames for one tab.
* Prefers manifests from known players/interceptors over inline-script scraping.
* @param {Array} allManifests
* @returns {Array}
*/
function dedupManifests(allManifests) {
const seenUrls = new Set();
const unique = allManifests.filter(m => {
if (seenUrls.has(m.url)) return false;
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 aRank = playerRank[a.player] ?? 50;
const bRank = playerRank[b.player] ?? 50;
return aRank - bRank;
});
return unique;
}
/**
* 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.
*
* Runs in the MAIN world so it can see page globals:
* - Hls.cast(video) — the official hls.js API to get bound instances
* - window.__goon_plugin_data (interceptor-collected manifests)
*
* The default isolated world would miss all of these, causing the same
* "does not detect any videos" failure kfj001 reported.
* @param {number} tabId
* @param {Array} manifests - HLS manifests found by probing
* @returns {Promise<Object|null>} scrape result or null on error
*/
async function scrapeTab(tabId, manifests) {
try {
const results = await chrome.scripting.executeScript({
target: { tabId, allFrames: true, world: 'MAIN' },
func: scrapeFn,
args: [manifests],
});
// 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;
}
}
/**
* 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, posterFromContent) {
const entry = {
src: video.hlsManifest || video.src || '',
poster: video.poster || posterFromContent || null,
tab: tabTitle,
mediaType: video.mediaType || 'video',
};
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest;
return entry;
}
/**
* 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 }>}
*/
async function scanVideos(tabsToScan) {
// Step 1: Probe for HLS manifests in all frames
const manifestPromises = tabsToScan.map(t => probeHlsManifests(t.id));
const allManifestResults = await Promise.all(manifestPromises);
// Build per-tab manifest map (deduped)
const tabManifestMap = new Map();
for (let i = 0; i < tabsToScan.length; i++) {
const unique = dedupManifests(allManifestResults[i]);
if (unique.length > 0) {
tabManifestMap.set(tabsToScan[i].id, unique);
}
}
// 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 contentPromises = tabsToScan.map(t => queryContentScript(t.id));
const [scrapeResults, contentResults] = await Promise.all([
Promise.all(scrapePromises),
Promise.all(contentPromises),
]);
// 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();
for (const group of tabResults) {
for (const video of group.result.videos) {
const primarySrc = video.hlsManifest || video.src || '(empty)';
const key = primarySrc + '||' + (video.poster || '(none)');
if (!seen.has(key)) {
seen.set(key, makeVideoEntry(video, group.tabTitle));
}
}
}
const items = [...seen.values()];
const skipped = scrapeResults.filter(r => r === null).length;
return { items, scanned: tabResults.length, skipped };
}
/** Store video entries in chrome.storage.local. */
async function storeVideos(items) {
const stored = items.map(v => ({
src: v.src,
poster: v.poster,
tab: v.tab,
mediaType: v.mediaType || 'video',
hlsManifest: v.hlsManifest || undefined,
}));
await chrome.storage.local.set({
[STORAGE_KEY]: stored,
[TAGGED_KEY]: items.map(v => v.tab),
});
}
/* ======================================================================== */
/* Event handlers */
/* ======================================================================== */
findBtn.addEventListener('click', async () => {
setStatus('Scanning tabs...');
const allTabs = await chrome.tabs.query({ currentWindow: true });
const validTabs = filterValidTabs(allTabs);
if (validTabs.length === 0) {
setStatus('No browsable tabs found.');
return;
}
setStatus(`Scanning ${validTabs.length} tab(s)...`);
try {
const { items, scanned, skipped } = await scanVideos(validTabs);
await storeVideos(items);
const msg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
setStatus(skipped > 0 ? `${msg} ${skipped} tab(s) skipped (idle).` : msg);
btnRow.style.display = 'flex';
} catch (e) {
setStatus(`Scan failed: ${e.message}`);
warn('executeScript failed:', e);
}
});
/* === "Open Viewer" - launch the NxN grid === */
openBtn.addEventListener('click', async () => {
const { videos, tags } = await loadVideos();
if (!videos || videos.length === 0) {
setStatus('No videos found. Click "Find Videos" first.');
return;
}
const N = Math.ceil(Math.sqrt(videos.length));
const PANEL_MIN_W = 420;
const PANEL_MIN_H = 340;
const GAP = 2;
const winW = Math.max(PANEL_MIN_W * N + GAP * (N - 1), 400);
const winH = Math.max(PANEL_MIN_H * N + GAP * (N - 1) + 40, 300);
const id = 'goonViewer_' + Date.now();
await chrome.storage.local.set({ [id]: videos });
const tagsParam = tags.length > 0 ? '&tags=' + encodeURIComponent(JSON.stringify(tags)) : '';
const viewerUrl = chrome.runtime.getURL('viewer/viewer.html') + '?id=' + id + '&grid=' + N + tagsParam;
chrome.windows.create({ url: viewerUrl, width: winW, height: winH });
});
/* === "Clear" - reset the stored list === */
clearBtn.addEventListener('click', async () => {
await chrome.storage.local.remove([STORAGE_KEY, TAGGED_KEY]);
listEl.style.display = 'none';
listEl.innerHTML = '';
setStatus('');
});
/* === Init - restore saved list === */
showActions();