/** * 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'; // Durable "hide instructions" toggle. Absent key === SHOWN (true), matching the // first-time "expanded on mount" behavior. chrome.storage.local is extension-wide, // so the viewer window and every popup instance read/write the same value — the // preference is respected application-wide and survives reloads / SW termination. const INSTRUCTIONS_VISIBLE_KEY = 'goon_instructions_visible'; // Set to true to enable verbose console output in the popup. const DEBUG = true; // TEMP: enabled for debugging — revert to false when done function log(...args) { if (DEBUG) console.log('[vidplex]', ...args); } function warn(...args) { if (DEBUG) console.warn('[vidplex]', ...args); } /** Log a message to the active tab's page console (visible to the user in DevTools). */ async function logToPage(msg) { if (!DEBUG) return; try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab) return; await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: (m) => { console.log('[vidplex-page]', m); }, args: [msg], }); } catch { /* ignore — tab may have navigated away */ } } // 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'); const instructionsToggle = document.getElementById('instructions-toggle'); const instructionsBody = document.getElementById('instructions-body'); listEl.style.wordBreak = 'break-word'; listEl.style.whiteSpace = 'pre-wrap'; function setInstructionsExpanded(expanded) { if (!instructionsToggle || !instructionsBody) return; instructionsBody.hidden = !expanded; instructionsToggle.setAttribute('aria-expanded', String(expanded)); instructionsToggle.textContent = expanded ? 'Hide instructions' : 'Show instructions'; } if (instructionsToggle && instructionsBody) { // On mount, read the stored visibility. An absent key means SHOWN (true), so a // first-time user still sees the steps. The viewer window reads the same // extension-wide key, so the choice is respected across the whole app. chrome.storage.local.get(INSTRUCTIONS_VISIBLE_KEY).then(stored => { const visible = stored[INSTRUCTIONS_VISIBLE_KEY]; setInstructionsExpanded(visible === undefined ? true : visible); }).catch(() => { // Any read failure falls back to shown. setInstructionsExpanded(true); }); // On every user toggle, apply in-DOM and persist. Both directions write, so // the state survives reloads and SW termination until the user flips it again. instructionsToggle.addEventListener('click', () => { const next = instructionsBody.hidden; setInstructionsExpanded(next); chrome.storage.local.set({ [INSTRUCTIONS_VISIBLE_KEY]: next }); }); } /* ======================================================================== */ /* 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(); // DEBUG: Log probe activity to console function probeLog(...args) { try { console.log('[vidplex-probe]', ...args); } catch(e) {} } // NOTE: the probe is injected with chrome.scripting's `world: 'MAIN'`, so it // runs in the page's main world and can see page globals. `typeof chrome` is // NOT a reliable world discriminator (extensions expose `chrome` in the // isolated world too), so don't infer it that way. probeLog('Deep probe started on:', window.location.href, '(main world)'); // Helpers available in the MAIN world context function resolveUrl(url) { if (!url) return null; try { if (url.startsWith('//')) return 'https:' + url; if (url.startsWith('http')) return url; return new URL(url, window.location.origin).href; } catch { return null; } } function isManifestUrl(url) { if (!url) return false; const u = url.toLowerCase(); // Concrete HLS/manifest signals win over the template heuristics below: a // real .m3u8/.hlsvod//hls/ path is fetchable even if it happens to carry a // trailing '%' query token or a trailing slash — so check these first. if (u.includes('.m3u8') || u.includes('.hlsvod') || u.includes('/hls/')) return true; // Dynamic route TEMPLATES (e.g. /playlist/%1%/ or /videos/{id}/) are nav // links, not fetchable manifests. A placeholder is a `%` that is NOT the // start of a valid %-encoded hex pair (so real tokens like %2B, %2F are // preserved) or a {name} brace token — so genuine %-encoded auth tokens are // never mistaken for template placeholders. if (/%(?!([0-9A-Fa-f]{2}))/i.test(u) || /\{[a-z0-9_]+\}/i.test(u)) return false; // Plural collection paths are nav menus, not manifests. if (u.includes('/playlists') || u.includes('/manifests')) return false; // A singular manifest-ish path segment with no concrete file may still be a // real (rare, extensionless) endpoint, so allow it when it carries http. return (u.includes('/manifest') || u.includes('/playlist')) && u.includes('http'); } function walk(obj, path, depth) { if (depth > 5) return; // Cycle guard: the live window objects hold circular references // (window/_parent/_top/frames/_globalObject/_globalProxy are mutually // reachable), so a depth cap alone does NOT terminate — descending a cycle // re-walks the same identities along exponentially many paths. Each object // tree gets its own visited set so an identity is walked at most once per // tree (trees are independent, so the set is declared per call). const visited = new WeakSet(); if (obj && typeof obj === 'object') { if (visited.has(obj)) return; visited.add(obj); } if (typeof obj === 'string') { const match = obj.match(/https?:\/\/[^"'`\s)]+?(?:\.m3u8|\.hlsvod|\/hls\/|\/playlist[^"'`\s]*|\/manifest[^"'`\s]*)[^"'`\s)]*/gi); if (match) { for (const u of match) { const resolved = resolveUrl(u); if (resolved && isManifestUrl(resolved) && !seen.has(resolved)) { seen.add(resolved); manifests.push({ url: resolved, player: path }); } } } return; } if (!obj || typeof obj !== 'object') return; try { for (const k of Object.keys(obj)) { walk(obj[k], `${path}.${k}`, depth + 1); } } catch { /* avoid circular ref errors */ } } // Standard