/** * index.js — Popup logic for the Goon Plugin. * Runs inside the popup; scans the current tab for videos and opens the viewer. */ const STORAGE_KEY = 'goon_videos'; const TAGGED_KEY = 'goon_tagged'; 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'; function setStatus(msg) { statusEl.textContent = msg; console.log('[goon]', msg); } /** Load the current video list and tags from storage. */ function loadVideos() { return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({ urls: data[STORAGE_KEY] || [], tags: data[TAGGED_KEY] || [], })); } /** Render the video list UI from stored {url, label} objects. */ function renderList(items) { listEl.innerHTML = ''; if (!items || items.length === 0) { listEl.style.display = 'none'; return; } listEl.style.display = 'block'; for (const item of items) { const link = document.createElement('a'); link.className = 'video-item'; let url = item.url; if (typeof url === 'string' && item.src) url = item.src; if (typeof url === 'object' && url.src) url = url.src; link.href = url; link.textContent = item.label ? `${item.label}: ${url}` : url; 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 = items.map(v => (typeof v === 'object' && v.src) ? v.src : (typeof v === 'object' ? v.url : v)); 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. */ function showActions() { btnRow.style.display = 'flex'; loadVideos().then(({ urls, tags }) => { if (urls.length > 0) { const items = urls.map((url, i) => { const entry = { url: url.src || url, label: tags[i] }; if (typeof url === 'object' && url.src) entry.src = url.src; return entry; }); renderList(items); setStatus(`Using ${urls.length} video(s) from last search.`); } else { setStatus(''); } }); } /* === "Find Videos" — scan all tabs in the window === */ findBtn.addEventListener('click', async () => { setStatus('Scanning tabs…'); // Get all tabs in the current window const allTabs = await chrome.tabs.query({ currentWindow: true }); // Filter out chrome/system pages const validTabs = allTabs.filter(t => t.url && !t.url.startsWith('chrome://') && !t.url.startsWith('chrome-extension://') && !t.url.startsWith('edge://') && !t.url.startsWith('about:') && !t.url.startsWith('view-source:') ); if (validTabs.length === 0) { setStatus('No browsable tabs found.'); return; } const tabsToScan = validTabs; setStatus(`Scanning ${tabsToScan.length} tab(s)…`); try { // --- Helper: deep object walker that finds .m3u8 URLs up to depth 5 --- const m3u8DeepProbeFn = () => { const manifests = []; const seen = new Set(); function walk(obj, path, depth) { if (!obj || typeof obj !== 'object' || depth > 5) return; if (typeof obj === 'string') { if (obj.includes('.m3u8')) { // Try to extract a URL from the 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; } for (const k of Object.keys(obj)) { walk(obj[k], `${path}.${k}`, depth + 1); } } // Strategy A: Walk known player instances (shallow + deep) const playerGlobals = [ 'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer', '__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer', 'fhplayer', 'flowplayer', 'jwplayer', '__pj', 'playerInstance', 'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance', ]; for (const name of playerGlobals) { const obj = window[name]; if (!obj || typeof obj === 'function') continue; // Shallow: direct URL properties 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}` }); } } } // Deep walk walk(obj, name, 0); } // Strategy B: Video element HLS instances const videos = document.querySelectorAll('video'); for (const v of videos) { 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 const scripts = document.querySelectorAll('script:not([src])'); for (const script of scripts) { 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: window.config, window.__INITIAL_STATE__, etc. 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); } return manifests; }; // Step 1: Probe for HLS manifests — run in ALL frames so we catch nested iframes too. const hlsProbeResults = await Promise.all( tabsToScan.map(async t => { try { return await chrome.scripting.executeScript({ target: { tabId: t.id, allFrames: true }, func: m3u8DeepProbeFn, }); } catch { return [{ result: [] }]; } }) ); // Aggregate manifests across all frames within a tab, deduped by URL. const tabManifestMap = new Map(); for (let i = 0; i < tabsToScan.length; i++) { const tab = tabsToScan[i]; const frameResults = hlsProbeResults[i] || []; const allManifests = []; for (const fr of frameResults) { if (fr.result) allManifests.push(...fr.result); } const seenUrls = new Set(); const uniqueManifests = allManifests.filter(m => { if (seenUrls.has(m.url)) return false; seenUrls.add(m.url); return true; }); // Prefer manifests from known players over inline-script scraping uniqueManifests.sort((a, b) => { const aInline = a.player === 'inline-script' ? 1 : 0; const bInline = b.player === 'inline-script' ? 1 : 0; return aInline - bInline; }); if (uniqueManifests.length > 0) { tabManifestMap.set(tab.id, uniqueManifests); } } // Step 2: Scrape video elements — MAIN FRAME ONLY to avoid iframe noise. // Pornhub's main player is in the top-level document. Iframes contain // related videos, ads, and thumbnails — not the primary video stream. const 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; // Is the raw src a direct .m3u8 URL? if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) { hlsManifest = rawSrc; mediaType = 'hls'; } else if (rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) { // Direct HTTP video (mp4, webm, etc.) — keep as-is mediaType = 'video'; } else if (hlsManifests && hlsManifests.length > 0) { // Blob URL or empty — this is likely an HLS-powered player. // Use the best manifest from the probe. hlsManifest = hlsManifests[0].url || hlsManifests[0]; mediaType = 'hls'; src = hlsManifest; } return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest }; }); // Sort by displayed size descending allVideos.sort((a, b) => b.area - a.area); // Filter: only keep entries that have 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); } } // Return ONLY the largest video element from the main frame const largest = unique.length > 0 ? unique[0] : null; return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, hlsManifest: largest?.hlsManifest || null, }; }; const results = await Promise.all( tabsToScan.map(async t => { try { const manifests = tabManifestMap.get(t.id) || []; // Main frame only — prevents iframe noise (related videos, ads) return await chrome.scripting.executeScript({ target: { tabId: t.id }, func: scrapeFn, args: [manifests], }); } catch { console.warn('[goon] skipping tab', t.id, ':', t.url); return null; } }) ); // Build tab results — now each tab has at most 1 result (main frame only) const tabResults = tabsToScan.map((tab, i) => ({ tabTitle: tab.title || 'Unknown', result: results[i]?.[0]?.result || null, })).filter(g => g.result && g.result.videos?.length > 0); const scanned = tabResults.length; // Dedup across tabs: one entry per unique (hlsManifest/src + poster) const seen = new Map(); let totalFound = 0; for (const group of tabResults) { for (const v of group.result.videos) { totalFound++; const primarySrc = v.hlsManifest || v.src || '(empty)'; const key = primarySrc + '||' + (v.poster || '(none)'); if (!seen.has(key)) { const item = { url: v.hlsManifest || v.src || '', poster: v.poster || null, tab: group.tabTitle, src: v.hlsManifest || v.src || '', mediaType: v.mediaType || 'video', }; if (v.hlsManifest) item.hlsManifest = v.hlsManifest; seen.set(key, item); } } } const items = [...seen.values()]; console.log('[goon] ===== Found', totalFound, 'video element(s) across', tabsToScan.length, 'tab(s), unique:', items.length); // Replace the entire list (fresh scan) const storedItems = items.map(v => ({ url: v.url, src: v.src || v.url, poster: v.poster, tab: v.tab, mediaType: v.mediaType || 'video', hlsManifest: v.hlsManifest || undefined, })); await chrome.storage.local.set({ [STORAGE_KEY]: storedItems, [TAGGED_KEY]: items.map(v => v.tab), }); const skippedTabs = results.filter(r => r === null).length; const statusMsg = `Found ${items.length} video(s) across ${scanned} tab(s).`; setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped (idle).` : statusMsg); btnRow.style.display = 'flex'; } catch (e) { setStatus(`Scan failed: ${e.message}`); console.warn('[goon] executeScript failed:', e); } }); /* === "Open Viewer" — launch the 2x2 grid === */ openBtn.addEventListener('click', async () => { const { urls, tags } = await loadVideos(); console.log('[goon] Open Viewer clicked, loaded from storage:', urls, 'tags:', tags); if (!urls || urls.length === 0) { setStatus('No videos found. Click "Find Videos" first.'); return; } // Dedup by index to preserve tag alignment const videoUrls = urls; const videoTags = tags || []; // Compute NxN grid const N = Math.ceil(Math.sqrt(videoUrls.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]: videoUrls }); // Verify write const verify = await chrome.storage.local.get([id]); console.log('[goon] viewer storage verified:', verify[id]?.length, 'entries'); await new Promise(r => setTimeout(r, 100)); const tagsParam = videoTags.length > 0 ? '&tags=' + encodeURIComponent(JSON.stringify(videoTags)) : ''; const gridParam = '&grid=' + N; const viewerUrl = chrome.runtime.getURL('viewer/viewer.html') + '?id=' + id + gridParam + 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();