Compare commits
6
Commits
baa72a4c33
...
7416ae8ff9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7416ae8ff9 | ||
|
|
c59c96ce19 | ||
|
|
e17b228ee0 | ||
|
|
c22631d1f1 | ||
|
|
db52db7799 | ||
|
|
90c3425200 |
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>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
"run_at": "document_start",
|
||||
"all_frames": true
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
|
||||
Generated
+911
-10
File diff suppressed because it is too large.
Load diff
+7
-1
@@ -9,13 +9,19 @@
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"test": "node __tests__/run.mjs",
|
||||
"test:watch": "vitest",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.5.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"chrome-types": "^0.1.336",
|
||||
"eslint": "^9.0.0",
|
||||
"jsdom": "^24.1.3",
|
||||
"sharp": "^0.34.5",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
|
||||
+232
-11
@@ -1,19 +1,240 @@
|
||||
/**
|
||||
* content.js — Content script injected into matching pages.
|
||||
* Communicates with the background service worker via chrome.runtime.
|
||||
* Use cautiously: runs in the page's DOM context, so isolate from page globals.
|
||||
* content.js - Content script injected into matching pages at document_start.
|
||||
* Runs in the ISOLATED world.
|
||||
*
|
||||
* 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 () {
|
||||
'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.
|
||||
//
|
||||
// const badge = document.createElement('div');
|
||||
// badge.textContent = 'Goon Plugin Active';
|
||||
// badge.style.cssText = 'position:fixed;bottom:8px;right:8px;padding:4px 8px;...';
|
||||
// document.body.appendChild(badge);
|
||||
/**
|
||||
* Inject interceptors into the MAIN world at document_start.
|
||||
* Runs BEFORE any page JS, so we catch all HLS activity.
|
||||
*/
|
||||
function injectInterceptors() {
|
||||
const interceptCode = `
|
||||
(function() {
|
||||
if (window.__goon_plugin_intercepted) return;
|
||||
window.__goon_plugin_intercepted = true;
|
||||
|
||||
window.__goon_plugin_data = window.__goon_plugin_data || {
|
||||
manifests: [],
|
||||
sources: []
|
||||
};
|
||||
|
||||
// Helper: resolve relative URLs
|
||||
function resolveUrl(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
if (url.startsWith('//')) return 'https:' + url;
|
||||
if (url.startsWith('http')) return url;
|
||||
return new URL(url, window.location.origin).href;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// Helper: report manifest to content script via postMessage
|
||||
function reportManifest(url, source) {
|
||||
var resolved = resolveUrl(url);
|
||||
if (!resolved) return;
|
||||
if (window.__goon_plugin_data.manifests.includes(resolved)) return;
|
||||
window.__goon_plugin_data.manifests.push(resolved);
|
||||
|
||||
window.postMessage({
|
||||
type: 'GOON_PLUGIN_HLS_INTERCEPT',
|
||||
url: resolved,
|
||||
source: source,
|
||||
timestamp: Date.now()
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// === INTERCEPTORS ===
|
||||
|
||||
// 1. Intercept XMLHttpRequest for manifest-like requests
|
||||
var originalOpen = XMLHttpRequest.prototype.open;
|
||||
var originalSend = XMLHttpRequest.prototype.send;
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this._goon_url = url;
|
||||
return originalOpen.apply(this, arguments);
|
||||
};
|
||||
XMLHttpRequest.prototype.send = function(body) {
|
||||
if (this._goon_url && typeof this._goon_url === 'string') {
|
||||
var resolved = resolveUrl(this._goon_url);
|
||||
if (resolved && (resolved.includes('.m3u8') ||
|
||||
resolved.includes('/playlist') ||
|
||||
resolved.includes('/manifest'))) {
|
||||
reportManifest(resolved, 'xhr');
|
||||
}
|
||||
}
|
||||
return originalSend.apply(this, arguments);
|
||||
};
|
||||
|
||||
// 2. Intercept fetch() for manifest-like requests
|
||||
var originalFetch = window.fetch;
|
||||
window.fetch = function(resource, init) {
|
||||
var url = typeof resource === 'string' ? resource : (resource && resource.url);
|
||||
if (url && typeof url === 'string') {
|
||||
var resolved = resolveUrl(url);
|
||||
if (resolved && (resolved.includes('.m3u8') ||
|
||||
resolved.includes('/playlist') ||
|
||||
resolved.includes('/manifest'))) {
|
||||
reportManifest(resolved, 'fetch');
|
||||
}
|
||||
}
|
||||
return originalFetch.apply(this, arguments);
|
||||
};
|
||||
|
||||
// 3. Intercept HLS.js loadSource (catches HLS.js instances)
|
||||
function patchHlsPrototype() {
|
||||
if (typeof Hls === 'undefined') return;
|
||||
var originalLoadSource = Hls.prototype.loadSource;
|
||||
if (!originalLoadSource) return;
|
||||
|
||||
Hls.prototype.loadSource = function(url) {
|
||||
reportManifest(url, 'hls_loadSource');
|
||||
return originalLoadSource.apply(this, arguments);
|
||||
};
|
||||
|
||||
var originalAttachMedia = Hls.prototype.attachMedia;
|
||||
if (originalAttachMedia) {
|
||||
Hls.prototype.attachMedia = function(media) {
|
||||
if (media && media.tagName === 'VIDEO') {
|
||||
media._goon_hls_manifest = this.url;
|
||||
reportManifest(this.url, 'hls_attachMedia');
|
||||
}
|
||||
return originalAttachMedia.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Patch immediately if HLS is already defined
|
||||
patchHlsPrototype();
|
||||
|
||||
// Watch for HLS.js to be dynamically defined later
|
||||
var originalDefineProperty = Object.defineProperty;
|
||||
var hlsPatched = false;
|
||||
Object.defineProperty = function(obj, prop, descriptor) {
|
||||
var result = originalDefineProperty.apply(this, arguments);
|
||||
if (obj === window && prop === 'Hls' && !hlsPatched) {
|
||||
hlsPatched = true;
|
||||
patchHlsPrototype();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
})();
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
||||
+413
-80
@@ -1,6 +1,11 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -50,7 +55,6 @@ function renderList(items) {
|
||||
listEl.appendChild(link);
|
||||
}
|
||||
|
||||
// Copy all URLs button
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.className = 'copy-btn';
|
||||
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 === */
|
||||
findBtn.addEventListener('click', async () => {
|
||||
setStatus('Scanning tabs…');
|
||||
setStatus('Scanning tabs...');
|
||||
|
||||
// Get all tabs in the current window
|
||||
const allTabs = await chrome.tabs.query({ currentWindow: true });
|
||||
@@ -105,96 +322,215 @@ findBtn.addEventListener('click', async () => {
|
||||
}
|
||||
|
||||
const tabsToScan = validTabs;
|
||||
setStatus(`Scanning ${tabsToScan.length} tab(s)…`);
|
||||
setStatus(`Scanning ${tabsToScan.length} tab(s)...`);
|
||||
|
||||
try {
|
||||
// Execute scraping on all tabs in parallel
|
||||
const scrapeFn = () => {
|
||||
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
|
||||
// Collect all visible video elements, measure by rendered pixel area
|
||||
const allVideos = Array.from(document.querySelectorAll('video')).filter(v => {
|
||||
const style = window.getComputedStyle(v);
|
||||
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
|
||||
}).map(v => {
|
||||
const area = v.clientWidth * v.clientHeight;
|
||||
const src = dataKeys.map(k => v.getAttribute('data-' + k)).find(Boolean)
|
||||
|| (v.currentSrc || v.src || '')
|
||||
|| null;
|
||||
const poster = (v.poster || '')
|
||||
|| dataKeys.filter(k => k !== 'poster').map(k => v.getAttribute('data-' + k)).find(Boolean)
|
||||
|| v.getAttribute('data-poster') || null;
|
||||
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);
|
||||
const largest = allVideos.length > 0 ? allVideos[0] : null;
|
||||
const sources = Array.from(document.querySelectorAll('video source[src]')).map(s => s.src);
|
||||
const audios = Array.from(document.querySelectorAll('audio')).length;
|
||||
return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, sources, audios };
|
||||
};
|
||||
const results = await Promise.all(
|
||||
tabsToScan.map(async t => {
|
||||
try {
|
||||
return await chrome.scripting.executeScript({
|
||||
// DOM scrape function (runs in main world)
|
||||
const scrapeFn = () => {
|
||||
const dataKeys = ['src', 'video', 'mp4', 'webm', 'poster'];
|
||||
|
||||
// Collect all visible video elements, measure by rendered pixel area
|
||||
const allVideos = Array.from(document.querySelectorAll('video')).filter(v => {
|
||||
const style = window.getComputedStyle(v);
|
||||
return style.display !== 'none' && v.clientWidth > 0 && v.clientHeight > 0;
|
||||
}).map(v => {
|
||||
const area = v.clientWidth * v.clientHeight;
|
||||
const src = dataKeys.map(k => v.getAttribute('data-' + k)).find(Boolean)
|
||||
|| (v.currentSrc || v.src || '')
|
||||
|| null;
|
||||
const poster = (v.poster || '')
|
||||
|| dataKeys.filter(k => k !== 'poster').map(k => v.getAttribute('data-' + k)).find(Boolean)
|
||||
|| v.getAttribute('data-poster') || null;
|
||||
return { src, poster, area, width: v.clientWidth, height: v.clientHeight };
|
||||
});
|
||||
allVideos.sort((a, b) => b.area - a.area);
|
||||
const largest = allVideos.length > 0 ? allVideos[0] : null;
|
||||
const sources = Array.from(document.querySelectorAll('video source[src]')).map(s => s.src);
|
||||
const audios = Array.from(document.querySelectorAll('audio')).length;
|
||||
|
||||
return { videos: largest ? [largest] : [], videoCount: largest ? 1 : 0, sources, audios };
|
||||
};
|
||||
|
||||
// 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(
|
||||
tabsToScan.map(async t => {
|
||||
try {
|
||||
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 },
|
||||
func: scrapeFn,
|
||||
});
|
||||
} catch {
|
||||
console.warn('[goon] skipping tab', t.id, ':', t.url);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
}),
|
||||
// STRATEGY 3: Content script intercepted manifests + poster
|
||||
queryContentScript(t.id),
|
||||
]);
|
||||
return { hlsQueryResults: hlsQueryResults.filter(Boolean), scriptResult: scriptResult.filter(Boolean), contentData };
|
||||
} catch {
|
||||
console.warn('[goon] skipping tab', t.id, ':', t.url);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Dedup across all tabs by (src+poster) key
|
||||
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;
|
||||
// Merge all results into unique video entries
|
||||
const seen = new Map();
|
||||
let totalFound = 0;
|
||||
|
||||
const seen = new Map();
|
||||
let totalFound = 0;
|
||||
for (const group of tabResults) {
|
||||
for (const r of group.frames) {
|
||||
if (!r.result) continue;
|
||||
for (const v of r.result.videos) {
|
||||
totalFound++;
|
||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
||||
if (!seen.has(key)) {
|
||||
const item = { url: v.src || v.poster || '', poster: v.poster || null, tab: group.tabTitle };
|
||||
if (v.src) item.src = v.src;
|
||||
seen.set(key, item);
|
||||
for (let ti = 0; ti < tabsToScan.length; ti++) {
|
||||
const tab = tabsToScan[ti];
|
||||
const res = results[ti];
|
||||
if (!res) continue;
|
||||
|
||||
const tabTitle = tab.title || 'Unknown';
|
||||
const contentPoster = (res.contentData && res.contentData.posterUrl) || null;
|
||||
|
||||
// --- PROCESS HLS MANIFESTS FROM MAIN-WORLD QUERY ---
|
||||
const hlsManifests = [];
|
||||
for (const frameResult of (res.hlsQueryResults || [])) {
|
||||
const q = frameResult.result;
|
||||
if (q && Array.isArray(q.manifests)) {
|
||||
for (const m of q.manifests) {
|
||||
if (m && m.startsWith('http')) {
|
||||
hlsManifests.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items = [...seen.values()].filter(v => {
|
||||
const url = v.src || v.url || '';
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
});
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[goon] ===== Found', totalFound, 'video elements across', tabsToScan.length, 'tabs, unique:', items.length);
|
||||
// Merge all manifest sources
|
||||
const allManifests = [...new Set([...hlsManifests, ...interceptedManifests])];
|
||||
|
||||
// Replace the entire list (fresh scan)
|
||||
const storedItems = items.map(v => ({ url: v.url, src: v.src || v.url, poster: v.poster, tab: v.tab }));
|
||||
await chrome.storage.local.set({
|
||||
[STORAGE_KEY]: storedItems,
|
||||
[TAGGED_KEY]: items.map(v => v.tab),
|
||||
});
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skippedTabs = results.filter(r => r === null).length;
|
||||
const statusMsg = `Found ${items.length} video(s) across ${scanned} tab(s).`;
|
||||
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped (idle).` : statusMsg);
|
||||
btnRow.style.display = 'flex';
|
||||
} catch (e) {
|
||||
setStatus(`Scan failed: ${e.message}`);
|
||||
console.warn('[goon] executeScript failed:', e);
|
||||
// --- 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++;
|
||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
||||
if (!seen.has(key)) {
|
||||
const item = {
|
||||
url: v.src,
|
||||
src: v.src,
|
||||
poster: v.poster || contentPoster || null,
|
||||
tab: tabTitle,
|
||||
};
|
||||
// If HLS is available for this tab, attach it
|
||||
if (allManifests.length > 0) {
|
||||
item.hlsManifest = allManifests[0];
|
||||
item.mediaType = 'hls';
|
||||
}
|
||||
seen.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Content script video elements
|
||||
for (const v of csVideos) {
|
||||
if (!v.src || v.src.startsWith('blob:')) continue;
|
||||
totalFound++;
|
||||
const key = (v.src || '(empty)') + '||' + (v.poster || '(none)');
|
||||
if (!seen.has(key)) {
|
||||
const item = {
|
||||
url: v.src,
|
||||
src: v.src,
|
||||
poster: v.poster || contentPoster || null,
|
||||
tab: tabTitle,
|
||||
};
|
||||
if (allManifests.length > 0) {
|
||||
item.hlsManifest = allManifests[0];
|
||||
item.mediaType = 'hls';
|
||||
}
|
||||
seen.set(key, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filter: keep only http/https URLs
|
||||
const items = [...seen.values()].filter(v => {
|
||||
const url = v.src || v.url || '';
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
});
|
||||
|
||||
console.log('[goon] ===== Found', totalFound, 'video elements, unique:', items.length, 'across', tabsToScan.length, 'tabs');
|
||||
|
||||
// Store results
|
||||
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({
|
||||
[STORAGE_KEY]: storedItems,
|
||||
[TAGGED_KEY]: items.map(v => v.tab),
|
||||
});
|
||||
|
||||
const skippedTabs = results.filter(r => r === null).length;
|
||||
const statusMsg = `Found ${items.length} video(s) across ${tabsToScan.length} tab(s).`;
|
||||
setStatus(skippedTabs > 0 ? `${statusMsg} ${skippedTabs} tab(s) skipped.` : statusMsg);
|
||||
btnRow.style.display = 'flex';
|
||||
});
|
||||
|
||||
/* === "Open Viewer" — launch the 2x2 grid === */
|
||||
/* === "Open Viewer" — launch the NxN grid === */
|
||||
openBtn.addEventListener('click', async () => {
|
||||
const { urls, tags } = await loadVideos();
|
||||
console.log('[goon] Open Viewer clicked, loaded from storage:', urls, 'tags:', tags);
|
||||
@@ -204,11 +540,9 @@ openBtn.addEventListener('click', async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedup by index to preserve tag alignment
|
||||
const videoUrls = urls;
|
||||
const videoTags = tags || [];
|
||||
|
||||
// Compute NxN grid
|
||||
const N = Math.ceil(Math.sqrt(videoUrls.length));
|
||||
const PANEL_MIN_W = 420;
|
||||
const PANEL_MIN_H = 340;
|
||||
@@ -219,7 +553,6 @@ openBtn.addEventListener('click', async () => {
|
||||
const id = 'goonViewer_' + Date.now();
|
||||
await chrome.storage.local.set({ [id]: videoUrls });
|
||||
|
||||
// Verify write
|
||||
const verify = await chrome.storage.local.get([id]);
|
||||
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+141
-16
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* viewer.js — Goon Viewer page logic.
|
||||
* 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);
|
||||
@@ -9,6 +10,117 @@ const gridN = parseInt(params.get('grid'), 10) || 2;
|
||||
const tags = params.get('tags') ? JSON.parse(params.get('tags')) : [];
|
||||
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
|
||||
async function init() {
|
||||
console.log('[goon-viewer] init called, id:', id, 'url:', location.href);
|
||||
@@ -25,7 +137,9 @@ async function init() {
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
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.insertBefore(overlay, document.body.firstChild);
|
||||
|
||||
@@ -40,21 +154,26 @@ async function init() {
|
||||
panel.classList.add('panel');
|
||||
|
||||
if (i < videoUrls.length && videoUrls[i]) {
|
||||
const video = document.createElement('video');
|
||||
video.src = (typeof videoUrls[i] === 'object') ? videoUrls[i].src : 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);
|
||||
try {
|
||||
const video = await createVideoElement(videoUrls[i]);
|
||||
panel.appendChild(video);
|
||||
|
||||
// Add tab label if available
|
||||
if (tags[i]) {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'panel-label';
|
||||
label.textContent = tags[i];
|
||||
panel.appendChild(label);
|
||||
// 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
|
||||
if (tags[i]) {
|
||||
const label = document.createElement('div');
|
||||
label.className = 'panel-label';
|
||||
label.textContent = tags[i];
|
||||
panel.appendChild(label);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[goon-viewer] Error creating video panel', i + 1, ':', err);
|
||||
showPanelError(panel, 'Error: ' + err.message);
|
||||
}
|
||||
} else {
|
||||
panel.classList.add('empty');
|
||||
@@ -68,8 +187,14 @@ async function init() {
|
||||
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 () => {
|
||||
// Destroy all HLS instances
|
||||
const allVideos = panelsEl.querySelectorAll('video');
|
||||
for (const video of allVideos) {
|
||||
destroyHls(video);
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await chrome.storage.local.remove([id]);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user