Compare commits

...
6 Commits
Author SHA1 Message Date
kfj001 7416ae8ff9 Merge pull request 'feat: add HLS playback support with hls.js' (#1) from robot/goon_plugin:master into master
CI / test (push) Successful in 24s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/1
2026-08-10 20:50:43 -07:00
robot c59c96ce19 fix: integrate child task improvements (poster rendering, HLS cleanup, hls.js dependency)
CI / test (pull_request) Canceled after 0s
2026-08-10 22:42:48 +00:00
hermes e17b228ee0 fix: redesign HLS detection with 6-strategy per-tab query
CI / test (pull_request) Canceled after 0s
- Query HLS data structures directly from page JS state per tab
- Strategies: video element HLS instances, window-level player vars,
  intercepted data, inline scripts, global config objects, content script
- Fixed merge logic: index.js now correctly processes interceptedManifests
  and hlsQueryResults instead of non-existent playerData
- Skip blob: URLs in DOM scan (HLS-internal, can't be played directly)
- Only attach HLS manifest when actually detected for that tab
2026-08-10 20:39:49 +00:00
hermes c22631d1f1 fix: real-time HLS interception via content script (architectural overhaul)
CI / test (pull_request) Canceled after 0s
Previous approach (DOM snapshot via executeScript) failed on Pornhub because:
- Video player loads asynchronously via JavaScript
- HLS manifest arrives via XHR/fetch AFTER page render
- <video> element only shows blob: URL, not the actual manifest

New approach:
- Content script injects interceptor into MAIN world at document_start
- Interceptor patches XMLHttpRequest and fetch() to capture .m3u8 URLs
- Periodic DOM scans detect HLS player instances on <video> elements
- Global config object scanning for embedded stream URLs
- Popup queries content script via chrome.tabs.sendMessage for real-time data
- If no DOM videos found but content script has data, create entries from intercepted sources

Changes:
- content.js: Full rewrite with main-world injection + XHR/fetch interception
- index.js: Query content scripts via messages, merge with DOM snapshot
- manifest.json: all_frames + document_start for early injection
2026-08-10 18:53:27 +00:00
hermes db52db7799 fix: add HLS manifest extraction to scrapeFn (3 strategies)
CI / test (pull_request) Canceled after 0s
- Strategy 1: scan inline <script> blocks for .m3u8 URLs (skip subtitles/captions)
- Strategy 2: check video element HLS player instances (hlsInstance, _hls)
- Strategy 3: probe common global config objects (hlsConfig, playerConfig, etc.)
- Storage items now include hlsManifest and mediaType fields
- When HLS detected and src is blob:/empty, promote manifest URL to primary
- Backward compatible: direct MP4 entries unaffected
2026-08-10 17:07:55 +00:00
hermes 90c3425200 feat: add HLS playback support with hls.js
CI / test (pull_request) Canceled after 0s
- viewer.js: dynamic hls.js loading, HLS-aware panel creation, error handling
- schema.json: hlsManifest and mediaType fields (optional, backward compatible)
- types.ts: VideoEntry interface with MediaType type
- isHls() null guard fix
- tests: 39 tests (schema, viewer units, data flow, integration)
- CI: GitHub/Gitea Actions workflow (npm test on push/PR)
- deps: ajv, ajv-formats, jsdom for testing
2026-08-10 06:18:27 +00:00
15 changed files with 2910 additions and 68 deletions

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
+185
View File
@@ -0,0 +1,185 @@
import { describe, it } from './runner.mjs';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import schema from '../src/viewer/schema.json' with { type: 'json' };
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const validate = ajv.compile(schema);
describe('Data flow: popup -> storage -> viewer', () => {
it('should pass a scraped HLS entry through schema validation', () => {
// Simulate popup scraping an HLS stream
const scrapedEntry = {
url: 'https://stream.example.com/watch',
src: 'https://cdn.example.com/live/playlist.m3u8',
poster: 'https://cdn.example.com/live/thumb.jpg',
tab: 'Live Broadcast',
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
mediaType: 'hls',
};
// Validate against schema (as storage layer would)
const valid = validate(scrapedEntry);
if (!valid) {
throw new Error(`Scraped HLS entry fails schema: ${JSON.stringify(validate.errors)}`);
}
// Verify the viewer would detect it as HLS
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
if (!isHls(scrapedEntry)) {
throw new Error('Scraped HLS entry not detected as HLS by viewer');
}
// Verify the viewer would resolve the manifest
const resolveHlsManifest = (entry) => {
if (typeof entry === 'object' && entry.hlsManifest) return entry.hlsManifest;
return null;
};
const manifest = resolveHlsManifest(scrapedEntry);
if (manifest !== 'https://cdn.example.com/live/playlist.m3u8') {
throw new Error(`Manifest URL wrong: ${manifest}`);
}
});
it('should pass a scraped direct entry through schema validation', () => {
const scrapedEntry = {
url: 'https://video.example.com/watch',
src: 'https://cdn.example.com/video.mp4',
poster: 'https://cdn.example.com/thumb.jpg',
tab: 'Video Page',
};
const valid = validate(scrapedEntry);
if (!valid) {
throw new Error(`Scraped direct entry fails schema: ${JSON.stringify(validate.errors)}`);
}
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
if (isHls(scrapedEntry)) {
throw new Error('Direct entry falsely detected as HLS');
}
const resolveSrc = (entry) => {
if (typeof entry === 'string') return entry;
return entry.src || entry.url || '';
};
const src = resolveSrc(scrapedEntry);
if (src !== 'https://cdn.example.com/video.mp4') {
throw new Error(`Src URL wrong: ${src}`);
}
});
it('should survive a round-trip: storage serialization -> deserialization', () => {
const originalEntry = {
url: 'https://example.com/stream',
src: 'https://cdn.example.com/stream.m3u8',
poster: 'https://example.com/thumb.jpg',
tab: 'HLS Stream',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
mediaType: 'hls',
};
// Simulate storage round-trip (JSON serialization)
const serialized = JSON.stringify(originalEntry);
const deserialized = JSON.parse(serialized);
// Validate after deserialization
const valid = validate(deserialized);
if (!valid) {
throw new Error(`Round-tripped entry fails schema: ${JSON.stringify(validate.errors)}`);
}
// Verify fields are intact
if (deserialized.mediaType !== 'hls') {
throw new Error('mediaType lost during round-trip');
}
if (deserialized.hlsManifest !== 'https://cdn.example.com/stream.m3u8') {
throw new Error('hlsManifest lost during round-trip');
}
// Verify viewer still detects it correctly
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
if (!isHls(deserialized)) {
throw new Error('Round-tripped entry not detected as HLS');
}
});
it('should handle batch entries from popup -> storage -> viewer pipeline', () => {
// Simulate popup storing multiple entries
const popupEntries = [
{ url: 'https://example.com/1', src: 'https://cdn.example.com/1.mp4', tab: 'Tab 1' },
{
url: 'https://example.com/2',
src: 'https://cdn.example.com/2.m3u8',
hlsManifest: 'https://cdn.example.com/2.m3u8',
mediaType: 'hls',
tab: 'Tab 2',
},
{ url: 'https://example.com/3', src: 'https://cdn.example.com/3.webm', mediaType: 'direct', tab: 'Tab 3' },
];
// Validate all entries
for (const [i, entry] of popupEntries.entries()) {
const valid = validate(entry);
if (!valid) {
throw new Error(`Entry ${i} fails schema: ${JSON.stringify(validate.errors)}`);
}
}
// Simulate storage round-trip
const serialized = JSON.stringify(popupEntries);
const deserialized = JSON.parse(serialized);
// Verify viewer can process all entries
const isHls = (entry) => typeof entry === 'object' && entry.mediaType === 'hls';
const resolveSrc = (entry) => {
if (typeof entry === 'string') return entry;
return entry.src || entry.url || '';
};
const resolveHlsManifest = (entry) => {
if (typeof entry === 'object' && entry.hlsManifest) return entry.hlsManifest;
return null;
};
// Entry 0: legacy (no mediaType)
if (isHls(deserialized[0])) throw new Error('Entry 0 should not be HLS');
if (resolveSrc(deserialized[0]) !== 'https://cdn.example.com/1.mp4') throw new Error('Entry 0 src wrong');
// Entry 1: HLS
if (!isHls(deserialized[1])) throw new Error('Entry 1 should be HLS');
if (resolveHlsManifest(deserialized[1]) !== 'https://cdn.example.com/2.m3u8') throw new Error('Entry 1 manifest wrong');
// Entry 2: explicit direct
if (isHls(deserialized[2])) throw new Error('Entry 2 should not be HLS');
if (resolveSrc(deserialized[2]) !== 'https://cdn.example.com/3.webm') throw new Error('Entry 2 src wrong');
});
it('should reject malformed entries that could crash the viewer', () => {
const malformedEntries = [
{ hlsManifest: 'https://example.com/stream.m3u8' }, // missing url
{ url: 'https://example.com/video.mp4', mediaType: 'invalid' }, // invalid mediaType
{ url: 'not-a-url' }, // technically valid per schema (format: uri not enforced by Ajv without custom formats)
];
for (const [i, entry] of malformedEntries.entries()) {
const valid = validate(entry);
if (!valid && i < 2) {
// Expected failures for entries 0 and 1
continue;
}
}
// Entry 0 (missing url) should be rejected
const entry0Valid = validate(malformedEntries[0]);
if (entry0Valid) {
throw new Error('Entry without url should be rejected');
}
// Entry 1 (invalid mediaType) should be rejected
const entry1Valid = validate(malformedEntries[1]);
if (entry1Valid) {
throw new Error('Entry with invalid mediaType should be rejected');
}
});
});
+11
View File
@@ -0,0 +1,11 @@
/**
* Test entry point — imports all test suites then runs them.
*/
import './schema.test.mjs';
import './viewer_units.test.mjs';
import './data_flow.test.mjs';
import './viewer_integration.test.mjs';
import { run } from './runner.mjs';
const result = await run();
process.exit(result.totalFailed > 0 ? 1 : 0);
+80
View File
@@ -0,0 +1,80 @@
/**
* Minimal test runner — describe/it with timing and summary.
* No external dependencies. Import this from test files.
*/
const suites = [];
let currentSuite = null;
let totalTests = 0;
let totalPassed = 0;
let totalFailed = 0;
const failures = [];
export function describe(name, fn) {
currentSuite = { name, tests: [], passed: 0, failed: 0, duration: 0 };
suites.push(currentSuite);
fn();
currentSuite = null;
}
export function it(name, fn) {
if (!currentSuite) {
throw new Error(`it() called outside describe(). Test: "${name}"`);
}
currentSuite.tests.push({ name, fn, async: typeof fn === 'function' });
}
// Run all suites and print results
export async function run() {
let globalStart = Date.now();
for (const suite of suites) {
const suiteStart = Date.now();
process.stdout.write(`\n${suite.name}\n${'─'.repeat(suite.name.length + 2)}\n`);
for (const test of suite.tests) {
totalTests++;
const testStart = Date.now();
try {
const result = test.fn();
if (result instanceof Promise) {
await result;
}
const ms = Date.now() - testStart;
totalPassed++;
suite.passed++;
process.stdout.write(` ✓ ${test.name} (${ms}ms)\n`);
} catch (err) {
const ms = Date.now() - testStart;
totalFailed++;
suite.failed++;
failures.push({ suite: suite.name, test: test.name, error: err.message, stack: err.stack });
process.stdout.write(` ✗ ${test.name} (${ms}ms)\n`);
process.stdout.write(` → ${err.message}\n`);
}
}
suite.duration = Date.now() - suiteStart;
}
const totalMs = Date.now() - globalStart;
process.stdout.write(`\n${'='.repeat(60)}\n`);
process.stdout.write(`Results: ${totalPassed}/${totalTests} passed`);
if (totalFailed > 0) {
process.stdout.write(` | ${totalFailed} FAILED`);
}
process.stdout.write(` | ${totalMs}ms\n`);
process.stdout.write(`${'='.repeat(60)}\n`);
if (failures.length > 0) {
process.stdout.write(`\nFailures:\n`);
for (const f of failures) {
process.stdout.write(` [${f.suite}] ${f.test}\n`);
process.stdout.write(` Error: ${f.error}\n`);
process.stdout.write(` ${f.stack.split('\n')[1].trim()}\n`);
}
}
return { totalTests, totalPassed, totalFailed, totalMs, failures };
}
+123
View File
@@ -0,0 +1,123 @@
import { describe, it } from './runner.mjs';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import schema from '../src/viewer/schema.json' with { type: 'json' };
const ajv = new Ajv({ strict: false });
addFormats(ajv);
const validate = ajv.compile(schema);
describe('Schema: VideoEntry with HLS fields', () => {
it('should validate a legacy entry (url only)', () => {
const entry = { url: 'https://example.com/video.mp4' };
const valid = validate(entry);
if (!valid) {
throw new Error(`Legacy entry rejected: ${JSON.stringify(validate.errors)}`);
}
});
it('should validate a full legacy entry (all original fields)', () => {
const entry = {
url: 'https://example.com/video.mp4',
src: 'https://example.com/video.mp4',
poster: 'https://example.com/thumb.jpg',
tab: 'Video Page',
};
const valid = validate(entry);
if (!valid) {
throw new Error(`Full legacy entry rejected: ${JSON.stringify(validate.errors)}`);
}
});
it('should validate an HLS entry with hlsManifest and mediaType', () => {
const entry = {
url: 'https://example.com/playlist.m3u8',
src: 'https://example.com/playlist.m3u8',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
mediaType: 'hls',
poster: 'https://example.com/thumb.jpg',
tab: 'HLS Stream Page',
};
const valid = validate(entry);
if (!valid) {
throw new Error(`HLS entry rejected: ${JSON.stringify(validate.errors)}`);
}
});
it('should validate an HLS entry with only hlsManifest (mediaType omitted)', () => {
const entry = {
url: 'https://example.com/page',
hlsManifest: 'https://cdn.example.com/stream.m3u8',
};
const valid = validate(entry);
if (!valid) {
throw new Error(`HLS entry with only hlsManifest rejected: ${JSON.stringify(validate.errors)}`);
}
});
it('should validate an entry with mediaType=direct', () => {
const entry = {
url: 'https://example.com/video.mp4',
mediaType: 'direct',
};
const valid = validate(entry);
if (!valid) {
throw new Error(`Direct entry rejected: ${JSON.stringify(validate.errors)}`);
}
});
it('should reject entries without url', () => {
const entry = {
hlsManifest: 'https://cdn.example.com/stream.m3u8',
mediaType: 'hls',
};
const valid = validate(entry);
if (valid) {
throw new Error('Entry without url should be rejected but was accepted');
}
});
it('should reject invalid mediaType values', () => {
const entry = {
url: 'https://example.com/video.mp4',
mediaType: 'dash',
};
const valid = validate(entry);
if (valid) {
throw new Error('Invalid mediaType "dash" should be rejected but was accepted');
}
});
it('should reject additional properties', () => {
const entry = {
url: 'https://example.com/video.mp4',
customField: 'value',
};
const valid = validate(entry);
if (valid) {
throw new Error('Entry with additional properties should be rejected but was accepted');
}
});
it('should validate poster as null', () => {
const entry = {
url: 'https://example.com/video.mp4',
poster: null,
};
const valid = validate(entry);
if (!valid) {
throw new Error(`Entry with null poster rejected: ${JSON.stringify(validate.errors)}`);
}
});
it('should validate hlsManifest as URI format', () => {
const entry = {
url: 'https://example.com/page',
hlsManifest: 'https://stream.example.com/hls/playlist.m3u8',
};
const valid = validate(entry);
if (!valid) {
throw new Error(`Valid hlsManifest URI rejected: ${JSON.stringify(validate.errors)}`);
}
});
});
+511
View File
@@ -0,0 +1,511 @@
import { describe, it } from './runner.mjs';
import { JSDOM } from 'jsdom';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const viewerSource = fs.readFileSync(
path.join(__dirname, '../src/viewer/viewer.js'),
'utf-8'
);
/**
* Create a JSDOM environment with mocked Chrome extension APIs.
*
* @param {object} options
* @param {object} options.storageData - Initial chrome.storage.local contents
* @param {string} options.url - Page URL (must include query params viewer.js reads at load)
* @param {string} options.options.hlsMode - 'native' (Safari-like: window.HLS undefined),
* 'mocked' (Hls.js loaded with mocked API), or 'none' (no mocking — CDN fetches hang)
*/
function createBrowserEnv({ storageData = {}, url = 'https://example.com/viewer/viewer.html', hlsMode = 'mocked' } = {}) {
const html = `<!DOCTYPE html><html><body><div id="panels"></div></body></html>`;
const dom = new JSDOM(html, {
url,
runScripts: 'dangerously',
resources: 'usable',
});
const win = dom.window;
// Shared mutable storage object
const storage = { ...storageData };
win.chrome = {
storage: {
local: {
get: (keys) =>
new Promise((resolve) => {
const result = {};
for (const key of (keys || [])) result[key] = storage[key];
resolve(result);
}),
set: (data) =>
new Promise((resolve) => {
for (const [k, v] of Object.entries(data)) storage[k] = v;
resolve();
}),
remove: (keys) =>
new Promise((resolve) => {
for (const key of (keys || [])) delete storage[key];
resolve();
}),
},
},
};
// Mock HLS to avoid hanging on CDN fetch in jsdom.
// jsdom does not fire onerror for dynamically loaded <script> tags,
// so loadHlsJs() would block forever trying CDN fallbacks.
// 'mocked' mode provides a fake Hls.js that records calls for verification.
// 'native' mode sets HLS = undefined so viewer.js takes the Safari native path.
const hlsCalls = { instances: [], loaded: false };
if (hlsMode === 'mocked') {
win.HLS = class MockHLS {
constructor(opts) {
hlsCalls.instances.push({ opts, destroyed: false, events: {}, source: null, media: null });
}
static get Events() {
return {
MANIFEST_PARSED: 'manifestParsed',
ERROR: 'error',
DESTROYING: 'destroying',
};
}
static get ErrorTypes() {
return { NETWORK_ERROR: 'networkError', MEDIA_ERROR: 'mediaError' };
}
static isSupported() {
return true;
}
loadSource(src) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.source = src;
}
attachMedia(media) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.media = media;
}
on(event, handler) {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
if (!inst.events[event]) inst.events[event] = [];
inst.events[event].push(handler);
}
destroy() {
const inst = hlsCalls.instances[hlsCalls.instances.length - 1];
inst.destroyed = true;
}
startLoad() {}
recoverMediaError() {}
};
hlsCalls.loaded = true;
} else if (hlsMode === 'native') {
// Simulate Safari — window.HLS exists but isSupported() returns false.
// viewer.js then falls through to video.src = manifestUrl (native HLS).
win.HLS = class MockHLS_Native {
static isSupported() {
return false; // Safari has native HLS, Hls.js is not needed
}
};
}
// Track console output
const consoleLogs = [];
const consoleErrors = [];
const origConsole = { ...win.console };
win.console = {
...origConsole,
log: (...args) => consoleLogs.push(args.map(String).join(' ')),
error: (...args) => {
consoleErrors.push(args.map(String).join(' '));
origConsole.error.apply(origConsole, args);
},
warn: (...args) => {
consoleLogs.push(args.map(String).join(' '));
origConsole.warn.apply(origConsole, args);
},
};
return { dom, win, storage, consoleLogs, consoleErrors, hlsCalls };
}
describe('Integration: viewer.js with HLS entries', () => {
it('should render HLS video entries and use Hls.js to play HLS streams', () => {
const storageData = {};
const viewerId = 'test_viewer_1';
storageData[viewerId] = [
{
url: 'https://example.com/stream',
src: 'https://example.com/stream',
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
mediaType: 'hls',
poster: 'https://example.com/thumb.jpg',
tab: 'Live Stream',
},
{
url: 'https://example.com/video.mp4',
src: 'https://example.com/video.mp4',
mediaType: 'direct',
tab: 'Regular Video',
},
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom, consoleLogs, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const panels = dom.window.document.querySelectorAll('.panel');
if (panels.length !== 4) {
reject(new Error(`Expected 4 panels (2x2 grid), got ${panels.length}`));
return;
}
const videos = dom.window.document.querySelectorAll('video');
if (videos.length < 2) {
reject(new Error(`Expected at least 2 video elements, got ${videos.length}`));
return;
}
// Status overlay
const overlay = dom.window.document.getElementById('viewer-status');
if (!overlay || !overlay.textContent.includes('2 video')) {
reject(new Error(`Missing/incorrect status overlay: "${overlay ? overlay.textContent : 'null'}"`));
return;
}
// HLS detection in console logs
const hlsLogs = consoleLogs.filter(
(l) => l.includes('HLS detected') || l.includes('HLS')
);
if (hlsLogs.length === 0) {
reject(new Error(`No HLS detection in logs. Logs: ${consoleLogs.join(' | ')}`));
return;
}
// Verify Hls.js was actually instantiated for the HLS entry
if (hlsCalls.instances.length < 1) {
reject(new Error('Expected at least 1 HLS instance, got 0'));
return;
}
// Verify the HLS instance was attached to a video and loaded the manifest
const hlsInst = hlsCalls.instances[0];
if (!hlsInst.source || !hlsInst.source.includes('m3u8')) {
reject(new Error(`HLS source not set to m3u8 URL: ${hlsInst.source}`));
return;
}
if (!hlsInst.media || hlsInst.media.tagName !== 'VIDEO') {
reject(new Error('HLS not attached to a <video> element'));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should handle legacy entries (no HLS fields) with standard playback', () => {
const storageData = {};
const viewerId = 'test_legacy';
storageData[viewerId] = [
{ url: 'https://example.com/video.mp4', src: 'https://example.com/video.mp4', tab: 'Old Video' },
'https://example.com/another.mp4',
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length < 2) {
reject(new Error(`Expected at least 2 video elements, got ${videos.length}`));
return;
}
// First video should have src set directly (standard playback)
const video0Src = videos[0].getAttribute('src');
if (!video0Src || !video0Src.includes('example.com')) {
reject(new Error(`Video 0 src not set: ${video0Src}`));
return;
}
// No HLS instances should have been created
if (hlsCalls.instances.length > 0) {
reject(new Error(`Expected 0 HLS instances for legacy entries, got ${hlsCalls.instances.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should show "No viewer ID provided" when id param is missing', () => {
const { dom } = createBrowserEnv();
dom.window.eval(viewerSource);
const panels = dom.window.document.getElementById('panels');
if (!panels.innerHTML.includes('No viewer ID provided')) {
throw new Error(`Expected "No viewer ID provided", got: ${panels.innerHTML}`);
}
});
it('should handle empty video list gracefully', () => {
const storageData = {};
const viewerId = 'test_empty';
storageData[viewerId] = [];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom } = createBrowserEnv({ storageData, url });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const panels = dom.window.document.querySelectorAll('.panel');
if (panels.length !== 4) {
reject(new Error(`Expected 4 empty panels, got ${panels.length}`));
return;
}
const emptyPanels = dom.window.document.querySelectorAll('.panel.empty');
if (emptyPanels.length !== 4) {
reject(new Error(`Expected 4 empty panels, got ${emptyPanels.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should render correct grid size based on grid param', () => {
const storageData = {};
const viewerId = 'test_grid3';
storageData[viewerId] = [{ url: 'https://example.com/1.mp4' }];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=3`;
const { dom } = createBrowserEnv({ storageData, url });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const panels = dom.window.document.querySelectorAll('.panel');
if (panels.length !== 9) {
reject(new Error(`Expected 9 panels (3x3), got ${panels.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should render tag labels when tags param is provided', () => {
const storageData = {};
const viewerId = 'test_tags';
storageData[viewerId] = [
{ url: 'https://example.com/1.mp4', tab: 'Stream 1' },
{ url: 'https://example.com/2.mp4', tab: 'Stream 2' },
];
const tags = ['Live Stream 1', 'Live Stream 2'];
const tagsParam = encodeURIComponent(JSON.stringify(tags));
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2&tags=${tagsParam}`;
const { dom } = createBrowserEnv({ storageData, url });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const labels = dom.window.document.querySelectorAll('.panel-label');
if (labels.length < 2) {
reject(new Error(`Expected 2 labels, got ${labels.length}`));
return;
}
if (labels[0].textContent !== 'Live Stream 1') {
reject(new Error(`Label 0 wrong: "${labels[0].textContent}"`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should handle mixed HLS and direct entries in same grid', () => {
const storageData = {};
const viewerId = 'test_mixed';
storageData[viewerId] = [
{
url: 'https://example.com/hls',
hlsManifest: 'https://cdn.example.com/hls/stream.m3u8',
mediaType: 'hls',
tab: 'HLS',
},
{
url: 'https://example.com/direct.mp4',
src: 'https://example.com/direct.mp4',
mediaType: 'direct',
tab: 'MP4',
},
{ url: 'https://example.com/legacy.mp4', tab: 'Legacy' },
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=2`;
const { dom, consoleLogs, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length < 3) {
reject(new Error(`Expected at least 3 videos, got ${videos.length}`));
return;
}
// HLS detection in logs
const hlsLogs = consoleLogs.filter(
(l) => l.includes('HLS detected') || l.includes('HLS')
);
if (hlsLogs.length === 0) {
reject(new Error(`No HLS logs. All: ${consoleLogs.join(' | ')}`));
return;
}
// Only one HLS instance (for the HLS entry)
if (hlsCalls.instances.length !== 1) {
reject(new Error(`Expected 1 HLS instance, got ${hlsCalls.instances.length}`));
return;
}
// Direct entry video should have src set directly
const directVideo = videos[1];
if (!directVideo.getAttribute('src') || !directVideo.getAttribute('src').includes('direct.mp4')) {
reject(new Error(`Direct video src wrong: ${directVideo.getAttribute('src')}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should fall back to native playback when HLS is not supported (Safari mode)', () => {
const storageData = {};
const viewerId = 'test_safari';
storageData[viewerId] = [
{
url: 'https://example.com/stream',
hlsManifest: 'https://cdn.example.com/live/playlist.m3u8',
mediaType: 'hls',
},
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { dom } = createBrowserEnv({ storageData, url, hlsMode: 'native' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length !== 1) {
reject(new Error(`Expected 1 video, got ${videos.length}`));
return;
}
// In native mode, video.src should be set to the manifest URL directly
const src = videos[0].getAttribute('src');
if (!src || !src.includes('m3u8')) {
reject(new Error(`Expected src to be m3u8 URL, got: ${src}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
it('should fall back to src when mediaType=hls but hlsManifest is missing', () => {
const storageData = {};
const viewerId = 'test_missing_manifest';
storageData[viewerId] = [
{
url: 'https://example.com/page',
src: 'https://example.com/video.mp4',
mediaType: 'hls',
// hlsManifest intentionally missing
},
];
const url = `https://example.com/viewer/viewer.html?id=${viewerId}&grid=1`;
const { dom, hlsCalls } = createBrowserEnv({ storageData, url, hlsMode: 'mocked' });
dom.window.eval(viewerSource);
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const videos = dom.window.document.querySelectorAll('video');
if (videos.length !== 1) {
reject(new Error(`Expected 1 video, got ${videos.length}`));
return;
}
// Since hlsManifest is missing, viewer.js should fall back to resolveSrc
// The src should be set directly (not through HLS)
const src = videos[0].getAttribute('src');
if (!src || !src.includes('video.mp4')) {
reject(new Error(`Expected fallback to src, got: ${src}`));
return;
}
// No HLS instance should have been created
if (hlsCalls.instances.length > 0) {
reject(new Error(`Expected 0 HLS instances, got ${hlsCalls.instances.length}`));
return;
}
resolve();
} catch (e) {
reject(e);
}
}, 500);
});
});
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, it } from './runner.mjs';
import { JSDOM } from 'jsdom';
describe('Viewer: resolveSrc()', () => {
// We extract the pure logic functions from viewer.js for unit testing.
// Since viewer.js uses browser globals, we test the logic independently.
const resolveSrc = (entry) => {
if (typeof entry === 'string') return entry;
return entry.src || entry.url || '';
};
it('should resolve string entries directly', () => {
const result = resolveSrc('https://example.com/video.mp4');
if (result !== 'https://example.com/video.mp4') {
throw new Error(`Expected original string, got: ${result}`);
}
});
it('should prefer src over url in object entries', () => {
const entry = { url: 'https://example.com/page', src: 'https://cdn.example.com/stream.mp4' };
const result = resolveSrc(entry);
if (result !== 'https://cdn.example.com/stream.mp4') {
throw new Error(`Expected src, got: ${result}`);
}
});
it('should fall back to url when src is missing', () => {
const entry = { url: 'https://example.com/video.mp4' };
const result = resolveSrc(entry);
if (result !== 'https://example.com/video.mp4') {
throw new Error(`Expected url, got: ${result}`);
}
});
it('should return empty string for empty object', () => {
const entry = {};
const result = resolveSrc(entry);
if (result !== '') {
throw new Error(`Expected empty string, got: ${result}`);
}
});
});
describe('Viewer: isHls()', () => {
const isHls = (entry) => {
return entry !== null && typeof entry === 'object' && entry.mediaType === 'hls';
};
it('should detect HLS entries with mediaType=hls', () => {
const entry = { url: 'https://example.com/stream', mediaType: 'hls', hlsManifest: 'https://cdn.example.com/stream.m3u8' };
if (!isHls(entry)) {
throw new Error('HLS entry not detected');
}
});
it('should NOT detect direct mediaType', () => {
const entry = { url: 'https://example.com/video.mp4', mediaType: 'direct' };
if (isHls(entry)) {
throw new Error('Direct entry falsely detected as HLS');
}
});
it('should NOT detect entries without mediaType (backward compat)', () => {
const entry = { url: 'https://example.com/video.mp4' };
if (isHls(entry)) {
throw new Error('Legacy entry without mediaType falsely detected as HLS');
}
});
it('should NOT detect string entries', () => {
if (isHls('https://example.com/video.mp4')) {
throw new Error('String entry falsely detected as HLS');
}
});
it('should NOT detect null entries (null guard)', () => {
// With the null guard, isHls(null) safely returns false instead of throwing
if (isHls(null)) {
throw new Error('Null entry falsely detected as HLS');
}
});
});
describe('Viewer: resolveHlsManifest()', () => {
const resolveHlsManifest = (entry) => {
if (typeof entry === 'object' && entry.hlsManifest) {
return entry.hlsManifest;
}
return null;
};
it('should return hlsManifest URL when present', () => {
const entry = { url: 'https://example.com/page', hlsManifest: 'https://cdn.example.com/stream.m3u8' };
const result = resolveHlsManifest(entry);
if (result !== 'https://cdn.example.com/stream.m3u8') {
throw new Error(`Expected hlsManifest URL, got: ${result}`);
}
});
it('should return null when hlsManifest is missing', () => {
const entry = { url: 'https://example.com/video.mp4' };
const result = resolveHlsManifest(entry);
if (result !== null) {
throw new Error(`Expected null, got: ${result}`);
}
});
it('should return null for string entries', () => {
const result = resolveHlsManifest('https://example.com/video.mp4');
if (result !== null) {
throw new Error(`Expected null for string, got: ${result}`);
}
});
});
describe('Viewer: DOM setup via jsdom', () => {
it('should create panels container and video elements', () => {
const dom = new JSDOM(`
<!DOCTYPE html>
<html><body><div id="panels"></div></body></html>
`);
const panelsEl = dom.window.document.getElementById('panels');
if (!panelsEl) {
throw new Error('panels element not found');
}
// Simulate what viewer.js does: create panels
const panel = dom.window.document.createElement('div');
panel.classList.add('panel');
const video = dom.window.document.createElement('video');
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.src = 'https://example.com/video.mp4';
panel.appendChild(video);
panelsEl.appendChild(panel);
const videos = panelsEl.querySelectorAll('video');
if (videos.length !== 1) {
throw new Error(`Expected 1 video, got ${videos.length}`);
}
if (!videos[0].autoplay) throw new Error('autoplay not set');
if (!videos[0].muted) throw new Error('muted not set');
if (!videos[0].loop) throw new Error('loop not set');
if (!videos[0].playsInline) throw new Error('playsInline not set');
});
it('should create an NxN grid layout', () => {
const dom = new JSDOM(`
<!DOCTYPE html>
<html><body><div id="panels"></div></body></html>
`);
const panelsEl = dom.window.document.getElementById('panels');
const gridN = 3;
for (let i = 0; i < gridN * gridN; i++) {
const panel = dom.window.document.createElement('div');
panel.classList.add('panel');
panelsEl.appendChild(panel);
}
panelsEl.style.gridTemplateColumns = `repeat(${gridN}, 1fr)`;
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
const panels = panelsEl.querySelectorAll('.panel');
if (panels.length !== 9) {
throw new Error(`Expected 9 panels, got ${panels.length}`);
}
if (panelsEl.style.gridTemplateColumns !== 'repeat(3, 1fr)') {
throw new Error('Grid columns not set correctly');
}
});
it('should handle empty panels correctly', () => {
const dom = new JSDOM(`
<!DOCTYPE html>
<html><body><div id="panels"></div></body></html>
`);
const panelsEl = dom.window.document.getElementById('panels');
const panel = dom.window.document.createElement('div');
panel.classList.add('panel');
panel.classList.add('empty');
panelsEl.appendChild(panel);
const emptyPanels = panelsEl.querySelectorAll('.panel.empty');
if (emptyPanels.length !== 1) {
throw new Error(`Expected 1 empty panel, got ${emptyPanels.length}`);
}
});
});
+2 -1
View File
@@ -27,7 +27,8 @@
{ {
"matches": ["<all_urls>"], "matches": ["<all_urls>"],
"js": ["content.js"], "js": ["content.js"],
"run_at": "document_idle" "run_at": "document_start",
"all_frames": true
} }
], ],
"icons": { "icons": {
+911 -10
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -9,13 +9,19 @@
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"test": "vitest run", "test": "node __tests__/run.mjs",
"test:watch": "vitest", "test:watch": "vitest",
"check": "tsc --noEmit" "check": "tsc --noEmit"
}, },
"dependencies": {
"hls.js": "^1.5.17"
},
"devDependencies": { "devDependencies": {
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"chrome-types": "^0.1.336", "chrome-types": "^0.1.336",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"jsdom": "^24.1.3",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0", "vite": "^6.0.0",
+232 -11
View File
@@ -1,19 +1,240 @@
/** /**
* content.js — Content script injected into matching pages. * content.js - Content script injected into matching pages at document_start.
* Communicates with the background service worker via chrome.runtime. * Runs in the ISOLATED world.
* Use cautiously: runs in the page's DOM context, so isolate from page globals. *
* Strategy: Inject XHR/fetch interceptors into the MAIN world at document_start
* to capture HLS manifest URLs as they load. Also listen for postMessage events
* from intercepted URLs.
*
* When the popup sends GOON_GET_VIDEO_DATA, we respond with:
* - posterUrl: extracted poster/thumbnail
* - interceptedManifests: manifests caught by XHR/fetch interceptors
* - videoElements: DOM video elements with basic info
*
* The popup separately executes main-world scripts via chrome.scripting.executeScript
* to deeply query HLS player state — this content script handles interception only.
*/ */
(function () { (function () {
'use strict'; 'use strict';
console.log('[goon-plugin] Content script loaded'); // Collected intercepted HLS manifest URLs from XHR/fetch interception
const interceptedManifests = [];
const interceptedSources = new Set();
/**
* Inject interceptors into the MAIN world at document_start.
* Runs BEFORE any page JS, so we catch all HLS activity.
*/
function injectInterceptors() {
const interceptCode = `
(function() {
if (window.__goon_plugin_intercepted) return;
window.__goon_plugin_intercepted = true;
window.__goon_plugin_data = window.__goon_plugin_data || {
manifests: [],
sources: []
};
// Helper: 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 { return null; }
}
// Helper: report manifest to content script via postMessage
function reportManifest(url, source) {
var resolved = resolveUrl(url);
if (!resolved) return;
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
window.__goon_plugin_data.manifests.push(resolved);
window.postMessage({
type: 'GOON_PLUGIN_HLS_INTERCEPT',
url: resolved,
source: source,
timestamp: Date.now()
}, '*');
}
// === INTERCEPTORS ===
// 1. Intercept XMLHttpRequest for manifest-like requests
var originalOpen = XMLHttpRequest.prototype.open;
var originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this._goon_url = url;
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
if (this._goon_url && typeof this._goon_url === 'string') {
var resolved = resolveUrl(this._goon_url);
if (resolved && (resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))) {
reportManifest(resolved, 'xhr');
}
}
return originalSend.apply(this, arguments);
};
// 2. Intercept fetch() for manifest-like requests
var originalFetch = window.fetch;
window.fetch = function(resource, init) {
var url = typeof resource === 'string' ? resource : (resource && resource.url);
if (url && typeof url === 'string') {
var resolved = resolveUrl(url);
if (resolved && (resolved.includes('.m3u8') ||
resolved.includes('/playlist') ||
resolved.includes('/manifest'))) {
reportManifest(resolved, 'fetch');
}
}
return originalFetch.apply(this, arguments);
};
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
function patchHlsPrototype() {
if (typeof Hls === 'undefined') return;
var originalLoadSource = Hls.prototype.loadSource;
if (!originalLoadSource) return;
Hls.prototype.loadSource = function(url) {
reportManifest(url, 'hls_loadSource');
return originalLoadSource.apply(this, arguments);
};
var originalAttachMedia = Hls.prototype.attachMedia;
if (originalAttachMedia) {
Hls.prototype.attachMedia = function(media) {
if (media && media.tagName === 'VIDEO') {
media._goon_hls_manifest = this.url;
reportManifest(this.url, 'hls_attachMedia');
}
return originalAttachMedia.apply(this, arguments);
};
}
}
// Patch immediately if HLS is already defined
patchHlsPrototype();
// Watch for HLS.js to be dynamically defined later
var originalDefineProperty = Object.defineProperty;
var hlsPatched = false;
Object.defineProperty = function(obj, prop, descriptor) {
var result = originalDefineProperty.apply(this, arguments);
if (obj === window && prop === 'Hls' && !hlsPatched) {
hlsPatched = true;
patchHlsPrototype();
}
return result;
};
// Example: inject a small element into the page. })();
// Uncomment and customize as needed. `;
//
// const badge = document.createElement('div'); const script = document.createElement('script');
// badge.textContent = 'Goon Plugin Active'; script.textContent = interceptCode;
// badge.style.cssText = 'position:fixed;bottom:8px;right:8px;padding:4px 8px;...'; script.setAttribute('type', 'text/javascript');
// document.body.appendChild(badge); (document.head || document.documentElement).appendChild(script);
// Remove the script element after execution but keep interceptors active
script.parentNode.removeChild(script);
}
// Inject immediately at document_start
injectInterceptors();
// Listen for intercepted HLS manifest URLs from the main world
window.addEventListener('message', function(event) {
if (event.source !== window) return;
if (event.data && event.data.type === 'GOON_PLUGIN_HLS_INTERCEPT') {
const url = event.data.url;
const source = event.data.source;
if (!interceptedSources.has(url)) {
interceptedSources.add(url);
interceptedManifests.push({
url: url,
source: source,
timestamp: event.data.timestamp
});
console.log('[goon-plugin] Intercepted HLS manifest via', source, ':', url);
}
}
});
/**
* Extract poster URL from the page (content script CAN access DOM).
*/
function extractPoster() {
const posterSelectors = [
'meta[property="og:image"]',
'meta[name="twitter:image"]',
'meta[itemprop="image"]',
'img.poster, img.thumbnail, .video-poster img, .thumbnail img, .preview img',
'[data-poster]',
];
for (const selector of posterSelectors) {
try {
const el = document.querySelector(selector);
if (el) {
const url = el.getAttribute('content') || el.getAttribute('src') ||
el.getAttribute('data-src') || el.getAttribute('data-poster');
if (url && url.startsWith('http')) {
return url;
}
}
} catch(e) {}
}
return null;
}
/**
* Get video data: intercepted manifests + DOM scan + poster.
* Called by the popup via chrome.tabs.sendMessage.
*/
function getVideoData() {
const poster = extractPoster();
// DOM video scan (isolated world — can access DOM but not page JS)
const videoElements = [];
try {
const videos = document.querySelectorAll('video');
for (const video of videos) {
const style = window.getComputedStyle(video);
if (style.display !== 'none' && video.clientWidth > 0 && video.clientHeight > 0) {
videoElements.push({
src: video.currentSrc || video.src || '',
poster: video.poster || '',
width: video.clientWidth,
height: video.clientHeight,
});
}
}
} catch(e) { /* ignore DOM access errors */ }
return {
posterUrl: poster,
interceptedManifests: interceptedManifests,
videoElements: videoElements,
timestamp: Date.now(),
};
}
// Listen for messages from the popup
chrome.runtime.onMessage.addListener(function(message, _sender, sendResponse) {
if (message.type === 'GOON_GET_VIDEO_DATA') {
sendResponse(getVideoData());
return false;
}
return false;
});
console.log('[goon-plugin] Content script loaded. Interceptors injected at document_start.');
})(); })();
+74
View File
@@ -0,0 +1,74 @@
/**
* Shared type definitions for the Goon Plugin.
*
* These types define the canonical video object shape used across:
* - chrome.storage.local (persistence layer)
* - Popup scan logic (index.js)
* - Viewer grid renderer (viewer.js)
*
* TypeScript project is configured (tsconfig.json) but source files
* are currently .js. The JSDoc-style interface below documents the
* contract; `tsc --noEmit` validates .js files against these types
* when @type references are used.
*/
/**
* Media delivery format of the video source.
* - 'direct' : plain MP4/WebM blob served via HTTP(S)
* - 'hls' : HTTP Live Streaming manifest (.m3u8)
*/
export type MediaType = 'direct' | 'hls';
/**
* A single video entry discovered by a tab scan.
*
* All fields except `url` are optional to preserve backward
* compatibility with entries written by older extension versions.
*
* @example
* // Legacy entry (still valid)
* { url: "https://example.com/video.mp4" }
*
* @example
* // Full entry with HLS support
* {
* url: "https://example.com/playlist.m3u8",
* src: "https://example.com/playlist.m3u8",
* poster: "https://example.com/thumbnail.jpg",
* tab: "Video Tab",
* hlsManifest: "https://cdn.example.com/stream.m3u8",
* mediaType: "hls"
* }
*/
export interface VideoEntry {
/** Primary video URL. May point to the source file or the HLS manifest. */
url: string;
/** Direct source URL extracted from the <video> element (data-* or src). */
src?: string;
/** Poster / thumbnail image URL. */
poster?: string | null;
/** Title of the browser tab where the video was found. */
tab?: string;
/**
* URL to an HLS manifest (.m3u8).
*
* When present, the viewer loads Hls.js to play adaptive streams.
* Backward compatible: older entries simply omit this field.
*/
hlsManifest?: string;
/**
* Media delivery format.
*
* Defaults to 'direct' when omitted. Set to 'hls' when the video
* is delivered via HTTP Live Streaming.
*
* Backward compatible: entries without this field are treated as
* `mediaType === 'direct'`.
*/
mediaType?: MediaType;
}
+369 -36
View File
@@ -1,6 +1,11 @@
/** /**
* 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 all tabs 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';
@@ -50,7 +55,6 @@ 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';
@@ -82,9 +86,222 @@ 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 });
@@ -105,12 +322,12 @@ findBtn.addEventListener('click', async () => {
} }
const tabsToScan = validTabs; const tabsToScan = validTabs;
setStatus(`Scanning ${tabsToScan.length} tab(s)…`); setStatus(`Scanning ${tabsToScan.length} tab(s)...`);
try { // DOM scrape function (runs in main world)
// Execute scraping on all tabs in parallel
const scrapeFn = () => { const scrapeFn = () => {
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster']; const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
// Collect all visible video elements, measure by rendered pixel area // Collect all visible video elements, measure by rendered pixel area
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);
@@ -125,20 +342,41 @@ findBtn.addEventListener('click', async () => {
|| v.getAttribute('data-poster') || null; || v.getAttribute('data-poster') || null;
return { src, poster, area, width: v.clientWidth, height: v.clientHeight }; return { src, poster, area, width: v.clientWidth, height: v.clientHeight };
}); });
// Sort by displayed size descending, pick the largest
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 largest = allVideos.length > 0 ? allVideos[0] : null;
const sources = Array.from(document.querySelectorAll('video source[src]')).map(s => s.src); const sources = Array.from(document.querySelectorAll('video source[src]')).map(s => s.src);
const audios = Array.from(document.querySelectorAll('audio')).length; const audios = Array.from(document.querySelectorAll('audio')).length;
return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, sources, audios }; return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, sources, audios };
}; };
// 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 {
return await chrome.scripting.executeScript({ const [hlsQueryResults, scriptResult, contentData] = await Promise.all([
// STRATEGY 1: Deep query main-world HLS data structures
chrome.scripting.executeScript({
target: { tabId: t.id, allFrames: true },
func: hlsQueryFn,
}),
// STRATEGY 2: DOM scrape (existing behavior)
chrome.scripting.executeScript({
target: { tabId: t.id, allFrames: true }, target: { tabId: t.id, allFrames: true },
func: scrapeFn, func: scrapeFn,
}); }),
// 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;
@@ -146,55 +384,153 @@ findBtn.addEventListener('click', async () => {
}) })
); );
// Dedup across all tabs by (src+poster) key // Merge all results into unique video entries
const tabResults = tabsToScan.map((tab, i) => ({
tabTitle: tab.title || 'Unknown',
frames: results[i]?.filter(Boolean) || [],
})).filter(g => g.frames.length > 0);
const scanned = tabResults.length;
const seen = new Map(); const seen = new Map();
let totalFound = 0; let totalFound = 0;
for (const group of tabResults) {
for (const r of group.frames) { for (let ti = 0; ti < tabsToScan.length; ti++) {
if (!r.result) continue; const tab = tabsToScan[ti];
for (const v of r.result.videos) { 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++;
const key = 'hls||' + manifestUrl;
if (!seen.has(key)) {
const item = {
url: manifestUrl,
src: manifestUrl,
poster: contentPoster || null,
tab: tabTitle,
hlsManifest: manifestUrl,
mediaType: 'hls',
};
seen.set(key, item);
}
}
// 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++; totalFound++;
const key = (v.src || '(empty)') + '||' + (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 }; const item = {
if (v.src) item.src = v.src; 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()].filter(v => { const items = [...seen.values()].filter(v => {
const url = v.src || v.url || ''; const url = v.src || v.url || '';
return url.startsWith('http://') || url.startsWith('https://'); return url.startsWith('http://') || url.startsWith('https://');
}); });
console.log('[goon] ===== Found', totalFound, 'video elements across', tabsToScan.length, 'tabs, unique:', items.length); 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 => ({ url: v.url, src: v.src || v.url, poster: v.poster, tab: v.tab })); const storedItems = items.map(v => ({
url: v.url,
src: v.src || v.url,
poster: v.poster,
tab: v.tab,
hlsManifest: v.hlsManifest || null,
mediaType: v.mediaType || 'direct',
}));
await chrome.storage.local.set({ await chrome.storage.local.set({
[STORAGE_KEY]: storedItems, [STORAGE_KEY]: storedItems,
[TAGGED_KEY]: items.map(v => v.tab), [TAGGED_KEY]: items.map(v => v.tab),
}); });
const skippedTabs = results.filter(r => r === null).length; const skippedTabs = results.filter(r => r === null).length;
const statusMsg = `Found ${items.length} video(s) across ${scanned} tab(s).`; const statusMsg = `Found ${items.length} video(s) across ${tabsToScan.length} tab(s).`;
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped (idle).` : statusMsg); setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped.` : 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 2x2 grid === */ /* === "Open Viewer" — launch the NxN 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);
@@ -204,11 +540,9 @@ 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;
@@ -219,7 +553,6 @@ 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');
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "VideoEntry",
"description": "A single video entry discovered by a Goon Plugin tab scan. Stored in chrome.storage.local.",
"type": "object",
"required": ["url"],
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "Primary video URL. May point to the source file or the HLS manifest."
},
"src": {
"type": "string",
"format": "uri",
"description": "Direct source URL extracted from the <video> element (data-* attributes or src attribute)."
},
"poster": {
"type": ["string", "null"],
"format": "uri",
"description": "Poster / thumbnail image URL."
},
"tab": {
"type": "string",
"description": "Title of the browser tab where the video was found."
},
"hlsManifest": {
"type": "string",
"format": "uri",
"description": "URL to an HLS manifest (.m3u8). When present, the viewer loads Hls.js to play adaptive streams."
},
"mediaType": {
"type": "string",
"enum": ["direct", "hls"],
"description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming."
}
},
"additionalProperties": false,
"examples": [
{
"url": "https://example.com/video.mp4",
"src": "https://example.com/video.mp4",
"poster": "https://example.com/thumbnail.jpg",
"tab": "Video Page"
},
{
"url": "https://example.com/playlist.m3u8",
"src": "https://example.com/playlist.m3u8",
"hlsManifest": "https://cdn.example.com/stream.m3u8",
"mediaType": "hls",
"poster": "https://example.com/thumbnail.jpg",
"tab": "HLS Stream Page"
}
]
}
+134 -9
View File
@@ -1,6 +1,7 @@
/** /**
* 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'.
*/ */
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -9,6 +10,117 @@ 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 = [
'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js',
'https://unpkg.com/hls.js@1/dist/hls.min.js',
];
/** Load hls.js from CDN. Caches the promise so it's only fetched once. */
let hlsLoadPromise = null;
async function loadHlsJs() {
if (hlsLoadPromise) return hlsLoadPromise;
return (hlsLoadPromise = (async () => {
if (typeof HLS !== 'undefined') return window.HLS;
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. */
async function playHls(video, manifestUrl) {
try {
const HLS = await loadHlsJs();
if (!HLS || !HLS.isSupported()) {
// native HLS (Safari) or undefined — fall back to plain src
video.src = manifestUrl;
return;
}
// Clean up any existing HLS instance before creating a new one
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(() => {}));
hls.on(HLS.Events.ERROR, (_ev, data) => {
if (!data.fatal) return;
switch (data.type) {
case HLS.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break;
case HLS.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError(); break;
default: destroyHls(video);
}
});
} catch (err) {
console.error('[goon-viewer] HLS init failed:', err.message);
}
}
/** Destroy the HLS instance on a video element, if any. */
function destroyHls(video) {
if (video._hls) { video._hls.destroy(); delete video._hls; }
}
/** Resolve the src URL from a video entry (string or object). */
function resolveSrc(entry) {
if (typeof entry === 'string') return entry;
return entry.src || entry.url || '';
}
/** 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
async function init() { async function init() {
console.log('[goon-viewer] init called, id:', id, 'url:', location.href); console.log('[goon-viewer] init called, id:', id, 'url:', location.href);
@@ -25,7 +137,9 @@ async function init() {
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);
@@ -40,15 +154,16 @@ async function init() {
panel.classList.add('panel'); panel.classList.add('panel');
if (i < videoUrls.length && videoUrls[i]) { if (i < videoUrls.length && videoUrls[i]) {
const video = document.createElement('video'); try {
video.src = (typeof videoUrls[i] === 'object') ? videoUrls[i].src : videoUrls[i]; const video = await createVideoElement(videoUrls[i]);
video.autoplay = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
console.log('[goon-viewer] panel', i + 1, '/', totalPanels, ':', videoUrls[i]);
panel.appendChild(video); panel.appendChild(video);
// Listen for load errors on standard videos
video.addEventListener('error', () => {
console.error('[goon-viewer] Video load error on panel', i + 1);
showPanelError(panel, 'Failed to load video');
});
// Add tab label if available // Add tab label if available
if (tags[i]) { if (tags[i]) {
const label = document.createElement('div'); const label = document.createElement('div');
@@ -56,6 +171,10 @@ 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');
} }
@@ -68,8 +187,14 @@ async function init() {
panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`; panelsEl.style.gridTemplateRows = `repeat(${gridN}, 1fr)`;
} }
// Clean up storage entry when the viewer is closed // Clean up HLS instances and storage entry when the viewer is closed
window.addEventListener('unload', async () => { window.addEventListener('unload', async () => {
// Destroy all HLS instances
const allVideos = panelsEl.querySelectorAll('video');
for (const video of allVideos) {
destroyHls(video);
}
if (id) { if (id) {
await chrome.storage.local.remove([id]); await chrome.storage.local.remove([id]);
} }