openvibers/openvibe.mediahub Commits

VOD PiPs play in sync with the main recording, on OVFloat, with tap-to-swap (#442)

OP OpenVibers committed 3 h ago · 1f84f23d53 · 10 files, +950 −219

* VOD PiPs play in sync, on OVFloat, and swap with the main view

* Merge remote-tracking branch 'origin/main' into vod-pip-sync-1113

# Conflicts:
#	CHANGELOG.md

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e2db655..4203c77 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 a
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
ddiff --git a/public/css/video.css b/public/css/video.css
iindex d7ebc0c..7d01a73 100644
---- a/public/css/video.css
++++ b/public/css/video.css
@@@ -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
ddiff --git a/public/js/video.js b/public/js/video.js
iindex 7559aaa..4b75e57 100644
---- a/public/js/video.js
++++ b/public/js/video.js
@@@ -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 the
ddiff --git a/public/js/vod-pip.js b/public/js/vod-pip.js
nnew file mode 100644
iindex 0000000..cf659ce
---- /dev/null
++++ b/public/js/vod-pip.js
@@@ -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 };
++});
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 1a9cc88..a861005 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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="ovv
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',
}),
-- 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">
ddiff --git a/test/fixtures/vod-main.webm b/test/fixtures/vod-main.webm
nnew file mode 100644
iindex 0000000..fe9ada8
BBinary files /dev/null and b/test/fixtures/vod-main.webm differ
ddiff --git a/test/fixtures/vod-pip.webm b/test/fixtures/vod-pip.webm
nnew file mode 100644
iindex 0000000..2b5216f
BBinary files /dev/null and b/test/fixtures/vod-pip.webm differ
ddiff --git a/test/video-pips.test.js b/test/video-pips.test.js
iindex 0c6c9b6..3553a7e 100644
---- a/test/video-pips.test.js
++++ b/test/video-pips.test.js
@@@ -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();
ddiff --git a/test/vod-pip-chrome.test.js b/test/vod-pip-chrome.test.js
nnew file mode 100644
iindex 0000000..391aef7
---- /dev/null
++++ b/test/vod-pip-chrome.test.js
@@@ -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)); });
ddiff --git a/test/vod-pip-core.test.js b/test/vod-pip-core.test.js
nnew file mode 100644
iindex 0000000..5264304
---- /dev/null
++++ b/test/vod-pip-core.test.js
@@@ -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');