openvibers/openvibe.live Commits

Play VODs and clips from Media's timeline playlist when they have one (#26)

OP OpenVibers committed 5 Oct 2026 · 2dd43a3a65 · 5 files, +198 −3

* Play VODs and clips from Media's timeline playlist when they have one (plan T4)

A finished VOD or a clip whose Media row carries hls_url now plays the HLS
playlist (hls.js, or natively where the browser can), so a seek reads only
the segments it needs, packed chunks included. Everything else keeps the
file URL: a recording (the growing file), a VOD or clip without a timeline,
a browser without HLS support. A playlist that fails falls back to the file
once, at the same position, before the clip player shows its error.

- public/js/app-media.js: attachVodSource / vodFallBackToFile; hls.js is
  loaded on demand, major-pinned (cdn.jsdelivr.net/npm/hls.js@1), and a
  previous instance is destroyed on every switch. The clip creator's
  preview cuts from the remembered file URL (the player's src may be a
  blob: URL now).
- server/media-proxy/{vods,clips}.js: hls_url is absolutized like
  playback_url (Media sends it absolute; this keeps a relative one working).
- CSP already allows it: media-src blob: + openvibe.media, worker-src
  blob:, connect-src openvibe.media (Media serves packed ranges itself).
- test/vod-hls-source.test.js runs the helpers against a fake video and a
  fake hls.js: playlist preferred, fatal error -> file at the same time,
  non-fatal ignored, native HLS, no playlist / no support -> file, and the
  previous instance dropped.

Inert until Media's v1 VOD/clip answers include hls_url (OpenVibe.Media,
"Expose the timeline HLS URL"), then it lights up per VOD.

* Leaving the page drops the hls.js instance; a load that finishes after leaving attaches nothing (review)

Changes

