- viewer.js: dynamic hls.js loading, HLS-aware panel creation, error handling - schema.json: hlsManifest and mediaType fields (optional, backward compatible) - types.ts: VideoEntry interface with MediaType type - isHls() null guard fix - tests: 39 tests (schema, viewer units, data flow, integration) - CI: GitHub/Gitea Actions workflow (npm test on push/PR) - deps: ajv, ajv-formats, jsdom for testing
This commit is contained in:
1 parent
baa72a4c33
commit
90c3425200
12 files changed
+2295
-27
No files matched your search
@@ -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
-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,110 @@ 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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 +130,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 +147,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 +180,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