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)
Changes
@@@ -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; } }@@@ -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@@@ -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="ovvconst 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">nnew file mode 100644@@@ -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();++})();
@@ -493,3 +493,19 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -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. */
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; }
.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; } }
@@ -316,6 +316,151 @@
@@ -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 () {
document.addEventListener('DOMContentLoaded', function () {
// A clip still being cut: look again in a few seconds (the server answers once it is ready).
// 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]');
const cutting = document.querySelector('[data-ovv-reload]');
@@ -375,6 +520,7 @@
@@ -375,6 +520,7 @@
attachSource(video).then(function (source) {
attachSource(video).then(function (source) {
video.setAttribute('data-ovv-source', source);
video.setAttribute('data-ovv-source', source);
if (t != null) seek(video, t);
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
// A view counts on OpenVibe.Live once playback starts, from this browser (so the viewer's own address is the
@@ -157,6 +157,45 @@ function hlsAttrs(item) {
@@ -157,6 +157,45 @@ function hlsAttrs(item) {
return html` data-ovv-hls="${hls}" data-ovv-hls-lib="${asset('vendor/hls.light.min.js')}"`;
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) {
function dvrPlaylist(item, file) {
const open = (item.visibility || (item.is_public ? 'public' : 'private')) !== 'private';
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;
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
@@ -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 nextAttr = up.nextUrl ? html` data-ovv-next="${up.nextUrl}"` : '';
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const viewAttr = html` data-ovv-view="${livePublic}/api/views/vod/${id}"`;
const hlsAttr = hlsAttrs(vod);
const hlsAttr = hlsAttrs(vod);
+ const pips = await pipsOf(vod, live);
const thin = thinRecording(vod, segs.length);
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(' '));
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
@@ -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-col ovv-col--main">
<div class="ovv-stage">
<div class="ovv-stage">
${savedNotice(req)}${ownOnly ? notice('Private: only you (and OpenVibe staff) can see this recording.', 'info') : ''}
${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">
<div class="ovv-head">
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
<div class="ovv-head-row">
<div class="ovv-head-row">
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -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();
+})();