CI / test (pull_request) Successful in 27s
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.
607 lines
21 KiB
JavaScript
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();
|