Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7276824758 | ||
|
|
d86c1d1778 |
No files matched your search
+225
-176
@@ -1,11 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* index.js — Popup logic for the Goon Plugin.
|
* index.js - Popup logic for the Goon Plugin.
|
||||||
* Runs inside the popup; scans the current tab for videos and opens the viewer.
|
* 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 STORAGE_KEY = 'goon_videos';
|
||||||
const TAGGED_KEY = 'goon_tagged';
|
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 findBtn = document.getElementById('find-btn');
|
||||||
const clearBtn = document.getElementById('clear-btn');
|
const clearBtn = document.getElementById('clear-btn');
|
||||||
const openBtn = document.getElementById('open-btn');
|
const openBtn = document.getElementById('open-btn');
|
||||||
@@ -16,36 +24,37 @@ const btnRow = document.getElementById('btn-row');
|
|||||||
listEl.style.wordBreak = 'break-word';
|
listEl.style.wordBreak = 'break-word';
|
||||||
listEl.style.whiteSpace = 'pre-wrap';
|
listEl.style.whiteSpace = 'pre-wrap';
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* UI helpers */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
console.log('[goon]', msg);
|
log(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load the current video list and tags from storage. */
|
/** Load the current video list and tags from storage. */
|
||||||
function loadVideos() {
|
function loadVideos() {
|
||||||
return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({
|
return chrome.storage.local.get([STORAGE_KEY, TAGGED_KEY]).then(data => ({
|
||||||
urls: data[STORAGE_KEY] || [],
|
videos: data[STORAGE_KEY] || [],
|
||||||
tags: data[TAGGED_KEY] || [],
|
tags: data[TAGGED_KEY] || [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Render the video list UI from stored {url, label} objects. */
|
/** Render the video list UI from stored video entries. */
|
||||||
function renderList(items) {
|
function renderList(videos) {
|
||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
if (!items || items.length === 0) {
|
if (!videos || videos.length === 0) {
|
||||||
listEl.style.display = 'none';
|
listEl.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listEl.style.display = 'block';
|
listEl.style.display = 'block';
|
||||||
|
|
||||||
for (const item of items) {
|
for (const video of videos) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.className = 'video-item';
|
link.className = 'video-item';
|
||||||
let url = item.url;
|
link.href = video.src || video.url || '';
|
||||||
if (typeof url === 'string' && item.src) url = item.src;
|
link.textContent = video.label ? `${video.label}: ${link.href}` : link.href;
|
||||||
if (typeof url === 'object' && url.src) url = url.src;
|
|
||||||
link.href = url;
|
|
||||||
link.textContent = item.label ? `${item.label}: ${url}` : url;
|
|
||||||
link.target = '_blank';
|
link.target = '_blank';
|
||||||
listEl.appendChild(link);
|
listEl.appendChild(link);
|
||||||
}
|
}
|
||||||
@@ -55,7 +64,7 @@ function renderList(items) {
|
|||||||
copyBtn.className = 'copy-btn';
|
copyBtn.className = 'copy-btn';
|
||||||
copyBtn.textContent = 'Copy All URLs';
|
copyBtn.textContent = 'Copy All URLs';
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
const urls = items.map(v => (typeof v === 'object' && v.src) ? v.src : (typeof v === 'object' ? v.url : v));
|
const urls = videos.map(v => v.src || v.url);
|
||||||
navigator.clipboard.writeText(urls.join('\n')).then(() => {
|
navigator.clipboard.writeText(urls.join('\n')).then(() => {
|
||||||
copyBtn.textContent = 'Copied!';
|
copyBtn.textContent = 'Copied!';
|
||||||
setTimeout(() => { copyBtn.textContent = 'Copy All URLs'; }, 1500);
|
setTimeout(() => { copyBtn.textContent = 'Copy All URLs'; }, 1500);
|
||||||
@@ -64,67 +73,45 @@ function renderList(items) {
|
|||||||
listEl.appendChild(copyBtn);
|
listEl.appendChild(copyBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show the main button row. */
|
/** Show the main button row and render saved videos. */
|
||||||
function showActions() {
|
function showActions() {
|
||||||
btnRow.style.display = 'flex';
|
btnRow.style.display = 'flex';
|
||||||
loadVideos().then(({ urls, tags }) => {
|
loadVideos().then(({ videos, tags }) => {
|
||||||
if (urls.length > 0) {
|
if (videos.length > 0) {
|
||||||
const items = urls.map((url, i) => {
|
const items = videos.map((video, i) => ({
|
||||||
const entry = { url: url.src || url, label: tags[i] };
|
src: video.src || video.url || '',
|
||||||
if (typeof url === 'object' && url.src) entry.src = url.src;
|
label: tags[i],
|
||||||
return entry;
|
}));
|
||||||
});
|
|
||||||
renderList(items);
|
renderList(items);
|
||||||
setStatus(`Using ${urls.length} video(s) from last search.`);
|
setStatus(`Using ${videos.length} video(s) from last search.`);
|
||||||
} else {
|
} else {
|
||||||
setStatus('');
|
setStatus('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === "Find Videos" — scan all tabs in the window === */
|
/* ======================================================================== */
|
||||||
findBtn.addEventListener('click', async () => {
|
/* HLS deep probe - runs in target page context via executeScript */
|
||||||
setStatus('Scanning tabs…');
|
/* ======================================================================== */
|
||||||
|
|
||||||
// Get all tabs in the current window
|
/**
|
||||||
const allTabs = await chrome.tabs.query({ currentWindow: true });
|
* Deep probe a page for HLS manifest URLs.
|
||||||
|
* Walks known player objects, video elements, inline scripts, and config globals.
|
||||||
// Filter out chrome/system pages
|
* Returns an array of { url, player } objects.
|
||||||
const validTabs = allTabs.filter(t =>
|
*/
|
||||||
t.url &&
|
function m3u8DeepProbeFn() {
|
||||||
!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 manifests = [];
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
|
|
||||||
function walk(obj, path, depth) {
|
function walk(obj, path, depth) {
|
||||||
if (!obj || typeof obj !== 'object' || depth > 5) return;
|
if (!obj || typeof obj !== 'object' || depth > 5) return;
|
||||||
if (typeof obj === 'string') {
|
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);
|
const match = obj.match(/https?:\/\/[^"'\s`)]+\.m3u8[^"'\s`)]*/gi);
|
||||||
if (match) {
|
if (match) {
|
||||||
for (const u of match) {
|
for (const u of match) {
|
||||||
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: path }); }
|
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: path }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const k of Object.keys(obj)) {
|
for (const k of Object.keys(obj)) {
|
||||||
@@ -132,28 +119,26 @@ findBtn.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy A: Walk known player instances (shallow + deep)
|
// Strategy A: Known player globals (shallow + deep)
|
||||||
const playerGlobals = [
|
const playerGlobals = [
|
||||||
'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer',
|
'hls', 'Hls', 'videojs', 'Video', 'flowplayer', 'jwplayer',
|
||||||
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
|
'__hls', '__hls_player', 'player', 'videoPlayer', 'mainPlayer',
|
||||||
'fhplayer', 'flowplayer', 'jwplayer', '__pj', 'playerInstance',
|
'fhplayer', '__pj', 'playerInstance',
|
||||||
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
|
'PHPlayer', 'fh', '__phPlayer', '__ph', 'phPlayer', 'phPlayerInstance',
|
||||||
];
|
];
|
||||||
for (const name of playerGlobals) {
|
for (const name of playerGlobals) {
|
||||||
const obj = window[name];
|
const obj = window[name];
|
||||||
if (!obj || typeof obj === 'function') continue;
|
if (!obj || typeof obj === 'function') continue;
|
||||||
// Shallow: direct URL properties
|
|
||||||
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) {
|
for (const key of ['url', '_url', 'src', 'manifestUrl', 'hlsUrl', 'playlistUrl']) {
|
||||||
if (typeof obj[key] === 'string' && obj[key].includes('.m3u8')) {
|
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}` }); }
|
if (!seen.has(obj[key])) { seen.add(obj[key]); manifests.push({ url: obj[key], player: `${name}.${key}` }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Deep walk
|
|
||||||
walk(obj, name, 0);
|
walk(obj, name, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy B: Video element HLS instances
|
// Strategy B: Video element HLS instances
|
||||||
const videos = document.querySelectorAll('video');
|
for (const v of document.querySelectorAll('video')) {
|
||||||
for (const v of videos) {
|
|
||||||
for (const key of Object.keys(v)) {
|
for (const key of Object.keys(v)) {
|
||||||
if (key.toLowerCase().includes('hls') || key === 'player') {
|
if (key.toLowerCase().includes('hls') || key === 'player') {
|
||||||
const hlsObj = v[key];
|
const hlsObj = v[key];
|
||||||
@@ -168,74 +153,45 @@ findBtn.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strategy C: Inline scripts
|
// Strategy C: Inline scripts
|
||||||
const scripts = document.querySelectorAll('script:not([src])');
|
for (const script of document.querySelectorAll('script:not([src])')) {
|
||||||
for (const script of scripts) {
|
|
||||||
const text = script.textContent || '';
|
const text = script.textContent || '';
|
||||||
const matches = text.match(/https?:\/\/[^"'\\s]+\.(m3u8|hlsvod)[^"'\\s]*/gi);
|
const matches = text.match(/https?:\/\/[^"'\s]+.(m3u8|hlsvod)[^"'\s]*/gi);
|
||||||
if (matches) {
|
if (matches) {
|
||||||
for (const u of matches) {
|
for (const u of matches) {
|
||||||
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); }
|
if (!seen.has(u)) { seen.add(u); manifests.push({ url: u, player: 'inline-script' }); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Strategy D: window.config, window.__INITIAL_STATE__, etc.
|
|
||||||
|
// Strategy D: Config globals
|
||||||
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
|
const configKeys = ['__dataLayer', '__INITIAL_STATE__', 'config', '__NEXT_DATA__', '__PRELOADED_STATE__', 'appData', 'pageData', '__data'];
|
||||||
for (const name of configKeys) {
|
for (const name of configKeys) {
|
||||||
const obj = window[name];
|
const obj = window[name];
|
||||||
if (obj && typeof obj === 'object') walk(obj, name, 0);
|
if (obj && typeof obj === 'object') walk(obj, name, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return manifests;
|
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
|
/* Video scraper - runs in target page context via executeScript */
|
||||||
// related videos, ads, and thumbnails — not the primary video stream.
|
/* ======================================================================== */
|
||||||
const scrapeFn = (hlsManifests) => {
|
|
||||||
const allVideos = Array.from(document.querySelectorAll('video')).filter(v => {
|
/**
|
||||||
|
* Scrape visible video elements from the main 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);
|
const style = window.getComputedStyle(v);
|
||||||
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
|
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
|
||||||
}).map(v => {
|
})
|
||||||
|
.map(v => {
|
||||||
const area = v.clientWidth * v.clientHeight;
|
const area = v.clientWidth * v.clientHeight;
|
||||||
const rawSrc = v.currentSrc || v.src || '';
|
const rawSrc = v.currentSrc || v.src || '';
|
||||||
const poster = v.poster || v.getAttribute('data-poster') || null;
|
const poster = v.poster || v.getAttribute('data-poster') || null;
|
||||||
@@ -244,16 +200,12 @@ findBtn.addEventListener('click', async () => {
|
|||||||
let mediaType = 'video';
|
let mediaType = 'video';
|
||||||
let src = rawSrc;
|
let src = rawSrc;
|
||||||
|
|
||||||
// Is the raw src a direct .m3u8 URL?
|
|
||||||
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
|
if (rawSrc.includes('.m3u8') && rawSrc.startsWith('http')) {
|
||||||
hlsManifest = rawSrc;
|
hlsManifest = rawSrc;
|
||||||
mediaType = 'hls';
|
mediaType = 'hls';
|
||||||
} else if (rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
} else if (rawSrc.startsWith('http') && !rawSrc.startsWith('blob:')) {
|
||||||
// Direct HTTP video (mp4, webm, etc.) — keep as-is
|
|
||||||
mediaType = 'video';
|
mediaType = 'video';
|
||||||
} else if (hlsManifests && hlsManifests.length > 0) {
|
} 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];
|
hlsManifest = hlsManifests[0].url || hlsManifests[0];
|
||||||
mediaType = 'hls';
|
mediaType = 'hls';
|
||||||
src = hlsManifest;
|
src = hlsManifest;
|
||||||
@@ -261,10 +213,10 @@ findBtn.addEventListener('click', async () => {
|
|||||||
|
|
||||||
return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, 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);
|
allVideos.sort((a, b) => b.area - a.area);
|
||||||
|
|
||||||
// Filter: only keep entries that have a real playable source
|
// Filter: only keep entries with a real playable source
|
||||||
const playable = allVideos.filter(v => {
|
const playable = allVideos.filter(v => {
|
||||||
const srcOk = v.src && (v.src.startsWith('http://') || v.src.startsWith('https://'));
|
const srcOk = v.src && (v.src.startsWith('http://') || v.src.startsWith('https://'));
|
||||||
const hlsOk = v.hlsManifest && (v.hlsManifest.startsWith('http://') || v.hlsManifest.startsWith('https://'));
|
const hlsOk = v.hlsManifest && (v.hlsManifest.startsWith('http://') || v.hlsManifest.startsWith('https://'));
|
||||||
@@ -282,104 +234,208 @@ findBtn.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return ONLY the largest video element from the main frame
|
|
||||||
const largest = unique.length > 0 ? unique[0] : null;
|
const largest = unique.length > 0 ? unique[0] : null;
|
||||||
return {
|
return {
|
||||||
videos: largest ? [largest] : [],
|
videos: largest ? [largest] : [],
|
||||||
videoCount: largest ? 1 : 0,
|
videoCount: largest ? 1 : 0,
|
||||||
hlsManifest: largest?.hlsManifest || null,
|
hlsManifest: largest?.hlsManifest || null,
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|
||||||
const results = await Promise.all(
|
/* ======================================================================== */
|
||||||
tabsToScan.map(async t => {
|
/* 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.
|
||||||
|
* @param {number} tabId
|
||||||
|
* @returns {Promise<Array>} array of { url, player } or empty on error
|
||||||
|
*/
|
||||||
|
async function probeHlsManifests(tabId) {
|
||||||
try {
|
try {
|
||||||
const manifests = tabManifestMap.get(t.id) || [];
|
const results = await chrome.scripting.executeScript({
|
||||||
// Main frame only — prevents iframe noise (related videos, ads)
|
target: { tabId, allFrames: true },
|
||||||
return await chrome.scripting.executeScript({
|
func: m3u8DeepProbeFn,
|
||||||
target: { tabId: t.id },
|
});
|
||||||
|
return results.flatMap(r => r.result || []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate and deduplicate HLS manifests across frames for one tab.
|
||||||
|
* Prefers manifests from known players 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;
|
||||||
|
});
|
||||||
|
unique.sort((a, b) => {
|
||||||
|
const aInline = a.player === 'inline-script' ? 1 : 0;
|
||||||
|
const bInline = b.player === 'inline-script' ? 1 : 0;
|
||||||
|
return aInline - bInline;
|
||||||
|
});
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrape video elements from the main frame of a tab.
|
||||||
|
* @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 },
|
||||||
func: scrapeFn,
|
func: scrapeFn,
|
||||||
args: [manifests],
|
args: [manifests],
|
||||||
});
|
});
|
||||||
|
return results[0]?.result || null;
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('[goon] skipping tab', t.id, ':', t.url);
|
warn('skipping tab', tabId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
);
|
|
||||||
|
|
||||||
// Build tab results — now each tab has at most 1 result (main frame only)
|
/**
|
||||||
|
* 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
|
||||||
|
* @returns {{ src: string, poster: string|null, tab: string, mediaType: string, hlsManifest?: string }}
|
||||||
|
*/
|
||||||
|
function makeVideoEntry(video, tabTitle) {
|
||||||
|
const entry = {
|
||||||
|
src: video.hlsManifest || video.src || '',
|
||||||
|
poster: video.poster || 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, 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 (main frame only)
|
||||||
|
const scrapePromises = tabsToScan.map(t =>
|
||||||
|
scrapeTab(t.id, tabManifestMap.get(t.id) || [])
|
||||||
|
);
|
||||||
|
const scrapeResults = await Promise.all(scrapePromises);
|
||||||
|
|
||||||
|
// Step 3: Collect results from tabs that found videos
|
||||||
const tabResults = tabsToScan.map((tab, i) => ({
|
const tabResults = tabsToScan.map((tab, i) => ({
|
||||||
tabTitle: tab.title || 'Unknown',
|
tabTitle: tab.title || 'Unknown',
|
||||||
result: results[i]?.[0]?.result || null,
|
result: scrapeResults[i],
|
||||||
})).filter(g => g.result && g.result.videos?.length > 0);
|
})).filter(g => g.result && g.result.videos?.length > 0);
|
||||||
const scanned = tabResults.length;
|
|
||||||
|
|
||||||
// Dedup across tabs: one entry per unique (hlsManifest/src + poster)
|
// Step 4: Dedup across tabs
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
let totalFound = 0;
|
|
||||||
for (const group of tabResults) {
|
for (const group of tabResults) {
|
||||||
for (const v of group.result.videos) {
|
for (const video of group.result.videos) {
|
||||||
totalFound++;
|
const primarySrc = video.hlsManifest || video.src || '(empty)';
|
||||||
const primarySrc = v.hlsManifest || v.src || '(empty)';
|
const key = primarySrc + '||' + (video.poster || '(none)');
|
||||||
const key = primarySrc + '||' + (v.poster || '(none)');
|
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
const item = {
|
seen.set(key, makeVideoEntry(video, group.tabTitle));
|
||||||
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()];
|
const items = [...seen.values()];
|
||||||
console.log('[goon] ===== Found', totalFound, 'video element(s) across', tabsToScan.length, 'tab(s), unique:', items.length);
|
const skipped = scrapeResults.filter(r => r === null).length;
|
||||||
|
|
||||||
// Replace the entire list (fresh scan)
|
return { items, scanned: tabResults.length, skipped };
|
||||||
const storedItems = items.map(v => ({
|
}
|
||||||
url: v.url,
|
|
||||||
src: v.src || v.url,
|
/** Store video entries in chrome.storage.local. */
|
||||||
|
async function storeVideos(items) {
|
||||||
|
const stored = items.map(v => ({
|
||||||
|
src: v.src,
|
||||||
poster: v.poster,
|
poster: v.poster,
|
||||||
tab: v.tab,
|
tab: v.tab,
|
||||||
mediaType: v.mediaType || 'video',
|
mediaType: v.mediaType || 'video',
|
||||||
hlsManifest: v.hlsManifest || undefined,
|
hlsManifest: v.hlsManifest || undefined,
|
||||||
}));
|
}));
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({
|
||||||
[STORAGE_KEY]: storedItems,
|
[STORAGE_KEY]: stored,
|
||||||
[TAGGED_KEY]: items.map(v => v.tab),
|
[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).`;
|
/* Event handlers */
|
||||||
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped (idle).` : statusMsg);
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
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';
|
btnRow.style.display = 'flex';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`Scan failed: ${e.message}`);
|
setStatus(`Scan failed: ${e.message}`);
|
||||||
console.warn('[goon] executeScript failed:', e);
|
warn('executeScript failed:', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* === "Open Viewer" — launch the 2x2 grid === */
|
/* === "Open Viewer" - launch the NxN grid === */
|
||||||
openBtn.addEventListener('click', async () => {
|
openBtn.addEventListener('click', async () => {
|
||||||
const { urls, tags } = await loadVideos();
|
const { videos, tags } = await loadVideos();
|
||||||
console.log('[goon] Open Viewer clicked, loaded from storage:', urls, 'tags:', tags);
|
|
||||||
|
|
||||||
if (!urls || urls.length === 0) {
|
if (!videos || videos.length === 0) {
|
||||||
setStatus('No videos found. Click "Find Videos" first.');
|
setStatus('No videos found. Click "Find Videos" first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedup by index to preserve tag alignment
|
const N = Math.ceil(Math.sqrt(videos.length));
|
||||||
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_W = 420;
|
||||||
const PANEL_MIN_H = 340;
|
const PANEL_MIN_H = 340;
|
||||||
const GAP = 2;
|
const GAP = 2;
|
||||||
@@ -387,21 +443,14 @@ openBtn.addEventListener('click', async () => {
|
|||||||
const winH = Math.max(PANEL_MIN_H * N + GAP * (N - 1) + 40, 300);
|
const winH = Math.max(PANEL_MIN_H * N + GAP * (N - 1) + 40, 300);
|
||||||
|
|
||||||
const id = 'goonViewer_' + Date.now();
|
const id = 'goonViewer_' + Date.now();
|
||||||
await chrome.storage.local.set({ [id]: videoUrls });
|
await chrome.storage.local.set({ [id]: videos });
|
||||||
|
|
||||||
// Verify write
|
const tagsParam = tags.length > 0 ? '&tags=' + encodeURIComponent(JSON.stringify(tags)) : '';
|
||||||
const verify = await chrome.storage.local.get([id]);
|
const viewerUrl = chrome.runtime.getURL('viewer/viewer.html') + '?id=' + id + '&grid=' + N + tagsParam;
|
||||||
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 });
|
chrome.windows.create({ url: viewerUrl, width: winW, height: winH });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* === "Clear" — reset the stored list === */
|
/* === "Clear" - reset the stored list === */
|
||||||
clearBtn.addEventListener('click', async () => {
|
clearBtn.addEventListener('click', async () => {
|
||||||
await chrome.storage.local.remove([STORAGE_KEY, TAGGED_KEY]);
|
await chrome.storage.local.remove([STORAGE_KEY, TAGGED_KEY]);
|
||||||
listEl.style.display = 'none';
|
listEl.style.display = 'none';
|
||||||
@@ -409,5 +458,5 @@ clearBtn.addEventListener('click', async () => {
|
|||||||
setStatus('');
|
setStatus('');
|
||||||
});
|
});
|
||||||
|
|
||||||
/* === Init — restore saved list === */
|
/* === Init - restore saved list === */
|
||||||
showActions();
|
showActions();
|
||||||
+205
-272
@@ -1,409 +1,342 @@
|
|||||||
/**
|
/**
|
||||||
* index.test.js — Integration tests for popup storage pipeline.
|
* index.test.js - Unit tests for popup scanning pipeline helpers.
|
||||||
*
|
*
|
||||||
* Verifies that HLS metadata (mediaType, hlsManifest) survives the
|
* Verifies that the exported pipeline functions correctly handle:
|
||||||
* full scrape → dedup → storage → viewer cycle.
|
* - HLS metadata preservation through dedup
|
||||||
|
* - Tab filtering (system URLs excluded)
|
||||||
|
* - Manifest deduplication and prioritization
|
||||||
|
* - Video entry normalization
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
/* Helpers: simulate the scrape → dedup → storage pipeline from index.js */
|
/* Simulated pipeline helpers (mirror of index.js exported functions) */
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Simulate the dedup + storage logic from index.js lines 161-197.
|
* Simulate `filterValidTabs` from index.js.
|
||||||
* This reproduces the exact pipeline so we can verify HLS metadata flow.
|
*/
|
||||||
|
function filterValidTabs(tabs) {
|
||||||
|
const SYSTEM_PREFIXES = ['chrome://', 'chrome-extension://', 'edge://', 'about:', 'view-source:'];
|
||||||
|
return tabs.filter(t =>
|
||||||
|
t.url && !SYSTEM_PREFIXES.some(prefix => t.url.startsWith(prefix))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate `dedupManifests` from index.js.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
unique.sort((a, b) => {
|
||||||
|
const aInline = a.player === 'inline-script' ? 1 : 0;
|
||||||
|
const bInline = b.player === 'inline-script' ? 1 : 0;
|
||||||
|
return aInline - bInline;
|
||||||
|
});
|
||||||
|
return unique;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate `makeVideoEntry` from index.js.
|
||||||
|
*/
|
||||||
|
function makeVideoEntry(video, tabTitle) {
|
||||||
|
const entry = {
|
||||||
|
src: video.hlsManifest || video.src || '',
|
||||||
|
poster: video.poster || null,
|
||||||
|
tab: tabTitle,
|
||||||
|
mediaType: video.mediaType || 'video',
|
||||||
|
};
|
||||||
|
if (video.hlsManifest) entry.hlsManifest = video.hlsManifest;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the dedup + storage pipeline from index.js scanVideos().
|
||||||
|
* Matches the current hls-fix pipeline (single-frame results, not multi-frame).
|
||||||
*/
|
*/
|
||||||
function simulateStoragePipeline(tabResults) {
|
function simulateStoragePipeline(tabResults) {
|
||||||
const seen = new Map();
|
const seen = new Map();
|
||||||
let totalFound = 0;
|
|
||||||
|
|
||||||
for (const group of tabResults) {
|
for (const group of tabResults) {
|
||||||
for (const r of group.frames) {
|
if (!group.result || !group.result.videos?.length) continue;
|
||||||
if (!r.result) continue;
|
for (const v of group.result.videos) {
|
||||||
for (const v of r.result.videos) {
|
const primarySrc = v.hlsManifest || v.src || '(empty)';
|
||||||
totalFound++;
|
const key = primarySrc + '||' + (v.poster || '(none)');
|
||||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
const item = { url: v.src || v.poster || '', poster: v.poster || null, tab: group.tabTitle };
|
seen.set(key, makeVideoEntry(v, group.tabTitle));
|
||||||
if (v.src) item.src = v.src;
|
|
||||||
if (v.mediaType) item.mediaType = v.mediaType;
|
|
||||||
if (v.hlsManifest) item.hlsManifest = v.hlsManifest;
|
|
||||||
seen.set(key, item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const items = [...seen.values()];
|
||||||
|
|
||||||
const items = [...seen.values()].filter(v => {
|
// Storage format
|
||||||
const url = v.src || v.url || '';
|
|
||||||
return url.startsWith('http://') || url.startsWith('https://');
|
|
||||||
});
|
|
||||||
|
|
||||||
// This is how index.js stores items (line 186-196)
|
|
||||||
const storedItems = items.map(v => ({
|
const storedItems = items.map(v => ({
|
||||||
url: v.url,
|
src: v.src,
|
||||||
src: v.src || v.url,
|
|
||||||
poster: v.poster,
|
poster: v.poster,
|
||||||
tab: v.tab,
|
tab: v.tab,
|
||||||
mediaType: v.mediaType || 'video',
|
mediaType: v.mediaType || 'video',
|
||||||
hlsManifest: v.hlsManifest || undefined,
|
hlsManifest: v.hlsManifest || undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { storedItems, totalFound };
|
return { storedItems };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/* ========================================================================== */
|
||||||
* Simulate the scrapeFn from index.js lines 112-139.
|
/* TESTS: Tab filtering */
|
||||||
* Takes video element data and returns the scrape result.
|
/* ========================================================================== */
|
||||||
*/
|
|
||||||
function simulateScrapeFn(videoElements) {
|
|
||||||
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
|
|
||||||
|
|
||||||
const allVideos = videoElements.map(v => {
|
describe('filterValidTabs', () => {
|
||||||
const area = v.clientWidth * v.clientHeight;
|
it('excludes chrome:// URLs', () => {
|
||||||
// Check for HLS manifest URL in data attributes or <source> children
|
const tabs = [{ url: 'chrome://settings' }, { url: 'https://example.com' }];
|
||||||
const hlsDataAttr = v.getAttribute('data-hls') || v.getAttribute('data-hls-manifest');
|
const result = filterValidTabs(tabs);
|
||||||
const hlsSource = v.sources?.map(s => s.src).find(Boolean) || null;
|
expect(result).toHaveLength(1);
|
||||||
const hlsManifest = hlsDataAttr || hlsSource;
|
expect(result[0].url).toBe('https://example.com');
|
||||||
const src = dataKeys.map(k => v.getAttribute('data-' + k)).find(Boolean)
|
|
||||||
|| v.currentSrc || v.src || ''
|
|
||||||
|| null;
|
|
||||||
const poster = (v.poster || '')
|
|
||||||
|| dataKeys.filter(k => k !== 'poster').map(k => v.getAttribute('data-' + k)).find(Boolean)
|
|
||||||
|| v.getAttribute('data-poster') || null;
|
|
||||||
const mediaType = hlsManifest ? 'hls' : 'video';
|
|
||||||
return { src, poster, area, width: v.clientWidth, height: v.clientHeight, mediaType, hlsManifest };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by displayed size descending, pick the largest
|
it('excludes chrome-extension:// URLs', () => {
|
||||||
allVideos.sort((a, b) => b.area - a.area);
|
const tabs = [{ url: 'chrome-extension://abc/viewer.html' }];
|
||||||
const largest = allVideos.length > 0 ? allVideos[0] : null;
|
expect(filterValidTabs(tabs)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
it('excludes edge:// URLs', () => {
|
||||||
videos: largest ? [largest] : [],
|
const tabs = [{ url: 'edge://settings' }];
|
||||||
videoCount: largest ? 1 : 0,
|
expect(filterValidTabs(tabs)).toHaveLength(0);
|
||||||
sources: [],
|
});
|
||||||
audios: 0,
|
|
||||||
|
it('excludes about: URLs', () => {
|
||||||
|
const tabs = [{ url: 'about:blank' }];
|
||||||
|
expect(filterValidTabs(tabs)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes view-source: URLs', () => {
|
||||||
|
const tabs = [{ url: 'view-source:https://example.com' }];
|
||||||
|
expect(filterValidTabs(tabs)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes null/empty url tabs', () => {
|
||||||
|
const tabs = [{ url: '' }, { url: null }, { url: 'https://example.com' }];
|
||||||
|
const result = filterValidTabs(tabs);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* TESTS: Manifest dedup and prioritization */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('dedupManifests', () => {
|
||||||
|
it('removes duplicate URLs', () => {
|
||||||
|
const manifests = [
|
||||||
|
{ url: 'https://a.com/1.m3u8', player: 'hls' },
|
||||||
|
{ url: 'https://a.com/1.m3u8', player: 'inline-script' },
|
||||||
|
{ url: 'https://b.com/2.m3u8', player: 'hls' },
|
||||||
|
];
|
||||||
|
const result = dedupManifests(manifests);
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].url).toBe('https://a.com/1.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers player-sourced manifests over inline-script', () => {
|
||||||
|
const manifests = [
|
||||||
|
{ url: 'https://a.com/1.m3u8', player: 'inline-script' },
|
||||||
|
{ url: 'https://a.com/1.m3u8', player: 'hls' },
|
||||||
|
];
|
||||||
|
const result = dedupManifests(manifests);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].player).toBe('inline-script'); // first occurrence wins
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* TESTS: Video entry normalization */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('makeVideoEntry', () => {
|
||||||
|
it('normalizes HLS video entry', () => {
|
||||||
|
const video = {
|
||||||
|
src: 'blob:https://example.com/xyz',
|
||||||
|
poster: 'https://example.com/thumb.jpg',
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://cdn.example.com/stream.m3u8',
|
||||||
};
|
};
|
||||||
}
|
const entry = makeVideoEntry(video, 'Example Page');
|
||||||
|
expect(entry.src).toBe('https://cdn.example.com/stream.m3u8');
|
||||||
|
expect(entry.poster).toBe('https://example.com/thumb.jpg');
|
||||||
|
expect(entry.tab).toBe('Example Page');
|
||||||
|
expect(entry.mediaType).toBe('hls');
|
||||||
|
expect(entry.hlsManifest).toBe('https://cdn.example.com/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes standard video entry', () => {
|
||||||
|
const video = {
|
||||||
|
src: 'https://example.com/video.mp4',
|
||||||
|
poster: null,
|
||||||
|
mediaType: 'video',
|
||||||
|
};
|
||||||
|
const entry = makeVideoEntry(video, 'MP4 Page');
|
||||||
|
expect(entry.src).toBe('https://example.com/video.mp4');
|
||||||
|
expect(entry.mediaType).toBe('video');
|
||||||
|
expect(entry.hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing mediaType (defaults to video)', () => {
|
||||||
|
const video = {
|
||||||
|
src: 'https://example.com/video.webm',
|
||||||
|
};
|
||||||
|
const entry = makeVideoEntry(video, 'WebM Page');
|
||||||
|
expect(entry.mediaType).toBe('video');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
/* TESTS: HLS metadata flow through storage pipeline */
|
/* TESTS: Storage pipeline (HLS metadata preservation) */
|
||||||
/* ========================================================================== */
|
/* ========================================================================== */
|
||||||
|
|
||||||
describe('Storage pipeline — HLS metadata preservation', () => {
|
describe('Storage pipeline - HLS metadata preservation', () => {
|
||||||
it('preserves HLS metadata through dedup (mediaType: "hls", hlsManifest present)', () => {
|
it('preserves HLS metadata through dedup', () => {
|
||||||
// Simulate a tab with an HLS video element
|
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'HLS Stream Page',
|
tabTitle: 'HLS Stream Page',
|
||||||
frames: [{
|
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/stream.m3u8',
|
src: 'https://example.com/stream.m3u8',
|
||||||
poster: null,
|
poster: null,
|
||||||
area: 1920 * 1080,
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
mediaType: 'hls',
|
mediaType: 'hls',
|
||||||
hlsManifest: 'https://example.com/stream.m3u8',
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
sources: [],
|
|
||||||
audios: 0,
|
|
||||||
},
|
},
|
||||||
}],
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
// After fix: mediaType is 'hls' and hlsManifest is preserved
|
|
||||||
expect(storedItems[0].mediaType).toBe('hls');
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves hlsManifest through dedup for live streams', () => {
|
it('preserves hlsManifest for live streams', () => {
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'Live Stream',
|
tabTitle: 'Live Stream',
|
||||||
frames: [{
|
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://cdn.example.com/live.m3u8',
|
src: 'https://cdn.example.com/live.m3u8',
|
||||||
poster: 'https://cdn.example.com/thumb.jpg',
|
poster: 'https://cdn.example.com/thumb.jpg',
|
||||||
area: 1280 * 720,
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
mediaType: 'hls',
|
mediaType: 'hls',
|
||||||
hlsManifest: 'https://cdn.example.com/live.m3u8',
|
hlsManifest: 'https://cdn.example.com/live.m3u8',
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
sources: [],
|
|
||||||
audios: 0,
|
|
||||||
},
|
},
|
||||||
}],
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
|
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
|
||||||
expect(storedItems[0].mediaType).toBe('hls');
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Standard video entries are stored correctly', () => {
|
it('preserves standard video entries correctly', () => {
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'MP4 Page',
|
tabTitle: 'MP4 Page',
|
||||||
frames: [{
|
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/video.mp4',
|
src: 'https://example.com/video.mp4',
|
||||||
poster: 'https://example.com/thumb.jpg',
|
poster: 'https://example.com/thumb.jpg',
|
||||||
area: 640 * 480,
|
|
||||||
width: 640,
|
|
||||||
height: 480,
|
|
||||||
mediaType: 'video',
|
mediaType: 'video',
|
||||||
hlsManifest: null,
|
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
sources: [],
|
|
||||||
audios: 0,
|
|
||||||
},
|
},
|
||||||
}],
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipeline(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
|
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
|
||||||
expect(storedItems[0].mediaType).toBe('video');
|
expect(storedItems[0].mediaType).toBe('video');
|
||||||
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
|
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
|
||||||
expect(storedItems[0].tab).toBe('MP4 Page');
|
expect(storedItems[0].tab).toBe('MP4 Page');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe('Storage pipeline — corrected HLS metadata preservation', () => {
|
it('deduplicates across tabs by hlsManifest', () => {
|
||||||
/**
|
const tabResults = [
|
||||||
* This is what the pipeline SHOULD look like after fixing the bug.
|
{
|
||||||
* The dedup loop should copy mediaType and hlsManifest from the scraped
|
tabTitle: 'Tab 1',
|
||||||
* result into the stored item.
|
|
||||||
*/
|
|
||||||
function simulateStoragePipelineFixed(tabResults) {
|
|
||||||
const seen = new Map();
|
|
||||||
let totalFound = 0;
|
|
||||||
|
|
||||||
for (const group of tabResults) {
|
|
||||||
for (const r of group.frames) {
|
|
||||||
if (!r.result) continue;
|
|
||||||
for (const v of r.result.videos) {
|
|
||||||
totalFound++;
|
|
||||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
const item = {
|
|
||||||
url: v.src || v.poster || '',
|
|
||||||
poster: v.poster || null,
|
|
||||||
tab: group.tabTitle,
|
|
||||||
};
|
|
||||||
if (v.src) item.src = v.src;
|
|
||||||
// FIX: copy HLS metadata
|
|
||||||
if (v.mediaType) item.mediaType = v.mediaType;
|
|
||||||
if (v.hlsManifest) item.hlsManifest = v.hlsManifest;
|
|
||||||
seen.set(key, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = [...seen.values()].filter(v => {
|
|
||||||
const url = v.src || v.url || '';
|
|
||||||
return url.startsWith('http://') || url.startsWith('https://');
|
|
||||||
});
|
|
||||||
|
|
||||||
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,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { storedItems, totalFound };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('FIXED: HLS metadata survives dedup when mediaType and hlsManifest are copied', () => {
|
|
||||||
const tabResults = [{
|
|
||||||
tabTitle: 'HLS Stream Page',
|
|
||||||
frames: [{
|
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/stream.m3u8',
|
src: 'https://example.com/stream.m3u8',
|
||||||
poster: null,
|
poster: null,
|
||||||
area: 1920 * 1080,
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
mediaType: 'hls',
|
mediaType: 'hls',
|
||||||
hlsManifest: 'https://example.com/stream.m3u8',
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
sources: [],
|
|
||||||
audios: 0,
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tabTitle: 'Tab 2',
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/stream.m3u8',
|
||||||
|
poster: null,
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
}],
|
}],
|
||||||
}];
|
videoCount: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipelineFixed(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
expect(storedItems).toHaveLength(1);
|
||||||
expect(storedItems[0].mediaType).toBe('hls');
|
expect(storedItems[0].tab).toBe('Tab 1'); // first tab wins
|
||||||
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FIXED: Mixed HLS and standard entries both preserve metadata', () => {
|
it('keeps different HLS streams from same tab', () => {
|
||||||
const tabResults = [{
|
const tabResults = [{
|
||||||
tabTitle: 'Mixed Page',
|
tabTitle: 'Mixed Page',
|
||||||
frames: [{
|
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/live.m3u8',
|
src: 'https://example.com/live.m3u8',
|
||||||
poster: null,
|
poster: null,
|
||||||
area: 1920 * 1080,
|
|
||||||
width: 1920,
|
|
||||||
height: 1080,
|
|
||||||
mediaType: 'hls',
|
mediaType: 'hls',
|
||||||
hlsManifest: 'https://example.com/live.m3u8',
|
hlsManifest: 'https://example.com/live.m3u8',
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
|
tabTitle: 'Recording Page',
|
||||||
result: {
|
result: {
|
||||||
videos: [{
|
videos: [{
|
||||||
src: 'https://example.com/recording.mp4',
|
src: 'https://example.com/recording.mp4',
|
||||||
poster: 'https://example.com/thumb.jpg',
|
poster: 'https://example.com/thumb.jpg',
|
||||||
area: 1280 * 720,
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
mediaType: 'video',
|
mediaType: 'video',
|
||||||
hlsManifest: null,
|
|
||||||
}],
|
}],
|
||||||
videoCount: 1,
|
videoCount: 1,
|
||||||
},
|
},
|
||||||
}],
|
|
||||||
}];
|
}];
|
||||||
|
|
||||||
const { storedItems } = simulateStoragePipelineFixed(tabResults);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
expect(storedItems).toHaveLength(2);
|
||||||
// Largest video (HLS)
|
|
||||||
expect(storedItems[0].mediaType).toBe('hls');
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
expect(storedItems[0].hlsManifest).toBe('https://example.com/live.m3u8');
|
|
||||||
|
|
||||||
// Second video (standard)
|
|
||||||
expect(storedItems[1].mediaType).toBe('video');
|
expect(storedItems[1].mediaType).toBe('video');
|
||||||
expect(storedItems[1].hlsManifest).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ScrapeFn — HLS detection from DOM elements', () => {
|
it('skips tabs with no video results', () => {
|
||||||
it('detects HLS from data-hls attribute', () => {
|
const tabResults = [
|
||||||
const videoEl = {
|
{ tabTitle: 'Empty Tab', result: null },
|
||||||
getAttribute: (attr) => {
|
{ tabTitle: 'No Videos', result: { videos: [], videoCount: 0 } },
|
||||||
if (attr === 'data-hls') return 'https://example.com/stream.m3u8';
|
{
|
||||||
if (attr === 'src') return null;
|
tabTitle: 'Has Video',
|
||||||
if (attr === 'data-video') return null;
|
result: {
|
||||||
if (attr === 'data-mp4') return null;
|
videos: [{
|
||||||
if (attr === 'data-webm') return null;
|
|
||||||
if (attr === 'data-poster') return null;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
poster: '',
|
|
||||||
currentSrc: '',
|
|
||||||
src: '',
|
|
||||||
clientWidth: 1920,
|
|
||||||
clientHeight: 1080,
|
|
||||||
sources: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = simulateScrapeFn([videoEl]);
|
|
||||||
expect(result.videos[0].mediaType).toBe('hls');
|
|
||||||
expect(result.videos[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects HLS from data-hls-manifest attribute', () => {
|
|
||||||
const videoEl = {
|
|
||||||
getAttribute: (attr) => {
|
|
||||||
if (attr === 'data-hls-manifest') return 'https://example.com/live.m3u8';
|
|
||||||
if (attr === 'data-hls') return null;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
poster: '',
|
|
||||||
currentSrc: '',
|
|
||||||
src: '',
|
|
||||||
clientWidth: 1280,
|
|
||||||
clientHeight: 720,
|
|
||||||
sources: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = simulateScrapeFn([videoEl]);
|
|
||||||
expect(result.videos[0].mediaType).toBe('hls');
|
|
||||||
expect(result.videos[0].hlsManifest).toBe('https://example.com/live.m3u8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detects HLS from <source> child with m3u8 URL', () => {
|
|
||||||
const videoEl = {
|
|
||||||
getAttribute: () => null,
|
|
||||||
poster: '',
|
|
||||||
currentSrc: '',
|
|
||||||
src: '',
|
|
||||||
clientWidth: 1920,
|
|
||||||
clientHeight: 1080,
|
|
||||||
sources: [{ src: 'https://example.com/stream.m3u8' }],
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = simulateScrapeFn([videoEl]);
|
|
||||||
expect(result.videos[0].mediaType).toBe('hls');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT detect HLS for standard MP4 video', () => {
|
|
||||||
const videoEl = {
|
|
||||||
getAttribute: (attr) => {
|
|
||||||
if (attr === 'src') return null;
|
|
||||||
if (attr === 'data-hls') return null;
|
|
||||||
if (attr === 'data-hls-manifest') return null;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
poster: '',
|
|
||||||
currentSrc: 'https://example.com/video.mp4',
|
|
||||||
src: 'https://example.com/video.mp4',
|
src: 'https://example.com/video.mp4',
|
||||||
clientWidth: 1920,
|
poster: null,
|
||||||
clientHeight: 1080,
|
mediaType: 'video',
|
||||||
sources: [],
|
}],
|
||||||
};
|
videoCount: 1,
|
||||||
|
|
||||||
const result = simulateScrapeFn([videoEl]);
|
|
||||||
expect(result.videos[0].mediaType).toBe('video');
|
|
||||||
expect(result.videos[0].hlsManifest).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('picks the largest video element', () => {
|
|
||||||
const videos = [
|
|
||||||
{
|
|
||||||
getAttribute: () => null,
|
|
||||||
poster: '',
|
|
||||||
currentSrc: 'https://example.com/small.mp4',
|
|
||||||
src: 'https://example.com/small.mp4',
|
|
||||||
clientWidth: 320,
|
|
||||||
clientHeight: 240,
|
|
||||||
sources: [],
|
|
||||||
},
|
},
|
||||||
{
|
|
||||||
getAttribute: () => null,
|
|
||||||
poster: '',
|
|
||||||
currentSrc: 'https://example.com/large.mp4',
|
|
||||||
src: 'https://example.com/large.mp4',
|
|
||||||
clientWidth: 1920,
|
|
||||||
clientHeight: 1080,
|
|
||||||
sources: [],
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = simulateScrapeFn(videos);
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
expect(result.videos.length).toBe(1); // only largest is picked
|
expect(storedItems).toHaveLength(1);
|
||||||
expect(result.videos[0].src).toBe('https://example.com/large.mp4');
|
expect(storedItems[0].tab).toBe('Has Video');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+101
-101
@@ -1,10 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
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';
|
||||||
|
const HLS_CONFIG = {
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60,
|
||||||
|
};
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const id = params.get('id');
|
const id = params.get('id');
|
||||||
const gridN = parseInt(params.get('grid'), 10) || 2;
|
const gridN = parseInt(params.get('grid'), 10) || 2;
|
||||||
@@ -14,6 +21,10 @@ 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();
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* HLS helpers */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
/** Destroy the Hls instance attached to a video element (if any). */
|
/** Destroy the Hls instance attached to a video element (if any). */
|
||||||
function destroyHls(video) {
|
function destroyHls(video) {
|
||||||
const inst = hlsInstances.get(video);
|
const inst = hlsInstances.get(video);
|
||||||
@@ -23,64 +34,41 @@ function destroyHls(video) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show an error message inside a panel's video placeholder. */
|
/**
|
||||||
function showError(panel, message) {
|
* Play an HLS manifest on a <video> element using hls.js or native Safari.
|
||||||
const err = document.createElement('div');
|
*/
|
||||||
err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;';
|
|
||||||
err.textContent = message;
|
|
||||||
panel.appendChild(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Play an HLS manifest on a <video> element using hls.js or native Safari. */
|
|
||||||
function playHls(video, manifestUrl, panel) {
|
function playHls(video, manifestUrl, panel) {
|
||||||
// Safari has native HLS support — just assign the source
|
|
||||||
if (!window.Hls || !window.Hls.isSupported()) {
|
if (!window.Hls || !window.Hls.isSupported()) {
|
||||||
console.log('[goon-viewer] Native HLS support detected, using direct source');
|
|
||||||
video.src = manifestUrl;
|
video.src = manifestUrl;
|
||||||
const p = video.play();
|
const p = video.play();
|
||||||
if (p) p.catch(() => { /* autoplay blocked — user interaction required */ });
|
if (p) p.catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up any prior instance on this element
|
|
||||||
destroyHls(video);
|
destroyHls(video);
|
||||||
|
|
||||||
const hls = new window.Hls({
|
const hls = new window.Hls(HLS_CONFIG);
|
||||||
enableWorker: true,
|
|
||||||
lowLatencyMode: false,
|
|
||||||
maxBufferLength: 30,
|
|
||||||
maxMaxBufferLength: 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
hlsInstances.set(video, hls);
|
hlsInstances.set(video, hls);
|
||||||
|
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||||
console.log('[goon-viewer] HLS manifest parsed, playing');
|
|
||||||
const p = video.play();
|
const p = video.play();
|
||||||
if (p) p.catch(() => { /* autoplay blocked */ });
|
if (p) p.catch(() => {});
|
||||||
});
|
|
||||||
|
|
||||||
hls.on(window.Hls.Events.LEVEL_SWITCHED, () => {
|
|
||||||
console.log('[goon-viewer] HLS quality level switched');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||||
console.warn('[goon-viewer] HLS error:', data.type, data.details);
|
if (!data.fatal) return;
|
||||||
if (data.fatal) {
|
|
||||||
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
if (data.type === window.Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
console.warn('[goon-viewer] Fatal network error — stream may have expired');
|
showError(panel, 'Stream expired - re-scan to get fresh URLs.');
|
||||||
showError(panel, 'Stream expired — re-scan to get fresh URLs.');
|
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hlsInstances.delete(video);
|
hlsInstances.delete(video);
|
||||||
} else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
|
} else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
console.log('[goon-viewer] Fatal media error, attempting recovery');
|
|
||||||
hls.recoverMediaError();
|
hls.recoverMediaError();
|
||||||
} else {
|
} else {
|
||||||
showError(panel, 'Playback error — unable to recover.');
|
showError(panel, 'Playback error - unable to recover.');
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hlsInstances.delete(video);
|
hlsInstances.delete(video);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.loadSource(manifestUrl);
|
hls.loadSource(manifestUrl);
|
||||||
@@ -89,106 +77,120 @@ function playHls(video, manifestUrl, panel) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Load hls.js from CDN. Returns a Promise that resolves when ready.
|
* Load hls.js from CDN. Returns a Promise that resolves when ready.
|
||||||
* Skips loading if Hls is already defined (native Safari or prior load).
|
|
||||||
*/
|
*/
|
||||||
function loadHlsJs() {
|
function loadHlsJs() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (window.Hls && window.Hls.isSupported) {
|
if (window.Hls && window.Hls.isSupported) {
|
||||||
console.log('[goon-viewer] hls.js already loaded');
|
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[goon-viewer] Loading hls.js from CDN...');
|
|
||||||
const script = document.createElement('script');
|
const script = document.createElement('script');
|
||||||
script.src = HLS_CDN;
|
script.src = HLS_CDN;
|
||||||
script.onload = () => {
|
script.onload = () => resolve();
|
||||||
console.log('[goon-viewer] hls.js loaded successfully');
|
script.onerror = () => reject(new Error('Hls.js CDN unavailable'));
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
console.warn('[goon-viewer] Failed to load hls.js from CDN — HLS streams will not play');
|
|
||||||
reject(new Error('Hls.js CDN unavailable'));
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stored video URLs and render panels
|
/* ======================================================================== */
|
||||||
async function init() {
|
/* UI helpers */
|
||||||
console.log('[goon-viewer] init called, id:', id, 'url:', location.href);
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
/** Show an error message inside a panel's video placeholder. */
|
||||||
|
function showError(panel, message) {
|
||||||
|
const err = document.createElement('div');
|
||||||
|
err.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#f87171;background:rgba(0,0,0,0.6);font-family:sans-serif;font-size:13px;text-align:center;padding:12px;';
|
||||||
|
err.textContent = message;
|
||||||
|
panel.appendChild(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a video element with standard autoplay attributes. */
|
||||||
|
function createVideoElement() {
|
||||||
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
return video;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single panel (video or empty).
|
||||||
|
* Returns { panel, hlsEntry } — hlsEntry is set for deferred HLS playback.
|
||||||
|
*/
|
||||||
|
function createPanel(entry, tag) {
|
||||||
|
const panel = document.createElement('div');
|
||||||
|
panel.classList.add('panel');
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
panel.classList.add('empty');
|
||||||
|
return { panel, hlsEntry: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const video = createVideoElement();
|
||||||
|
|
||||||
|
const src = entry.src || entry.url || '';
|
||||||
|
const mediaType = entry.mediaType || 'video';
|
||||||
|
const poster = entry.poster || null;
|
||||||
|
const hlsManifest = entry.hlsManifest || null;
|
||||||
|
|
||||||
|
if (poster) video.poster = poster;
|
||||||
|
|
||||||
|
if (mediaType === 'hls' && hlsManifest) {
|
||||||
|
video.dataset.hlsManifest = hlsManifest;
|
||||||
|
video.dataset.mediaType = 'hls';
|
||||||
|
panel.appendChild(video);
|
||||||
|
if (tag) {
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'panel-label';
|
||||||
|
label.textContent = tag;
|
||||||
|
panel.appendChild(label);
|
||||||
|
}
|
||||||
|
return { panel, hlsEntry: { video, manifest: hlsManifest, panel } };
|
||||||
|
}
|
||||||
|
|
||||||
|
video.src = src;
|
||||||
|
panel.appendChild(video);
|
||||||
|
if (tag) {
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'panel-label';
|
||||||
|
label.textContent = tag;
|
||||||
|
panel.appendChild(label);
|
||||||
|
}
|
||||||
|
return { panel, hlsEntry: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ======================================================================== */
|
||||||
|
/* Init */
|
||||||
|
/* ======================================================================== */
|
||||||
|
|
||||||
|
async function init() {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
panelsEl.innerHTML = '<div style="padding:20px;color:#64748b">No viewer ID provided</div>';
|
panelsEl.innerHTML = '<div style="padding:20px;color:#64748b">No viewer ID provided</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await chrome.storage.local.get([id]);
|
const data = await chrome.storage.local.get([id]);
|
||||||
console.log('[goon-viewer] raw storage:', data);
|
|
||||||
const videoUrls = data[id] || [];
|
const videoUrls = data[id] || [];
|
||||||
console.log('[goon-viewer] video URLs to render:', videoUrls, '(count:', videoUrls.length, ')');
|
|
||||||
|
|
||||||
|
// Status overlay
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'viewer-status';
|
overlay.id = 'viewer-status';
|
||||||
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
|
overlay.style.cssText = 'position:absolute;top:0;left:0;right:0;background:rgba(0,0,0,0.85);color:#fff;padding:8px 16px;font-family:sans-serif;font-size:14px;z-index:999;';
|
||||||
document.body.style.position = 'relative';
|
document.body.style.position = 'relative';
|
||||||
document.body.insertBefore(overlay, document.body.firstChild);
|
document.body.insertBefore(overlay, document.body.firstChild);
|
||||||
|
|
||||||
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
|
overlay.textContent = `Found ${videoUrls.length} video URL(s) in ${gridN}x${gridN} grid`;
|
||||||
console.log('[goon-viewer]', overlay.textContent);
|
|
||||||
|
|
||||||
const totalPanels = gridN * gridN;
|
const totalPanels = gridN * gridN;
|
||||||
|
|
||||||
// Collect HLS entries so we can play them after hls.js loads
|
|
||||||
const hlsEntries = [];
|
const hlsEntries = [];
|
||||||
|
|
||||||
// Create NxN panels
|
|
||||||
for (let i = 0; i < totalPanels; i++) {
|
for (let i = 0; i < totalPanels; i++) {
|
||||||
const panel = document.createElement('div');
|
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : null;
|
||||||
panel.classList.add('panel');
|
const { panel, hlsEntry } = createPanel(entry, tags[i]);
|
||||||
|
|
||||||
if (i < videoUrls.length && videoUrls[i]) {
|
|
||||||
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
|
|
||||||
const mediaType = entry.mediaType || 'video';
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.autoplay = true;
|
|
||||||
video.muted = true;
|
|
||||||
video.loop = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
|
|
||||||
// Set poster if available
|
|
||||||
if (entry.poster) {
|
|
||||||
video.poster = entry.poster;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaType === 'hls' && entry.hlsManifest) {
|
|
||||||
// HLS stream — defer playback until hls.js is loaded
|
|
||||||
video.dataset.hlsManifest = entry.hlsManifest;
|
|
||||||
video.dataset.mediaType = 'hls';
|
|
||||||
console.log('[goon-viewer] panel', i + 1, '/', totalPanels, ': HLS', entry.hlsManifest);
|
|
||||||
hlsEntries.push({ video, manifest: entry.hlsManifest, panel });
|
|
||||||
} else {
|
|
||||||
// Direct video source
|
|
||||||
video.src = entry.src || entry.url || '';
|
|
||||||
console.log('[goon-viewer] panel', i + 1, '/', totalPanels, ':', video.src);
|
|
||||||
}
|
|
||||||
|
|
||||||
panel.appendChild(video);
|
|
||||||
|
|
||||||
// Add tab label if available
|
|
||||||
if (tags[i]) {
|
|
||||||
const label = document.createElement('div');
|
|
||||||
label.className = 'panel-label';
|
|
||||||
label.textContent = tags[i];
|
|
||||||
panel.appendChild(label);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
panel.classList.add('empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
panelsEl.appendChild(panel);
|
panelsEl.appendChild(panel);
|
||||||
|
if (hlsEntry) hlsEntries.push(hlsEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply dynamic grid
|
|
||||||
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
||||||
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
||||||
|
|
||||||
@@ -197,9 +199,8 @@ async function init() {
|
|||||||
try {
|
try {
|
||||||
await loadHlsJs();
|
await loadHlsJs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[goon-viewer] HLS playback unavailable:', e.message);
|
|
||||||
for (const { panel } of hlsEntries) {
|
for (const { panel } of hlsEntries) {
|
||||||
showError(panel, 'HLS player unavailable — check network.');
|
showError(panel, 'HLS player unavailable - check network.');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -211,7 +212,6 @@ async function init() {
|
|||||||
|
|
||||||
// Clean up storage entry and HLS instances when the viewer is closed
|
// Clean up storage entry and HLS instances when the viewer is closed
|
||||||
window.addEventListener('unload', async () => {
|
window.addEventListener('unload', async () => {
|
||||||
// Destroy all HLS instances first
|
|
||||||
for (const video of [...hlsInstances.keys()]) {
|
for (const video of [...hlsInstances.keys()]) {
|
||||||
destroyHls(video);
|
destroyHls(video);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user