ddiff --git a/public/js/app-media.js b/public/js/app-media.js
iindex 5423ba28..60f2f5e9 100644
---- a/public/js/app-media.js
++++ b/public/js/app-media.js
@@@ -185,7 +185,72 @@ function _attachVodProgressTracking(video, vodId) {
}, { once: true });
}
/* ── VOD Player ───────────────────────────────────────────────── */
++// ── Playback source: Media's timeline playlist when there is one, the file otherwise ──────────────────────
++// A finished VOD or clip with a Media timeline (hls_url) plays its HLS playlist, so a seek reads only the segments
++// it needs (packed chunks included). A recording, a VOD without a timeline, or a browser that cannot play HLS keeps
++// the file URL, and a playlist that fails falls back to the file once.
++let _vodHls = null;
++let _vodHlsScript = null;
++function vodLoadHlsScript() {
++ if (typeof Hls !== 'undefined') return Promise.resolve();
++ if (!_vodHlsScript) {
++ _vodHlsScript = new Promise((resolve, reject) => {
++ const s = document.createElement('script');
++ s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1/dist/hls.min.js';
++ s.async = true;
++ s.onload = () => resolve();
++ s.onerror = () => { _vodHlsScript = null; reject(new Error('hls.js did not load')); };
++ document.head.appendChild(s);
++ });
++ }
++ return _vodHlsScript;
++}
++function vodDropHls() {
++ if (_vodHls) { try { _vodHls.destroy(); } catch { /* already gone */ } _vodHls = null; }
++}
++/**
++ * Point `video` at the playlist (or the file); remembers the file URL for the fallback and the clip preview.
++ * `routeGen` is the router generation the caller started under: a load that finishes after the person left the
++ * page attaches nothing (no hls.js instance outlives the route).
++ */
++async function attachVodSource(video, hlsUrl, fileUrl, routeGen = null) {
++ const stale = () => routeGen != null && typeof ov !== 'undefined' && ov && typeof ov.isCurrent === 'function' && !ov.isCurrent(routeGen);
++ if (stale()) return 'stale';
++ vodDropHls();
++ video.dataset.fileUrl = fileUrl;
++ video.dataset.source = 'file';
++ if (!hlsUrl) { video.src = fileUrl; return 'file'; }
++ if (video.canPlayType('application/vnd.apple.mpegurl')) {
++ video.dataset.source = 'hls';
++ video.src = hlsUrl;
++ return 'hls';
++ }
++ try { await vodLoadHlsScript(); } catch { if (!stale()) video.src = fileUrl; return 'file'; }
++ if (stale()) return 'stale';
++ if (typeof Hls === 'undefined' || !Hls.isSupported()) { video.src = fileUrl; return 'file'; }
++ const hls = new Hls({ enableWorker: true, backBufferLength: 60 });
++ _vodHls = hls;
++ video.dataset.source = 'hls';
++ hls.on(Hls.Events.ERROR, (_event, data) => {
++ if (data && data.fatal && _vodHls === hls) vodFallBackToFile(video);
++ });
++ hls.loadSource(hlsUrl);
++ hls.attachMedia(video);
++ return 'hls';
++}
++/** A playlist that failed: play the file instead, once. Returns true when it switched (callers then ignore the error). */
++function vodFallBackToFile(video) {
++ if (video.dataset.source !== 'hls' || !video.dataset.fileUrl) return false;
++ const at = video.currentTime || 0;
++ vodDropHls();
++ video.dataset.source = 'file';
++ video.src = video.dataset.fileUrl;
++ if (at > 0) video.addEventListener('loadedmetadata', function _resume() { video.removeEventListener('loadedmetadata', _resume); try { video.currentTime = at; } catch { /* */ } });
++ return true;
++}
++
async function loadVodPlayer(vodId, seekTo) {
++ const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
try {
// Clean up any previous live VOD poll
if (window._liveVodPollTimer) {
@@@ -285,7 +350,12 @@ async function loadVodPlayer(vodId, seekTo) {
// to this when the browser mis-reports the WebM container duration.
const serverDur = Number(v.duration_seconds || v.duration || 0);
video.dataset.serverDuration = (serverDur > 0 && !v.is_recording) ? String(serverDur) : '';
-- video.src = `/api/vods/file/${filename}?t=${Date.now()}`;
++ // A recording keeps the growing file; a finished VOD with a timeline plays its playlist.
++ if (!video.dataset.hlsFallbackBound) {
++ video.dataset.hlsFallbackBound = '1';
++ video.addEventListener('error', () => { vodFallBackToFile(video); });
++ }
++ attachVodSource(video, v.is_recording ? null : v.hls_url, `/api/vods/file/${filename}?t=${Date.now()}`, routeGen);
video.style.display = 'block';
if (v.is_recording) {
@@@ -490,7 +560,8 @@ function openClipCreator() {
// Setup preview video
const preview = document.getElementById('clip-preview-video');
if (preview) {
-- const filename = video.src.split('/').pop().split('?')[0];
++ // The main player may be on the HLS playlist (a blob: URL): the preview cuts from the file.
++ const filename = (video.dataset.fileUrl || video.src).split('/').pop().split('?')[0];
preview.src = `/api/vods/file/${filename}`;
preview.currentTime = _vpClipStart;
preview.muted = true;
@@@ -927,6 +998,7 @@ function _renderClipAttribution(el, cl) {
}
async function loadClipPlayer(clipId) {
++ const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
try {
// Clean up chat replay
if (window._chatReplayTimer) {
@@@ -1055,6 +1127,7 @@ async function loadClipPlayer(clipId) {
const filename = cl.file_path.split('/').pop();
// Handle video load errors (corrupt files, codec issues)
video.onerror = () => {
++ if (vodFallBackToFile(video)) return; // the playlist failed: the file gets its chance first
const container = document.getElementById('clp-container');
if (container) {
container.innerHTML = `
@@@ -1065,7 +1138,7 @@ async function loadClipPlayer(clipId) {
</div>`;
}
};
-- video.src = `/api/vods/file/${filename}`;
++ attachVodSource(video, cl.hls_url, `/api/vods/file/${filename}`, routeGen);
video.style.display = 'block';
setupCustomVideoControls('clp');
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 8e8abb01..bb8d261b 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -913,6 +913,8 @@ function teardownRoute(nextPath) {
// Hardware controls: the socket and its reconnect loop outlived the channel, and the global
// key handler kept sending bound keys to the last channel's device from any page.
stop('destroyControlWs');
++ // A VOD or clip playing Media's playlist: its hls.js instance, worker and loaders end with the page.
++ stop('vodDropHls');
try { currentStreamId = null; } catch { /* */ }
// The VOD/clip chat replay re-arms itself every animation frame; nothing stopped it.
if (window._chatReplayTimer) { cancelAnimationFrame(window._chatReplayTimer); window._chatReplayTimer = null; }
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex e19711cf..9ffc86ab 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -69,6 +69,7 @@ function withUserFields(clip) {
}
// Absolutize Media-relative URLs so the SPA doesn't resolve them against Live.
clip.playback_url = media.publicUrl(clip.playback_url) || media.clipUrl(clip.id);
++ if (clip.hls_url) clip.hls_url = media.publicUrl(clip.hls_url);
if (clip.thumbnail_url) clip.thumbnail_url = media.publicUrl(clip.thumbnail_url);
// AI overview is Live-owned (clip_ai_state) — overlay short + full for the cards
// (the expander swaps the short teaser for the full text).
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex e31b6e8a..be1106c7 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -133,6 +133,8 @@ function withUserFields(row) {
// Media returns its own paths relative — absolutize onto MEDIA_PUBLIC_URL
// so the SPA doesn't resolve them against Live's origin.
row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
++ // Media's timeline playlist (absent until the VOD has one): the player prefers it to the file.
++ if (row.hls_url) row.hls_url = media.publicUrl(row.hls_url);
if (row.thumbnail_url) row.thumbnail_url = media.publicUrl(row.thumbnail_url);
// AI overview is Live-owned (vod_ai_state) — overlay short + full for the cards
// (the expander swaps the short teaser for the full text).
ddiff --git a/test/vod-hls-source.test.js b/test/vod-hls-source.test.js
nnew file mode 100644
iindex 00000000..acde24c3
---- /dev/null
++++ b/test/vod-hls-source.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++// VOD and clip playback source (public/js/app-media.js): a finished VOD or clip with Media's timeline playlist
++// (hls_url) plays HLS, through hls.js or natively; a recording or a VOD without one keeps the file; a failed
++// playlist falls back to the file once, at the same position; the clip preview always cuts from the file.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-media.js'), 'utf8');
++const between = (startMark, endMark) => { const a = js.indexOf(startMark); const b = js.indexOf(endMark, a); assert.ok(a >= 0 && b > a, startMark); return js.slice(a, b); };
++const helpers = between('let _vodHls = null;', 'async function loadVodPlayer(');
++
++assert.match(js, /attachVodSource\(video, v\.is_recording \? null : v\.hls_url, `\/api\/vods\/file\/\$\{filename\}\?t=\$\{Date\.now\(\)\}`, routeGen\)/, 'a finished VOD prefers its playlist; a recording keeps the file');
++assert.match(js, /attachVodSource\(video, cl\.hls_url, `\/api\/vods\/file\/\$\{filename\}`, routeGen\)/, 'a clip prefers its playlist');
++const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
++assert.match(appJs, /stop\('vodDropHls'\);/, 'leaving the page drops the hls.js instance');
++assert.match(js, /\(video\.dataset\.fileUrl \|\| video\.src\)\.split\('\/'\)/, 'the clip preview cuts from the file, not a blob: URL');
++assert.match(js, /if \(vodFallBackToFile\(video\)\) return;/, "the clip player's error message waits for the file fallback");
++
++class FakeVideo {
++ constructor(native = false) { this.native = native; this.dataset = {}; this.src = ''; this.currentTime = 0; this.listeners = {}; }
++ canPlayType(t) { return this.native && t === 'application/vnd.apple.mpegurl' ? 'maybe' : ''; }
++ addEventListener(e, f) { (this.listeners[e] = this.listeners[e] || []).push(f); }
++ removeEventListener(e, f) { this.listeners[e] = (this.listeners[e] || []).filter((x) => x !== f); }
++ fire(e) { for (const f of [...(this.listeners[e] || [])]) f(); }
++}
++function sandbox({ hlsSupported = true } = {}) {
++ const made = [];
++ class Hls {
++ static isSupported() { return hlsSupported; }
++ constructor(opts) { this.opts = opts; this.handlers = {}; this.destroyed = false; made.push(this); }
++ on(ev, f) { this.handlers[ev] = f; }
++ loadSource(u) { this.url = u; }
++ attachMedia(v) { this.media = v; }
++ destroy() { this.destroyed = true; }
++ }
++ Hls.Events = { ERROR: 'hlsError' };
++ const route = { gen: 1 };
++ const ov = { gen: () => route.gen, isCurrent: (g) => g === route.gen };
++ const ctx = { Hls, ov, document: { createElement() { throw new Error('the script is already loaded'); }, head: {} } };
++ vm.runInNewContext(`${helpers}; this.attachVodSource = attachVodSource; this.vodFallBackToFile = vodFallBackToFile; this.vodDropHls = vodDropHls;`, ctx);
++ ctx.route = route;
++ return { ctx, made };
++}
++
++(async () => {
++ { // hls.js path
++ const { ctx, made } = sandbox();
++ const v = new FakeVideo();
++ assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_1/master.m3u8', '/api/vods/file/a.webm?t=1'), 'hls');
++ assert.strictEqual(made[0].url, 'https://openvibe.media/o/obj_1/master.m3u8');
++ assert.strictEqual(made[0].media, v);
++ assert.strictEqual(v.dataset.fileUrl, '/api/vods/file/a.webm?t=1');
++ v.currentTime = 42;
++ made[0].handlers.hlsError(null, { fatal: true }); // the playlist fails: the file takes over at the same spot
++ assert.strictEqual(made[0].destroyed, true);
++ assert.strictEqual(v.src, '/api/vods/file/a.webm?t=1');
++ v.fire('loadedmetadata');
++ assert.strictEqual(v.currentTime, 42);
++ assert.strictEqual(ctx.vodFallBackToFile(v), false, 'it falls back once');
++ }
++ { // a non-fatal hls.js error changes nothing
++ const { ctx, made } = sandbox();
++ const v = new FakeVideo();
++ await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_2/master.m3u8', '/api/vods/file/b.webm');
++ made[0].handlers.hlsError(null, { fatal: false });
++ assert.strictEqual(v.src, '');
++ assert.strictEqual(made[0].destroyed, false);
++ }
++ { // native HLS (Safari): the playlist is the src; a media error falls back
++ const { ctx, made } = sandbox();
++ const v = new FakeVideo(true);
++ assert.strictEqual(await ctx.attachVodSource(v, 'https://openvibe.media/o/obj_3/master.m3u8', '/api/vods/file/c.webm'), 'hls');
++ assert.strictEqual(made.length, 0);
++ assert.strictEqual(v.src, 'https://openvibe.media/o/obj_3/master.m3u8');
++ assert.strictEqual(ctx.vodFallBackToFile(v), true);
++ assert.strictEqual(v.src, '/api/vods/file/c.webm');
++ }
++ { // no playlist, or no HLS support: the file
++ const { ctx } = sandbox();
++ const v = new FakeVideo();
++ assert.strictEqual(await ctx.attachVodSource(v, null, '/api/vods/file/d.webm'), 'file');
++ assert.strictEqual(v.src, '/api/vods/file/d.webm');
++ assert.strictEqual(ctx.vodFallBackToFile(v), false);
++ const { ctx: ctx2 } = sandbox({ hlsSupported: false });
++ const w = new FakeVideo();
++ assert.strictEqual(await ctx2.attachVodSource(w, 'https://openvibe.media/o/obj_4/master.m3u8', '/api/vods/file/e.webm'), 'file');
++ assert.strictEqual(w.src, '/api/vods/file/e.webm');
++ }
++ { // switching VODs drops the previous hls.js instance
++ const { ctx, made } = sandbox();
++ const v = new FakeVideo();
++ await ctx.attachVodSource(v, 'https://openvibe.media/o/a/master.m3u8', '/f/a');
++ await ctx.attachVodSource(v, 'https://openvibe.media/o/b/master.m3u8', '/f/b');
++ assert.strictEqual(made[0].destroyed, true);
++ assert.strictEqual(made[1].url, 'https://openvibe.media/o/b/master.m3u8');
++ }
++ { // leaving the page drops the instance; a load that finishes after leaving attaches nothing
++ const { ctx, made } = sandbox();
++ const v = new FakeVideo();
++ await ctx.attachVodSource(v, 'https://openvibe.media/o/x/master.m3u8', '/f/x', 1);
++ ctx.vodDropHls();
++ assert.strictEqual(made[0].destroyed, true, 'teardown destroys the instance');
++ ctx.route.gen = 2; // the person navigated away while a load was in flight
++ const w = new FakeVideo();
++ assert.strictEqual(await ctx.attachVodSource(w, 'https://openvibe.media/o/y/master.m3u8', '/f/y', 1), 'stale');
++ assert.strictEqual(made.length, 1, 'no new hls.js instance for a page that is gone');
++ assert.strictEqual(w.src, '');
++ }
++ // The media proxies keep Media's playlist URL on VOD and clip rows.
++ for (const f of ['vods.js', 'clips.js']) {
++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'media-proxy', f), 'utf8');
++ assert.match(src, /hls_url = media\.publicUrl\(/, `${f} keeps hls_url`);
++ }
++ console.log('vod hls source: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });