fix: scan main frame only, deep-probe HLS, always store mediaType/hlsManifest
- Run scrapeFn in main frame only to avoid iframe noise (23 thumbnails) - Deep-walk player objects up to depth 5 for .m3u8 URLs - Always include mediaType and hlsManifest in stored items - When blob URL detected, substitute with HLS manifest from probe - Add integration tests for storage pipeline and viewer rendering - Add eslintignore for dist/
This commit is contained in:
1 parent
7416ae8ff9
commit
23a2af6cad
5 files changed
+1277
-496
No files matched your search
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
+216
-379
@@ -1,11 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* index.js — Popup logic for the Goon Plugin.
|
* index.js — Popup logic for the Goon Plugin.
|
||||||
* Runs inside the popup; scans all tabs for videos and opens the viewer.
|
* Runs inside the popup; scans the current tab for videos and opens the viewer.
|
||||||
*
|
|
||||||
* Video detection strategy (per tab):
|
|
||||||
* 1. executeScript in main world: deeply query HLS player state for manifests
|
|
||||||
* 2. chrome.tabs.sendMessage to content script: get intercepted manifests + poster
|
|
||||||
* 3. executeScript DOM scan: fallback to <video> element attributes
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STORAGE_KEY = 'goon_videos';
|
const STORAGE_KEY = 'goon_videos';
|
||||||
@@ -55,6 +50,7 @@ function renderList(items) {
|
|||||||
listEl.appendChild(link);
|
listEl.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Copy all URLs button
|
||||||
const copyBtn = document.createElement('button');
|
const copyBtn = document.createElement('button');
|
||||||
copyBtn.className = 'copy-btn';
|
copyBtn.className = 'copy-btn';
|
||||||
copyBtn.textContent = 'Copy All URLs';
|
copyBtn.textContent = 'Copy All URLs';
|
||||||
@@ -86,222 +82,9 @@ function showActions() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================================
|
|
||||||
* HLS DATA STRUCTURE QUERY — runs in the MAIN world via executeScript
|
|
||||||
*
|
|
||||||
* This is the primary detection strategy. We deeply probe the page's
|
|
||||||
* JavaScript state for HLS player instances, config objects, and
|
|
||||||
* playlist URLs. Works regardless of whether the page uses HLS.js,
|
|
||||||
* video.js, DASH.js, FLV.js, or a custom player.
|
|
||||||
* ==================================================================== */
|
|
||||||
|
|
||||||
const hlsQueryFn = function () {
|
|
||||||
// Resolve relative URLs
|
|
||||||
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 (e) { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isManifestUrl(url) {
|
|
||||||
if (!url || typeof url !== 'string') return false;
|
|
||||||
return url.includes('.m3u8') ||
|
|
||||||
url.includes('/playlist') ||
|
|
||||||
url.includes('/manifest') ||
|
|
||||||
url.includes('.mpd');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect unique manifest URLs
|
|
||||||
const manifests = [];
|
|
||||||
const manifestSet = new Set();
|
|
||||||
|
|
||||||
function addManifest(url) {
|
|
||||||
const resolved = resolveUrl(url);
|
|
||||||
if (resolved && !manifestSet.has(resolved)) {
|
|
||||||
manifestSet.add(resolved);
|
|
||||||
manifests.push(resolved);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- STRATEGY 1: Probe video elements for HLS player instances ---
|
|
||||||
try {
|
|
||||||
const videos = document.querySelectorAll('video');
|
|
||||||
const hlsProps = [
|
|
||||||
'hls', 'hlsInstance', '_hls', '__hls', 'player', '_player',
|
|
||||||
'videoPlayer', '_videoPlayer', 'mediaPlayer', '_mediaPlayer',
|
|
||||||
'flvjsPlayer', '_flvjsPlayer', 'dashPlayer', '_dashPlayer',
|
|
||||||
'__hlsPlayer', '__player', '__videoPlayer', 'phPlayer',
|
|
||||||
'_phPlayer', 'phVideoPlayer', '_phVideoPlayer'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const video of videos) {
|
|
||||||
for (const prop of hlsProps) {
|
|
||||||
const instance = video[prop];
|
|
||||||
if (!instance || typeof instance !== 'object') continue;
|
|
||||||
|
|
||||||
// HLS.js: .url, .opts.url, ._stream.url
|
|
||||||
if (instance.url) addManifest(instance.url);
|
|
||||||
if (instance.opts && instance.opts.url) addManifest(instance.opts.url);
|
|
||||||
if (instance._stream && instance._stream.url) addManifest(instance._stream.url);
|
|
||||||
|
|
||||||
// HLS.js levels (quality variants)
|
|
||||||
if (instance.levels) {
|
|
||||||
for (const level of instance.levels) {
|
|
||||||
if (level.uri) addManifest(level.uri);
|
|
||||||
if (level._uri) addManifest(level._uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// video.js: .currentSource_.src or .src_
|
|
||||||
if (instance.currentSource_ && instance.currentSource_.src) addManifest(instance.currentSource_.src);
|
|
||||||
if (instance.src_) addManifest(instance.src_);
|
|
||||||
|
|
||||||
// Generic: .playlist, .source, .mediaUrl
|
|
||||||
if (instance.playlist) addManifest(instance.playlist);
|
|
||||||
if (instance.source) addManifest(instance.source);
|
|
||||||
if (instance.mediaUrl) addManifest(instance.mediaUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { /* DOM access error */ }
|
|
||||||
|
|
||||||
// --- STRATEGY 2: Probe window-level player variables ---
|
|
||||||
try {
|
|
||||||
const windowPlayerVars = [
|
|
||||||
'Hls', '_Hls', 'hls', 'FLV', 'flvjs', 'Video', 'Videojs',
|
|
||||||
'videojs', 'p7m', 'P7m', 'Player', 'player', '_player',
|
|
||||||
'PornhubPlayer', 'PHPlayer', '_PHPlayer', 'phVideoPlayer',
|
|
||||||
'phPlayer', '_phPlayer', 'videoPlayer', 'MediaPlayer',
|
|
||||||
'CustomPlayer', 'phVideo', 'ph_media', '_PH_VIDEO',
|
|
||||||
'_hls', '_dash', '_video', '__player', '__hls'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Deep-probe an object for manifest URLs (up to depth 3)
|
|
||||||
function deepProbe(obj, depth, seen) {
|
|
||||||
if (!obj || typeof obj !== 'object' || depth <= 0) return;
|
|
||||||
if (seen.has(obj)) return;
|
|
||||||
seen.add(obj);
|
|
||||||
|
|
||||||
for (const key of Object.keys(obj)) {
|
|
||||||
const val = obj[key];
|
|
||||||
if (typeof val === 'string' && isManifestUrl(val)) {
|
|
||||||
addManifest(val);
|
|
||||||
} else if (typeof val === 'object' && val !== null) {
|
|
||||||
deepProbe(val, depth - 1, seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const varName of windowPlayerVars) {
|
|
||||||
const obj = window[varName];
|
|
||||||
if (obj && typeof obj === 'object') {
|
|
||||||
deepProbe(obj, 3, new Set());
|
|
||||||
|
|
||||||
// Also check prototype chain
|
|
||||||
const proto = Object.getPrototypeOf(obj);
|
|
||||||
if (proto && proto !== Object.prototype) {
|
|
||||||
deepProbe(proto, 2, new Set());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { /* Ignore */ }
|
|
||||||
|
|
||||||
// --- STRATEGY 3: Probe intercepted data (from content script interceptors) ---
|
|
||||||
try {
|
|
||||||
if (window.__goon_plugin_data && window.__goon_plugin_data.manifests) {
|
|
||||||
for (const url of window.__goon_plugin_data.manifests) {
|
|
||||||
addManifest(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { /* Ignore */ }
|
|
||||||
|
|
||||||
// --- STRATEGY 4: Extract from inline scripts ---
|
|
||||||
try {
|
|
||||||
const scripts = document.querySelectorAll('script');
|
|
||||||
for (const script of scripts) {
|
|
||||||
const text = script.textContent || '';
|
|
||||||
if (!text.includes('.m3u8') && !text.includes('manifest') &&
|
|
||||||
!text.includes('playlist') && !text.includes('/hls')) continue;
|
|
||||||
|
|
||||||
// m3u8 URLs in quotes
|
|
||||||
const m3u8Regex = /['"]([^'"]*\.m3u8[^'"]*)['"]/g;
|
|
||||||
let match;
|
|
||||||
while ((match = m3u8Regex.exec(text)) !== null) {
|
|
||||||
addManifest(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// CDN-style HLS URLs (Pornhub CDN patterns)
|
|
||||||
const cdnRegex = /['"]([^'"]*(?:phncdn|pornhub|porn\.com|pornhubpremium)[^'"]*(?:m3u8|playlist|manifest|\/hls\/)[^'"]*)['"]/g;
|
|
||||||
while ((match = cdnRegex.exec(text)) !== null) {
|
|
||||||
addManifest(match[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic manifest/playlist URLs
|
|
||||||
const genericRegex = /['"]([^'"]*(?:\/manifest\/|\/playlist\/|\/hls\/)[^'"]*)['"]/g;
|
|
||||||
while ((match = genericRegex.exec(text)) !== null) {
|
|
||||||
addManifest(match[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) { /* Ignore */ }
|
|
||||||
|
|
||||||
// --- STRATEGY 5: Probe __dataLayer, __INITIAL_STATE__, and similar ---
|
|
||||||
try {
|
|
||||||
const dataKeys = Object.keys(window).filter(k =>
|
|
||||||
k.startsWith('__') && k !== '__goon_plugin_data' &&
|
|
||||||
k !== '__goon_plugin_intercepted'
|
|
||||||
);
|
|
||||||
|
|
||||||
const maxProbe = Math.min(dataKeys.length, 15);
|
|
||||||
for (let i = 0; i < maxProbe; i++) {
|
|
||||||
const key = dataKeys[i];
|
|
||||||
const obj = window[key];
|
|
||||||
if (!obj || typeof obj !== 'object') continue;
|
|
||||||
|
|
||||||
// Quick heuristic: only deep-probe if it looks media-related
|
|
||||||
try {
|
|
||||||
const peek = JSON.stringify(obj).substring(0, 300);
|
|
||||||
if (peek.includes('video') || peek.includes('m3u8') ||
|
|
||||||
peek.includes('stream') || peek.includes('playlist') ||
|
|
||||||
peek.includes('hls') || peek.includes('manifest') ||
|
|
||||||
peek.includes('phncdn') || peek.includes('pornhubpremium')) {
|
|
||||||
|
|
||||||
function deepProbeLimited(o, d) {
|
|
||||||
if (!o || typeof o !== 'object' || d <= 0) return;
|
|
||||||
for (const k of Object.keys(o)) {
|
|
||||||
const v = o[k];
|
|
||||||
if (typeof v === 'string' && isManifestUrl(v)) {
|
|
||||||
addManifest(v);
|
|
||||||
} else if (typeof v === 'object' && v !== null) {
|
|
||||||
deepProbeLimited(v, d - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
deepProbeLimited(obj, 3);
|
|
||||||
}
|
|
||||||
} catch (e) { /* Ignore stringify errors */ }
|
|
||||||
}
|
|
||||||
} catch (e) { /* Ignore */ }
|
|
||||||
|
|
||||||
// --- STRATEGY 6: Query intercepted data from main world ---
|
|
||||||
try {
|
|
||||||
if (window.__goon_plugin_data && Array.isArray(window.__goon_plugin_data.manifests)) {
|
|
||||||
for (const m of window.__goon_plugin_data.manifests) {
|
|
||||||
addManifest(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
manifests: manifests,
|
|
||||||
count: manifests.length,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/* === "Find Videos" — scan all tabs in the window === */
|
/* === "Find Videos" — scan all tabs in the window === */
|
||||||
findBtn.addEventListener('click', async () => {
|
findBtn.addEventListener('click', async () => {
|
||||||
setStatus('Scanning tabs...');
|
setStatus('Scanning tabs…');
|
||||||
|
|
||||||
// Get all tabs in the current window
|
// Get all tabs in the current window
|
||||||
const allTabs = await chrome.tabs.query({ currentWindow: true });
|
const allTabs = await chrome.tabs.query({ currentWindow: true });
|
||||||
@@ -322,61 +105,202 @@ findBtn.addEventListener('click', async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tabsToScan = validTabs;
|
const tabsToScan = validTabs;
|
||||||
setStatus(`Scanning ${tabsToScan.length} tab(s)...`);
|
setStatus(`Scanning ${tabsToScan.length} tab(s)…`);
|
||||||
|
|
||||||
// DOM scrape function (runs in main world)
|
try {
|
||||||
const scrapeFn = () => {
|
// --- Helper: deep object walker that finds .m3u8 URLs up to depth 5 ---
|
||||||
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
|
const m3u8DeepProbeFn = () => {
|
||||||
|
const manifests = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
// Collect all visible video elements, measure by rendered pixel area
|
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 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 src = dataKeys.map(k => v.getAttribute('data-' + k)).find(Boolean)
|
const rawSrc = v.currentSrc || v.src || '';
|
||||||
|| (v.currentSrc || v.src || '')
|
const poster = v.poster || v.getAttribute('data-poster') || null;
|
||||||
|| null;
|
|
||||||
const poster = (v.poster || '')
|
let hlsManifest = null;
|
||||||
|| dataKeys.filter(k => k !== 'poster').map(k => v.getAttribute('data-' + k)).find(Boolean)
|
let mediaType = 'video';
|
||||||
|| v.getAttribute('data-poster') || null;
|
let src = rawSrc;
|
||||||
return { src, poster, area, width: v.clientWidth, height: v.clientHeight };
|
|
||||||
|
// 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);
|
allVideos.sort((a, b) => b.area - a.area);
|
||||||
const largest = allVideos.length > 0 ? allVideos[0] : null;
|
|
||||||
const sources = Array.from(document.querySelectorAll('video source[src]')).map(s => s.src);
|
|
||||||
const audios = Array.from(document.querySelectorAll('audio')).length;
|
|
||||||
|
|
||||||
return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, sources, audios };
|
// 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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Query content script for intercepted data + poster
|
|
||||||
async function queryContentScript(tabId) {
|
|
||||||
try {
|
|
||||||
return await chrome.tabs.sendMessage(tabId, { type: 'GOON_GET_VIDEO_DATA' });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
tabsToScan.map(async t => {
|
tabsToScan.map(async t => {
|
||||||
try {
|
try {
|
||||||
const [hlsQueryResults, scriptResult, contentData] = await Promise.all([
|
const manifests = tabManifestMap.get(t.id) || [];
|
||||||
// STRATEGY 1: Deep query main-world HLS data structures
|
// Main frame only — prevents iframe noise (related videos, ads)
|
||||||
chrome.scripting.executeScript({
|
return await chrome.scripting.executeScript({
|
||||||
target: { tabId: t.id, allFrames: true },
|
target: { tabId: t.id },
|
||||||
func: hlsQueryFn,
|
|
||||||
}),
|
|
||||||
// STRATEGY 2: DOM scrape (existing behavior)
|
|
||||||
chrome.scripting.executeScript({
|
|
||||||
target: { tabId: t.id, allFrames: true },
|
|
||||||
func: scrapeFn,
|
func: scrapeFn,
|
||||||
}),
|
args: [manifests],
|
||||||
// STRATEGY 3: Content script intercepted manifests + poster
|
});
|
||||||
queryContentScript(t.id),
|
|
||||||
]);
|
|
||||||
return { hlsQueryResults: hlsQueryResults.filter(Boolean), scriptResult: scriptResult.filter(Boolean), contentData };
|
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('[goon] skipping tab', t.id, ':', t.url);
|
console.warn('[goon] skipping tab', t.id, ':', t.url);
|
||||||
return null;
|
return null;
|
||||||
@@ -384,140 +308,46 @@ findBtn.addEventListener('click', async () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Merge all results into unique video entries
|
// 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();
|
const seen = new Map();
|
||||||
let totalFound = 0;
|
let totalFound = 0;
|
||||||
|
for (const group of tabResults) {
|
||||||
for (let ti = 0; ti < tabsToScan.length; ti++) {
|
for (const v of group.result.videos) {
|
||||||
const tab = tabsToScan[ti];
|
|
||||||
const res = results[ti];
|
|
||||||
if (!res) continue;
|
|
||||||
|
|
||||||
const tabTitle = tab.title || 'Unknown';
|
|
||||||
const contentPoster = (res.contentData && res.contentData.posterUrl) || null;
|
|
||||||
|
|
||||||
// --- PROCESS HLS MANIFESTS FROM MAIN-WORLD QUERY ---
|
|
||||||
const hlsManifests = [];
|
|
||||||
for (const frameResult of (res.hlsQueryResults || [])) {
|
|
||||||
const q = frameResult.result;
|
|
||||||
if (q && Array.isArray(q.manifests)) {
|
|
||||||
for (const m of q.manifests) {
|
|
||||||
if (m && m.startsWith('http')) {
|
|
||||||
hlsManifests.push(m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PROCESS INTERCEPTED MANIFESTS FROM CONTENT SCRIPT ---
|
|
||||||
const interceptedManifests = [];
|
|
||||||
if (res.contentData && Array.isArray(res.contentData.interceptedManifests)) {
|
|
||||||
for (const im of res.contentData.interceptedManifests) {
|
|
||||||
if (im.url && im.url.startsWith('http')) {
|
|
||||||
interceptedManifests.push(im.url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge all manifest sources
|
|
||||||
const allManifests = [...new Set([...hlsManifests, ...interceptedManifests])];
|
|
||||||
|
|
||||||
// --- PROCESS DOM VIDEO ELEMENTS ---
|
|
||||||
const domVideos = [];
|
|
||||||
for (const frameResult of (res.scriptResult || [])) {
|
|
||||||
const r = frameResult.result;
|
|
||||||
if (r && r.videos) {
|
|
||||||
for (const v of r.videos) {
|
|
||||||
if (v) domVideos.push(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PROCESS CONTENT SCRIPT VIDEO ELEMENTS ---
|
|
||||||
const csVideos = [];
|
|
||||||
if (res.contentData && Array.isArray(res.contentData.videoElements)) {
|
|
||||||
for (const v of res.contentData.videoElements) {
|
|
||||||
csVideos.push(v);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- BUILD ENTRIES ---
|
|
||||||
// Priority 1: HLS manifests (per tab, only if present)
|
|
||||||
for (const manifestUrl of allManifests) {
|
|
||||||
totalFound++;
|
totalFound++;
|
||||||
const key = 'hls||' + manifestUrl;
|
const primarySrc = v.hlsManifest || v.src || '(empty)';
|
||||||
|
const key = primarySrc + '||' + (v.poster || '(none)');
|
||||||
if (!seen.has(key)) {
|
if (!seen.has(key)) {
|
||||||
const item = {
|
const item = {
|
||||||
url: manifestUrl,
|
url: v.hlsManifest || v.src || '',
|
||||||
src: manifestUrl,
|
poster: v.poster || null,
|
||||||
poster: contentPoster || null,
|
tab: group.tabTitle,
|
||||||
tab: tabTitle,
|
src: v.hlsManifest || v.src || '',
|
||||||
hlsManifest: manifestUrl,
|
mediaType: v.mediaType || 'video',
|
||||||
mediaType: 'hls',
|
|
||||||
};
|
};
|
||||||
seen.set(key, item);
|
if (v.hlsManifest) item.hlsManifest = v.hlsManifest;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: DOM video elements (non-blob direct URLs)
|
|
||||||
for (const v of domVideos) {
|
|
||||||
// Skip blob: URLs — they're HLS-internal and can't be played directly
|
|
||||||
if (!v.src || v.src.startsWith('blob:')) continue;
|
|
||||||
totalFound++;
|
|
||||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
const item = {
|
|
||||||
url: v.src,
|
|
||||||
src: v.src,
|
|
||||||
poster: v.poster || contentPoster || null,
|
|
||||||
tab: tabTitle,
|
|
||||||
};
|
|
||||||
// If HLS is available for this tab, attach it
|
|
||||||
if (allManifests.length > 0) {
|
|
||||||
item.hlsManifest = allManifests[0];
|
|
||||||
item.mediaType = 'hls';
|
|
||||||
}
|
|
||||||
seen.set(key, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Content script video elements
|
|
||||||
for (const v of csVideos) {
|
|
||||||
if (!v.src || v.src.startsWith('blob:')) continue;
|
|
||||||
totalFound++;
|
|
||||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
|
||||||
if (!seen.has(key)) {
|
|
||||||
const item = {
|
|
||||||
url: v.src,
|
|
||||||
src: v.src,
|
|
||||||
poster: v.poster || contentPoster || null,
|
|
||||||
tab: tabTitle,
|
|
||||||
};
|
|
||||||
if (allManifests.length > 0) {
|
|
||||||
item.hlsManifest = allManifests[0];
|
|
||||||
item.mediaType = 'hls';
|
|
||||||
}
|
|
||||||
seen.set(key, item);
|
seen.set(key, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter: keep only http/https URLs
|
const items = [...seen.values()];
|
||||||
const items = [...seen.values()].filter(v => {
|
console.log('[goon] ===== Found', totalFound, 'video element(s) across', tabsToScan.length, 'tab(s), unique:', items.length);
|
||||||
const url = v.src || v.url || '';
|
|
||||||
return url.startsWith('http://') || url.startsWith('https://');
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('[goon] ===== Found', totalFound, 'video elements, unique:', items.length, 'across', tabsToScan.length, 'tabs');
|
// Replace the entire list (fresh scan)
|
||||||
|
|
||||||
// Store results
|
|
||||||
const storedItems = items.map(v => ({
|
const storedItems = items.map(v => ({
|
||||||
url: v.url,
|
url: v.url,
|
||||||
src: v.src || v.url,
|
src: v.src || v.url,
|
||||||
poster: v.poster,
|
poster: v.poster,
|
||||||
tab: v.tab,
|
tab: v.tab,
|
||||||
hlsManifest: v.hlsManifest || null,
|
mediaType: v.mediaType || 'video',
|
||||||
mediaType: v.mediaType || 'direct',
|
hlsManifest: v.hlsManifest || undefined,
|
||||||
}));
|
}));
|
||||||
await chrome.storage.local.set({
|
await chrome.storage.local.set({
|
||||||
[STORAGE_KEY]: storedItems,
|
[STORAGE_KEY]: storedItems,
|
||||||
@@ -525,12 +355,16 @@ findBtn.addEventListener('click', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const skippedTabs = results.filter(r => r === null).length;
|
const skippedTabs = results.filter(r => r === null).length;
|
||||||
const statusMsg = `Found ${items.length} video(s) across ${tabsToScan.length} tab(s).`;
|
const statusMsg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
|
||||||
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped.` : statusMsg);
|
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped (idle).` : statusMsg);
|
||||||
btnRow.style.display = 'flex';
|
btnRow.style.display = 'flex';
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(`Scan failed: ${e.message}`);
|
||||||
|
console.warn('[goon] executeScript failed:', e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* === "Open Viewer" — launch the NxN grid === */
|
/* === "Open Viewer" — launch the 2x2 grid === */
|
||||||
openBtn.addEventListener('click', async () => {
|
openBtn.addEventListener('click', async () => {
|
||||||
const { urls, tags } = await loadVideos();
|
const { urls, tags } = await loadVideos();
|
||||||
console.log('[goon] Open Viewer clicked, loaded from storage:', urls, 'tags:', tags);
|
console.log('[goon] Open Viewer clicked, loaded from storage:', urls, 'tags:', tags);
|
||||||
@@ -540,9 +374,11 @@ openBtn.addEventListener('click', async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dedup by index to preserve tag alignment
|
||||||
const videoUrls = urls;
|
const videoUrls = urls;
|
||||||
const videoTags = tags || [];
|
const videoTags = tags || [];
|
||||||
|
|
||||||
|
// Compute NxN grid
|
||||||
const N = Math.ceil(Math.sqrt(videoUrls.length));
|
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;
|
||||||
@@ -553,6 +389,7 @@ openBtn.addEventListener('click', async () => {
|
|||||||
const id = 'goonViewer_' + Date.now();
|
const id = 'goonViewer_' + Date.now();
|
||||||
await chrome.storage.local.set({ [id]: videoUrls });
|
await chrome.storage.local.set({ [id]: videoUrls });
|
||||||
|
|
||||||
|
// Verify write
|
||||||
const verify = await chrome.storage.local.get([id]);
|
const verify = await chrome.storage.local.get([id]);
|
||||||
console.log('[goon] viewer storage verified:', verify[id]?.length, 'entries');
|
console.log('[goon] viewer storage verified:', verify[id]?.length, 'entries');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* index.test.js — Integration tests for popup storage pipeline.
|
||||||
|
*
|
||||||
|
* Verifies that HLS metadata (mediaType, hlsManifest) survives the
|
||||||
|
* full scrape → dedup → storage → viewer cycle.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* Helpers: simulate the scrape → dedup → storage pipeline from index.js */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the dedup + storage logic from index.js lines 161-197.
|
||||||
|
* This reproduces the exact pipeline so we can verify HLS metadata flow.
|
||||||
|
*/
|
||||||
|
function simulateStoragePipeline(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;
|
||||||
|
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://');
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is how index.js stores items (line 186-196)
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the scrapeFn from index.js lines 112-139.
|
||||||
|
* Takes video element data and returns the scrape result.
|
||||||
|
*/
|
||||||
|
function simulateScrapeFn(videoElements) {
|
||||||
|
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
|
||||||
|
|
||||||
|
const allVideos = videoElements.map(v => {
|
||||||
|
const area = v.clientWidth * v.clientHeight;
|
||||||
|
// Check for HLS manifest URL in data attributes or <source> children
|
||||||
|
const hlsDataAttr = v.getAttribute('data-hls') || v.getAttribute('data-hls-manifest');
|
||||||
|
const hlsSource = v.sources?.map(s => s.src).find(Boolean) || null;
|
||||||
|
const hlsManifest = hlsDataAttr || hlsSource;
|
||||||
|
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
|
||||||
|
allVideos.sort((a, b) => b.area - a.area);
|
||||||
|
const largest = allVideos.length > 0 ? allVideos[0] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
videos: largest ? [largest] : [],
|
||||||
|
videoCount: largest ? 1 : 0,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* TESTS: HLS metadata flow through storage pipeline */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('Storage pipeline — HLS metadata preservation', () => {
|
||||||
|
it('preserves HLS metadata through dedup (mediaType: "hls", hlsManifest present)', () => {
|
||||||
|
// Simulate a tab with an HLS video element
|
||||||
|
const tabResults = [{
|
||||||
|
tabTitle: 'HLS Stream Page',
|
||||||
|
frames: [{
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/stream.m3u8',
|
||||||
|
poster: null,
|
||||||
|
area: 1920 * 1080,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
|
// After fix: mediaType is 'hls' and hlsManifest is preserved
|
||||||
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
|
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves hlsManifest through dedup for live streams', () => {
|
||||||
|
const tabResults = [{
|
||||||
|
tabTitle: 'Live Stream',
|
||||||
|
frames: [{
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://cdn.example.com/live.m3u8',
|
||||||
|
poster: 'https://cdn.example.com/thumb.jpg',
|
||||||
|
area: 1280 * 720,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://cdn.example.com/live.m3u8',
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
|
expect(storedItems[0].hlsManifest).toBe('https://cdn.example.com/live.m3u8');
|
||||||
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Standard video entries are stored correctly', () => {
|
||||||
|
const tabResults = [{
|
||||||
|
tabTitle: 'MP4 Page',
|
||||||
|
frames: [{
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/video.mp4',
|
||||||
|
poster: 'https://example.com/thumb.jpg',
|
||||||
|
area: 640 * 480,
|
||||||
|
width: 640,
|
||||||
|
height: 480,
|
||||||
|
mediaType: 'video',
|
||||||
|
hlsManifest: null,
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { storedItems } = simulateStoragePipeline(tabResults);
|
||||||
|
|
||||||
|
expect(storedItems[0].src).toBe('https://example.com/video.mp4');
|
||||||
|
expect(storedItems[0].mediaType).toBe('video');
|
||||||
|
expect(storedItems[0].poster).toBe('https://example.com/thumb.jpg');
|
||||||
|
expect(storedItems[0].tab).toBe('MP4 Page');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Storage pipeline — corrected HLS metadata preservation', () => {
|
||||||
|
/**
|
||||||
|
* This is what the pipeline SHOULD look like after fixing the bug.
|
||||||
|
* The dedup loop should copy mediaType and hlsManifest from the scraped
|
||||||
|
* 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: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/stream.m3u8',
|
||||||
|
poster: null,
|
||||||
|
area: 1920 * 1080,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
sources: [],
|
||||||
|
audios: 0,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { storedItems } = simulateStoragePipelineFixed(tabResults);
|
||||||
|
|
||||||
|
expect(storedItems[0].mediaType).toBe('hls');
|
||||||
|
expect(storedItems[0].hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FIXED: Mixed HLS and standard entries both preserve metadata', () => {
|
||||||
|
const tabResults = [{
|
||||||
|
tabTitle: 'Mixed Page',
|
||||||
|
frames: [{
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/live.m3u8',
|
||||||
|
poster: null,
|
||||||
|
area: 1920 * 1080,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/live.m3u8',
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
result: {
|
||||||
|
videos: [{
|
||||||
|
src: 'https://example.com/recording.mp4',
|
||||||
|
poster: 'https://example.com/thumb.jpg',
|
||||||
|
area: 1280 * 720,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
mediaType: 'video',
|
||||||
|
hlsManifest: null,
|
||||||
|
}],
|
||||||
|
videoCount: 1,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
|
||||||
|
const { storedItems } = simulateStoragePipelineFixed(tabResults);
|
||||||
|
|
||||||
|
// Largest video (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].hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ScrapeFn — HLS detection from DOM elements', () => {
|
||||||
|
it('detects HLS from data-hls attribute', () => {
|
||||||
|
const videoEl = {
|
||||||
|
getAttribute: (attr) => {
|
||||||
|
if (attr === 'data-hls') return 'https://example.com/stream.m3u8';
|
||||||
|
if (attr === 'src') return null;
|
||||||
|
if (attr === 'data-video') return null;
|
||||||
|
if (attr === 'data-mp4') return null;
|
||||||
|
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',
|
||||||
|
clientWidth: 1920,
|
||||||
|
clientHeight: 1080,
|
||||||
|
sources: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(result.videos.length).toBe(1); // only largest is picked
|
||||||
|
expect(result.videos[0].src).toBe('https://example.com/large.mp4');
|
||||||
|
});
|
||||||
|
});
|
||||||
+135
-117
@@ -1,124 +1,114 @@
|
|||||||
/**
|
/**
|
||||||
* 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 when mediaType is 'hls'.
|
* 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 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;
|
||||||
const tags = params.get('tags') ? JSON.parse(params.get('tags')) : [];
|
const tags = params.get('tags') ? JSON.parse(params.get('tags')) : [];
|
||||||
const panelsEl = document.getElementById('panels');
|
const panelsEl = document.getElementById('panels');
|
||||||
|
|
||||||
const HLS_CDNS = [
|
/** Track all Hls instances so we can destroy them on unload. */
|
||||||
'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js',
|
const hlsInstances = new Map();
|
||||||
'https://unpkg.com/hls.js@1/dist/hls.min.js',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Load hls.js from CDN. Caches the promise so it's only fetched once. */
|
/** Destroy the Hls instance attached to a video element (if any). */
|
||||||
let hlsLoadPromise = null;
|
function destroyHls(video) {
|
||||||
async function loadHlsJs() {
|
const inst = hlsInstances.get(video);
|
||||||
if (hlsLoadPromise) return hlsLoadPromise;
|
if (inst) {
|
||||||
return (hlsLoadPromise = (async () => {
|
inst.destroy();
|
||||||
if (typeof HLS !== 'undefined') return window.HLS;
|
hlsInstances.delete(video);
|
||||||
for (const url of HLS_CDNS) {
|
|
||||||
try {
|
|
||||||
await new Promise((res, rej) => {
|
|
||||||
const s = document.createElement('script');
|
|
||||||
s.src = url;
|
|
||||||
s.onload = res;
|
|
||||||
s.onerror = rej;
|
|
||||||
document.head.appendChild(s);
|
|
||||||
});
|
|
||||||
if (typeof HLS !== 'undefined') return window.HLS;
|
|
||||||
} catch {
|
|
||||||
/* try next CDN */
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
throw new Error('Failed to load hls.js from any CDN');
|
|
||||||
})());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Play an HLS manifest on a <video> element. Loads hls.js if needed. */
|
/** Show an error message inside a panel's video placeholder. */
|
||||||
async function playHls(video, manifestUrl) {
|
function showError(panel, message) {
|
||||||
try {
|
const err = document.createElement('div');
|
||||||
const HLS = await loadHlsJs();
|
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;';
|
||||||
if (!HLS || !HLS.isSupported()) {
|
err.textContent = message;
|
||||||
// native HLS (Safari) or undefined — fall back to plain src
|
panel.appendChild(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play an HLS manifest on a <video> element using hls.js or native Safari. */
|
||||||
|
function playHls(video, manifestUrl, panel) {
|
||||||
|
// Safari has native HLS support — just assign the source
|
||||||
|
if (!window.Hls || !window.Hls.isSupported()) {
|
||||||
|
console.log('[goon-viewer] Native HLS support detected, using direct source');
|
||||||
video.src = manifestUrl;
|
video.src = manifestUrl;
|
||||||
|
video.play().catch(() => { /* autoplay blocked — user interaction required */ });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Clean up any existing HLS instance before creating a new one
|
|
||||||
|
// Clean up any prior instance on this element
|
||||||
destroyHls(video);
|
destroyHls(video);
|
||||||
const hls = new HLS({ debug: false, enableWorker: true, lowLatencyMode: false });
|
|
||||||
hls.loadSource(manifestUrl);
|
|
||||||
hls.attachMedia(video);
|
|
||||||
video._hls = hls;
|
|
||||||
|
|
||||||
hls.on(HLS.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
|
const hls = new window.Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: false,
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60,
|
||||||
|
});
|
||||||
|
|
||||||
hls.on(HLS.Events.ERROR, (_ev, data) => {
|
hlsInstances.set(video, hls);
|
||||||
if (!data.fatal) return;
|
|
||||||
switch (data.type) {
|
hls.on(window.Hls.Events.MANIFEST_PARSED, () => {
|
||||||
case HLS.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break;
|
console.log('[goon-viewer] HLS manifest parsed, playing');
|
||||||
case HLS.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError(); break;
|
video.play().catch(() => { /* autoplay blocked */ });
|
||||||
default: destroyHls(video);
|
});
|
||||||
|
|
||||||
|
hls.on(window.Hls.Events.LEVEL_SWITCHED, () => {
|
||||||
|
console.log('[goon-viewer] HLS quality level switched');
|
||||||
|
});
|
||||||
|
|
||||||
|
hls.on(window.Hls.Events.ERROR, (_event, data) => {
|
||||||
|
console.warn('[goon-viewer] HLS error:', data.type, data.details);
|
||||||
|
if (data.fatal) {
|
||||||
|
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.');
|
||||||
|
hls.destroy();
|
||||||
|
hlsInstances.delete(video);
|
||||||
|
} else if (data.type === window.Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
|
console.log('[goon-viewer] Fatal media error, attempting recovery');
|
||||||
|
hls.recoverMediaError();
|
||||||
|
} else {
|
||||||
|
showError(panel, 'Playback error — unable to recover.');
|
||||||
|
hls.destroy();
|
||||||
|
hlsInstances.delete(video);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error('[goon-viewer] HLS init failed:', err.message);
|
hls.loadSource(manifestUrl);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.Hls && window.Hls.isSupported) {
|
||||||
|
console.log('[goon-viewer] hls.js already loaded');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
console.log('[goon-viewer] Loading hls.js from CDN...');
|
||||||
|
const script = document.createElement('script');
|
||||||
/** Destroy the HLS instance on a video element, if any. */
|
script.src = HLS_CDN;
|
||||||
function destroyHls(video) {
|
script.onload = () => {
|
||||||
if (video._hls) { video._hls.destroy(); delete video._hls; }
|
console.log('[goon-viewer] hls.js loaded successfully');
|
||||||
}
|
resolve();
|
||||||
|
};
|
||||||
/** Resolve the src URL from a video entry (string or object). */
|
script.onerror = () => {
|
||||||
function resolveSrc(entry) {
|
console.warn('[goon-viewer] Failed to load hls.js from CDN — HLS streams will not play');
|
||||||
if (typeof entry === 'string') return entry;
|
reject(new Error('Hls.js CDN unavailable'));
|
||||||
return entry.src || entry.url || '';
|
};
|
||||||
}
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
/** Check if entry is HLS media. */
|
|
||||||
function isHls(entry) {
|
|
||||||
return entry !== null && typeof entry === 'object' && entry.mediaType === 'hls';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a <video> for a single entry — HLS or standard. */
|
|
||||||
async function createVideoElement(entry) {
|
|
||||||
const video = document.createElement('video');
|
|
||||||
video.autoplay = true;
|
|
||||||
video.muted = true;
|
|
||||||
video.loop = true;
|
|
||||||
video.playsInline = true;
|
|
||||||
|
|
||||||
if (isHls(entry) && entry.hlsManifest) {
|
|
||||||
console.log('[goon-viewer] HLS detected, manifest:', entry.hlsManifest);
|
|
||||||
await playHls(video, entry.hlsManifest);
|
|
||||||
} else {
|
|
||||||
video.src = resolveSrc(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set poster image so a thumbnail shows before playback begins
|
|
||||||
if (entry && typeof entry === 'object' && entry.poster) {
|
|
||||||
video.poster = entry.poster;
|
|
||||||
}
|
|
||||||
|
|
||||||
return video;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show an error overlay on a panel. */
|
|
||||||
function showPanelError(panel, msg) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'panel-error';
|
|
||||||
el.style.cssText =
|
|
||||||
'display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;' +
|
|
||||||
'font-family:sans-serif;font-size:12px;text-align:center;padding:8px;';
|
|
||||||
el.textContent = msg || 'Failed to load video';
|
|
||||||
panel.innerHTML = '';
|
|
||||||
panel.appendChild(el);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch stored video URLs and render panels
|
// Fetch stored video URLs and render panels
|
||||||
@@ -137,9 +127,7 @@ async function init() {
|
|||||||
|
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'viewer-status';
|
overlay.id = 'viewer-status';
|
||||||
overlay.style.cssText =
|
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;';
|
||||||
'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);
|
||||||
|
|
||||||
@@ -148,21 +136,41 @@ async function init() {
|
|||||||
|
|
||||||
const totalPanels = gridN * gridN;
|
const totalPanels = gridN * gridN;
|
||||||
|
|
||||||
|
// Collect HLS entries so we can play them after hls.js loads
|
||||||
|
const hlsEntries = [];
|
||||||
|
|
||||||
// Create NxN panels
|
// Create NxN panels
|
||||||
for (let i = 0; i < totalPanels; i++) {
|
for (let i = 0; i < totalPanels; i++) {
|
||||||
const panel = document.createElement('div');
|
const panel = document.createElement('div');
|
||||||
panel.classList.add('panel');
|
panel.classList.add('panel');
|
||||||
|
|
||||||
if (i < videoUrls.length && videoUrls[i]) {
|
if (i < videoUrls.length && videoUrls[i]) {
|
||||||
try {
|
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
|
||||||
const video = await createVideoElement(videoUrls[i]);
|
const mediaType = entry.mediaType || 'video';
|
||||||
panel.appendChild(video);
|
const video = document.createElement('video');
|
||||||
|
video.autoplay = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
// Listen for load errors on standard videos
|
// Set poster if available
|
||||||
video.addEventListener('error', () => {
|
if (entry.poster) {
|
||||||
console.error('[goon-viewer] Video load error on panel', i + 1);
|
video.poster = entry.poster;
|
||||||
showPanelError(panel, 'Failed to load video');
|
}
|
||||||
});
|
|
||||||
|
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
|
// Add tab label if available
|
||||||
if (tags[i]) {
|
if (tags[i]) {
|
||||||
@@ -171,10 +179,6 @@ async function init() {
|
|||||||
label.textContent = tags[i];
|
label.textContent = tags[i];
|
||||||
panel.appendChild(label);
|
panel.appendChild(label);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('[goon-viewer] Error creating video panel', i + 1, ':', err);
|
|
||||||
showPanelError(panel, 'Error: ' + err.message);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
panel.classList.add('empty');
|
panel.classList.add('empty');
|
||||||
}
|
}
|
||||||
@@ -185,16 +189,30 @@ async function init() {
|
|||||||
// Apply dynamic grid
|
// 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)`;
|
||||||
|
|
||||||
|
// Load hls.js and start HLS playback
|
||||||
|
if (hlsEntries.length > 0) {
|
||||||
|
try {
|
||||||
|
await loadHlsJs();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[goon-viewer] HLS playback unavailable:', e.message);
|
||||||
|
for (const { panel } of hlsEntries) {
|
||||||
|
showError(panel, 'HLS player unavailable — check network.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const { video, manifest, panel } of hlsEntries) {
|
||||||
|
playHls(video, manifest, panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up HLS instances and storage entry 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
|
// Destroy all HLS instances first
|
||||||
const allVideos = panelsEl.querySelectorAll('video');
|
for (const video of [...hlsInstances.keys()]) {
|
||||||
for (const video of allVideos) {
|
|
||||||
destroyHls(video);
|
destroyHls(video);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
await chrome.storage.local.remove([id]);
|
await chrome.storage.local.remove([id]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
/**
|
||||||
|
* viewer.test.js — Integration tests for viewer.js panel rendering.
|
||||||
|
*
|
||||||
|
* Verifies that:
|
||||||
|
* - HLS entries (mediaType: 'hls') render with data-hls-manifest + data-media-type attributes
|
||||||
|
* - Standard video entries render with src set
|
||||||
|
* - Edge cases (invalid manifests, missing fields, string URLs) are handled correctly
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers: mount a minimal DOM that mimics viewer.html */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function mountViewerDOM() {
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head><title>Goon Viewer</title></head>
|
||||||
|
<body>
|
||||||
|
<div id="panels"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const dom = new JSDOM(html, {
|
||||||
|
url: 'chrome-extension://test/viewer/viewer.html?id=test123&grid=2',
|
||||||
|
runScripts: 'dangerously',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
window: dom.window,
|
||||||
|
document: dom.window.document,
|
||||||
|
panelsEl: dom.window.document.getElementById('panels'),
|
||||||
|
location: dom.window.location,
|
||||||
|
URLSearchParams: dom.window.URLSearchParams,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Simulate the panel-rendering loop from viewer.js (init function) */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reproduce the exact panel-rendering logic from viewer.js so we can
|
||||||
|
* unit-test it without chrome.storage or live browser globals.
|
||||||
|
*/
|
||||||
|
function renderPanels(panelsEl, videoUrls, gridN, tags) {
|
||||||
|
const totalPanels = gridN * gridN;
|
||||||
|
|
||||||
|
for (let i = 0; i < totalPanels; i++) {
|
||||||
|
const panel = panelsEl.ownerDocument.createElement('div');
|
||||||
|
panel.classList.add('panel');
|
||||||
|
|
||||||
|
if (i < videoUrls.length && videoUrls[i]) {
|
||||||
|
const entry = (typeof videoUrls[i] === 'object') ? videoUrls[i] : { src: videoUrls[i] };
|
||||||
|
const mediaType = entry.mediaType || 'video';
|
||||||
|
const video = panelsEl.ownerDocument.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
|
||||||
|
video.dataset.hlsManifest = entry.hlsManifest;
|
||||||
|
video.dataset.mediaType = 'hls';
|
||||||
|
} else {
|
||||||
|
// Direct video source
|
||||||
|
video.src = entry.src || entry.url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.appendChild(video);
|
||||||
|
|
||||||
|
// Add tab label if available
|
||||||
|
if (tags && tags[i]) {
|
||||||
|
const label = panelsEl.ownerDocument.createElement('div');
|
||||||
|
label.className = 'panel-label';
|
||||||
|
label.textContent = tags[i];
|
||||||
|
panel.appendChild(label);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panel.classList.add('empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
panelsEl.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
|
||||||
|
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================================================== */
|
||||||
|
/* TESTS */
|
||||||
|
/* ========================================================================== */
|
||||||
|
|
||||||
|
describe('HLS playback — panel rendering', () => {
|
||||||
|
let panelsEl;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Re-mount fresh DOM before each test
|
||||||
|
panelsEl = mountViewerDOM().panelsEl;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HLS entry with data-hls-manifest and data-media-type attributes', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8', src: '' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video).not.toBeNull();
|
||||||
|
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
|
expect(video.dataset.mediaType).toBe('hls');
|
||||||
|
// HLS videos should NOT have src set to the manifest — src attribute is absent
|
||||||
|
expect(video.hasAttribute('src')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple HLS entries in a grid', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://a.com/1.m3u8' },
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://b.com/2.m3u8' },
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://c.com/3.m3u8' },
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://d.com/4.m3u8' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 2, []);
|
||||||
|
|
||||||
|
const videos = panelsEl.querySelectorAll('video');
|
||||||
|
expect(videos.length).toBe(4);
|
||||||
|
expect(videos[0].dataset.hlsManifest).toBe('https://a.com/1.m3u8');
|
||||||
|
expect(videos[1].dataset.hlsManifest).toBe('https://b.com/2.m3u8');
|
||||||
|
expect(videos[2].dataset.hlsManifest).toBe('https://c.com/3.m3u8');
|
||||||
|
expect(videos[3].dataset.hlsManifest).toBe('https://d.com/4.m3u8');
|
||||||
|
videos.forEach(v => expect(v.dataset.mediaType).toBe('hls'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets poster image on HLS entries', () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
mediaType: 'hls',
|
||||||
|
hlsManifest: 'https://example.com/stream.m3u8',
|
||||||
|
poster: 'https://example.com/thumb.jpg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.poster).toBe('https://example.com/thumb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores hlsManifest when mediaType is not "hls"', () => {
|
||||||
|
// An entry that has hlsManifest but mediaType is 'video' should play as direct src
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'video', src: 'https://example.com/video.mp4', hlsManifest: 'https://example.com/stream.m3u8' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.mp4');
|
||||||
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||||
|
expect(video.dataset.mediaType).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HLS entry with hlsManifest but no src', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.dataset.hlsManifest).toBe('https://example.com/stream.m3u8');
|
||||||
|
expect(video.dataset.mediaType).toBe('hls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tab labels alongside HLS entries', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/stream.m3u8' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, ['Live Stream']);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
const label = panelsEl.querySelector('.panel-label');
|
||||||
|
expect(video).not.toBeNull();
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
expect(label.textContent).toBe('Live Stream');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Standard video playback — regression tests', () => {
|
||||||
|
let { panelsEl } = mountViewerDOM();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const fresh = mountViewerDOM();
|
||||||
|
panelsEl = fresh.panelsEl;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders direct MP4 URLs as src', () => {
|
||||||
|
const entries = [
|
||||||
|
{ src: 'https://example.com/video.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.mp4');
|
||||||
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders entries with url instead of src', () => {
|
||||||
|
const entries = [
|
||||||
|
{ url: 'https://example.com/video.webm' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.webm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers src over url when both are present', () => {
|
||||||
|
const entries = [
|
||||||
|
{ url: 'https://example.com/page.html', src: 'https://example.com/video.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders string URLs as standard video entries', () => {
|
||||||
|
// Legacy storage format: plain string URL
|
||||||
|
const entries = ['https://example.com/video.mp4'];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.mp4');
|
||||||
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets poster on standard video entries', () => {
|
||||||
|
const entries = [
|
||||||
|
{ src: 'https://example.com/video.mp4', poster: 'https://example.com/thumb.jpg' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.poster).toBe('https://example.com/thumb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies video default attributes (autoplay, muted, loop, playsInline)', () => {
|
||||||
|
const entries = [
|
||||||
|
{ src: 'https://example.com/video.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.autoplay).toBe(true);
|
||||||
|
expect(video.muted).toBe(true);
|
||||||
|
expect(video.loop).toBe(true);
|
||||||
|
expect(video.playsInline).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders mixed HLS and standard entries in same grid', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/live.m3u8' },
|
||||||
|
{ src: 'https://example.com/recording.mp4' },
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/live2.m3u8' },
|
||||||
|
{ src: 'https://example.com/recording2.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 2, []);
|
||||||
|
|
||||||
|
const videos = panelsEl.querySelectorAll('video');
|
||||||
|
expect(videos.length).toBe(4);
|
||||||
|
|
||||||
|
// HLS entry
|
||||||
|
expect(videos[0].dataset.hlsManifest).toBe('https://example.com/live.m3u8');
|
||||||
|
expect(videos[0].dataset.mediaType).toBe('hls');
|
||||||
|
|
||||||
|
// Standard entry
|
||||||
|
expect(videos[1].src).toBe('https://example.com/recording.mp4');
|
||||||
|
expect(videos[1].dataset.hlsManifest).toBeUndefined();
|
||||||
|
|
||||||
|
// HLS entry
|
||||||
|
expect(videos[2].dataset.hlsManifest).toBe('https://example.com/live2.m3u8');
|
||||||
|
expect(videos[2].dataset.mediaType).toBe('hls');
|
||||||
|
|
||||||
|
// Standard entry
|
||||||
|
expect(videos[3].src).toBe('https://example.com/recording2.mp4');
|
||||||
|
expect(videos[3].dataset.hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty panels when fewer entries than grid size', () => {
|
||||||
|
const entries = [
|
||||||
|
{ src: 'https://example.com/video.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 2, []);
|
||||||
|
|
||||||
|
const panels = panelsEl.querySelectorAll('.panel');
|
||||||
|
expect(panels.length).toBe(4); // 2x2 grid
|
||||||
|
expect(panels[0].classList.contains('empty')).toBe(false);
|
||||||
|
expect(panels[1].classList.contains('empty')).toBe(true);
|
||||||
|
expect(panels[2].classList.contains('empty')).toBe(true);
|
||||||
|
expect(panels[3].classList.contains('empty')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies correct grid CSS for NxN layout', () => {
|
||||||
|
renderPanels(panelsEl, [], 3, []);
|
||||||
|
expect(panelsEl.style.gridTemplateColumns).toBe('repeat(3, 1fr)');
|
||||||
|
expect(panelsEl.style.gridTemplateRows).toBe('repeat(3, 1fr)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases — invalid manifests, missing fields, unsupported browsers', () => {
|
||||||
|
let { panelsEl } = mountViewerDOM();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const fresh = mountViewerDOM();
|
||||||
|
panelsEl = fresh.panelsEl;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles HLS entry with empty hlsManifest string', () => {
|
||||||
|
// mediaType is 'hls' but hlsManifest is an empty string — should fall through to direct src
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: '', src: 'https://example.com/fallback.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
// Empty hlsManifest is falsy, so the else branch runs
|
||||||
|
expect(video.src).toBe('https://example.com/fallback.mp4');
|
||||||
|
expect(video.dataset.hlsManifest).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles HLS entry with missing hlsManifest', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', src: 'https://example.com/video.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty src for non-HLS entry', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'video', src: '', url: '' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
// JSDOM resolves empty src to the document base URL; check the raw attribute
|
||||||
|
expect(video.getAttribute('src')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null poster gracefully', () => {
|
||||||
|
const entries = [
|
||||||
|
{ src: 'https://example.com/video.mp4', poster: null },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
// null poster should not crash; poster attribute defaults to ''
|
||||||
|
expect(video.poster).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined entry fields without crashing', () => {
|
||||||
|
const entries = [
|
||||||
|
{},
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video).not.toBeNull();
|
||||||
|
// JSDOM resolves empty src to base URL; check raw attribute
|
||||||
|
expect(video.getAttribute('src')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null entry in the array', () => {
|
||||||
|
const entries = [null];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const panels = panelsEl.querySelectorAll('.panel');
|
||||||
|
expect(panels[0].classList.contains('empty')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles malformed manifest URL (not .m3u8)', () => {
|
||||||
|
// Viewer doesn't validate manifest URL format — that's hls.js job
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'https://example.com/invalid-manifest' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.dataset.hlsManifest).toBe('https://example.com/invalid-manifest');
|
||||||
|
expect(video.dataset.mediaType).toBe('hls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles HTTP manifest URL (non-HTTPS)', () => {
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: 'http://example.com/stream.m3u8' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.dataset.hlsManifest).toBe('http://example.com/stream.m3u8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles extremely long manifest URL', () => {
|
||||||
|
const longUrl = 'https://example.com/stream.m3u8?' + 'x='.repeat(1000);
|
||||||
|
const entries = [
|
||||||
|
{ mediaType: 'hls', hlsManifest: longUrl },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.dataset.hlsManifest).toBe(longUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to url when src is missing for standard entries', () => {
|
||||||
|
const entries = [
|
||||||
|
{ url: 'https://example.com/video.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 1, []);
|
||||||
|
|
||||||
|
const video = panelsEl.querySelector('video');
|
||||||
|
expect(video.src).toBe('https://example.com/video.mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders panel without label when tags array is shorter than entries', () => {
|
||||||
|
const entries = [
|
||||||
|
{ src: 'https://example.com/video.mp4' },
|
||||||
|
{ src: 'https://example.com/video2.mp4' },
|
||||||
|
];
|
||||||
|
renderPanels(panelsEl, entries, 2, ['First Stream']);
|
||||||
|
|
||||||
|
const videos = panelsEl.querySelectorAll('video');
|
||||||
|
const labels = panelsEl.querySelectorAll('.panel-label');
|
||||||
|
expect(videos.length).toBe(2);
|
||||||
|
expect(labels.length).toBe(1); // only first entry has a label
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Browser HLS support detection (conceptual)', () => {
|
||||||
|
/*
|
||||||
|
* These tests verify the decision logic that determines whether
|
||||||
|
* hls.js should be loaded or native playback used.
|
||||||
|
*
|
||||||
|
* Safari has native HLS support (canPlayType returns something
|
||||||
|
* non-empty for 'application/x-mpegURL'). Other browsers need hls.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
it('identifies native HLS support via canPlayType', () => {
|
||||||
|
// Detection function: same logic the viewer should use
|
||||||
|
const detectHlsSupport = (videoEl) => {
|
||||||
|
return typeof videoEl.canPlayType === 'function' &&
|
||||||
|
videoEl.canPlayType('application/x-mpegURL') !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const { document: doc } = mountViewerDOM();
|
||||||
|
const video = doc.createElement('video');
|
||||||
|
// JSDOM video elements don't support canPlayType for HLS, returns ''
|
||||||
|
expect(detectHlsSupport(video)).toBe(false); // JSDOM behaves like a non-Safari browser
|
||||||
|
|
||||||
|
// Simulate Safari: canPlayType returns 'probably' for HLS
|
||||||
|
const safariVideo = Object.assign(doc.createElement('video'), {
|
||||||
|
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
|
||||||
|
});
|
||||||
|
expect(detectHlsSupport(safariVideo)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses hls.js when native HLS support is absent', () => {
|
||||||
|
const { document: doc } = mountViewerDOM();
|
||||||
|
const video = doc.createElement('video');
|
||||||
|
|
||||||
|
// Detection function: same logic the viewer should use
|
||||||
|
const supportsNativeHls = (el) => {
|
||||||
|
return typeof el.canPlayType === 'function' &&
|
||||||
|
el.canPlayType('application/x-mpegURL') !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// On non-Safari browsers, hls.js is required
|
||||||
|
const needsHlsJs = !supportsNativeHls(video);
|
||||||
|
expect(needsHlsJs).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips hls.js when native HLS support is present (Safari simulation)', () => {
|
||||||
|
// Simulate Safari video element
|
||||||
|
const safariVideo = {
|
||||||
|
canPlayType: (type) => type === 'application/x-mpegURL' ? 'probably' : '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const supportsNativeHls = (el) => {
|
||||||
|
return typeof el.canPlayType === 'function' &&
|
||||||
|
el.canPlayType('application/x-mpegURL') !== '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsHlsJs = !supportsNativeHls(safariVideo);
|
||||||
|
expect(needsHlsJs).toBe(false); // Safari doesn't need hls.js
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gracefully handles missing canPlayType method', () => {
|
||||||
|
const brokenVideo = {};
|
||||||
|
const supportsNativeHls = (el) => {
|
||||||
|
return typeof el.canPlayType === 'function' &&
|
||||||
|
el.canPlayType('application/x-mpegURL') !== '';
|
||||||
|
};
|
||||||
|
// Should not throw
|
||||||
|
expect(() => supportsNativeHls(brokenVideo)).not.toThrow();
|
||||||
|
expect(supportsNativeHls(brokenVideo)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user