openvibers/openvibe.live Commits
Play VODs and clips from Media's timeline playlist when they have one (#26)
Changes
@@@ -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 pollif (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 videoconst 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 replayif (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 firstconst 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');@@@ -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; }@@@ -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).@@@ -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).nnew file mode 100644@@@ -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); });
@@ -185,7 +185,72 @@ function _attachVodProgressTracking(video, vodId) {
@@ -185,7 +185,72 @@ function _attachVodProgressTracking(video, vodId) {
}, { once: true });
}, { once: true });
}
}
/* ── VOD Player ───────────────────────────────────────────────── */
/* ── 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) {
async function loadVodPlayer(vodId, seekTo) {
+ const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
try {
try {
// Clean up any previous live VOD poll
// Clean up any previous live VOD poll
if (window._liveVodPollTimer) {
if (window._liveVodPollTimer) {
@@ -285,7 +350,12 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -285,7 +350,12 @@ async function loadVodPlayer(vodId, seekTo) {
// to this when the browser mis-reports the WebM container duration.
// to this when the browser mis-reports the WebM container duration.
const serverDur = Number(v.duration_seconds || v.duration || 0);
const serverDur = Number(v.duration_seconds || v.duration || 0);
video.dataset.serverDuration = (serverDur > 0 && !v.is_recording) ? String(serverDur) : '';
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';
video.style.display = 'block';
if (v.is_recording) {
if (v.is_recording) {
@@ -490,7 +560,8 @@ function openClipCreator() {
@@ -490,7 +560,8 @@ function openClipCreator() {
// Setup preview video
// Setup preview video
const preview = document.getElementById('clip-preview-video');
const preview = document.getElementById('clip-preview-video');
if (preview) {
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.src = `/api/vods/file/${filename}`;
preview.currentTime = _vpClipStart;
preview.currentTime = _vpClipStart;
preview.muted = true;
preview.muted = true;
@@ -927,6 +998,7 @@ function _renderClipAttribution(el, cl) {
@@ -927,6 +998,7 @@ function _renderClipAttribution(el, cl) {
}
}
async function loadClipPlayer(clipId) {
async function loadClipPlayer(clipId) {
+ const routeGen = typeof ov !== 'undefined' && ov && typeof ov.gen === 'function' ? ov.gen() : null;
try {
try {
// Clean up chat replay
// Clean up chat replay
if (window._chatReplayTimer) {
if (window._chatReplayTimer) {
@@ -1055,6 +1127,7 @@ async function loadClipPlayer(clipId) {
@@ -1055,6 +1127,7 @@ async function loadClipPlayer(clipId) {
const filename = cl.file_path.split('/').pop();
const filename = cl.file_path.split('/').pop();
// Handle video load errors (corrupt files, codec issues)
// Handle video load errors (corrupt files, codec issues)
video.onerror = () => {
video.onerror = () => {
+ if (vodFallBackToFile(video)) return; // the playlist failed: the file gets its chance first
const container = document.getElementById('clp-container');
const container = document.getElementById('clp-container');
if (container) {
if (container) {
container.innerHTML = `
container.innerHTML = `
@@ -1065,7 +1138,7 @@ async function loadClipPlayer(clipId) {
@@ -1065,7 +1138,7 @@ async function loadClipPlayer(clipId) {
</div>`;
</div>`;
}
}
};
};
- video.src = `/api/vods/file/${filename}`;
+ attachVodSource(video, cl.hls_url, `/api/vods/file/${filename}`, routeGen);
video.style.display = 'block';
video.style.display = 'block';
setupCustomVideoControls('clp');
setupCustomVideoControls('clp');
@@ -913,6 +913,8 @@ function teardownRoute(nextPath) {
@@ -913,6 +913,8 @@ function teardownRoute(nextPath) {
// Hardware controls: the socket and its reconnect loop outlived the channel, and the global
// 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.
// key handler kept sending bound keys to the last channel's device from any page.
stop('destroyControlWs');
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 { /* */ }
try { currentStreamId = null; } catch { /* */ }
// The VOD/clip chat replay re-arms itself every animation frame; nothing stopped it.
// The VOD/clip chat replay re-arms itself every animation frame; nothing stopped it.
if (window._chatReplayTimer) { cancelAnimationFrame(window._chatReplayTimer); window._chatReplayTimer = null; }
if (window._chatReplayTimer) { cancelAnimationFrame(window._chatReplayTimer); window._chatReplayTimer = null; }
@@ -69,6 +69,7 @@ function withUserFields(clip) {
@@ -69,6 +69,7 @@ function withUserFields(clip) {
}
}
// Absolutize Media-relative URLs so the SPA doesn't resolve them against Live.
// 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);
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);
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
// AI overview is Live-owned (clip_ai_state) — overlay short + full for the cards
// (the expander swaps the short teaser for the full text).
// (the expander swaps the short teaser for the full text).
@@ -133,6 +133,8 @@ function withUserFields(row) {
@@ -133,6 +133,8 @@ function withUserFields(row) {
// Media returns its own paths relative — absolutize onto MEDIA_PUBLIC_URL
// Media returns its own paths relative — absolutize onto MEDIA_PUBLIC_URL
// so the SPA doesn't resolve them against Live's origin.
// so the SPA doesn't resolve them against Live's origin.
row.playback_url = media.publicUrl(row.playback_url) || media.vodPlaybackUrl(row.id);
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);
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
// AI overview is Live-owned (vod_ai_state) — overlay short + full for the cards
// (the expander swaps the short teaser for the full text).
// (the expander swaps the short teaser for the full text).
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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); });