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
CI / test (push) Successful in 24s
Reviewed-on: http://192.168.1.74:30008/kfj001/goon_plugin/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
commit
7416ae8ff9
15 files changed
+2910
-68
No files matched your search
@@ -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
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
Generated
+911
-10
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -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
@@ -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();
|
||||||
|
|
||||||
// Example: inject a small element into the page.
|
/**
|
||||||
// Uncomment and customize as needed.
|
* Inject interceptors into the MAIN world at document_start.
|
||||||
//
|
* Runs BEFORE any page JS, so we catch all HLS activity.
|
||||||
// const badge = document.createElement('div');
|
*/
|
||||||
// badge.textContent = 'Goon Plugin Active';
|
function injectInterceptors() {
|
||||||
// badge.style.cssText = 'position:fixed;bottom:8px;right:8px;padding:4px 8px;...';
|
const interceptCode = `
|
||||||
// document.body.appendChild(badge);
|
(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;
|
||||||
|
};
|
||||||
|
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.textContent = interceptCode;
|
||||||
|
script.setAttribute('type', 'text/javascript');
|
||||||
|
(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.');
|
||||||
})();
|
})();
|
||||||
@@ -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
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user