openvibers/openvibe.mediahub Commits

A recording plays the PIPs that were on screen while it was live, each its own VOD synced over the player (Live's vod.pips); viewers move, resize, mute or hide each (#148)

OP OpenVibers committed 17 days ago · 4159eac3b2 · 4 files, +296 −1

Changes

ddiff --git a/public/css/video.css b/public/css/video.css
iindex 05f715a..63039d2 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -493,3 +493,19 @@ a.ovv-channel-name:hover { text-decoration: underline; }
loads after this file and left the video at its 16:9 height. */
.ovv-watch .ovv-player-wrap video.ovv-player { aspect-ratio: auto; height: 100vh; height: 100dvh; object-fit: contain; }
}
++
++/* Picture-in-picture over a recording (video.js pips): inside the shared player, above the picture and under its
++ controls (z-index 3), each where the streamer put it; drag to move, the corner to resize. */
++.ovv-pip { position: absolute; z-index: 2; aspect-ratio: 16 / 9; border-radius: 10px; overflow: hidden; background: #000; box-shadow: 0 8px 24px rgba(0, 0, 0, .55); border: 1px solid rgba(255, 255, 255, .18); cursor: grab; touch-action: none; }
++.ovv-pip[hidden] { display: none; }
++.ovv-pip.is-moving { cursor: grabbing; }
++.ovv-pip-video { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
++.ovv-pip-bar { position: absolute; inset: 0 0 auto 0; display: flex; align-items: center; gap: 4px; padding: 4px 6px; background: linear-gradient(rgba(0, 0, 0, .72), transparent); opacity: 0; transition: opacity .15s; }
++.ovv-pip:hover .ovv-pip-bar, .ovv-pip:focus-within .ovv-pip-bar { opacity: 1; }
++.ovv-pip-name { flex: 1; min-width: 0; overflow: hidden; color: #fff; font-size: .72rem; font-weight: 600; text-decoration: none; text-overflow: ellipsis; white-space: nowrap; }
++.ovv-pip-name:hover, .ovv-pip-name:focus-visible { text-decoration: underline; }
++.ovv-pip-bar button { width: 26px; height: 26px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, .45); color: #fff; cursor: pointer; }
++.ovv-pip-grip { position: absolute; right: 0; bottom: 0; width: 18px; height: 18px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 50%, rgba(255, 255, 255, .55) 50%); }
++.ovv-pip-pills { position: absolute; right: 12px; top: 12px; z-index: 2; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; max-width: calc(100% - 24px); }
++.ovv-pip-pills button { padding: 5px 10px; border: 1px solid rgba(255, 255, 255, .2); border-radius: 999px; background: rgba(0, 0, 0, .6); color: #fff; font-size: .74rem; cursor: pointer; }
++@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 4f6e22a..ac9611c 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -316,6 +316,151 @@
});
}
++ // ── Picture-in-picture over a recording (pages.js pipsOf; OpenVibe.Live streaming/pip-history.js) ──
++ // The streams that were drawn over this one while it was live play over it again, each its own recording, shown
++ // while from <= t < to at offset + (t - from), following the main video's seeks, pauses and speed. Each starts where
++ // the streamer put it; a viewer moves it, resizes it from its corner, turns its sound on or hides it, and this
++ // browser remembers that for this recording. A PIP's file loads only when its moment comes near. They sit inside the
++ // shared player, above the picture and under its controls, and go full screen with it.
++ function pips(video) {
++ const data = video.parentNode && video.parentNode.querySelector('[data-ovv-pips]')
++ || document.querySelector('[data-ovv-pips]');
++ let list = [];
++ try { list = JSON.parse(data ? data.textContent : '[]') || []; } catch { list = []; }
++ if (!list.length) return;
++ const root = video.closest('.ov-player') || video.parentNode;
++ const key = 'ovv-pip-' + window.location.pathname;
++ let saved = {};
++ try { saved = JSON.parse(localStorage.getItem(key) || '{}') || {}; } catch { saved = {}; }
++ const save = function () { try { localStorage.setItem(key, JSON.stringify(saved)); } catch { /* private window */ } };
++ const clamp = function (v, lo, hi) { return Math.min(hi, Math.max(lo, v)); };
++ const items = list.map(function (p, i) {
++ const id = p.href.split('?')[0] + '#' + i;
++ const g = Object.assign({ x: p.x, y: p.y, w: p.w, muted: p.muted, hidden: false }, saved[id] || {});
++ const box = document.createElement('div');
++ box.className = 'ovv-pip';
++ box.hidden = true;
++ const v = document.createElement('video');
++ v.className = 'ovv-pip-video';
++ v.playsInline = true;
++ v.preload = 'none';
++ v.muted = true;
++ const bar = document.createElement('div');
++ bar.className = 'ovv-pip-bar';
++ const name = document.createElement('a');
++ name.className = 'ovv-pip-name';
++ name.href = p.href;
++ name.textContent = p.title;
++ const mute = document.createElement('button');
++ mute.type = 'button';
++ const hide = document.createElement('button');
++ hide.type = 'button';
++ hide.innerHTML = '<i class="fa-solid fa-eye-slash" aria-hidden="true"></i>';
++ hide.title = 'Hide ' + p.title;
++ hide.setAttribute('aria-label', hide.title);
++ const grip = document.createElement('span');
++ grip.className = 'ovv-pip-grip';
++ grip.title = 'Drag to resize';
++ bar.append(name, mute, hide);
++ box.append(v, bar, grip);
++ // The shared player plays and pauses on a click anywhere in it: a click on a PIP is the PIP's.
++ box.addEventListener('click', function (e) { e.stopPropagation(); });
++ root.appendChild(box);
++ const item = { p: p, id: id, g: g, box: box, v: v, loaded: false };
++ const paintMute = function () {
++ mute.innerHTML = '<i class="fa-solid ' + (g.muted ? 'fa-volume-xmark' : 'fa-volume-high') + '" aria-hidden="true"></i>';
++ mute.title = g.muted ? 'Turn on ' + p.title + "'s sound" : 'Mute ' + p.title;
++ mute.setAttribute('aria-label', mute.title);
++ v.muted = g.muted || video.muted;
++ };
++ const place = function () {
++ box.style.left = (g.x * 100) + '%';
++ box.style.top = (g.y * 100) + '%';
++ box.style.width = (g.w * 100) + '%';
++ };
++ const keep = function () { saved[id] = g; save(); };
++ mute.addEventListener('click', function () { g.muted = !g.muted; paintMute(); keep(); if (!g.muted) v.play().catch(function () {}); });
++ hide.addEventListener('click', function () { g.hidden = true; keep(); sync(); restoreBar(); });
++ // Move by its body, resize by its corner; both stay inside the player.
++ let drag = null;
++ box.addEventListener('pointerdown', function (e) {
++ if (e.target.closest('button, a')) return;
++ const r = root.getBoundingClientRect();
++ drag = { px: e.clientX, py: e.clientY, x: g.x, y: g.y, w: g.w, rw: r.width, rh: r.height, resize: e.target === grip };
++ box.setPointerCapture(e.pointerId);
++ box.classList.add('is-moving');
++ e.preventDefault();
++ });
++ box.addEventListener('pointermove', function (e) {
++ if (!drag) return;
++ const b = box.getBoundingClientRect();
++ if (drag.resize) g.w = clamp(drag.w + (e.clientX - drag.px) / drag.rw, 0.08, 0.6);
++ else {
++ g.x = clamp(drag.x + (e.clientX - drag.px) / drag.rw, 0, Math.max(0, 1 - b.width / drag.rw));
++ g.y = clamp(drag.y + (e.clientY - drag.py) / drag.rh, 0, Math.max(0, 1 - b.height / drag.rh));
++ }
++ place();
++ });
++ const end = function () { if (!drag) return; drag = null; box.classList.remove('is-moving'); keep(); };
++ box.addEventListener('pointerup', end);
++ box.addEventListener('pointercancel', end);
++ paintMute();
++ place();
++ return item;
++ });
++ // The "Show …" buttons of hidden PIPs, under the player's top-left corner.
++ let pills = null;
++ function restoreBar() {
++ if (pills) pills.remove();
++ const hidden = items.filter(function (it) { return it.g.hidden; });
++ if (!hidden.length) { pills = null; return; }
++ pills = document.createElement('div');
++ pills.className = 'ovv-pip-pills';
++ hidden.forEach(function (it) {
++ const b = document.createElement('button');
++ b.type = 'button';
++ b.textContent = 'Show ' + it.p.title;
++ b.addEventListener('click', function () { it.g.hidden = false; saved[it.id] = it.g; save(); restoreBar(); sync(); });
++ pills.appendChild(b);
++ });
++ pills.addEventListener('click', function (e) { e.stopPropagation(); });
++ root.appendChild(pills);
++ }
++ function load(it) {
++ if (it.loaded) return;
++ it.loaded = true;
++ it.v.preload = 'auto';
++ it.v.setAttribute('src', it.p.file);
++ if (it.p.hls) {
++ it.v.setAttribute('data-ovv-hls', it.p.hls);
++ it.v.setAttribute('data-ovv-hls-lib', video.getAttribute('data-ovv-hls-lib') || '');
++ attachSource(it.v);
++ }
++ }
++ function sync(forced) {
++ const t = video.currentTime || 0;
++ items.forEach(function (it) {
++ const p = it.p;
++ if (t >= p.from - 15 && t < p.to) load(it);
++ const on = t >= p.from && t < p.to && !it.g.hidden;
++ it.box.hidden = !on;
++ if (!on) { if (!it.v.paused) it.v.pause(); return; }
++ const want = p.offset + (t - p.from);
++ if (forced || Math.abs((it.v.currentTime || 0) - want) > 0.75) {
++ if (it.v.readyState >= 1) it.v.currentTime = want;
++ else it.v.addEventListener('loadedmetadata', function () { it.v.currentTime = p.offset + ((video.currentTime || 0) - p.from); }, { once: true });
++ }
++ it.v.playbackRate = video.playbackRate || 1;
++ it.v.muted = it.g.muted || video.muted;
++ if (video.paused) { if (!it.v.paused) it.v.pause(); } else if (it.v.paused) it.v.play().catch(function () {});
++ });
++ }
++ ['timeupdate', 'play', 'pause', 'ratechange', 'volumechange'].forEach(function (ev) { video.addEventListener(ev, function () { sync(false); }); });
++ video.addEventListener('seeked', function () { sync(true); });
++ restoreBar();
++ sync(true);
++ }
++
document.addEventListener('DOMContentLoaded', function () {
// A clip still being cut: look again in a few seconds (the server answers once it is ready).
const cutting = document.querySelector('[data-ovv-reload]');
@@@ -375,6 +520,7 @@
attachSource(video).then(function (source) {
video.setAttribute('data-ovv-source', source);
if (t != null) seek(video, t);
++ pips(video);
});
// A view counts on OpenVibe.Live once playback starts, from this browser (so the viewer's own address is the
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex f90ea61..2b4cf5a 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -157,6 +157,45 @@ function hlsAttrs(item) {
return html` data-ovv-hls="${hls}" data-ovv-hls-lib="${asset('vendor/hls.light.min.js')}"`;
}
++/**
++ * The picture-in-pictures that were on screen during a recording (Live's `vod.pips`, OpenVibe.Live
++ * streaming/pip-history.js), with what public/js/video.js needs to play each over the player: its own VOD's file and
++ * playlist, its window in this recording (from, to, in seconds), that VOD's second at `from`, where it starts (fractions
++ * of the player), whether it starts with sound, and a link to its own page. Each PIP's VOD comes from Live's cached
++ * answer (`live`, the page's Live client); one Live does not answer is left out.
++ */
++async function pipsOf(vod, live) {
++ const list = (Array.isArray(vod.pips) ? vod.pips : []).filter((p) => p && live.isId(String(p.vod_id))
++ && Number.isFinite(Number(p.from)) && Number(p.to) > Number(p.from)).slice(0, 60);
++ if (!list.length) return [];
++ const got = new Map();
++ await Promise.all([...new Set(list.map((p) => String(p.vod_id)))].slice(0, 6).map(async (pid) => {
++ try { const d = await live.vod(pid); if (d && d.vod) got.set(pid, d.vod); } catch { /* Live down: that PIP is left out */ }
++ }));
++ const unit = (v, lo, hi, dflt) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; };
++ const out = [];
++ for (const p of list) {
++ const pv = got.get(String(p.vod_id));
++ const file = pv && fileOf(pv);
++ if (!file) continue;
++ const hls = httpsUrl(pv.hls_url);
++ out.push({
++ file, hls: hls && new URL(hls).origin === new URL(file).origin ? hls : null,
++ from: Number(p.from), to: Number(p.to), offset: Math.max(0, Number(p.offset) || 0),
++ x: unit(p.x, 0, 1, 0.72), y: unit(p.y, 0, 1, 0.7), w: unit(p.w, 0.08, 0.6, 0.25), muted: p.muted !== false,
++ title: String(p.title || pv.title || 'Picture-in-picture').slice(0, 120),
++ href: `/v/${String(p.vod_id)}${Number(p.offset) >= 1 ? `?t=${Math.floor(Number(p.offset))}` : ''}`,
++ });
++ }
++ return out;
++}
++
++/** The PIPs as data for the player (a JSON block: never run, so the page's CSP is untouched). */
++function pipsTag(pips) {
++ if (!pips.length) return '';
++ return raw(`<script type="application/json" data-ovv-pips>${JSON.stringify(pips).replace(/</g, '\\u003c')}</script>`);
++}
++
function dvrPlaylist(item, file) {
const open = (item.visibility || (item.is_public ? 'public' : 'private')) !== 'private';
if (!file || !open || !/\.mp4$/i.test(String(item.file_path || '')) || !Number.isSafeInteger(Number(item.id))) return null;
@@@ -1124,6 +1163,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const hlsAttr = hlsAttrs(vod);
++ const pips = await pipsOf(vod, live);
const thin = thinRecording(vod, segs.length);
const about = vod.description || vod.ai_overview || factualDescription('vod', vod, segs.slice(0, 6).map((s) => s.text).join(' '));
@@@ -1154,7 +1194,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
<div class="ovv-col ovv-col--main">
<div class="ovv-stage">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
--<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
++<div class="ovv-player-wrap"><video class="ovv-player" data-ov-player controls preload="metadata" playsinline${posterAttr(httpsUrl(vod.thumbnail_url))} src="${fileOf(vod) || ''}" data-ovv-video${nextAttr}${viewAttr}${hlsAttr}>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video>${pipsTag(pips)}</div>
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-head-row">
ddiff --git a/test/video-pips.test.js b/test/video-pips.test.js
nnew file mode 100644
iindex 0000000..39bbe16
---- /dev/null
++++ b/test/video-pips.test.js
@@@ -0,0 +1,93 @@
++'use strict';
++/**
++ * Picture-in-picture over a recording (owner, 2026-10-26: "the VODs system should support and handle the PIPs a streamer
++ * had configured during that vod"). Live answers a VOD's `pips` (OpenVibe.Live streaming/pip-history.js): each its own
++ * VOD, its window in this one (from, to), that VOD's second at `from`, where it started and whether it had sound. The
++ * watch page hands public/js/video.js each PIP's file and playlist (from Live's answer for that VOD), and video.js plays
++ * it over the player, synced to it.
++ */
++const assert = require('assert');
++const http = require('http');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++function startLive() {
++ const vods = {
++ 800: { id: 800, stream_id: 900, title: 'Screen share', duration_seconds: 600, created_at: '2026-10-27T10:00:00.000Z',
++ playback_url: 'https://openvibe.media/v/800', file_url: 'https://openvibe.media/v/800', visibility: 'public', username: 'host', display_name: 'Host',
++ pips: [
++ { vod_id: 801, title: 'Webcam', username: 'host', from: 20, to: 120, offset: 0, x: 0.73, y: 0.7, w: 0.25, muted: true },
++ { vod_id: 802, title: 'Co-host </script><b>', username: 'cohost', from: 60, to: 300, offset: 42.5, x: 0.02, y: 0.02, w: 0.18, muted: false },
++ { vod_id: 803, title: 'Gone', from: 10, to: 20, offset: 0 },
++ { vod_id: 'x', from: 1, to: 2 },
++ { vod_id: 801, title: 'Backwards', from: 50, to: 40, offset: 0 },
++ ] },
++ 801: { id: 801, title: 'Webcam', duration_seconds: 200, playback_url: 'https://openvibe.media/v/801', file_url: 'https://openvibe.media/v/801',
++ hls_url: 'https://openvibe.media/o/med_801/master.m3u8', visibility: 'public', username: 'host' },
++ 802: { id: 802, title: 'Co-host', duration_seconds: 900, playback_url: 'https://openvibe.media/v/802', file_url: 'https://openvibe.media/v/802',
++ hls_url: 'https://evil.example/o/med_802/master.m3u8', visibility: 'public', username: 'cohost' },
++ 810: { id: 810, title: 'No PIPs', duration_seconds: 60, playback_url: 'https://openvibe.media/v/810', file_url: 'https://openvibe.media/v/810', visibility: 'public', username: 'host' },
++ };
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://127.0.0.1');
++ const json = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(body)); };
++ const v = /^\/api\/vods\/(\d+)$/.exec(url.pathname);
++ if (v) return vods[v[1]] ? json(200, { vod: vods[v[1]], clips: [] }) : json(404, { error: 'not found' });
++ return json(404, { error: 'not found' });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
++}
++
++const get = (base, p) => new Promise((resolve, reject) => {
++ const u = new URL(base + p);
++ http.get({ hostname: u.hostname, port: u.port, path: u.pathname, headers: { Host: 'openvibe.video' } }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
++ }).on('error', reject);
++});
++
++(async () => {
++ const live = await startLive();
++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: live.url, MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live' } });
++
++ await check("the watch page hands the player each PIP: its VOD's file and playlist, its window, its offset, its place", async () => {
++ const r = await get(t.base, '/v/800');
++ assert.strictEqual(r.status, 200);
++ const m = /<script type="application\/json" data-ovv-pips>([^<]*)<\/script>/.exec(r.text);
++ assert.ok(m, 'the PIP data rides in the player');
++ assert.ok(r.text.indexOf('data-ovv-pips') > r.text.indexOf('data-ovv-video') && r.text.indexOf('data-ovv-pips') < r.text.indexOf('<h1'), 'inside the player wrap');
++ const pips = JSON.parse(m[1]);
++ assert.deepStrictEqual(pips.map((p) => [p.file, p.hls, p.from, p.to, p.offset, p.x, p.w, p.muted, p.href]), [
++ ['https://openvibe.media/v/801', 'https://openvibe.media/o/med_801/master.m3u8', 20, 120, 0, 0.73, 0.25, true, '/v/801'],
++ ['https://openvibe.media/v/802', null, 60, 300, 42.5, 0.02, 0.18, false, '/v/802?t=42'],
++ ], "a PIP whose VOD Live does not answer, a bad id and a backwards window are left out; a playlist on another origin than the file is not used");
++ assert.strictEqual(pips[1].title, 'Co-host </script><b>', 'the title arrives as it is');
++ assert.ok(!m[1].includes('</script>') && m[1].includes('\\u003c/script>'), 'and never closes the data block early');
++ });
++
++ await check('a recording without PIPs carries no PIP data', async () => {
++ const r = await get(t.base, '/v/810');
++ assert.strictEqual(r.status, 200);
++ assert.ok(!r.text.includes('data-ovv-pips'));
++ });
++
++ await check('video.js plays each PIP over the player, synced to it, and lets a viewer move, resize, mute and hide it', async () => {
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'video.js'), 'utf8');
++ assert.ok(/if \(t != null\) seek\(video, t\);\n\s+pips\(video\);/.test(js), 'started once the main video has its source');
++ assert.ok(js.includes("const root = video.closest('.ov-player') || video.parentNode;"), 'inside the shared player (full screen with it)');
++ assert.ok(js.includes('const on = t >= p.from && t < p.to && !it.g.hidden;') && js.includes('const want = p.offset + (t - p.from);'), 'shown in its window at offset + (t - from)');
++ assert.ok(js.includes("video.addEventListener('seeked', function () { sync(true); });"), 'a seek moves it');
++ assert.ok(js.includes('if (t >= p.from - 15 && t < p.to) load(it);'), 'its file loads only when its moment comes near');
++ assert.ok(js.includes('attachSource(it.v);'), 'its playlist plays the way the main one does');
++ assert.ok(js.includes("box.addEventListener('click', function (e) { e.stopPropagation(); });"), 'a click on it never plays or pauses the video');
++ assert.ok(js.includes("const key = 'ovv-pip-' + window.location.pathname;"), 'where a viewer put it is remembered for this recording');
++ const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.css'), 'utf8');
++ assert.ok(/\.ovv-pip \{ position: absolute; z-index: 2;/.test(css), 'above the picture, under the controls (3)');
++ });
++
++ await t.close();
++ await live.close();
++ done();
++})();