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

+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]);
}