openvibers/openvibe.mediahub Commits
VOD PiPs play in sync with the main recording, on OVFloat, with tap-to-swap (#442)
Changes
@@@ -4,6 +4,17 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa## Unreleased++- A recording's picture-in-picture plays in sync with it (2026-11-13, Maticus: "the robot PiP freezes on VODs"). The++ streams a Live recording drew over itself now each plays its own VOD over the watch page: while the main video is++ inside a span the PiP is at `offset + (main - from)`, outside it the PiP hides. It follows the main's play, pause,++ seeks and speed (a seek on one seeks the other, a wait on either pauses both), drift over 0.3 s is nudged with a++ playbackRate factor between 0.95 and 1.05, and over 2 s seeks. The box is openvibe-shared's OVFloat (drag, tuck,++ pinch/grip size, its place kept per device); a tap (or Enter) swaps it with the main view, sources and times both++ ways. Several spans work, and a span whose PiP VOD is gone hides with one quiet line. The server keeps those++ missing spans in `vod.pips` (`missing: true`) and gives each its `vod_id`. Tests: `test/vod-pip-core.test.js` (the++ time mapping and the drift decision), `test/vod-pip-chrome.test.js` (two local videos: seeks on the main move the++ PiP, pause/rate/drift follow, a tap swaps) and `test/video-pips.test.js` (the page hands the player each span).++- openvibe.pics' home icons and "Choose a picture" (2026-11-13, the owner's phone). The "What you get", "The links apicture gets" and "What we promise, in writing" cards gave the shared showcase bare glyph names, which it drew asFont Awesome placeholders with no font and no mapping: every icon was an empty 40 px square. They are now the glyph@@@ -581,22 +581,22 @@ a.ovv-channel-name:hover { text-decoration: underline; }.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; }++/* Picture-in-picture over a recording (public/js/vod-pip.js): the box is openvibe-shared/ov-float.js, which++ places, moves, tucks and sizes it inside the shared player; the bar and the picture are this page's. It sits above++ the picture and under the player's controls (z-index 3), and goes full screen with it. */++.ovv-pip { z-index: 2; 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); }.ovv-pip[hidden] { display: none; }--.ovv-pip.is-moving { cursor: grabbing; }--/* The box takes the recording's own shape (video.js sets aspect-ratio), so contain shows it whole (2026-11-07). */++.ovv-pip:focus-visible { outline: 2px solid var(--ovp-accent, #3b82f6); outline-offset: 2px; }++/* The box takes the recording's own shape (video.js readShape), so contain shows it whole (2026-11-07). */.ovv-pip-video { display: block; width: 100%; height: 100%; object-fit: contain; 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; } }++/* One quiet line, once: a span whose PiP VOD is gone (pages.js pipsOf sets missing). */++.ovv-pip-note { margin: .5rem 0 0; font-size: .8rem; color: var(--ovv-faint); }/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──Edit is in More; the panel opens right under the player and stays out of the way until then (a@@@ -106,6 +106,8 @@}function attachSource(video) {++ // A video can be given a new source (a PIP swapped with the main view): the old hls.js instance must stop.++ if (video._ovvHls) { try { video._ovvHls.destroy(); } catch (_) { /* already gone */ } video._ovvHls = null; }const hlsUrl = video.getAttribute('data-ovv-hls');const fileUrl = video.getAttribute('src');if (!hlsUrl || !fileUrl) return Promise.resolve('file');@@@ -131,12 +133,14 @@return loadScript(video.getAttribute('data-ovv-hls-lib')).then(function () {if (typeof window.Hls === 'undefined' || !window.Hls.isSupported()) return 'file';const hls = new window.Hls({ enableWorker: true, backBufferLength: 60 });++ video._ovvHls = hls;let failed = false;hls.on(window.Hls.Events.ERROR, function (_event, data) {if (!data || !data.fatal || failed) return;failed = true;const at = video.currentTime || 0;hls.destroy();++ video._ovvHls = null;video.src = fileUrl;if (at > 0) seek(video, at);});@@@ -406,188 +410,6 @@});}-- // ── 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)); };-- // A PIP takes its recording's own shape (2026-11-07: a portrait camera was cut to 16:9). `w` is the width of a-- // 16:9 PIP; any other shape keeps that area, never more than 85% of the player's height, and on a phone-sized-- // player never less than a 150 px wide 16:9 box. Dropped near an edge it snaps to a 12 px margin.-- const BASE = 16 / 9;-- const boxOf = function (g, aspect) {-- const a = aspect || BASE;-- const r = root.getBoundingClientRect();-- const w0 = r.width && r.width < 640 ? Math.max(g.w, Math.min(0.6, 150 / r.width)) : g.w;-- let wf = w0 * Math.sqrt(a / BASE);-- if (r.width && r.height && (wf * r.width) / a > 0.85 * r.height) wf = (0.85 * r.height * a) / r.width;-- wf = Math.min(wf, 1);-- return { a: a, wf: wf, hf: r.width && r.height ? (wf * r.width) / a / r.height : wf / a, rw: r.width, rh: r.height };-- };-- 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="ovg-fill" data-ov-icon="eye-off" 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, aspect: null };-- const paintMute = function () {-- mute.innerHTML = '<i class="ovg-fill" data-ov-icon="' + (g.muted ? 'mute' : 'volume') + '" 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 () {-- const b = boxOf(g, item.aspect);-- box.style.left = (clamp(g.x, 0, Math.max(0, 1 - b.wf)) * 100) + '%';-- box.style.top = (clamp(g.y, 0, Math.max(0, 1 - b.hf)) * 100) + '%';-- box.style.width = (b.wf * 100) + '%';-- box.style.aspectRatio = String(b.a);-- box.classList.toggle('is-portrait', b.a < 1);-- };-- const settle = function () {-- const b = boxOf(g, item.aspect);-- const maxX = Math.max(0, 1 - b.wf), maxY = Math.max(0, 1 - b.hf);-- const mx = b.rw ? 12 / b.rw : 0, my = b.rh ? 12 / b.rh : 0;-- g.x = clamp(g.x, 0, maxX); g.y = clamp(g.y, 0, maxY);-- if (g.x < 0.04) g.x = Math.min(mx, maxX); else if (g.x > maxX - 0.04) g.x = Math.max(0, maxX - mx);-- if (g.y < 0.04) g.y = Math.min(my, maxY); else if (g.y > maxY - 0.04) g.y = Math.max(0, maxY - my);-- };-- const readShape = function () {-- if (!v.videoWidth || !v.videoHeight) return;-- const a = v.videoWidth / v.videoHeight;-- if (item.aspect && Math.abs(item.aspect - a) < 0.01) return;-- item.aspect = a;-- place();-- };-- v.addEventListener('loadedmetadata', readShape);-- v.addEventListener('resize', readShape);-- item.place = place;-- 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 k = Math.sqrt((item.aspect || BASE) / BASE) || 1;-- if (drag.resize) g.w = clamp(drag.w + (e.clientX - drag.px) / drag.rw / k, 0.08, 0.6);-- else {-- const b = boxOf(g, item.aspect);-- g.x = clamp(drag.x + (e.clientX - drag.px) / drag.rw, 0, Math.max(0, 1 - b.wf));-- g.y = clamp(drag.y + (e.clientY - drag.py) / drag.rh, 0, Math.max(0, 1 - b.hf));-- }-- place();-- });-- const end = function () { if (!drag) return; drag = null; box.classList.remove('is-moving'); settle(); place(); keep(); };-- box.addEventListener('pointerup', end);-- box.addEventListener('pointercancel', end);-- paintMute();-- place();-- return item;-- });-- // The player changes size (a window, full screen, a phone turned): each PIP's height cap follows.-- if (typeof ResizeObserver === 'function') new ResizeObserver(function () { items.forEach(function (it) { if (it.place) it.place(); }); }).observe(root);-- // 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]');@@@ -661,7 +483,8 @@attachSource(video).then(function (source) {video.setAttribute('data-ovv-source', source);if (t != null) seek(video, t);-- pips(video);++ // The PIPs the recording's stream had on screen play over it, in sync (public/js/vod-pip.js).++ if (window.VodPip) window.VodPip.attach(video, { attachSource: attachSource });});// A view counts on OpenVibe.Live once playback starts, from this browser (so the viewer's own address is thennew file mode 100644@@@ -0,0 +1,574 @@++/* vod-pip.js — the picture-in-pictures that were on screen during a recording, played over it again.++ *++ * OpenVibe.Live records each PIP span (streaming/pip-history.js) and its VOD API answers them as++ * [{ vod_id, file, hls, from, to, offset, x, y, w, muted, missing }] (OpenVibe.MediaHub server/video/pages.js++ * pipsOf): the PIP VOD's second at `from` is `offset`, so while from <= t < to it plays at offset + (t - from).++ *++ * What this file does (owner, 2026-11-13: "the robot PiP freezes on VODs — play it in sync with the main VOD"):++ * - the PIP is a second <video> that follows the main one: play, pause, seek, speed, and a wait on either side++ * pauses the other; drift over 0.3 s is nudged with playbackRate (0.95–1.05 × the main's rate) and over 2 s++ * is seeked;++ * - outside its span it hides (no last-frame freeze); several spans may cover the recording, and overlapping++ * ones are all drawn;++ * - a span whose PIP VOD Live no longer has says so once, quietly, and hides;++ * - the box is openvibe-shared/ov-float.js (OVFloat.attach): drag, tuck, size, its place kept per device; a tap++ * (or Enter) swaps it with the main view, and the box shows the main recording until the next tap.++ *++ * The two pure functions the player's correctness rests on are exported (window.VodPip / module.exports):++ * activeSpan/ pipTimeAt/ hostTimeAt map either direction, and driftCorrection picks none/nudge/seek.++ */++(function (root, factory) {++ if (typeof module === 'object' && module.exports) { module.exports = factory(); return; }++ root.VodPip = factory();++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++++ const NUDGE_AT = 0.3; // drift (s) that starts the playbackRate nudge++ const SEEK_AT = 2; // drift (s) that a seek fixes outright++ const NUDGE_GAIN = 0.1; // 0.1 s of drift → 1% slower/faster++ const NUDGE_MAX = 0.05; // never outside 0.95–1.05 × the main's rate++ const TICK_MS = 250; // how often the small player is checked against the big one++ const LOAD_AHEAD = 10; // seconds before its span that a PIP's file is loaded++ const TAP_PX = 4; // a press that moves less is a tap (OVFloat's own threshold)++ const HOLD_MS = 450; // a PIP that waits this long pauses the main one too++++ const clamp = (v, lo, hi, dflt) => {++ const n = Number(v);++ if (!Number.isFinite(n)) return dflt;++ return Math.min(hi, Math.max(lo, n));++ };++++ /* ── pure: the two timelines ──────────────────────────────────────────────────────────────────────────── */++++ /** The span on screen at `t` (seconds of the main recording), or null: [from, to), latest first on overlap. */++ function activeSpan(spans, t) {++ if (!Array.isArray(spans) || !Number.isFinite(t)) return null;++ let pick = null;++ for (const span of spans) {++ if (!span || !Number.isFinite(Number(span.from)) || !Number.isFinite(Number(span.to))) continue;++ if (t >= Number(span.from) && t < Number(span.to)) pick = span;++ }++ return pick;++ }++++ /** The PIP's own second at this second of the main recording: offset + (t - from); null outside the span. */++ function pipTimeAt(span, t) {++ if (!span || !Number.isFinite(t)) return null;++ const from = Number(span.from), to = Number(span.to), offset = Number(span.offset);++ if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isFinite(offset)) return null;++ if (t < from || t >= to) return null;++ return offset + (t - from);++ }++++ /** The main recording's second for this second of the PIP's VOD (the inverse mapping); null outside it. */++ function hostTimeAt(span, pipTime) {++ if (!span || !Number.isFinite(pipTime)) return null;++ const from = Number(span.from), to = Number(span.to), offset = Number(span.offset);++ if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isFinite(offset)) return null;++ const end = offset + (to - from);++ if (pipTime < offset || pipTime >= end) return null;++ return from + (pipTime - offset);++ }++++ /**++ * What to do about `actual` (the PIP's currentTime) when it should be at `expected`.++ * { action: 'none' | 'nudge' | 'seek', rate, time }++ * A seek beyond 2 s, a nudge between 0.3 s and 2 s (0.95–1.05 × baseRate, proportional to the drift), and++ * `rate: baseRate` in the dead zone. The factor is within 0.95–1.05 whatever the main's speed; `rate` is++ * the factor times baseRate so a 2× main stays 2× while it catches up.++ */++ function driftCorrection(actual, expected, baseRate) {++ const base = Number.isFinite(Number(baseRate)) && Number(baseRate) > 0 ? Number(baseRate) : 1;++ if (!Number.isFinite(Number(expected))) return { action: 'none', rate: base, time: null, drift: 0, factor: 1 };++ if (!Number.isFinite(Number(actual))) return { action: 'seek', rate: base, time: Number(expected), drift: 0, factor: 1 };++ const d = Number(actual) - Number(expected);++ const abs = Math.abs(d);++ if (abs > SEEK_AT + 1e-9) return { action: 'seek', rate: base, time: Number(expected), drift: d, factor: 1 };++ if (abs > NUDGE_AT + 1e-9) {++ const factor = Math.min(1 + NUDGE_MAX, Math.max(1 - NUDGE_MAX, 1 - d * NUDGE_GAIN));++ return { action: 'nudge', rate: base * factor, time: null, drift: d, factor };++ }++ return { action: 'none', rate: base, time: null, drift: d, factor: 1 };++ }++++ /* ── the player ───────────────────────────────────────────────────────────────────────────────────────── */++++ /** /watch?v=<id>&t=… keeps one place per recording: the moment is not part of the key. */++ function pageKey() {++ if (typeof location === 'undefined') return '';++ try {++ const u = new URL(location.href);++ u.searchParams.delete('t');++ return u.pathname + (u.search || '');++ } catch { return location.pathname || ''; }++ }++++ function glyph(name) {++ return '<i class="ovg-fill" data-ov-icon="' + name + '" aria-hidden="true"></i>';++ }++++ /**++ * Put the PIPs over `main` (the page's video[data-ovv-video]).++ * options.data the PIP list (default: the [data-ovv-pips] JSON beside the player)++ * options.attachSource the page's HLS/file loader, if a PIP VOD has a playlist (video.js's attachSource)++ * options.root the frame the float docks in (default: the shared player's .ov-player)++ * Returns { items, spans, swap, destroy, sync } for tests and the page.++ */++ function attach(main, options) {++ const o = options || {};++ if (!main || typeof main.addEventListener !== 'function') return null;++ let spans = o.data;++ if (!Array.isArray(spans)) {++ const holder = main.parentNode && main.parentNode.querySelector('[data-ovv-pips]');++ try { spans = JSON.parse(holder ? holder.textContent : (document.querySelector('[data-ovv-pips]') || {}).textContent || '[]'); } catch { spans = []; }++ }++ if (!Array.isArray(spans) || !spans.length) return null;++++ const root = o.root || main.closest('.ov-player') || main.parentNode;++ if (!root) return null;++ const loader = typeof o.attachSource === 'function' ? o.attachSource : null;++ const state = { items: [], missing: [], swapped: null, host: null, intent: main.paused ? 'pause' : 'play',++ bufferHold: false, swapping: false, missingShown: false, destroyed: false, timer: 0 };++ const valid = spans.filter((p) => p && !p.missing && p.file && Number.isFinite(Number(p.from)) && Number(p.to) > Number(p.from)++ && Number.isFinite(Number(p.offset)));++ const missing = spans.filter((p) => p && p.missing && Number.isFinite(Number(p.from)) && Number(p.to) > Number(p.from));++ if (!valid.length && !missing.length) return null;++++ const tick = () => { if (!state.destroyed) sync(false); };++++ function makeIt(p) {++ const it = { p: p, box: null, video: null, name: null, mute: null, swapBtn: null, float: null,++ aspect: 16 / 9, loaded: false, waiting: false, waitTimer: 0, primed: false, needsAlign: false, started: false,++ g: { muted: p.muted !== false } };++ return it;++ }++++ function paintMute(it) {++ if (!it.mute || !it.video) return;++ const muted = !!(it.g && it.g.muted) || main.muted;++ it.video.muted = muted;++ it.mute.innerHTML = glyph(muted ? 'mute' : 'volume');++ const what = state.swapped === it ? 'the main recording' : it.p.title;++ it.mute.title = (muted ? 'Turn on ' : 'Mute ') + what;++ it.mute.setAttribute('aria-label', it.mute.title);++ it.mute.hidden = state.swapped === it;++ }++++ function paintLabel(it) {++ if (!it.name) return;++ const swapped = state.swapped === it;++ it.box.classList.toggle('is-swapped', swapped);++ it.name.textContent = swapped ? (document.querySelector('.ovv-head h1') || {}).textContent || 'The main recording' : it.p.title;++ it.name.href = swapped ? location.pathname + location.search : it.p.href;++ it.box.setAttribute('aria-label', swapped++ ? 'Showing the main recording. Tap to swap back.'++ : 'Picture-in-picture: ' + it.p.title + '. Tap to swap it with the main video.');++ if (it.swapBtn) it.swapBtn.title = swapped ? 'Swap back with the main video' : 'Swap with the main video';++ paintMute(it);++ }++++ function setSource(video, file, hls, hlsLib) {++ if (!video || !file) return Promise.resolve();++ if (video._ovvHls) { try { video._ovvHls.destroy(); } catch { /* gone */ } video._ovvHls = null; }++ video.removeAttribute('data-ovv-hls');++ if (hls) {++ video.setAttribute('data-ovv-hls', hls);++ if (hlsLib) video.setAttribute('data-ovv-hls-lib', hlsLib);++ } else {++ video.removeAttribute('data-ovv-hls-lib');++ }++ video.setAttribute('src', file);++ return loader ? Promise.resolve(loader(video)).catch(() => {}) : Promise.resolve();++ }++++ function whenMetadata(video) {++ return new Promise((resolve) => {++ if (!video) { resolve(); return; }++ if (video.readyState >= 1) { resolve(); return; }++ let done = false;++ const finish = () => { if (done) return; done = true; video.removeEventListener('loadedmetadata', finish); resolve(); };++ video.addEventListener('loadedmetadata', finish, { once: true });++ setTimeout(finish, 4000);++ });++ }++++ function readShape(it, video) {++ if (!video || !video.videoWidth || !video.videoHeight) return;++ it.aspect = video.videoWidth / video.videoHeight;++ if (it.float) it.float.setAspect(it.aspect);++ }++++ function ensureBuilt(it) {++ if (it.box) return it;++ const p = it.p;++ const box = document.createElement('div');++ box.className = 'ovv-pip';++ box.hidden = true;++ box.tabIndex = 0;++ box.setAttribute('role', 'group');++ box.setAttribute('aria-label', 'Picture-in-picture: ' + p.title + '. Tap to swap it with the main video.');++ const video = document.createElement('video');++ video.className = 'ovv-pip-video';++ video.playsInline = true;++ video.preload = 'none';++ video.muted = true;++ video.setAttribute('aria-hidden', '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 swapBtn = document.createElement('button');++ swapBtn.type = 'button';++ swapBtn.innerHTML = glyph('arrows-h');++ swapBtn.title = 'Swap with the main video';++ swapBtn.setAttribute('aria-label', 'Swap ' + p.title + ' with the main video');++ const mute = document.createElement('button');++ mute.type = 'button';++ mute.innerHTML = glyph('mute');++ bar.append(name, swapBtn, mute);++ box.append(video, bar);++ root.appendChild(box);++ it.box = box; it.video = video; it.name = name; it.mute = mute; it.swapBtn = swapBtn;++++ box.addEventListener('click', (e) => { e.stopPropagation(); });++ swapBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); swap(it); });++ mute.addEventListener('click', (e) => {++ e.preventDefault(); e.stopPropagation();++ if (!it.g) return;++ it.g.muted = !it.g.muted;++ if (it.float) it.float.set({ muted: it.g.muted }, { save: true });++ paintMute(it);++ });++ video.addEventListener('loadedmetadata', () => { readShape(it, video); it.primed = false; });++ video.addEventListener('resize', () => readShape(it, video));++ video.addEventListener('waiting', () => waiting(it));++ video.addEventListener('stalled', () => waiting(it));++ ['playing', 'canplay', 'seeked'].forEach((ev) => video.addEventListener(ev, () => ready(it)));++ // Its media clock starts a little after play() resolves (and after a buffer): align once more on the next++ // tick, so a slow start does not leave it half a second behind waiting for the 5% nudge to catch up.++ video.addEventListener('playing', () => {++ if (it.started) return;++ it.started = true;++ it.needsAlign = true;++ });++ video.addEventListener('play', () => { if (state.bufferHold) video.pause(); });++ video.addEventListener('error', () => { it.waiting = false; });++++ // A tap on the box (not its buttons or title link, and not a drag) swaps it with the main view. OVFloat++ // takes the pointer for its move; this listener sees the same pointer first and only counts a still one.++ let tap = null;++ box.addEventListener('pointerdown', (e) => {++ if (e.button > 0 || (e.target.closest && e.target.closest('button, a, .ov-float-resize, [data-ov-float-resize]'))) { tap = null; return; }++ tap = { id: e.pointerId, x: e.clientX, y: e.clientY };++ }, true);++ box.addEventListener('pointerup', (e) => {++ const s = tap; tap = null;++ if (!s || s.id !== e.pointerId || state.swapping) return;++ if (Math.hypot(e.clientX - s.x, e.clientY - s.y) > TAP_PX) return;++ if (it.float && it.float.geom().tuck) return; // a tucked handle's tap opens it instead++ swap(it);++ }, true);++ box.addEventListener('keydown', (e) => {++ if (e.key !== 'Enter' || e.target.closest('button, a')) return;++ e.preventDefault(); e.stopPropagation();++ swap(it);++ });++ return it;++ }++++ function engine(it) {++ if (it.float || !root || typeof window === 'undefined' || !window.OVFloat) {++ if (!it.float && it.box && !it.box.style.width) {++ // No shared engine (a page without /shared/ov-float.js): show the PIP where the streamer put it.++ it.box.style.left = clamp(it.p.x, 0, 1, 0.72) * 100 + '%';++ it.box.style.top = clamp(it.p.y, 0, 1, 0.7) * 100 + '%';++ it.box.style.width = clamp(it.p.w, 0.08, 0.6, 0.25) * 100 + '%';++ it.box.style.aspectRatio = '16 / 9';++ }++ return it.float;++ }++ it.float = window.OVFloat.attach(it.box, {++ frame: root,++ key: 'ovv-pip-' + pageKey() + ':' + it.p.vod_id,++ store: null, // the place is this device's (localStorage), not the account's++ defaults: { x: it.p.x, y: it.p.y, w: it.p.w, mode: 'dock', muted: it.g.muted },++ aspect: it.aspect || 16 / 9,++ minPx: 150,++ doubleTap: false, // a tap is the swap; the corner/pinch still sizes it++ onChange: (geom) => { it.g = geom; paintMute(it); },++ });++ it.g = it.float.geom();++ paintMute(it);++ return it.float;++ }++++ function load(it) {++ if (it.loaded) return;++ it.loaded = true;++ it.video.preload = 'auto';++ setSource(it.video, it.p.file, it.p.hls, main.getAttribute('data-ovv-hls-lib'));++ }++++ function waiting(it) {++ if (!it.video || it.box.hidden || !isActiveNow(it)) return;++ if (it.waiting) return;++ it.waiting = true;++ clearTimeout(it.waitTimer);++ it.waitTimer = setTimeout(() => {++ if (!it.waiting || state.destroyed || it.box.hidden || main.paused || state.bufferHold) return;++ state.bufferHold = true;++ main.pause();++ pauseAll();++ }, HOLD_MS);++ }++++ function ready(it) {++ it.waiting = false;++ clearTimeout(it.waitTimer);++ if (!state.bufferHold) return;++ state.bufferHold = false;++ it.needsAlign = true;++ if (state.intent === 'play') {++ const played = main.play();++ if (played && played.catch) played.catch(() => {});++ playAll();++ }++ }++++ function isActiveNow(it) {++ const t = main.currentTime;++ if (state.swapped) return state.swapped === it && inSwapped(it, t);++ return t >= Number(it.p.from) && t < Number(it.p.to);++ }++++ function inSwapped(it, t) {++ return Number.isFinite(t) && t >= Number(it.p.offset) && t < Number(it.p.offset) + (Number(it.p.to) - Number(it.p.from));++ }++++ function expectedOf(it, t) {++ return state.swapped === it ? hostTimeAt(it.p, t) : pipTimeAt(it.p, t);++ }++++ function pauseAll() {++ for (const it of state.items) if (it.video && !it.video.paused) it.video.pause();++ }++++ function playAll() {++ if (state.bufferHold || state.intent !== 'play' || main.paused) return;++ for (const it of state.items) {++ if (!it.video || it.box.hidden || it.video.readyState < 1) continue;++ if (it.video.paused) { const p = it.video.play(); if (p && p.catch) p.catch(() => {}); }++ }++ }++++ function noteMissing(t) {++ if (state.missingShown || state.swapped) return;++ const span = missing.find((p) => t >= Number(p.from) && t < Number(p.to));++ if (!span) return;++ state.missingShown = true;++ let note = document.querySelector('.ovv-pip-note');++ if (!note) {++ note = document.createElement('p');++ note.className = 'ovv-pip-note';++ note.setAttribute('role', 'status');++ const wrap = main.closest('.ovv-player-wrap') || root.parentNode || root;++ if (wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap.nextSibling);++ else root.appendChild(note);++ }++ note.textContent = (span.title ? '“' + span.title + '”' : 'A picture-in-picture') + ' is no longer available, so it is not shown here.';++ note.hidden = false;++ }++++ function sync(force) {++ if (state.destroyed || !main || state.swapping) return;++ const t = main.currentTime;++ if (!Number.isFinite(t) || main.readyState < 1) return;++ const wanted = new Set();++ if (state.swapped) {++ const it = state.swapped;++ if (inSwapped(it, t)) wanted.add(it);++ } else {++ for (const it of state.items) {++ if (t >= Number(it.p.from) - LOAD_AHEAD && t < Number(it.p.to)) { ensureBuilt(it); load(it); }++ if (t >= Number(it.p.from) && t < Number(it.p.to)) wanted.add(it);++ }++ }++ for (const it of state.items) {++ if (!it.box) continue;++ const on = wanted.has(it);++ if (on) {++ if (it.box.hidden) {++ it.box.hidden = false;++ engine(it);++ if (it.float) it.float.relayout();++ it.primed = false;++ }++ if (!it.video || it.video.readyState < 1) continue;++ const expected = expectedOf(it, t);++ if (expected == null) { it.box.hidden = true; it.video.pause(); continue; }++ const duration = Number(it.video.duration);++ if (Number.isFinite(duration) && duration > 0 && expected >= duration - 0.05) {++ it.box.hidden = true;++ if (!it.video.paused) it.video.pause();++ continue;++ }++ const base = Number(main.playbackRate) || 1;++ const actual = Number(it.video.currentTime) || 0;++ const c = driftCorrection(actual, expected, base);++ if (force || !it.primed || it.needsAlign) {++ const at = Math.max(0, Math.min(expected, Number.isFinite(duration) && duration > 0 ? duration - 0.05 : expected));++ if (Math.abs(actual - at) > 0.08 && it.video.readyState >= 1) it.video.currentTime = at;++ it.video.playbackRate = base;++ it.primed = true;++ it.needsAlign = false;++ } else if (!main.paused && !it.video.paused && it.video.readyState >= 2) {++ if (c.action === 'seek') it.video.currentTime = c.time;++ else it.video.playbackRate = c.rate;++ } else {++ it.video.playbackRate = base;++ }++ paintMute(it);++ if (main.paused || state.bufferHold) { if (!it.video.paused) it.video.pause(); }++ else if (it.video.paused && !state.bufferHold) { const p = it.video.play(); if (p && p.catch) p.catch(() => {}); }++ } else if (!it.box.hidden) {++ it.box.hidden = true;++ if (it.video && !it.video.paused) it.video.pause();++ if (it.float) it.float.save();++ }++ }++ noteMissing(t);++ }++++ /* ── swap: main <-> the floating view, sources and times exchanged ───────────────────────────────── */++++ function snapshotHost() {++ return {++ file: main.getAttribute('src'),++ hls: main.getAttribute('data-ovv-hls'),++ hlsLib: main.getAttribute('data-ovv-hls-lib'),++ poster: main.getAttribute('poster'),++ tracks: Array.prototype.slice.call(main.querySelectorAll('track')),++ crossorigin: main.getAttribute('crossorigin'),++ };++ }++++ function restoreHostTracks(host) {++ main.querySelectorAll('track').forEach((t) => t.remove());++ for (const t of host.tracks) main.appendChild(t);++ }++++ function swap(it) {++ if (!it || !it.loaded || !it.video || state.swapping || it.p.missing) return;++ const back = state.swapped === it;++ if (!back && state.swapped) return; // swap the shown one back first++ if (back && !state.host) return;++ const wasPlaying = !main.paused;++ state.swapping = true;++ const t = main.currentTime;++ main.pause();++ pauseAll();++ if (!back) {++ const pt = pipTimeAt(it.p, t);++ if (pt == null) { state.swapping = false; return; }++ if (!state.host) state.host = snapshotHost();++ const host = state.host;++ const hostAt = t;++ state.swapped = it;++ // The floating view becomes the main recording, at the same second.++ const hostLoad = setSource(it.video, host.file, host.hls, host.hlsLib).then(() => whenMetadata(it.video)).then(() => {++ if (it.video.readyState >= 1) it.video.currentTime = Math.max(0, hostAt);++ it.video.muted = true;++ it.primed = false;++ });++ // The main player becomes the PIP's recording, at the mapped second.++ const pipLoad = setSource(main, it.p.file, it.p.hls, host.hlsLib).then(() => whenMetadata(main)).then(() => {++ if (main.readyState >= 1) main.currentTime = Math.max(0, pt);++ restoreHostTracks({ tracks: [] });++ main.removeAttribute('poster');++ });++ Promise.all([hostLoad, pipLoad]).then(() => {++ state.swapping = false;++ state.intent = wasPlaying ? 'play' : 'pause';++ paintLabel(it);++ for (const other of state.items) if (other !== it && other.box) other.box.hidden = true;++ sync(true);++ if (wasPlaying) { const p = main.play(); if (p && p.catch) p.catch(() => {}); playAll(); }++ });++ return;++ }++ const pt = main.currentTime;++ const hostAt = it.video.currentTime;++ state.swapped = null;++ const backMain = setSource(main, state.host.file, state.host.hls, state.host.hlsLib).then(() => whenMetadata(main)).then(() => {++ if (main.readyState >= 1) main.currentTime = Math.max(0, hostAt);++ if (state.host.poster) main.setAttribute('poster', state.host.poster); else main.removeAttribute('poster');++ if (state.host.crossorigin) main.setAttribute('crossorigin', state.host.crossorigin);++ restoreHostTracks(state.host);++ });++ const backPip = setSource(it.video, it.p.file, it.p.hls, state.host.hlsLib).then(() => whenMetadata(it.video)).then(() => {++ if (it.video.readyState >= 1) it.video.currentTime = Math.max(0, pt);++ it.primed = false;++ });++ Promise.all([backMain, backPip]).then(() => {++ state.swapping = false;++ state.intent = wasPlaying ? 'play' : 'pause';++ paintLabel(it);++ paintMute(it);++ sync(true);++ if (wasPlaying) { const p = main.play(); if (p && p.catch) p.catch(() => {}); playAll(); }++ });++ }++++ /* ── wire it to the main player ─────────────────────────────────────────────────────────────────── */++++ state.items = valid.map(makeIt);++ state.missing = missing;++++ main.addEventListener('play', () => {++ if (!state.bufferHold && !state.swapping) state.intent = 'play';++ for (const it of state.items) it.primed = false; // a resume after a pause starts the clock again++ sync(true);++ });++ main.addEventListener('pause', () => {++ if (state.bufferHold || state.swapping) return;++ state.intent = 'pause';++ pauseAll();++ });++ main.addEventListener('timeupdate', () => sync(false));++ main.addEventListener('ratechange', () => sync(false));++ main.addEventListener('volumechange', () => { for (const it of state.items) paintMute(it); });++ main.addEventListener('seeking', () => { for (const it of state.items) it.primed = false; });++ main.addEventListener('seeked', () => { for (const it of state.items) it.primed = false; sync(true); });++ main.addEventListener('waiting', () => { pauseAll(); });++ main.addEventListener('playing', () => {++ for (const it of state.items) it.primed = false; // after its own wait, both start together again++ if (state.intent === 'play' && !state.bufferHold) { playAll(); sync(true); }++ });++ main.addEventListener('ended', () => pauseAll());++++ state.timer = setInterval(tick, TICK_MS);++ sync(true);++++ return {++ items: state.items,++ spans: spans,++ missing: state.missing,++ swap: (it) => swap(it || state.swapped || state.items[0]),++ sync: (force) => sync(force),++ destroy() {++ state.destroyed = true;++ clearInterval(state.timer);++ for (const it of state.items) {++ clearTimeout(it.waitTimer);++ if (it.float) it.float.destroy();++ if (it.box) it.box.remove();++ }++ state.items = [];++ },++ };++ }++++ return { attach, activeSpan, pipTimeAt, hostTimeAt, driftCorrection, NUDGE_AT, SEEK_AT, NUDGE_MAX };++});@@@ -196,32 +196,49 @@ function hlsAttrs(item) {/*** 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.++ * streaming/pip-history.js), with what public/js/vod-pip.js needs to play each over the player: its `vod_id`, 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). A VOD Live no longer answers stays in the list as++ * `missing` (file null), so the player can hide that span and say so once.*/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 ids = [...new Set(list.map((p) => String(p.vod_id)))];++ // Six Live answers are enough for one page; a PIP beyond that is left out rather than guessed at.++ const attempted = new Set(ids.slice(0, 6));++ await Promise.all(ids.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 missing */ }}));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 pid = String(p.vod_id);++ const pv = got.get(pid);const file = pv && fileOf(pv);-- if (!file) continue;++ const title = String(p.title || (pv && pv.title) || 'Picture-in-picture').slice(0, 120);++ const href = `${pv && pv.watch_id ? `/watch?v=${pv.watch_id}` : `/v/${pid}`}${Number(p.offset) >= 1 ? `${pv && pv.watch_id ? '&' : '?'}t=${Math.floor(Number(p.offset))}` : ''}`;++ // A span whose VOD Live no longer answers stays in the list as `missing`: the player hides it and says so++ // once, quietly, at its moment (a robot camera deleted or made private after the stream, 2026-11-13).++ if (!file) {++ if (!attempted.has(pid)) continue;++ out.push({++ vod_id: Number(p.vod_id), missing: true, file: null, 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, href,++ });++ continue;++ }const hls = httpsUrl(pv.hls_url);out.push({-- file, hls: hls && new URL(hls).origin === new URL(file).origin ? hls : null,++ vod_id: Number(p.vod_id), 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: `${pv && pv.watch_id ? `/watch?v=${pv.watch_id}` : `/v/${String(p.vod_id)}`}${Number(p.offset) >= 1 ? `${pv && pv.watch_id ? '&' : '?'}t=${Math.floor(Number(p.offset))}` : ''}`,++ title, href,});}return out;@@@ -1585,7 +1602,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvjsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',}),-- scripts: ['js/video.js', 'js/comments.js'],++ sharedScripts: ['ov-float.js'],++ scripts: ['js/vod-pip.js', 'js/video.js', 'js/comments.js'],localStyles: [...(manageable ? ['css/labels.css'] : [])],player: true,body: html`<div class="ovv-watch ovv-watch--vod">nnew file mode 100644BBinary files /dev/null and b/test/fixtures/vod-main.webm differnnew file mode 100644BBinary files /dev/null and b/test/fixtures/vod-pip.webm differ@@@ -62,7 +62,10 @@ const get = (base, p) => new Promise((resolve, reject) => {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");++ [null, null, 10, 20, 0, 0.72, 0.25, true, '/v/803'],++ ], "a PIP whose VOD Live no longer answers stays as missing; a bad id and a backwards window are left out; a playlist on another origin than the file is not used");++ assert.strictEqual(pips[2].missing, true, 'the missing span is marked, so the player can say so once');++ assert.deepStrictEqual(pips.map((p) => p.vod_id), [801, 802, 803], 'each span names its own VOD');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');});@@@ -73,23 +76,23 @@ const get = (base, p) => new Promise((resolve, reject) => {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 () => {++ await check('the watch page loads OVFloat and vod-pip.js, and video.js hands the PIPs to it', async () => {++ const r = await get(t.base, '/v/800');++ assert.ok(/\/shared\/ov-float\.js\?v=/.test(r.text), 'the shared float system is on the page');++ assert.ok(r.text.indexOf('/js/vod-pip.js?v=') < r.text.indexOf('/js/video.js?v='), 'the PIP engine loads before the page script');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');++ assert.ok(js.includes('if (window.VodPip) window.VodPip.attach(video, { attachSource: attachSource });'), 'started once the main video has its source');++ const pip = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'vod-pip.js'), 'utf8');++ assert.ok(pip.includes('window.OVFloat.attach(it.box'), 'the box uses the shared float system');++ assert.ok(pip.includes("main.addEventListener('seeked'") && pip.includes('sync(true)'), 'a seek on the main seeks the PIP');++ assert.ok(pip.includes('driftCorrection(actual, expected, base)'), 'drift is corrected from the main timeline');++ assert.ok(pip.includes('state.bufferHold'), 'a wait on either side pauses the other');++ assert.ok(pip.includes('function swap(it)') && pip.includes('pointerup'), 'a tap (or Enter) swaps it with the main view');++ assert.ok(pip.includes("'ovv-pip-' + pageKey()"), '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)');-- // Its own shape (2026-11-07: a portrait camera was cut to 16:9): the box follows the video, the video is contained.-- assert.ok(/\.ovv-pip-video \{[^}]*object-fit: contain/.test(css) && !/\.ovv-pip-video \{[^}]*object-fit: cover/.test(css), 'the recording is shown whole, never cropped');-- assert.ok(js.includes("v.addEventListener('loadedmetadata', readShape);") && js.includes("v.addEventListener('resize', readShape);"), 'the shape comes from the video, and follows a turned phone');-- assert.ok(js.includes('box.style.aspectRatio = String(b.a);'), 'the box takes that shape');-- assert.ok(js.includes('0.85 * r.height'), 'never taller than 85% of the player');++ assert.ok(/\.ovv-pip \{[^}]*z-index: 2;/.test(css), 'above the picture, under the controls (3)');++ assert.ok(/\.ovv-pip-video \{[^}]*object-fit: contain/.test(css), 'the recording is shown whole, never cropped');++ assert.ok(/\.ovv-pip-note \{/.test(css), 'the one quiet line for a missing PIP has its style');});await t.close();nnew file mode 100644@@@ -0,0 +1,223 @@++'use strict';++/**++ * The VOD picture-in-picture in a real headless Chrome, with two short local videos:++ * - the PIP follows a seek on the main one (its currentTime is offset + (main - from)), and works across++ * several seeks;++ * - pause, speed and the end of the span follow too, and drift over 0.3 s is corrected with a playbackRate nudge;++ * - a tap on the box swaps it with the main view and a second tap swaps back;++ * - a span whose VOD is missing says so once, quietly, and draws no PIP.++ *++ * node test/vod-pip-chrome.test.js++ * PLAYWRIGHT_PATH=/path/to/playwright CHROME_BIN=/usr/bin/google-chrome node test/vod-pip-chrome.test.js++ *++ * Skips (exit 0) when neither Playwright nor Chrome is available, like the shared browser tests.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const http = require('node:http');++const path = require('node:path');++++function findPlaywright() {++ for (const where of [process.env.PLAYWRIGHT_PATH, 'playwright', '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright']) {++ if (!where) continue;++ try { return require(where); } catch { /* next */ }++ }++ return null;++}++const playwright = findPlaywright();++const chrome = process.env.CHROME_BIN || '/usr/bin/google-chrome';++if (!playwright || !fs.existsSync(chrome)) {++ console.log(`vod-pip-chrome: skipped (${playwright ? 'no Chrome' : 'no Playwright'}; set PLAYWRIGHT_PATH / CHROME_BIN)`);++ process.exit(0);++}++++const root = path.join(__dirname, '..');++const sharedRoot = path.dirname(require.resolve('openvibe-shared/files'));++const fixture = (name) => fs.readFileSync(path.join(root, 'test', 'fixtures', name));++const PIP = {++ vod_id: 801, file: '/pip.webm', hls: null, from: 1, to: 5, offset: 0.5,++ x: 0.66, y: 0.14, w: 0.3, muted: true, title: 'Robot camera', href: '/v/801',++};++const MISSING = { vod_id: 899, missing: true, file: null, hls: null, from: 4.5, to: 5.5, offset: 0, x: 0.1, y: 0.1, w: 0.2, muted: true, title: 'Gone camera', href: '/v/899' };++++const page0 = `<!doctype html><meta charset="utf-8"><title>VOD PiP test</title>++<meta name="viewport" content="width=device-width,initial-scale=1">++<style>body{margin:0;background:#111;color:#eee;font:14px system-ui}#frame{position:relative;width:640px;height:360px;background:#000;margin:20px}++#main{width:100%;height:100%;display:block;object-fit:contain}</style>++<div id="frame" class="ov-player"><video id="main" src="/main.webm" playsinline preload="metadata"></video></div>++<script src="/ov-float.js"></script>++<script src="/vod-pip.js"></script>++<script>++window.attachCalls = 0;++function attachSource(video) { window.attachCalls += 1; return Promise.resolve('file'); }++window.api = VodPip.attach(document.getElementById('main'), {++ data: ${JSON.stringify([PIP, MISSING])},++ attachSource: attachSource,++});++</script>`;++++const server = http.createServer((req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ if (url.pathname === '/main.webm' || url.pathname === '/pip.webm') {++ const data = fixture(url.pathname === '/main.webm' ? 'vod-main.webm' : 'vod-pip.webm');++ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');++ if (range) {++ const start = range[1] ? Number(range[1]) : 0;++ const end = range[2] ? Math.min(Number(range[2]), data.length - 1) : data.length - 1;++ res.writeHead(206, {++ 'content-type': 'video/webm', 'cache-control': 'no-store', 'accept-ranges': 'bytes',++ 'content-range': `bytes ${start}-${end}/${data.length}`, 'content-length': end - start + 1,++ });++ res.end(data.subarray(start, end + 1));++ return;++ }++ res.writeHead(200, { 'content-type': 'video/webm', 'cache-control': 'no-store', 'accept-ranges': 'bytes', 'content-length': data.length });++ res.end(data);++ return;++ }++ if (url.pathname === '/ov-float.js') {++ res.writeHead(200, { 'content-type': 'application/javascript' });++ res.end(fs.readFileSync(path.join(sharedRoot, 'ov-float.js')));++ return;++ }++ if (url.pathname === '/vod-pip.js') {++ res.writeHead(200, { 'content-type': 'application/javascript' });++ res.end(fs.readFileSync(path.join(root, 'public', 'js', 'vod-pip.js')));++ return;++ }++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });++ res.end(page0);++});++++const wait = (ms) => new Promise((r) => setTimeout(r, ms));++const sample = (page) => page.evaluate(() => {++ const main = document.getElementById('main');++ const box = document.querySelector('.ovv-pip');++ const pip = box && box.querySelector('video');++ return {++ main: main.currentTime, mainPaused: main.paused, mainRate: main.playbackRate, mainSrc: main.getAttribute('src') || '',++ boxHidden: !box || box.hidden, pip: pip ? pip.currentTime : null, pipPaused: pip ? pip.paused : null,++ pipRate: pip ? pip.playbackRate : null, pipSrc: pip ? (pip.getAttribute('src') || '') : '',++ note: (document.querySelector('.ovv-pip-note') || {}).textContent || '',++ };++});++const mapped = (main) => PIP.offset + (main - PIP.from);++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}/`;++ const browser = await playwright.chromium.launch({++ executablePath: chrome,++ headless: true,++ args: ['--no-sandbox', '--disable-dev-shm-usage', '--autoplay-policy=no-user-gesture-required'],++ });++ try {++ const page = await browser.newPage({ viewport: { width: 900, height: 620 } });++ const errors = [];++ page.on('pageerror', (e) => errors.push(String(e)));++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });++ await page.goto(base, { waitUntil: 'load' });++ await page.waitForFunction(() => document.getElementById('main').readyState >= 1);++ // Let the first seek land before playing (play() during a seek cancels it in Chrome).++ await page.evaluate(() => { const m = document.getElementById('main'); m.muted = true; m.currentTime = 2; });++ await wait(300);++ await page.evaluate(() => document.getElementById('main').play());++++ // The first frame of the PIP: shown inside the span, playing at the mapped second.++ await page.waitForFunction(() => {++ const box = document.querySelector('.ovv-pip');++ const v = box && box.querySelector('video');++ return box && !box.hidden && v && v.readyState >= 2 && !v.paused;++ }, null, { timeout: 12000 });++ await wait(600);++ let s = await sample(page);++ assert.ok(!s.boxHidden, 'the PIP shows inside its span');++ assert.ok(Math.abs(s.pip - mapped(s.main)) < 0.35, `at the mapped second (main ${s.main.toFixed(2)}, PIP ${s.pip.toFixed(2)}, want ${mapped(s.main).toFixed(2)})`);++ assert.ok(s.pipRate >= 0.95 && s.pipRate <= 1.05, `at the main's speed (${s.pipRate})`);++++ // A seek on the main seeks the PIP.++ for (const at of [3.5, 2.25, 4.2]) {++ await page.evaluate((t) => { document.getElementById('main').currentTime = t; }, at);++ await wait(450);++ s = await sample(page);++ assert.ok(!s.boxHidden, `the PIP is still shown after seeking to ${at}`);++ assert.ok(Math.abs(s.pip - mapped(s.main)) < 0.35, `a seek to ${at} moves the PIP (main ${s.main.toFixed(2)}, PIP ${s.pip.toFixed(2)}, want ${mapped(s.main).toFixed(2)})`);++ }++++ // Pause follows.++ await page.evaluate(() => document.getElementById('main').pause());++ await wait(350);++ s = await sample(page);++ assert.ok(s.pipPaused, 'pausing the main pauses the PIP');++ const frozen = s.pip;++ await wait(400);++ s = await sample(page);++ assert.equal(s.pip, frozen, 'and it stays put');++++ // Rate follows (with the small nudge allowed for drift).++ await page.evaluate(() => { const m = document.getElementById('main'); m.playbackRate = 1.4; return m.play(); });++ await wait(700);++ s = await sample(page);++ assert.ok(s.pipRate >= 1.4 * 0.95 - 0.01 && s.pipRate <= 1.4 * 1.05 + 0.01, `the PIP plays at the main's 1.4× (${s.pipRate})`);++++ // Drift over 0.3 s is nudged, not frozen.++ await page.evaluate(() => { document.getElementById('main').currentTime = 2.6; });++ await wait(350);++ await page.evaluate(() => {++ const v = document.querySelector('.ovv-pip video');++ v.currentTime = Math.max(0, v.currentTime - 0.7);++ });++ await wait(450);++ s = await sample(page);++ assert.ok(s.pipRate > 1.4, `behind the main it speeds up (${s.pipRate})`);++++ // The end of the span hides it (no frozen last frame). Pause first so the main does not run past 5 while we look.++ await page.evaluate(() => document.getElementById('main').pause());++ await wait(300);++ await page.evaluate(() => { document.getElementById('main').currentTime = 5.4; });++ await wait(400);++ s = await sample(page);++ assert.ok(s.boxHidden, 'outside [from, to) the PIP hides');++ assert.ok(s.pipPaused, 'and stops playing');++++ // The missing PIP's span: no box, one quiet note.++ await page.evaluate(() => { document.getElementById('main').currentTime = 4.7; });++ await wait(400);++ s = await sample(page);++ assert.match(s.note, /Gone camera/, 'a span whose VOD is missing says which one');++ assert.ok(!s.boxHidden, 'the other PIP still shows');++++ // Back inside; then a tap on the box swaps it with the main view.++ await page.evaluate(() => { const m = document.getElementById('main'); m.currentTime = 2.2; return m.play(); });++ await wait(500);++ const rect = await page.evaluate(() => {++ const r = document.querySelector('.ovv-pip').getBoundingClientRect();++ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };++ });++ await page.mouse.click(rect.x, rect.y);++ await page.waitForFunction(() => (document.getElementById('main').getAttribute('src') || '').endsWith('/pip.webm')++ && document.querySelector('.ovv-pip').classList.contains('is-swapped'), null, { timeout: 8000 });++ s = await sample(page);++ assert.equal(s.mainSrc, '/pip.webm', 'the main view plays the PIP recording after a tap');++ assert.match(s.pipSrc, /main\.webm$/, 'and the floating view plays the main recording');++ assert.ok(!s.boxHidden, 'the swapped view stays visible');++++ // A second tap swaps back.++ const rect2 = await page.evaluate(() => {++ const r = document.querySelector('.ovv-pip').getBoundingClientRect();++ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };++ });++ await page.mouse.click(rect2.x, rect2.y);++ await page.waitForFunction(() => (document.getElementById('main').getAttribute('src') || '').endsWith('/main.webm')++ && !document.querySelector('.ovv-pip').classList.contains('is-swapped'), null, { timeout: 8000 });++ s = await sample(page);++ assert.equal(s.mainSrc, '/main.webm', 'the second tap brings the main recording back');++ assert.match(s.pipSrc, /pip\.webm$/, 'and the PIP returns to the box');++++ assert.deepEqual(errors, [], 'no page errors');++ process.stdout.write(' ✓ the PIP follows seeks, pause, rate, drift and the span; a tap swaps it; a missing one says so\n');++ } finally {++ await browser.close();++ await new Promise((r) => server.close(r));++ }++})().catch((err) => { console.error(err); server.close(() => process.exit(1)); });nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/**++ * The VOD picture-in-picture's correctness rests on two pure functions (public/js/vod-pip.js):++ * - the time mapping between the main recording and the PIP's own VOD (offset + (t - from) inside [from, to),++ * and the inverse when the viewer swaps the two views);++ * - the drift decision: nothing under 0.3 s, a playbackRate nudge between 0.3 s and 2 s, a seek beyond 2 s.++ * The browser test (test/vod-pip-chrome.test.js) then checks them on two real <video> elements.++ */++const assert = require('assert');++const core = require('../public/js/vod-pip');++++const span = (from, to, offset) => ({ from, to, offset });++++// ── the mapping ─────────────────────────────────────────────++assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 20), 5, 'at the start of the span the PIP is at its offset');++assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 30), 15, 'one second of the main is one second of the PIP');++assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 119.9), 104.9, 'the PIP runs to the end of the span');++assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 120), null, 'the span is half-open: at `to` it is gone');++assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 19.9), null, 'before the span it is not shown');++assert.strictEqual(core.pipTimeAt({ from: 1, to: 2 }, 1.5), null, 'a span with no offset has no mapping');++++assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 5), 20, 'the inverse mapping: offset is from');++assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 105), null, 'the PIP range is half-open too');++assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 104.9), 119.9, 'and just inside it maps to just before `to`');++assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 4.9), null, 'before its offset there is no main second');++assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 106), null, 'past its end there is none either');++++// A round trip, for every kind of span.++for (const s of [span(0, 10, 0), span(20, 120, 42.5), span(60, 70, 3.25)]) {++ for (const t of [s.from, s.from + 0.5, (s.from + s.to) / 2, s.to - 0.1]) {++ const p = core.pipTimeAt(s, t);++ assert.ok(Math.abs(core.hostTimeAt(s, p) - t) < 1e-9, `round trip at ${t} (${p})`);++ }++}++++// Several spans (the PIP came, went and came back): the one holding t wins; a gap has none.++const spans = [span(10, 20, 0), span(40, 60, 12), span(55, 70, 30)];++assert.strictEqual(core.activeSpan(spans, 5), null, 'before every span');++assert.strictEqual(core.activeSpan(spans, 15), spans[0], 'inside the first');++assert.strictEqual(core.activeSpan(spans, 30), null, 'in the gap it is hidden');++assert.strictEqual(core.activeSpan(spans, 50), spans[1], 'inside the second');++assert.strictEqual(core.activeSpan(spans, 65), spans[2], 'overlapping spans: the latest one wins');++assert.strictEqual(core.activeSpan([], 10), null);++++// ── drift correction ────────────────────────────────────────++assert.strictEqual(core.NUDGE_AT, 0.3);++assert.strictEqual(core.SEEK_AT, 2);++++const none = core.driftCorrection(10.2, 10, 1);++assert.strictEqual(none.action, 'none', 'under 0.3 s: leave it alone');++assert.strictEqual(none.rate, 1);++assert.strictEqual(core.driftCorrection(10.3, 10, 1).action, 'none', 'exactly 0.3 s is still inside');++++const ahead = core.driftCorrection(10.5, 10, 1);++assert.strictEqual(ahead.action, 'nudge', 'over 0.3 s: nudge, not seek');++assert.ok(ahead.factor < 1 && ahead.factor >= 0.95, `ahead of the main → slower (${ahead.factor})`);++assert.ok(Math.abs(ahead.rate - ahead.factor) < 1e-12, 'at 1× the rate is the factor');++++const behind = core.driftCorrection(9.5, 10, 1);++assert.strictEqual(behind.action, 'nudge', 'behind the main is nudged too');++assert.ok(behind.factor > 1 && behind.factor <= 1.05, `behind the main → faster (${behind.factor})`);++++const atSpeed = core.driftCorrection(9.2, 10, 2);++assert.ok(Math.abs(atSpeed.rate - 2 * atSpeed.factor) < 1e-12, 'a 2× main keeps 2× while it catches up');++assert.ok(atSpeed.factor >= 0.95 && atSpeed.factor <= 1.05, 'the nudge factor stays within 0.95–1.05');++assert.ok(Math.abs(core.driftCorrection(9.2, 10, 1).factor - 1.05) < 1e-12, '0.8 s behind is 5% faster, capped');++++const seekAhead = core.driftCorrection(13, 10, 1);++assert.deepStrictEqual(seekAhead, { action: 'seek', rate: 1, time: 10, drift: 3, factor: 1 }, 'over 2 s: seek, not nudge');++assert.strictEqual(core.driftCorrection(12.01, 10, 1).action, 'seek', 'just over 2 s seeks');++assert.strictEqual(core.driftCorrection(12, 10, 1).action, 'nudge', 'exactly 2 s still only nudges');++assert.strictEqual(core.driftCorrection(1, 10).action, 'seek', 'a PIP at 0 when it should be at 10 seeks');++++// NaN never turns into a wild rate.++assert.deepStrictEqual(core.driftCorrection(NaN, 10, 1), { action: 'seek', rate: 1, time: 10, drift: 0, factor: 1 });++assert.strictEqual(core.driftCorrection(3, NaN, 1).action, 'none');++assert.strictEqual(core.driftCorrection(3, 3, 0).rate, 1, 'a nonsense base rate falls back to 1×');++++process.stdout.write(' ✓ the time mapping and the drift correction\n');
@@ -4,6 +4,17 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -4,6 +4,17 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
## Unreleased
## Unreleased
+- A recording's picture-in-picture plays in sync with it (2026-11-13, Maticus: "the robot PiP freezes on VODs"). The
+ streams a Live recording drew over itself now each plays its own VOD over the watch page: while the main video is
+ inside a span the PiP is at `offset + (main - from)`, outside it the PiP hides. It follows the main's play, pause,
+ seeks and speed (a seek on one seeks the other, a wait on either pauses both), drift over 0.3 s is nudged with a
+ playbackRate factor between 0.95 and 1.05, and over 2 s seeks. The box is openvibe-shared's OVFloat (drag, tuck,
+ pinch/grip size, its place kept per device); a tap (or Enter) swaps it with the main view, sources and times both
+ ways. Several spans work, and a span whose PiP VOD is gone hides with one quiet line. The server keeps those
+ missing spans in `vod.pips` (`missing: true`) and gives each its `vod_id`. Tests: `test/vod-pip-core.test.js` (the
+ time mapping and the drift decision), `test/vod-pip-chrome.test.js` (two local videos: seeks on the main move the
+ PiP, pause/rate/drift follow, a tap swaps) and `test/video-pips.test.js` (the page hands the player each span).
+
- openvibe.pics' home icons and "Choose a picture" (2026-11-13, the owner's phone). The "What you get", "The links a
- openvibe.pics' home icons and "Choose a picture" (2026-11-13, the owner's phone). The "What you get", "The links a
picture gets" and "What we promise, in writing" cards gave the shared showcase bare glyph names, which it drew as
picture gets" and "What we promise, in writing" cards gave the shared showcase bare glyph names, which it drew as
Font Awesome placeholders with no font and no mapping: every icon was an empty 40 px square. They are now the glyph
Font Awesome placeholders with no font and no mapping: every icon was an empty 40 px square. They are now the glyph
@@ -581,22 +581,22 @@ a.ovv-channel-name:hover { text-decoration: underline; }
@@ -581,22 +581,22 @@ a.ovv-channel-name:hover { text-decoration: underline; }
.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; }
+/* Picture-in-picture over a recording (public/js/vod-pip.js): the box is openvibe-shared/ov-float.js, which
+ places, moves, tucks and sizes it inside the shared player; the bar and the picture are this page's. It sits above
+ the picture and under the player's controls (z-index 3), and goes full screen with it. */
+.ovv-pip { z-index: 2; 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); }
.ovv-pip[hidden] { display: none; }
.ovv-pip[hidden] { display: none; }
-.ovv-pip.is-moving { cursor: grabbing; }
-/* The box takes the recording's own shape (video.js sets aspect-ratio), so contain shows it whole (2026-11-07). */
+.ovv-pip:focus-visible { outline: 2px solid var(--ovp-accent, #3b82f6); outline-offset: 2px; }
+/* The box takes the recording's own shape (video.js readShape), so contain shows it whole (2026-11-07). */
.ovv-pip-video { display: block; width: 100%; height: 100%; object-fit: contain; pointer-events: none; }
.ovv-pip-video { display: block; width: 100%; height: 100%; object-fit: contain; 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-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: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 { 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-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-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; } }
@media (hover: none) { .ovv-pip-bar { opacity: 1; } }
+/* One quiet line, once: a span whose PiP VOD is gone (pages.js pipsOf sets missing). */
+.ovv-pip-note { margin: .5rem 0 0; font-size: .8rem; color: var(--ovv-faint); }
/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──
/* ── The owner's tools (2026-10-27: "I don't see any way to edit things … I just had to scroll way down") ──
Edit is in More; the panel opens right under the player and stays out of the way until then (a
Edit is in More; the panel opens right under the player and stays out of the way until then (a
@@ -106,6 +106,8 @@
@@ -106,6 +106,8 @@
}
}
function attachSource(video) {
function attachSource(video) {
+ // A video can be given a new source (a PIP swapped with the main view): the old hls.js instance must stop.
+ if (video._ovvHls) { try { video._ovvHls.destroy(); } catch (_) { /* already gone */ } video._ovvHls = null; }
const hlsUrl = video.getAttribute('data-ovv-hls');
const hlsUrl = video.getAttribute('data-ovv-hls');
const fileUrl = video.getAttribute('src');
const fileUrl = video.getAttribute('src');
if (!hlsUrl || !fileUrl) return Promise.resolve('file');
if (!hlsUrl || !fileUrl) return Promise.resolve('file');
@@ -131,12 +133,14 @@
@@ -131,12 +133,14 @@
return loadScript(video.getAttribute('data-ovv-hls-lib')).then(function () {
return loadScript(video.getAttribute('data-ovv-hls-lib')).then(function () {
if (typeof window.Hls === 'undefined' || !window.Hls.isSupported()) return 'file';
if (typeof window.Hls === 'undefined' || !window.Hls.isSupported()) return 'file';
const hls = new window.Hls({ enableWorker: true, backBufferLength: 60 });
const hls = new window.Hls({ enableWorker: true, backBufferLength: 60 });
+ video._ovvHls = hls;
let failed = false;
let failed = false;
hls.on(window.Hls.Events.ERROR, function (_event, data) {
hls.on(window.Hls.Events.ERROR, function (_event, data) {
if (!data || !data.fatal || failed) return;
if (!data || !data.fatal || failed) return;
failed = true;
failed = true;
const at = video.currentTime || 0;
const at = video.currentTime || 0;
hls.destroy();
hls.destroy();
+ video._ovvHls = null;
video.src = fileUrl;
video.src = fileUrl;
if (at > 0) seek(video, at);
if (at > 0) seek(video, at);
});
});
@@ -406,188 +410,6 @@
@@ -406,188 +410,6 @@
});
});
}
}
- // ── 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)); };
- // A PIP takes its recording's own shape (2026-11-07: a portrait camera was cut to 16:9). `w` is the width of a
- // 16:9 PIP; any other shape keeps that area, never more than 85% of the player's height, and on a phone-sized
- // player never less than a 150 px wide 16:9 box. Dropped near an edge it snaps to a 12 px margin.
- const BASE = 16 / 9;
- const boxOf = function (g, aspect) {
- const a = aspect || BASE;
- const r = root.getBoundingClientRect();
- const w0 = r.width && r.width < 640 ? Math.max(g.w, Math.min(0.6, 150 / r.width)) : g.w;
- let wf = w0 * Math.sqrt(a / BASE);
- if (r.width && r.height && (wf * r.width) / a > 0.85 * r.height) wf = (0.85 * r.height * a) / r.width;
- wf = Math.min(wf, 1);
- return { a: a, wf: wf, hf: r.width && r.height ? (wf * r.width) / a / r.height : wf / a, rw: r.width, rh: r.height };
- };
- 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="ovg-fill" data-ov-icon="eye-off" 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, aspect: null };
- const paintMute = function () {
- mute.innerHTML = '<i class="ovg-fill" data-ov-icon="' + (g.muted ? 'mute' : 'volume') + '" 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 () {
- const b = boxOf(g, item.aspect);
- box.style.left = (clamp(g.x, 0, Math.max(0, 1 - b.wf)) * 100) + '%';
- box.style.top = (clamp(g.y, 0, Math.max(0, 1 - b.hf)) * 100) + '%';
- box.style.width = (b.wf * 100) + '%';
- box.style.aspectRatio = String(b.a);
- box.classList.toggle('is-portrait', b.a < 1);
- };
- const settle = function () {
- const b = boxOf(g, item.aspect);
- const maxX = Math.max(0, 1 - b.wf), maxY = Math.max(0, 1 - b.hf);
- const mx = b.rw ? 12 / b.rw : 0, my = b.rh ? 12 / b.rh : 0;
- g.x = clamp(g.x, 0, maxX); g.y = clamp(g.y, 0, maxY);
- if (g.x < 0.04) g.x = Math.min(mx, maxX); else if (g.x > maxX - 0.04) g.x = Math.max(0, maxX - mx);
- if (g.y < 0.04) g.y = Math.min(my, maxY); else if (g.y > maxY - 0.04) g.y = Math.max(0, maxY - my);
- };
- const readShape = function () {
- if (!v.videoWidth || !v.videoHeight) return;
- const a = v.videoWidth / v.videoHeight;
- if (item.aspect && Math.abs(item.aspect - a) < 0.01) return;
- item.aspect = a;
- place();
- };
- v.addEventListener('loadedmetadata', readShape);
- v.addEventListener('resize', readShape);
- item.place = place;
- 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 k = Math.sqrt((item.aspect || BASE) / BASE) || 1;
- if (drag.resize) g.w = clamp(drag.w + (e.clientX - drag.px) / drag.rw / k, 0.08, 0.6);
- else {
- const b = boxOf(g, item.aspect);
- g.x = clamp(drag.x + (e.clientX - drag.px) / drag.rw, 0, Math.max(0, 1 - b.wf));
- g.y = clamp(drag.y + (e.clientY - drag.py) / drag.rh, 0, Math.max(0, 1 - b.hf));
- }
- place();
- });
- const end = function () { if (!drag) return; drag = null; box.classList.remove('is-moving'); settle(); place(); keep(); };
- box.addEventListener('pointerup', end);
- box.addEventListener('pointercancel', end);
- paintMute();
- place();
- return item;
- });
- // The player changes size (a window, full screen, a phone turned): each PIP's height cap follows.
- if (typeof ResizeObserver === 'function') new ResizeObserver(function () { items.forEach(function (it) { if (it.place) it.place(); }); }).observe(root);
- // 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]');
@@ -661,7 +483,8 @@
@@ -661,7 +483,8 @@
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);
+ // The PIPs the recording's stream had on screen play over it, in sync (public/js/vod-pip.js).
+ if (window.VodPip) window.VodPip.attach(video, { attachSource: attachSource });
});
});
// 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
new file mode 100644
new file mode 100644
@@ -0,0 +1,574 @@
@@ -0,0 +1,574 @@
+/* vod-pip.js — the picture-in-pictures that were on screen during a recording, played over it again.
+ *
+ * OpenVibe.Live records each PIP span (streaming/pip-history.js) and its VOD API answers them as
+ * [{ vod_id, file, hls, from, to, offset, x, y, w, muted, missing }] (OpenVibe.MediaHub server/video/pages.js
+ * pipsOf): the PIP VOD's second at `from` is `offset`, so while from <= t < to it plays at offset + (t - from).
+ *
+ * What this file does (owner, 2026-11-13: "the robot PiP freezes on VODs — play it in sync with the main VOD"):
+ * - the PIP is a second <video> that follows the main one: play, pause, seek, speed, and a wait on either side
+ * pauses the other; drift over 0.3 s is nudged with playbackRate (0.95–1.05 × the main's rate) and over 2 s
+ * is seeked;
+ * - outside its span it hides (no last-frame freeze); several spans may cover the recording, and overlapping
+ * ones are all drawn;
+ * - a span whose PIP VOD Live no longer has says so once, quietly, and hides;
+ * - the box is openvibe-shared/ov-float.js (OVFloat.attach): drag, tuck, size, its place kept per device; a tap
+ * (or Enter) swaps it with the main view, and the box shows the main recording until the next tap.
+ *
+ * The two pure functions the player's correctness rests on are exported (window.VodPip / module.exports):
+ * activeSpan/ pipTimeAt/ hostTimeAt map either direction, and driftCorrection picks none/nudge/seek.
+ */
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) { module.exports = factory(); return; }
+ root.VodPip = factory();
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ const NUDGE_AT = 0.3; // drift (s) that starts the playbackRate nudge
+ const SEEK_AT = 2; // drift (s) that a seek fixes outright
+ const NUDGE_GAIN = 0.1; // 0.1 s of drift → 1% slower/faster
+ const NUDGE_MAX = 0.05; // never outside 0.95–1.05 × the main's rate
+ const TICK_MS = 250; // how often the small player is checked against the big one
+ const LOAD_AHEAD = 10; // seconds before its span that a PIP's file is loaded
+ const TAP_PX = 4; // a press that moves less is a tap (OVFloat's own threshold)
+ const HOLD_MS = 450; // a PIP that waits this long pauses the main one too
+
+ const clamp = (v, lo, hi, dflt) => {
+ const n = Number(v);
+ if (!Number.isFinite(n)) return dflt;
+ return Math.min(hi, Math.max(lo, n));
+ };
+
+ /* ── pure: the two timelines ──────────────────────────────────────────────────────────────────────────── */
+
+ /** The span on screen at `t` (seconds of the main recording), or null: [from, to), latest first on overlap. */
+ function activeSpan(spans, t) {
+ if (!Array.isArray(spans) || !Number.isFinite(t)) return null;
+ let pick = null;
+ for (const span of spans) {
+ if (!span || !Number.isFinite(Number(span.from)) || !Number.isFinite(Number(span.to))) continue;
+ if (t >= Number(span.from) && t < Number(span.to)) pick = span;
+ }
+ return pick;
+ }
+
+ /** The PIP's own second at this second of the main recording: offset + (t - from); null outside the span. */
+ function pipTimeAt(span, t) {
+ if (!span || !Number.isFinite(t)) return null;
+ const from = Number(span.from), to = Number(span.to), offset = Number(span.offset);
+ if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isFinite(offset)) return null;
+ if (t < from || t >= to) return null;
+ return offset + (t - from);
+ }
+
+ /** The main recording's second for this second of the PIP's VOD (the inverse mapping); null outside it. */
+ function hostTimeAt(span, pipTime) {
+ if (!span || !Number.isFinite(pipTime)) return null;
+ const from = Number(span.from), to = Number(span.to), offset = Number(span.offset);
+ if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isFinite(offset)) return null;
+ const end = offset + (to - from);
+ if (pipTime < offset || pipTime >= end) return null;
+ return from + (pipTime - offset);
+ }
+
+ /**
+ * What to do about `actual` (the PIP's currentTime) when it should be at `expected`.
+ * { action: 'none' | 'nudge' | 'seek', rate, time }
+ * A seek beyond 2 s, a nudge between 0.3 s and 2 s (0.95–1.05 × baseRate, proportional to the drift), and
+ * `rate: baseRate` in the dead zone. The factor is within 0.95–1.05 whatever the main's speed; `rate` is
+ * the factor times baseRate so a 2× main stays 2× while it catches up.
+ */
+ function driftCorrection(actual, expected, baseRate) {
+ const base = Number.isFinite(Number(baseRate)) && Number(baseRate) > 0 ? Number(baseRate) : 1;
+ if (!Number.isFinite(Number(expected))) return { action: 'none', rate: base, time: null, drift: 0, factor: 1 };
+ if (!Number.isFinite(Number(actual))) return { action: 'seek', rate: base, time: Number(expected), drift: 0, factor: 1 };
+ const d = Number(actual) - Number(expected);
+ const abs = Math.abs(d);
+ if (abs > SEEK_AT + 1e-9) return { action: 'seek', rate: base, time: Number(expected), drift: d, factor: 1 };
+ if (abs > NUDGE_AT + 1e-9) {
+ const factor = Math.min(1 + NUDGE_MAX, Math.max(1 - NUDGE_MAX, 1 - d * NUDGE_GAIN));
+ return { action: 'nudge', rate: base * factor, time: null, drift: d, factor };
+ }
+ return { action: 'none', rate: base, time: null, drift: d, factor: 1 };
+ }
+
+ /* ── the player ───────────────────────────────────────────────────────────────────────────────────────── */
+
+ /** /watch?v=<id>&t=… keeps one place per recording: the moment is not part of the key. */
+ function pageKey() {
+ if (typeof location === 'undefined') return '';
+ try {
+ const u = new URL(location.href);
+ u.searchParams.delete('t');
+ return u.pathname + (u.search || '');
+ } catch { return location.pathname || ''; }
+ }
+
+ function glyph(name) {
+ return '<i class="ovg-fill" data-ov-icon="' + name + '" aria-hidden="true"></i>';
+ }
+
+ /**
+ * Put the PIPs over `main` (the page's video[data-ovv-video]).
+ * options.data the PIP list (default: the [data-ovv-pips] JSON beside the player)
+ * options.attachSource the page's HLS/file loader, if a PIP VOD has a playlist (video.js's attachSource)
+ * options.root the frame the float docks in (default: the shared player's .ov-player)
+ * Returns { items, spans, swap, destroy, sync } for tests and the page.
+ */
+ function attach(main, options) {
+ const o = options || {};
+ if (!main || typeof main.addEventListener !== 'function') return null;
+ let spans = o.data;
+ if (!Array.isArray(spans)) {
+ const holder = main.parentNode && main.parentNode.querySelector('[data-ovv-pips]');
+ try { spans = JSON.parse(holder ? holder.textContent : (document.querySelector('[data-ovv-pips]') || {}).textContent || '[]'); } catch { spans = []; }
+ }
+ if (!Array.isArray(spans) || !spans.length) return null;
+
+ const root = o.root || main.closest('.ov-player') || main.parentNode;
+ if (!root) return null;
+ const loader = typeof o.attachSource === 'function' ? o.attachSource : null;
+ const state = { items: [], missing: [], swapped: null, host: null, intent: main.paused ? 'pause' : 'play',
+ bufferHold: false, swapping: false, missingShown: false, destroyed: false, timer: 0 };
+ const valid = spans.filter((p) => p && !p.missing && p.file && Number.isFinite(Number(p.from)) && Number(p.to) > Number(p.from)
+ && Number.isFinite(Number(p.offset)));
+ const missing = spans.filter((p) => p && p.missing && Number.isFinite(Number(p.from)) && Number(p.to) > Number(p.from));
+ if (!valid.length && !missing.length) return null;
+
+ const tick = () => { if (!state.destroyed) sync(false); };
+
+ function makeIt(p) {
+ const it = { p: p, box: null, video: null, name: null, mute: null, swapBtn: null, float: null,
+ aspect: 16 / 9, loaded: false, waiting: false, waitTimer: 0, primed: false, needsAlign: false, started: false,
+ g: { muted: p.muted !== false } };
+ return it;
+ }
+
+ function paintMute(it) {
+ if (!it.mute || !it.video) return;
+ const muted = !!(it.g && it.g.muted) || main.muted;
+ it.video.muted = muted;
+ it.mute.innerHTML = glyph(muted ? 'mute' : 'volume');
+ const what = state.swapped === it ? 'the main recording' : it.p.title;
+ it.mute.title = (muted ? 'Turn on ' : 'Mute ') + what;
+ it.mute.setAttribute('aria-label', it.mute.title);
+ it.mute.hidden = state.swapped === it;
+ }
+
+ function paintLabel(it) {
+ if (!it.name) return;
+ const swapped = state.swapped === it;
+ it.box.classList.toggle('is-swapped', swapped);
+ it.name.textContent = swapped ? (document.querySelector('.ovv-head h1') || {}).textContent || 'The main recording' : it.p.title;
+ it.name.href = swapped ? location.pathname + location.search : it.p.href;
+ it.box.setAttribute('aria-label', swapped
+ ? 'Showing the main recording. Tap to swap back.'
+ : 'Picture-in-picture: ' + it.p.title + '. Tap to swap it with the main video.');
+ if (it.swapBtn) it.swapBtn.title = swapped ? 'Swap back with the main video' : 'Swap with the main video';
+ paintMute(it);
+ }
+
+ function setSource(video, file, hls, hlsLib) {
+ if (!video || !file) return Promise.resolve();
+ if (video._ovvHls) { try { video._ovvHls.destroy(); } catch { /* gone */ } video._ovvHls = null; }
+ video.removeAttribute('data-ovv-hls');
+ if (hls) {
+ video.setAttribute('data-ovv-hls', hls);
+ if (hlsLib) video.setAttribute('data-ovv-hls-lib', hlsLib);
+ } else {
+ video.removeAttribute('data-ovv-hls-lib');
+ }
+ video.setAttribute('src', file);
+ return loader ? Promise.resolve(loader(video)).catch(() => {}) : Promise.resolve();
+ }
+
+ function whenMetadata(video) {
+ return new Promise((resolve) => {
+ if (!video) { resolve(); return; }
+ if (video.readyState >= 1) { resolve(); return; }
+ let done = false;
+ const finish = () => { if (done) return; done = true; video.removeEventListener('loadedmetadata', finish); resolve(); };
+ video.addEventListener('loadedmetadata', finish, { once: true });
+ setTimeout(finish, 4000);
+ });
+ }
+
+ function readShape(it, video) {
+ if (!video || !video.videoWidth || !video.videoHeight) return;
+ it.aspect = video.videoWidth / video.videoHeight;
+ if (it.float) it.float.setAspect(it.aspect);
+ }
+
+ function ensureBuilt(it) {
+ if (it.box) return it;
+ const p = it.p;
+ const box = document.createElement('div');
+ box.className = 'ovv-pip';
+ box.hidden = true;
+ box.tabIndex = 0;
+ box.setAttribute('role', 'group');
+ box.setAttribute('aria-label', 'Picture-in-picture: ' + p.title + '. Tap to swap it with the main video.');
+ const video = document.createElement('video');
+ video.className = 'ovv-pip-video';
+ video.playsInline = true;
+ video.preload = 'none';
+ video.muted = true;
+ video.setAttribute('aria-hidden', '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 swapBtn = document.createElement('button');
+ swapBtn.type = 'button';
+ swapBtn.innerHTML = glyph('arrows-h');
+ swapBtn.title = 'Swap with the main video';
+ swapBtn.setAttribute('aria-label', 'Swap ' + p.title + ' with the main video');
+ const mute = document.createElement('button');
+ mute.type = 'button';
+ mute.innerHTML = glyph('mute');
+ bar.append(name, swapBtn, mute);
+ box.append(video, bar);
+ root.appendChild(box);
+ it.box = box; it.video = video; it.name = name; it.mute = mute; it.swapBtn = swapBtn;
+
+ box.addEventListener('click', (e) => { e.stopPropagation(); });
+ swapBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); swap(it); });
+ mute.addEventListener('click', (e) => {
+ e.preventDefault(); e.stopPropagation();
+ if (!it.g) return;
+ it.g.muted = !it.g.muted;
+ if (it.float) it.float.set({ muted: it.g.muted }, { save: true });
+ paintMute(it);
+ });
+ video.addEventListener('loadedmetadata', () => { readShape(it, video); it.primed = false; });
+ video.addEventListener('resize', () => readShape(it, video));
+ video.addEventListener('waiting', () => waiting(it));
+ video.addEventListener('stalled', () => waiting(it));
+ ['playing', 'canplay', 'seeked'].forEach((ev) => video.addEventListener(ev, () => ready(it)));
+ // Its media clock starts a little after play() resolves (and after a buffer): align once more on the next
+ // tick, so a slow start does not leave it half a second behind waiting for the 5% nudge to catch up.
+ video.addEventListener('playing', () => {
+ if (it.started) return;
+ it.started = true;
+ it.needsAlign = true;
+ });
+ video.addEventListener('play', () => { if (state.bufferHold) video.pause(); });
+ video.addEventListener('error', () => { it.waiting = false; });
+
+ // A tap on the box (not its buttons or title link, and not a drag) swaps it with the main view. OVFloat
+ // takes the pointer for its move; this listener sees the same pointer first and only counts a still one.
+ let tap = null;
+ box.addEventListener('pointerdown', (e) => {
+ if (e.button > 0 || (e.target.closest && e.target.closest('button, a, .ov-float-resize, [data-ov-float-resize]'))) { tap = null; return; }
+ tap = { id: e.pointerId, x: e.clientX, y: e.clientY };
+ }, true);
+ box.addEventListener('pointerup', (e) => {
+ const s = tap; tap = null;
+ if (!s || s.id !== e.pointerId || state.swapping) return;
+ if (Math.hypot(e.clientX - s.x, e.clientY - s.y) > TAP_PX) return;
+ if (it.float && it.float.geom().tuck) return; // a tucked handle's tap opens it instead
+ swap(it);
+ }, true);
+ box.addEventListener('keydown', (e) => {
+ if (e.key !== 'Enter' || e.target.closest('button, a')) return;
+ e.preventDefault(); e.stopPropagation();
+ swap(it);
+ });
+ return it;
+ }
+
+ function engine(it) {
+ if (it.float || !root || typeof window === 'undefined' || !window.OVFloat) {
+ if (!it.float && it.box && !it.box.style.width) {
+ // No shared engine (a page without /shared/ov-float.js): show the PIP where the streamer put it.
+ it.box.style.left = clamp(it.p.x, 0, 1, 0.72) * 100 + '%';
+ it.box.style.top = clamp(it.p.y, 0, 1, 0.7) * 100 + '%';
+ it.box.style.width = clamp(it.p.w, 0.08, 0.6, 0.25) * 100 + '%';
+ it.box.style.aspectRatio = '16 / 9';
+ }
+ return it.float;
+ }
+ it.float = window.OVFloat.attach(it.box, {
+ frame: root,
+ key: 'ovv-pip-' + pageKey() + ':' + it.p.vod_id,
+ store: null, // the place is this device's (localStorage), not the account's
+ defaults: { x: it.p.x, y: it.p.y, w: it.p.w, mode: 'dock', muted: it.g.muted },
+ aspect: it.aspect || 16 / 9,
+ minPx: 150,
+ doubleTap: false, // a tap is the swap; the corner/pinch still sizes it
+ onChange: (geom) => { it.g = geom; paintMute(it); },
+ });
+ it.g = it.float.geom();
+ paintMute(it);
+ return it.float;
+ }
+
+ function load(it) {
+ if (it.loaded) return;
+ it.loaded = true;
+ it.video.preload = 'auto';
+ setSource(it.video, it.p.file, it.p.hls, main.getAttribute('data-ovv-hls-lib'));
+ }
+
+ function waiting(it) {
+ if (!it.video || it.box.hidden || !isActiveNow(it)) return;
+ if (it.waiting) return;
+ it.waiting = true;
+ clearTimeout(it.waitTimer);
+ it.waitTimer = setTimeout(() => {
+ if (!it.waiting || state.destroyed || it.box.hidden || main.paused || state.bufferHold) return;
+ state.bufferHold = true;
+ main.pause();
+ pauseAll();
+ }, HOLD_MS);
+ }
+
+ function ready(it) {
+ it.waiting = false;
+ clearTimeout(it.waitTimer);
+ if (!state.bufferHold) return;
+ state.bufferHold = false;
+ it.needsAlign = true;
+ if (state.intent === 'play') {
+ const played = main.play();
+ if (played && played.catch) played.catch(() => {});
+ playAll();
+ }
+ }
+
+ function isActiveNow(it) {
+ const t = main.currentTime;
+ if (state.swapped) return state.swapped === it && inSwapped(it, t);
+ return t >= Number(it.p.from) && t < Number(it.p.to);
+ }
+
+ function inSwapped(it, t) {
+ return Number.isFinite(t) && t >= Number(it.p.offset) && t < Number(it.p.offset) + (Number(it.p.to) - Number(it.p.from));
+ }
+
+ function expectedOf(it, t) {
+ return state.swapped === it ? hostTimeAt(it.p, t) : pipTimeAt(it.p, t);
+ }
+
+ function pauseAll() {
+ for (const it of state.items) if (it.video && !it.video.paused) it.video.pause();
+ }
+
+ function playAll() {
+ if (state.bufferHold || state.intent !== 'play' || main.paused) return;
+ for (const it of state.items) {
+ if (!it.video || it.box.hidden || it.video.readyState < 1) continue;
+ if (it.video.paused) { const p = it.video.play(); if (p && p.catch) p.catch(() => {}); }
+ }
+ }
+
+ function noteMissing(t) {
+ if (state.missingShown || state.swapped) return;
+ const span = missing.find((p) => t >= Number(p.from) && t < Number(p.to));
+ if (!span) return;
+ state.missingShown = true;
+ let note = document.querySelector('.ovv-pip-note');
+ if (!note) {
+ note = document.createElement('p');
+ note.className = 'ovv-pip-note';
+ note.setAttribute('role', 'status');
+ const wrap = main.closest('.ovv-player-wrap') || root.parentNode || root;
+ if (wrap && wrap.parentNode) wrap.parentNode.insertBefore(note, wrap.nextSibling);
+ else root.appendChild(note);
+ }
+ note.textContent = (span.title ? '“' + span.title + '”' : 'A picture-in-picture') + ' is no longer available, so it is not shown here.';
+ note.hidden = false;
+ }
+
+ function sync(force) {
+ if (state.destroyed || !main || state.swapping) return;
+ const t = main.currentTime;
+ if (!Number.isFinite(t) || main.readyState < 1) return;
+ const wanted = new Set();
+ if (state.swapped) {
+ const it = state.swapped;
+ if (inSwapped(it, t)) wanted.add(it);
+ } else {
+ for (const it of state.items) {
+ if (t >= Number(it.p.from) - LOAD_AHEAD && t < Number(it.p.to)) { ensureBuilt(it); load(it); }
+ if (t >= Number(it.p.from) && t < Number(it.p.to)) wanted.add(it);
+ }
+ }
+ for (const it of state.items) {
+ if (!it.box) continue;
+ const on = wanted.has(it);
+ if (on) {
+ if (it.box.hidden) {
+ it.box.hidden = false;
+ engine(it);
+ if (it.float) it.float.relayout();
+ it.primed = false;
+ }
+ if (!it.video || it.video.readyState < 1) continue;
+ const expected = expectedOf(it, t);
+ if (expected == null) { it.box.hidden = true; it.video.pause(); continue; }
+ const duration = Number(it.video.duration);
+ if (Number.isFinite(duration) && duration > 0 && expected >= duration - 0.05) {
+ it.box.hidden = true;
+ if (!it.video.paused) it.video.pause();
+ continue;
+ }
+ const base = Number(main.playbackRate) || 1;
+ const actual = Number(it.video.currentTime) || 0;
+ const c = driftCorrection(actual, expected, base);
+ if (force || !it.primed || it.needsAlign) {
+ const at = Math.max(0, Math.min(expected, Number.isFinite(duration) && duration > 0 ? duration - 0.05 : expected));
+ if (Math.abs(actual - at) > 0.08 && it.video.readyState >= 1) it.video.currentTime = at;
+ it.video.playbackRate = base;
+ it.primed = true;
+ it.needsAlign = false;
+ } else if (!main.paused && !it.video.paused && it.video.readyState >= 2) {
+ if (c.action === 'seek') it.video.currentTime = c.time;
+ else it.video.playbackRate = c.rate;
+ } else {
+ it.video.playbackRate = base;
+ }
+ paintMute(it);
+ if (main.paused || state.bufferHold) { if (!it.video.paused) it.video.pause(); }
+ else if (it.video.paused && !state.bufferHold) { const p = it.video.play(); if (p && p.catch) p.catch(() => {}); }
+ } else if (!it.box.hidden) {
+ it.box.hidden = true;
+ if (it.video && !it.video.paused) it.video.pause();
+ if (it.float) it.float.save();
+ }
+ }
+ noteMissing(t);
+ }
+
+ /* ── swap: main <-> the floating view, sources and times exchanged ───────────────────────────────── */
+
+ function snapshotHost() {
+ return {
+ file: main.getAttribute('src'),
+ hls: main.getAttribute('data-ovv-hls'),
+ hlsLib: main.getAttribute('data-ovv-hls-lib'),
+ poster: main.getAttribute('poster'),
+ tracks: Array.prototype.slice.call(main.querySelectorAll('track')),
+ crossorigin: main.getAttribute('crossorigin'),
+ };
+ }
+
+ function restoreHostTracks(host) {
+ main.querySelectorAll('track').forEach((t) => t.remove());
+ for (const t of host.tracks) main.appendChild(t);
+ }
+
+ function swap(it) {
+ if (!it || !it.loaded || !it.video || state.swapping || it.p.missing) return;
+ const back = state.swapped === it;
+ if (!back && state.swapped) return; // swap the shown one back first
+ if (back && !state.host) return;
+ const wasPlaying = !main.paused;
+ state.swapping = true;
+ const t = main.currentTime;
+ main.pause();
+ pauseAll();
+ if (!back) {
+ const pt = pipTimeAt(it.p, t);
+ if (pt == null) { state.swapping = false; return; }
+ if (!state.host) state.host = snapshotHost();
+ const host = state.host;
+ const hostAt = t;
+ state.swapped = it;
+ // The floating view becomes the main recording, at the same second.
+ const hostLoad = setSource(it.video, host.file, host.hls, host.hlsLib).then(() => whenMetadata(it.video)).then(() => {
+ if (it.video.readyState >= 1) it.video.currentTime = Math.max(0, hostAt);
+ it.video.muted = true;
+ it.primed = false;
+ });
+ // The main player becomes the PIP's recording, at the mapped second.
+ const pipLoad = setSource(main, it.p.file, it.p.hls, host.hlsLib).then(() => whenMetadata(main)).then(() => {
+ if (main.readyState >= 1) main.currentTime = Math.max(0, pt);
+ restoreHostTracks({ tracks: [] });
+ main.removeAttribute('poster');
+ });
+ Promise.all([hostLoad, pipLoad]).then(() => {
+ state.swapping = false;
+ state.intent = wasPlaying ? 'play' : 'pause';
+ paintLabel(it);
+ for (const other of state.items) if (other !== it && other.box) other.box.hidden = true;
+ sync(true);
+ if (wasPlaying) { const p = main.play(); if (p && p.catch) p.catch(() => {}); playAll(); }
+ });
+ return;
+ }
+ const pt = main.currentTime;
+ const hostAt = it.video.currentTime;
+ state.swapped = null;
+ const backMain = setSource(main, state.host.file, state.host.hls, state.host.hlsLib).then(() => whenMetadata(main)).then(() => {
+ if (main.readyState >= 1) main.currentTime = Math.max(0, hostAt);
+ if (state.host.poster) main.setAttribute('poster', state.host.poster); else main.removeAttribute('poster');
+ if (state.host.crossorigin) main.setAttribute('crossorigin', state.host.crossorigin);
+ restoreHostTracks(state.host);
+ });
+ const backPip = setSource(it.video, it.p.file, it.p.hls, state.host.hlsLib).then(() => whenMetadata(it.video)).then(() => {
+ if (it.video.readyState >= 1) it.video.currentTime = Math.max(0, pt);
+ it.primed = false;
+ });
+ Promise.all([backMain, backPip]).then(() => {
+ state.swapping = false;
+ state.intent = wasPlaying ? 'play' : 'pause';
+ paintLabel(it);
+ paintMute(it);
+ sync(true);
+ if (wasPlaying) { const p = main.play(); if (p && p.catch) p.catch(() => {}); playAll(); }
+ });
+ }
+
+ /* ── wire it to the main player ─────────────────────────────────────────────────────────────────── */
+
+ state.items = valid.map(makeIt);
+ state.missing = missing;
+
+ main.addEventListener('play', () => {
+ if (!state.bufferHold && !state.swapping) state.intent = 'play';
+ for (const it of state.items) it.primed = false; // a resume after a pause starts the clock again
+ sync(true);
+ });
+ main.addEventListener('pause', () => {
+ if (state.bufferHold || state.swapping) return;
+ state.intent = 'pause';
+ pauseAll();
+ });
+ main.addEventListener('timeupdate', () => sync(false));
+ main.addEventListener('ratechange', () => sync(false));
+ main.addEventListener('volumechange', () => { for (const it of state.items) paintMute(it); });
+ main.addEventListener('seeking', () => { for (const it of state.items) it.primed = false; });
+ main.addEventListener('seeked', () => { for (const it of state.items) it.primed = false; sync(true); });
+ main.addEventListener('waiting', () => { pauseAll(); });
+ main.addEventListener('playing', () => {
+ for (const it of state.items) it.primed = false; // after its own wait, both start together again
+ if (state.intent === 'play' && !state.bufferHold) { playAll(); sync(true); }
+ });
+ main.addEventListener('ended', () => pauseAll());
+
+ state.timer = setInterval(tick, TICK_MS);
+ sync(true);
+
+ return {
+ items: state.items,
+ spans: spans,
+ missing: state.missing,
+ swap: (it) => swap(it || state.swapped || state.items[0]),
+ sync: (force) => sync(force),
+ destroy() {
+ state.destroyed = true;
+ clearInterval(state.timer);
+ for (const it of state.items) {
+ clearTimeout(it.waitTimer);
+ if (it.float) it.float.destroy();
+ if (it.box) it.box.remove();
+ }
+ state.items = [];
+ },
+ };
+ }
+
+ return { attach, activeSpan, pipTimeAt, hostTimeAt, driftCorrection, NUDGE_AT, SEEK_AT, NUDGE_MAX };
+});
@@ -196,32 +196,49 @@ function hlsAttrs(item) {
@@ -196,32 +196,49 @@ function hlsAttrs(item) {
/**
/**
* The picture-in-pictures that were on screen during a recording (Live's `vod.pips`, OpenVibe.Live
* 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.
+ * streaming/pip-history.js), with what public/js/vod-pip.js needs to play each over the player: its `vod_id`, 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). A VOD Live no longer answers stays in the list as
+ * `missing` (file null), so the player can hide that span and say so once.
*/
*/
async function pipsOf(vod, live) {
async function pipsOf(vod, live) {
const list = (Array.isArray(vod.pips) ? vod.pips : []).filter((p) => p && live.isId(String(p.vod_id))
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);
&& Number.isFinite(Number(p.from)) && Number(p.to) > Number(p.from)).slice(0, 60);
if (!list.length) return [];
if (!list.length) return [];
const got = new Map();
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 ids = [...new Set(list.map((p) => String(p.vod_id)))];
+ // Six Live answers are enough for one page; a PIP beyond that is left out rather than guessed at.
+ const attempted = new Set(ids.slice(0, 6));
+ await Promise.all(ids.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 missing */ }
}));
}));
const unit = (v, lo, hi, dflt) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; };
const unit = (v, lo, hi, dflt) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : dflt; };
const out = [];
const out = [];
for (const p of list) {
for (const p of list) {
- const pv = got.get(String(p.vod_id));
+ const pid = String(p.vod_id);
+ const pv = got.get(pid);
const file = pv && fileOf(pv);
const file = pv && fileOf(pv);
- if (!file) continue;
+ const title = String(p.title || (pv && pv.title) || 'Picture-in-picture').slice(0, 120);
+ const href = `${pv && pv.watch_id ? `/watch?v=${pv.watch_id}` : `/v/${pid}`}${Number(p.offset) >= 1 ? `${pv && pv.watch_id ? '&' : '?'}t=${Math.floor(Number(p.offset))}` : ''}`;
+ // A span whose VOD Live no longer answers stays in the list as `missing`: the player hides it and says so
+ // once, quietly, at its moment (a robot camera deleted or made private after the stream, 2026-11-13).
+ if (!file) {
+ if (!attempted.has(pid)) continue;
+ out.push({
+ vod_id: Number(p.vod_id), missing: true, file: null, 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, href,
+ });
+ continue;
+ }
const hls = httpsUrl(pv.hls_url);
const hls = httpsUrl(pv.hls_url);
out.push({
out.push({
- file, hls: hls && new URL(hls).origin === new URL(file).origin ? hls : null,
+ vod_id: Number(p.vod_id), 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),
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,
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: `${pv && pv.watch_id ? `/watch?v=${pv.watch_id}` : `/v/${String(p.vod_id)}`}${Number(p.offset) >= 1 ? `${pv && pv.watch_id ? '&' : '?'}t=${Math.floor(Number(p.offset))}` : ''}`,
+ title, href,
});
});
}
}
return out;
return out;
@@ -1585,7 +1602,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1585,7 +1602,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
- scripts: ['js/video.js', 'js/comments.js'],
+ sharedScripts: ['ov-float.js'],
+ scripts: ['js/vod-pip.js', 'js/video.js', 'js/comments.js'],
localStyles: [...(manageable ? ['css/labels.css'] : [])],
localStyles: [...(manageable ? ['css/labels.css'] : [])],
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
body: html`<div class="ovv-watch ovv-watch--vod">
new file mode 100644
new file mode 100644
Binary files /dev/null and b/test/fixtures/vod-main.webm differ
Binary files /dev/null and b/test/fixtures/vod-main.webm differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/test/fixtures/vod-pip.webm differ
Binary files /dev/null and b/test/fixtures/vod-pip.webm differ
@@ -62,7 +62,10 @@ const get = (base, p) => new Promise((resolve, reject) => {
@@ -62,7 +62,10 @@ const get = (base, p) => new Promise((resolve, reject) => {
assert.deepStrictEqual(pips.map((p) => [p.file, p.hls, p.from, p.to, p.offset, p.x, p.w, p.muted, p.href]), [
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/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'],
['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");
+ [null, null, 10, 20, 0, 0.72, 0.25, true, '/v/803'],
+ ], "a PIP whose VOD Live no longer answers stays as missing; a bad id and a backwards window are left out; a playlist on another origin than the file is not used");
+ assert.strictEqual(pips[2].missing, true, 'the missing span is marked, so the player can say so once');
+ assert.deepStrictEqual(pips.map((p) => p.vod_id), [801, 802, 803], 'each span names its own VOD');
assert.strictEqual(pips[1].title, 'Co-host </script><b>', 'the title arrives as it is');
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');
assert.ok(!m[1].includes('</script>') && m[1].includes('\\u003c/script>'), 'and never closes the data block early');
});
});
@@ -73,23 +76,23 @@ const get = (base, p) => new Promise((resolve, reject) => {
@@ -73,23 +76,23 @@ const get = (base, p) => new Promise((resolve, reject) => {
assert.ok(!r.text.includes('data-ovv-pips'));
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 () => {
+ await check('the watch page loads OVFloat and vod-pip.js, and video.js hands the PIPs to it', async () => {
+ const r = await get(t.base, '/v/800');
+ assert.ok(/\/shared\/ov-float\.js\?v=/.test(r.text), 'the shared float system is on the page');
+ assert.ok(r.text.indexOf('/js/vod-pip.js?v=') < r.text.indexOf('/js/video.js?v='), 'the PIP engine loads before the page script');
const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'video.js'), 'utf8');
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');
+ assert.ok(js.includes('if (window.VodPip) window.VodPip.attach(video, { attachSource: attachSource });'), 'started once the main video has its source');
+ const pip = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'vod-pip.js'), 'utf8');
+ assert.ok(pip.includes('window.OVFloat.attach(it.box'), 'the box uses the shared float system');
+ assert.ok(pip.includes("main.addEventListener('seeked'") && pip.includes('sync(true)'), 'a seek on the main seeks the PIP');
+ assert.ok(pip.includes('driftCorrection(actual, expected, base)'), 'drift is corrected from the main timeline');
+ assert.ok(pip.includes('state.bufferHold'), 'a wait on either side pauses the other');
+ assert.ok(pip.includes('function swap(it)') && pip.includes('pointerup'), 'a tap (or Enter) swaps it with the main view');
+ assert.ok(pip.includes("'ovv-pip-' + pageKey()"), 'where a viewer put it is remembered for this recording');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'video.css'), 'utf8');
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)');
- // Its own shape (2026-11-07: a portrait camera was cut to 16:9): the box follows the video, the video is contained.
- assert.ok(/\.ovv-pip-video \{[^}]*object-fit: contain/.test(css) && !/\.ovv-pip-video \{[^}]*object-fit: cover/.test(css), 'the recording is shown whole, never cropped');
- assert.ok(js.includes("v.addEventListener('loadedmetadata', readShape);") && js.includes("v.addEventListener('resize', readShape);"), 'the shape comes from the video, and follows a turned phone');
- assert.ok(js.includes('box.style.aspectRatio = String(b.a);'), 'the box takes that shape');
- assert.ok(js.includes('0.85 * r.height'), 'never taller than 85% of the player');
+ assert.ok(/\.ovv-pip \{[^}]*z-index: 2;/.test(css), 'above the picture, under the controls (3)');
+ assert.ok(/\.ovv-pip-video \{[^}]*object-fit: contain/.test(css), 'the recording is shown whole, never cropped');
+ assert.ok(/\.ovv-pip-note \{/.test(css), 'the one quiet line for a missing PIP has its style');
});
});
await t.close();
await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,223 @@
@@ -0,0 +1,223 @@
+'use strict';
+/**
+ * The VOD picture-in-picture in a real headless Chrome, with two short local videos:
+ * - the PIP follows a seek on the main one (its currentTime is offset + (main - from)), and works across
+ * several seeks;
+ * - pause, speed and the end of the span follow too, and drift over 0.3 s is corrected with a playbackRate nudge;
+ * - a tap on the box swaps it with the main view and a second tap swaps back;
+ * - a span whose VOD is missing says so once, quietly, and draws no PIP.
+ *
+ * node test/vod-pip-chrome.test.js
+ * PLAYWRIGHT_PATH=/path/to/playwright CHROME_BIN=/usr/bin/google-chrome node test/vod-pip-chrome.test.js
+ *
+ * Skips (exit 0) when neither Playwright nor Chrome is available, like the shared browser tests.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const http = require('node:http');
+const path = require('node:path');
+
+function findPlaywright() {
+ for (const where of [process.env.PLAYWRIGHT_PATH, 'playwright', '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright']) {
+ if (!where) continue;
+ try { return require(where); } catch { /* next */ }
+ }
+ return null;
+}
+const playwright = findPlaywright();
+const chrome = process.env.CHROME_BIN || '/usr/bin/google-chrome';
+if (!playwright || !fs.existsSync(chrome)) {
+ console.log(`vod-pip-chrome: skipped (${playwright ? 'no Chrome' : 'no Playwright'}; set PLAYWRIGHT_PATH / CHROME_BIN)`);
+ process.exit(0);
+}
+
+const root = path.join(__dirname, '..');
+const sharedRoot = path.dirname(require.resolve('openvibe-shared/files'));
+const fixture = (name) => fs.readFileSync(path.join(root, 'test', 'fixtures', name));
+const PIP = {
+ vod_id: 801, file: '/pip.webm', hls: null, from: 1, to: 5, offset: 0.5,
+ x: 0.66, y: 0.14, w: 0.3, muted: true, title: 'Robot camera', href: '/v/801',
+};
+const MISSING = { vod_id: 899, missing: true, file: null, hls: null, from: 4.5, to: 5.5, offset: 0, x: 0.1, y: 0.1, w: 0.2, muted: true, title: 'Gone camera', href: '/v/899' };
+
+const page0 = `<!doctype html><meta charset="utf-8"><title>VOD PiP test</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<style>body{margin:0;background:#111;color:#eee;font:14px system-ui}#frame{position:relative;width:640px;height:360px;background:#000;margin:20px}
+#main{width:100%;height:100%;display:block;object-fit:contain}</style>
+<div id="frame" class="ov-player"><video id="main" src="/main.webm" playsinline preload="metadata"></video></div>
+<script src="/ov-float.js"></script>
+<script src="/vod-pip.js"></script>
+<script>
+window.attachCalls = 0;
+function attachSource(video) { window.attachCalls += 1; return Promise.resolve('file'); }
+window.api = VodPip.attach(document.getElementById('main'), {
+ data: ${JSON.stringify([PIP, MISSING])},
+ attachSource: attachSource,
+});
+</script>`;
+
+const server = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ if (url.pathname === '/main.webm' || url.pathname === '/pip.webm') {
+ const data = fixture(url.pathname === '/main.webm' ? 'vod-main.webm' : 'vod-pip.webm');
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+ if (range) {
+ const start = range[1] ? Number(range[1]) : 0;
+ const end = range[2] ? Math.min(Number(range[2]), data.length - 1) : data.length - 1;
+ res.writeHead(206, {
+ 'content-type': 'video/webm', 'cache-control': 'no-store', 'accept-ranges': 'bytes',
+ 'content-range': `bytes ${start}-${end}/${data.length}`, 'content-length': end - start + 1,
+ });
+ res.end(data.subarray(start, end + 1));
+ return;
+ }
+ res.writeHead(200, { 'content-type': 'video/webm', 'cache-control': 'no-store', 'accept-ranges': 'bytes', 'content-length': data.length });
+ res.end(data);
+ return;
+ }
+ if (url.pathname === '/ov-float.js') {
+ res.writeHead(200, { 'content-type': 'application/javascript' });
+ res.end(fs.readFileSync(path.join(sharedRoot, 'ov-float.js')));
+ return;
+ }
+ if (url.pathname === '/vod-pip.js') {
+ res.writeHead(200, { 'content-type': 'application/javascript' });
+ res.end(fs.readFileSync(path.join(root, 'public', 'js', 'vod-pip.js')));
+ return;
+ }
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ res.end(page0);
+});
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const sample = (page) => page.evaluate(() => {
+ const main = document.getElementById('main');
+ const box = document.querySelector('.ovv-pip');
+ const pip = box && box.querySelector('video');
+ return {
+ main: main.currentTime, mainPaused: main.paused, mainRate: main.playbackRate, mainSrc: main.getAttribute('src') || '',
+ boxHidden: !box || box.hidden, pip: pip ? pip.currentTime : null, pipPaused: pip ? pip.paused : null,
+ pipRate: pip ? pip.playbackRate : null, pipSrc: pip ? (pip.getAttribute('src') || '') : '',
+ note: (document.querySelector('.ovv-pip-note') || {}).textContent || '',
+ };
+});
+const mapped = (main) => PIP.offset + (main - PIP.from);
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}/`;
+ const browser = await playwright.chromium.launch({
+ executablePath: chrome,
+ headless: true,
+ args: ['--no-sandbox', '--disable-dev-shm-usage', '--autoplay-policy=no-user-gesture-required'],
+ });
+ try {
+ const page = await browser.newPage({ viewport: { width: 900, height: 620 } });
+ const errors = [];
+ page.on('pageerror', (e) => errors.push(String(e)));
+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
+ await page.goto(base, { waitUntil: 'load' });
+ await page.waitForFunction(() => document.getElementById('main').readyState >= 1);
+ // Let the first seek land before playing (play() during a seek cancels it in Chrome).
+ await page.evaluate(() => { const m = document.getElementById('main'); m.muted = true; m.currentTime = 2; });
+ await wait(300);
+ await page.evaluate(() => document.getElementById('main').play());
+
+ // The first frame of the PIP: shown inside the span, playing at the mapped second.
+ await page.waitForFunction(() => {
+ const box = document.querySelector('.ovv-pip');
+ const v = box && box.querySelector('video');
+ return box && !box.hidden && v && v.readyState >= 2 && !v.paused;
+ }, null, { timeout: 12000 });
+ await wait(600);
+ let s = await sample(page);
+ assert.ok(!s.boxHidden, 'the PIP shows inside its span');
+ assert.ok(Math.abs(s.pip - mapped(s.main)) < 0.35, `at the mapped second (main ${s.main.toFixed(2)}, PIP ${s.pip.toFixed(2)}, want ${mapped(s.main).toFixed(2)})`);
+ assert.ok(s.pipRate >= 0.95 && s.pipRate <= 1.05, `at the main's speed (${s.pipRate})`);
+
+ // A seek on the main seeks the PIP.
+ for (const at of [3.5, 2.25, 4.2]) {
+ await page.evaluate((t) => { document.getElementById('main').currentTime = t; }, at);
+ await wait(450);
+ s = await sample(page);
+ assert.ok(!s.boxHidden, `the PIP is still shown after seeking to ${at}`);
+ assert.ok(Math.abs(s.pip - mapped(s.main)) < 0.35, `a seek to ${at} moves the PIP (main ${s.main.toFixed(2)}, PIP ${s.pip.toFixed(2)}, want ${mapped(s.main).toFixed(2)})`);
+ }
+
+ // Pause follows.
+ await page.evaluate(() => document.getElementById('main').pause());
+ await wait(350);
+ s = await sample(page);
+ assert.ok(s.pipPaused, 'pausing the main pauses the PIP');
+ const frozen = s.pip;
+ await wait(400);
+ s = await sample(page);
+ assert.equal(s.pip, frozen, 'and it stays put');
+
+ // Rate follows (with the small nudge allowed for drift).
+ await page.evaluate(() => { const m = document.getElementById('main'); m.playbackRate = 1.4; return m.play(); });
+ await wait(700);
+ s = await sample(page);
+ assert.ok(s.pipRate >= 1.4 * 0.95 - 0.01 && s.pipRate <= 1.4 * 1.05 + 0.01, `the PIP plays at the main's 1.4× (${s.pipRate})`);
+
+ // Drift over 0.3 s is nudged, not frozen.
+ await page.evaluate(() => { document.getElementById('main').currentTime = 2.6; });
+ await wait(350);
+ await page.evaluate(() => {
+ const v = document.querySelector('.ovv-pip video');
+ v.currentTime = Math.max(0, v.currentTime - 0.7);
+ });
+ await wait(450);
+ s = await sample(page);
+ assert.ok(s.pipRate > 1.4, `behind the main it speeds up (${s.pipRate})`);
+
+ // The end of the span hides it (no frozen last frame). Pause first so the main does not run past 5 while we look.
+ await page.evaluate(() => document.getElementById('main').pause());
+ await wait(300);
+ await page.evaluate(() => { document.getElementById('main').currentTime = 5.4; });
+ await wait(400);
+ s = await sample(page);
+ assert.ok(s.boxHidden, 'outside [from, to) the PIP hides');
+ assert.ok(s.pipPaused, 'and stops playing');
+
+ // The missing PIP's span: no box, one quiet note.
+ await page.evaluate(() => { document.getElementById('main').currentTime = 4.7; });
+ await wait(400);
+ s = await sample(page);
+ assert.match(s.note, /Gone camera/, 'a span whose VOD is missing says which one');
+ assert.ok(!s.boxHidden, 'the other PIP still shows');
+
+ // Back inside; then a tap on the box swaps it with the main view.
+ await page.evaluate(() => { const m = document.getElementById('main'); m.currentTime = 2.2; return m.play(); });
+ await wait(500);
+ const rect = await page.evaluate(() => {
+ const r = document.querySelector('.ovv-pip').getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ });
+ await page.mouse.click(rect.x, rect.y);
+ await page.waitForFunction(() => (document.getElementById('main').getAttribute('src') || '').endsWith('/pip.webm')
+ && document.querySelector('.ovv-pip').classList.contains('is-swapped'), null, { timeout: 8000 });
+ s = await sample(page);
+ assert.equal(s.mainSrc, '/pip.webm', 'the main view plays the PIP recording after a tap');
+ assert.match(s.pipSrc, /main\.webm$/, 'and the floating view plays the main recording');
+ assert.ok(!s.boxHidden, 'the swapped view stays visible');
+
+ // A second tap swaps back.
+ const rect2 = await page.evaluate(() => {
+ const r = document.querySelector('.ovv-pip').getBoundingClientRect();
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
+ });
+ await page.mouse.click(rect2.x, rect2.y);
+ await page.waitForFunction(() => (document.getElementById('main').getAttribute('src') || '').endsWith('/main.webm')
+ && !document.querySelector('.ovv-pip').classList.contains('is-swapped'), null, { timeout: 8000 });
+ s = await sample(page);
+ assert.equal(s.mainSrc, '/main.webm', 'the second tap brings the main recording back');
+ assert.match(s.pipSrc, /pip\.webm$/, 'and the PIP returns to the box');
+
+ assert.deepEqual(errors, [], 'no page errors');
+ process.stdout.write(' ✓ the PIP follows seeks, pause, rate, drift and the span; a tap swaps it; a missing one says so\n');
+ } finally {
+ await browser.close();
+ await new Promise((r) => server.close(r));
+ }
+})().catch((err) => { console.error(err); server.close(() => process.exit(1)); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/**
+ * The VOD picture-in-picture's correctness rests on two pure functions (public/js/vod-pip.js):
+ * - the time mapping between the main recording and the PIP's own VOD (offset + (t - from) inside [from, to),
+ * and the inverse when the viewer swaps the two views);
+ * - the drift decision: nothing under 0.3 s, a playbackRate nudge between 0.3 s and 2 s, a seek beyond 2 s.
+ * The browser test (test/vod-pip-chrome.test.js) then checks them on two real <video> elements.
+ */
+const assert = require('assert');
+const core = require('../public/js/vod-pip');
+
+const span = (from, to, offset) => ({ from, to, offset });
+
+// ── the mapping ─────────────────────────────────────────────
+assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 20), 5, 'at the start of the span the PIP is at its offset');
+assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 30), 15, 'one second of the main is one second of the PIP');
+assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 119.9), 104.9, 'the PIP runs to the end of the span');
+assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 120), null, 'the span is half-open: at `to` it is gone');
+assert.strictEqual(core.pipTimeAt(span(20, 120, 5), 19.9), null, 'before the span it is not shown');
+assert.strictEqual(core.pipTimeAt({ from: 1, to: 2 }, 1.5), null, 'a span with no offset has no mapping');
+
+assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 5), 20, 'the inverse mapping: offset is from');
+assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 105), null, 'the PIP range is half-open too');
+assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 104.9), 119.9, 'and just inside it maps to just before `to`');
+assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 4.9), null, 'before its offset there is no main second');
+assert.strictEqual(core.hostTimeAt(span(20, 120, 5), 106), null, 'past its end there is none either');
+
+// A round trip, for every kind of span.
+for (const s of [span(0, 10, 0), span(20, 120, 42.5), span(60, 70, 3.25)]) {
+ for (const t of [s.from, s.from + 0.5, (s.from + s.to) / 2, s.to - 0.1]) {
+ const p = core.pipTimeAt(s, t);
+ assert.ok(Math.abs(core.hostTimeAt(s, p) - t) < 1e-9, `round trip at ${t} (${p})`);
+ }
+}
+
+// Several spans (the PIP came, went and came back): the one holding t wins; a gap has none.
+const spans = [span(10, 20, 0), span(40, 60, 12), span(55, 70, 30)];
+assert.strictEqual(core.activeSpan(spans, 5), null, 'before every span');
+assert.strictEqual(core.activeSpan(spans, 15), spans[0], 'inside the first');
+assert.strictEqual(core.activeSpan(spans, 30), null, 'in the gap it is hidden');
+assert.strictEqual(core.activeSpan(spans, 50), spans[1], 'inside the second');
+assert.strictEqual(core.activeSpan(spans, 65), spans[2], 'overlapping spans: the latest one wins');
+assert.strictEqual(core.activeSpan([], 10), null);
+
+// ── drift correction ────────────────────────────────────────
+assert.strictEqual(core.NUDGE_AT, 0.3);
+assert.strictEqual(core.SEEK_AT, 2);
+
+const none = core.driftCorrection(10.2, 10, 1);
+assert.strictEqual(none.action, 'none', 'under 0.3 s: leave it alone');
+assert.strictEqual(none.rate, 1);
+assert.strictEqual(core.driftCorrection(10.3, 10, 1).action, 'none', 'exactly 0.3 s is still inside');
+
+const ahead = core.driftCorrection(10.5, 10, 1);
+assert.strictEqual(ahead.action, 'nudge', 'over 0.3 s: nudge, not seek');
+assert.ok(ahead.factor < 1 && ahead.factor >= 0.95, `ahead of the main → slower (${ahead.factor})`);
+assert.ok(Math.abs(ahead.rate - ahead.factor) < 1e-12, 'at 1× the rate is the factor');
+
+const behind = core.driftCorrection(9.5, 10, 1);
+assert.strictEqual(behind.action, 'nudge', 'behind the main is nudged too');
+assert.ok(behind.factor > 1 && behind.factor <= 1.05, `behind the main → faster (${behind.factor})`);
+
+const atSpeed = core.driftCorrection(9.2, 10, 2);
+assert.ok(Math.abs(atSpeed.rate - 2 * atSpeed.factor) < 1e-12, 'a 2× main keeps 2× while it catches up');
+assert.ok(atSpeed.factor >= 0.95 && atSpeed.factor <= 1.05, 'the nudge factor stays within 0.95–1.05');
+assert.ok(Math.abs(core.driftCorrection(9.2, 10, 1).factor - 1.05) < 1e-12, '0.8 s behind is 5% faster, capped');
+
+const seekAhead = core.driftCorrection(13, 10, 1);
+assert.deepStrictEqual(seekAhead, { action: 'seek', rate: 1, time: 10, drift: 3, factor: 1 }, 'over 2 s: seek, not nudge');
+assert.strictEqual(core.driftCorrection(12.01, 10, 1).action, 'seek', 'just over 2 s seeks');
+assert.strictEqual(core.driftCorrection(12, 10, 1).action, 'nudge', 'exactly 2 s still only nudges');
+assert.strictEqual(core.driftCorrection(1, 10).action, 'seek', 'a PIP at 0 when it should be at 10 seeks');
+
+// NaN never turns into a wild rate.
+assert.deepStrictEqual(core.driftCorrection(NaN, 10, 1), { action: 'seek', rate: 1, time: 10, drift: 0, factor: 1 });
+assert.strictEqual(core.driftCorrection(3, NaN, 1).action, 'none');
+assert.strictEqual(core.driftCorrection(3, 3, 0).rate, 1, 'a nonsense base rate falls back to 1×');
+
+process.stdout.write(' ✓ the time mapping and the drift correction\n');