feat: add HLS playback support with hls.js
CI / test (pull_request) Canceled after 0s

- viewer.js: dynamic hls.js loading, HLS-aware panel creation, error handling
- schema.json: hlsManifest and mediaType fields (optional, backward compatible)
- types.ts: VideoEntry interface with MediaType type
- isHls() null guard fix
- tests: 39 tests (schema, viewer units, data flow, integration)
- CI: GitHub/Gitea Actions workflow (npm test on push/PR)
- deps: ajv, ajv-formats, jsdom for testing
This commit is contained in:
hermes committed 2026-08-10 06:18:27 +00:00
1 parent baa72a4c33
commit 90c3425200
12 files changed
+2295 -27

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
/**
* Shared type definitions for the Goon Plugin.
*
* These types define the canonical video object shape used across:
* - chrome.storage.local (persistence layer)
* - Popup scan logic (index.js)
* - Viewer grid renderer (viewer.js)
*
* TypeScript project is configured (tsconfig.json) but source files
* are currently .js. The JSDoc-style interface below documents the
* contract; `tsc --noEmit` validates .js files against these types
* when @type references are used.
*/
/**
* Media delivery format of the video source.
* - 'direct' : plain MP4/WebM blob served via HTTP(S)
* - 'hls' : HTTP Live Streaming manifest (.m3u8)
*/
export type MediaType = 'direct' | 'hls';
/**
* A single video entry discovered by a tab scan.
*
* All fields except `url` are optional to preserve backward
* compatibility with entries written by older extension versions.
*
* @example
* // Legacy entry (still valid)
* { url: "https://example.com/video.mp4" }
*
* @example
* // Full entry with HLS support
* {
* url: "https://example.com/playlist.m3u8",
* src: "https://example.com/playlist.m3u8",
* poster: "https://example.com/thumbnail.jpg",
* tab: "Video Tab",
* hlsManifest: "https://cdn.example.com/stream.m3u8",
* mediaType: "hls"
* }
*/
export interface VideoEntry {
/** Primary video URL. May point to the source file or the HLS manifest. */
url: string;
/** Direct source URL extracted from the <video> element (data-* or src). */
src?: string;
/** Poster / thumbnail image URL. */
poster?: string | null;
/** Title of the browser tab where the video was found. */
tab?: string;
/**
* URL to an HLS manifest (.m3u8).
*
* When present, the viewer loads Hls.js to play adaptive streams.
* Backward compatible: older entries simply omit this field.
*/
hlsManifest?: string;
/**
* Media delivery format.
*
* Defaults to 'direct' when omitted. Set to 'hls' when the video
* is delivered via HTTP Live Streaming.
*
* Backward compatible: entries without this field are treated as
* `mediaType === 'direct'`.
*/
mediaType?: MediaType;
}
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "VideoEntry",
"description": "A single video entry discovered by a Goon Plugin tab scan. Stored in chrome.storage.local.",
"type": "object",
"required": ["url"],
"properties": {
"url": {
"type": "string",
"format": "uri",
"description": "Primary video URL. May point to the source file or the HLS manifest."
},
"src": {
"type": "string",
"format": "uri",
"description": "Direct source URL extracted from the <video> element (data-* attributes or src attribute)."
},
"poster": {
"type": ["string", "null"],
"format": "uri",
"description": "Poster / thumbnail image URL."
},
"tab": {
"type": "string",
"description": "Title of the browser tab where the video was found."
},
"hlsManifest": {
"type": "string",
"format": "uri",
"description": "URL to an HLS manifest (.m3u8). When present, the viewer loads Hls.js to play adaptive streams."
},
"mediaType": {
"type": "string",
"enum": ["direct", "hls"],
"description": "Media delivery format. Defaults to 'direct' when omitted. Set to 'hls' for HTTP Live Streaming."
}
},
"additionalProperties": false,
"examples": [
{
"url": "https://example.com/video.mp4",
"src": "https://example.com/video.mp4",
"poster": "https://example.com/thumbnail.jpg",
"tab": "Video Page"
},
{
"url": "https://example.com/playlist.m3u8",
"src": "https://example.com/playlist.m3u8",
"hlsManifest": "https://cdn.example.com/stream.m3u8",
"mediaType": "hls",
"poster": "https://example.com/thumbnail.jpg",
"tab": "HLS Stream Page"
}
]
}
+134 -16
View File
@@ -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]);
}