openvibers/openvibe.shared Commits
ov-sync.js: one timeline for many players (4.142.0) (#330)
Changes
@@@ -1,5 +1,10 @@# Changelog++## 4.143.0 — 2026-11-07++++- Add `ov-sync.js` (seven domains plan B4): `OVSync.attach(mediaEl, { room, socket })` follows a `common.sync-clock@1` timeline over Chat's room socket. It measures the server clock with five `time` samples (best round trip, refreshed every 60 s), computes the room's position from `position_ms + (serverNow - at_ms) * rate`, holds the element within ±80 ms (a 0.95–1.05 playback-rate nudge between 80 and 400 ms, a seek past 400 ms), pauses and plays with the room, and puts a non-holder's own seek, play, pause or rate change back with a short notice while a holder's change becomes a `sync`. No dependencies; served as `/shared/ov-sync.js` and imported as `openvibe-shared/ov-sync`.++- ov-sync follows an https source only; Chat already keeps a room's clock on OpenVibe's own hosts.++## 4.142.0 — 2026-11-07- **The menu has a close button on a phone** (the owner, 11-07: "This needs a close button"). Since 4.129.0 the menu@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.142.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.143.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.142.0",++ "version": "4.143.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)","contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)","sdk": "openvibe-sdk v0.94.0","features": [++ "one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)","the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)","the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)","the Compose export maps Material 3's container roles (cards, the navigation bar and its selected pill, dialogs) to Vibe surfaces, so native apps never show Material's lavender (4.140.0)",@@@ -54,6 +54,7 @@ const BROWSER = Object.freeze(['profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile'tooltip.js','player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css++ 'ov-sync.js', // one timeline for many players (seven domains B4): OVSync.attach(media, { room, socket }); no dependencies'vibes-pay.js', // Vibes Pay: OpenVibe.vibes.pay() and <button data-ov-vibes-pay> (MONEY.md, D12); 4.111.0'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demandnnew file mode 100644@@@ -0,0 +1,402 @@++/*++ * OVSync — one timeline for many players (OpenVibe_Seven_Domains_Plan_2026-11-07 §1 B4, §7).++ *++ * const clock = OVSync.attach(video, { room: 'friday-film', socket, self: 'usr_…' });++ * clock.destroy();++ *++ * One HTMLMediaElement follows a common.sync-clock@1 state that OpenVibe.Chat fans over /ws/chat:++ *++ * { room, media, position_ms, rate, paused, at_ms, set_by, seq }++ *++ * Where it should be is `position_ms + (serverNow() - at_ms) * rate` while the room plays, and exactly++ * `position_ms` while it is paused. serverNow() is the local clock corrected by NTP-style `time`++ * pings on the same socket: five samples, the one with the smallest round trip wins, refreshed every++ * 60 s. Until the first sample arrives the element is not moved.++ *++ * Drift |media - timeline| is held inside ±80 ms: 80–400 ms nudges the playback rate by 0.95–1.05 of++ * the room's rate until it is back, past 400 ms seeks. The element plays and pauses with the room.++ * When the local person is not the common.control-lease@1 holder, their own play, pause, seek or rate++ * change is put back with a short notice; the holder's own change is sent as a `sync` instead. `self`++ * is the local subject; when it is left out it is learned from the socket's `room_joined` or `auth`++ * frame (`subject`) or from `socket.subject`. `opts.holder: true|false` overrides the lease check.++ *++ * Media: a string is an https URL and is put on the element when it changes; a Media object is++ * reported as an `ovsync:media` event (and to `opts.onMedia`) for the page to resolve. The script has++ * no dependencies and works both as a browser classic script (`window.OVSync`, `window.OpenVibeSync`)++ * and through `require('openvibe-shared/ov-sync')`.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) { module.exports = api; return; }++ root.OVSync = api;++ root.OpenVibeSync = api;++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++++ const TARGET_MS = 80; // inside this the element is left alone++ const SEEK_MS = 400; // past this a seek, not a nudge++ const NUDGE = 0.05; // a nudge is 0.95–1.05 of the room's rate++ const TICK_MS = 100;++ const SAMPLES = 5;++ const SAMPLE_GAP_MS = 120;++ const CLOCK_TIMEOUT_MS = 2500;++ const REFRESH_MS = 60000;++++ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));++++ function attach(media, options) {++ if (!media || typeof media.play !== 'function' || !('currentTime' in media)) {++ throw new TypeError('OVSync.attach needs an HTMLMediaElement');++ }++ const opts = options || {};++ const socket = opts.socket;++ const room = String(opts.room || '');++ if (!socket || typeof socket.send !== 'function') throw new TypeError('OVSync.attach needs the room socket');++ if (!room) throw new TypeError('OVSync.attach needs a room');++++ let self = typeof opts.self === 'string' ? opts.self : (typeof opts.subject === 'string' ? opts.subject : null);++ let state = opts.state && typeof opts.state === 'object' ? opts.state : null;++ let lease = null;++ let offset = 0;++ let rtt = null;++ let clockReady = false;++ let generation = 0;++ let batch = null;++ const sentTimes = new Map();++ let destroyed = false;++ let tickTimer = null;++ let refreshTimer = null;++ let suppressUntil = 0;++ let pendingSelfUntil = 0;++ let lastDrift = 0;++ let mediaKey = null;++ let programmaticRate = null;++ const outbox = [];++ let noticeEl = null;++ let noticeTimer = null;++ let lastNoticeAt = -Infinity;++++ const perfNow = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());++ const serverNow = () => Date.now() + offset;++ const quiet = () => perfNow() < suppressUntil;++ const suppress = (ms) => { suppressUntil = Math.max(suppressUntil, perfNow() + ms); };++++ function emit(name, detail) {++ try { media.dispatchEvent(new CustomEvent(name, { bubbles: true, detail })); } catch (e) { /* no DOM */ }++ }++++ function send(frame) {++ if (destroyed) return;++ const ready = socket.readyState === undefined || socket.readyState === 1;++ if (!ready) { outbox.push(frame); return; }++ try { socket.send(JSON.stringify(frame)); } catch (e) { /* the socket went away */ }++ }++++ function flush() {++ while (outbox.length) {++ const frame = outbox.shift();++ try { socket.send(JSON.stringify(frame)); } catch (e) { return; }++ }++ }++++ // ── Clock: five samples, best round trip ─────────────────────────────++ function sampleClock() {++ if (destroyed) return;++ const gen = ++generation;++ const b = { gen, samples: [], timer: null };++ batch = b;++ for (let i = 0; i < SAMPLES; i++) {++ setTimeout(() => {++ if (destroyed || !batch || batch.gen !== gen) return;++ const id = `t${gen}-${i}-${Math.random().toString(36).slice(2, 8)}`;++ const t0 = Date.now();++ sentTimes.set(id, { t0, gen });++ send({ type: 'time', id, t0 });++ }, i * SAMPLE_GAP_MS);++ }++ b.timer = setTimeout(() => finishClock(gen), (SAMPLES - 1) * SAMPLE_GAP_MS + CLOCK_TIMEOUT_MS);++ }++++ function finishClock(gen) {++ if (!batch || batch.gen !== gen) return;++ clearTimeout(batch.timer);++ const samples = batch.samples;++ batch = null;++ if (!samples.length) {++ // Nothing came back (a sleeping tab, a lost socket): try again shortly, not in a minute.++ if (!destroyed) setTimeout(sampleClock, 5000);++ return;++ }++ const best = samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));++ offset = best.offset;++ rtt = best.rtt;++ clockReady = true;++ emit('ovsync:clock', { offset, rtt });++ tick();++ }++++ function onTime(msg) {++ const sent = sentTimes.get(msg.id);++ if (!sent || !batch || sent.gen !== batch.gen) return;++ sentTimes.delete(msg.id);++ const t0 = Number.isFinite(Number(msg.t0)) ? Number(msg.t0) : sent.t0;++ const t1 = Number(msg.t_server);++ const t3 = Date.now();++ if (!Number.isFinite(t0) || !Number.isFinite(t1)) return;++ const sampleRtt = t3 - t0;++ if (sampleRtt < 0 || sampleRtt > 5000) return;++ batch.samples.push({ offset: t1 - (t0 + t3) / 2, rtt: sampleRtt });++ if (batch.samples.length >= SAMPLES) finishClock(batch.gen);++ }++++ // ── The room's timeline ──────────────────────────────────────────────++ function targetMs() {++ if (!state) return null;++ const position = Number(state.position_ms);++ const at = Number(state.at_ms);++ const rate = Number(state.rate);++ if (!Number.isFinite(position) || !Number.isFinite(at) || !Number.isFinite(rate)) return null;++ if (state.paused) return Math.max(0, position);++ return Math.max(0, position + (serverNow() - at) * rate);++ }++++ function isHolder() {++ if (opts.holder === true) return true;++ if (opts.holder === false) return false;++ if (!self || !lease || !lease.holder) return false;++ return lease.holder.kind === 'person' && lease.holder.subject === self;++ }++++ function applyState(next, source) {++ if (!next || typeof next !== 'object') return;++ if (room && next.room && next.room !== room) return;++ if (state && Number(state.seq) > 0 && Number(next.seq) > 0 && Number(next.seq) < Number(state.seq)) return;++ state = next;++ if (self && next.set_by === self) pendingSelfUntil = 0;++ emit('ovsync:state', state);++ if (source) emit('ovsync:' + source, state);++ adoptMedia();++ tick();++ }++++ function adoptMedia() {++ if (!state || opts.followMedia === false) return;++ const m = state.media;++ if (typeof m === 'string') {++ let url;++ try { url = new URL(m, typeof location !== 'undefined' ? location.href : undefined); } catch (e) { return; }++ // Only an https source: Chat already refuses media off OpenVibe's own hosts, and the player never++ // follows a clock to http, data: or blob: whatever a server says.++ if (url.protocol !== 'https:') return;++ url = url.href;++ if (mediaKey === url) return;++ mediaKey = url;++ const current = media.currentSrc || media.src;++ if (current === url) return;++ suppress(600);++ try { media.src = url; media.load(); } catch (e) { /* the page owns the source */ }++ emit('ovsync:media', m);++ return;++ }++ if (m && typeof m === 'object') {++ const key = JSON.stringify(m);++ if (mediaKey === key) return;++ mediaKey = key;++ emit('ovsync:media', m);++ if (typeof opts.onMedia === 'function') { try { opts.onMedia(m); } catch (e) { /* the page's */ } }++ }++ }++++ function seek(ms) {++ const t = Math.max(0, Number(ms) / 1000);++ if (!Number.isFinite(t)) return;++ if (Math.abs(media.currentTime - t) < 0.005) return;++ suppress(600);++ try { media.currentTime = t; } catch (e) { /* not seekable yet */ }++ }++++ function setRate(rate) {++ const r = Math.round(clamp(rate, 0.0625, 16) * 1000) / 1000;++ if (Math.abs(media.playbackRate - r) < 0.001) return;++ programmaticRate = r;++ suppress(400);++ try { media.playbackRate = r; } catch (e) { /* the browser refused */ }++ }++++ function tick() {++ if (destroyed || !clockReady || !state) return;++ if (pendingSelfUntil && Date.now() < pendingSelfUntil) return;++ if (media.readyState < 1) {++ if (media.networkState === 0 && (media.src || media.currentSrc)) {++ try { media.load(); } catch (e) { /* no source yet */ }++ }++ return;++ }++ const target = targetMs();++ if (target == null) return;++ const base = clamp(Number(state.rate) || 1, 0.0625, 16);++ const drift = media.currentTime * 1000 - target;++ lastDrift = drift;++++ if (state.paused) {++ if (!media.paused) { suppress(500); try { media.pause(); } catch (e) { /* */ } }++ setRate(base);++ if (Math.abs(drift) > TARGET_MS) seek(target);++ return;++ }++++ if (media.paused) {++ suppress(700);++ const p = media.play();++ if (p && typeof p.catch === 'function') p.catch(() => { /* autoplay waits for a tap */ });++ }++ if (Math.abs(drift) > SEEK_MS) {++ seek(target);++ setRate(base);++ return;++ }++ if (Math.abs(drift) > TARGET_MS) {++ // Positive drift is ahead of the room: slow down. 80 ms is a 2% nudge, 400 ms the 5% cap.++ const factor = clamp(1 - drift / 5000, 1 - NUDGE, 1 + NUDGE);++ setRate(base * factor);++ } else {++ setRate(base);++ }++ }++++ function notify(message) {++ const now = perfNow();++ if (now - lastNoticeAt < 1200) return;++ lastNoticeAt = now;++ emit('ovsync:notice', { message });++ if (opts.notice === false || typeof document === 'undefined' || !document.body) return;++ if (!noticeEl) {++ noticeEl = document.createElement('div');++ noticeEl.setAttribute('data-ov-sync-notice', '');++ noticeEl.setAttribute('role', 'status');++ noticeEl.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;'++ + 'background:#10131a;color:#fff;font:14px/1.4 system-ui,sans-serif;padding:8px 14px;border-radius:999px;'++ + 'box-shadow:0 6px 20px rgba(0,0,0,.35);pointer-events:none;max-width:calc(100vw - 32px)';++ document.body.appendChild(noticeEl);++ }++ noticeEl.textContent = message;++ noticeEl.style.display = 'block';++ clearTimeout(noticeTimer);++ noticeTimer = setTimeout(() => { if (noticeEl) noticeEl.style.display = 'none'; }, 2500);++ }++++ function sendSelfSync() {++ if (!state) return;++ pendingSelfUntil = Date.now() + 1500;++ send({++ type: 'sync',++ room,++ media: state.media,++ position_ms: Math.max(0, Math.round(media.currentTime * 1000)),++ rate: media.paused ? (Number(state.rate) || 1) : (media.playbackRate || Number(state.rate) || 1),++ paused: !!media.paused,++ });++ }++++ function localChange() {++ if (destroyed || !state) return;++ if (isHolder()) { sendSelfSync(); return; }++ notify('Only the person with the remote can change the timeline.');++ suppress(30);++ setTimeout(tick, 40);++ }++++ // ── The socket and the element ───────────────────────────────────────++ function onMessage(event) {++ let msg;++ try { msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; } catch (e) { return; }++ if (!msg || typeof msg !== 'object') return;++ if ((msg.type === 'room_joined' || msg.type === 'auth') && !self && typeof msg.subject === 'string' && msg.subject) {++ self = msg.subject;++ tick();++ }++ if (msg.type === 'time') { onTime(msg); return; }++ if (room && msg.room && msg.room !== room) return;++ if (msg.type === 'sync') {++ const next = msg.state || (msg.position_ms !== undefined ? msg : null);++ if (next) applyState(next, 'sync');++ return;++ }++ if (msg.type === 'lease') {++ const next = msg.state || msg;++ if (next && next.holder) { lease = next; emit('ovsync:lease', next); tick(); }++ return;++ }++ if (msg.type === 'sync_error') {++ pendingSelfUntil = 0;++ notify(msg.message || 'The room did not accept that change.');++ tick();++ }++ }++++ const onOpen = () => flush();++ const onSeeking = () => { if (!quiet()) localChange(); };++ const onPlay = () => { if (!quiet()) localChange(); };++ const onPause = () => { if (!quiet()) localChange(); };++ const onRate = () => {++ if (programmaticRate != null && Math.abs(media.playbackRate - programmaticRate) < 0.002) return;++ programmaticRate = null;++ if (!quiet()) localChange();++ };++ const onReady = () => tick();++++ if (typeof socket.addEventListener === 'function') {++ socket.addEventListener('message', onMessage);++ socket.addEventListener('open', onOpen);++ }++ media.addEventListener('seeking', onSeeking);++ media.addEventListener('play', onPlay);++ media.addEventListener('pause', onPause);++ media.addEventListener('ratechange', onRate);++ media.addEventListener('loadedmetadata', onReady);++ media.addEventListener('canplay', onReady);++++ if (!self && typeof socket.subject === 'string') self = socket.subject;++ if (state) { adoptMedia(); tick(); }++ tickTimer = setInterval(tick, TICK_MS);++ refreshTimer = setInterval(sampleClock, REFRESH_MS);++ sampleClock();++++ return {++ get state() { return state; },++ get lease() { return lease; },++ isHolder,++ clock: () => ({ offset, rtt, ready: clockReady }),++ serverNow,++ targetMs,++ drift: () => lastDrift,++ setSelf: (subject) => { self = typeof subject === 'string' ? subject : null; tick(); },++ notify,++ destroy() {++ if (destroyed) return;++ destroyed = true;++ clearInterval(tickTimer);++ clearInterval(refreshTimer);++ if (batch && batch.timer) clearTimeout(batch.timer);++ batch = null;++ sentTimes.clear();++ outbox.length = 0;++ if (typeof socket.removeEventListener === 'function') {++ socket.removeEventListener('message', onMessage);++ socket.removeEventListener('open', onOpen);++ }++ media.removeEventListener('seeking', onSeeking);++ media.removeEventListener('play', onPlay);++ media.removeEventListener('pause', onPause);++ media.removeEventListener('ratechange', onRate);++ media.removeEventListener('loadedmetadata', onReady);++ media.removeEventListener('canplay', onReady);++ if (noticeTimer) clearTimeout(noticeTimer);++ if (noticeEl && noticeEl.parentNode) noticeEl.parentNode.removeChild(noticeEl);++ noticeEl = null;++ },++ };++ }++++ return { attach, TARGET_MS, SEEK_MS, NUDGE, clockSamples: SAMPLES };++});@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.142.0",++ "version": "4.143.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -60,6 +60,7 @@"./ov-forms": "./ov-forms.js","./ov-qr": "./ov-qr.js","./ov-share": "./ov-share.js",++ "./ov-sync": "./ov-sync.js","./ov-locker": "./ov-locker.js","./ov-locker-core": "./ov-locker-core.js","./ov-labels": "./ov-labels.js",nnew file mode 100644BBinary files /dev/null and b/test/fixtures/ov-sync-clock.webm differnnew file mode 100644@@@ -0,0 +1,236 @@++'use strict';++// ov-sync.js in a real headless Chrome (seven domains B4): two <video> elements follow one++// common.sync-clock@1 timeline over two mock room sockets. The clock is measured with the same++// `time` ping/pong frames Chat sends; the test watches both players for 10 s, then pauses, seeks,++// changes the room's rate, and checks that a non-holder's local seek is put back with a notice++// while a holder's local seek becomes the room's new state. Skipped without Chrome.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('ov-sync-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const VIDEO = path.join(__dirname, 'fixtures', 'ov-sync-clock.webm');++++// The page is both ends of the room protocol: a fake socket per player, one server clock 5 s ahead++// of the browser's, the same `time` and `sync` frames Chat sends, and a lease ann holds.++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>ov-sync</title>++<link rel="icon" href="data:,"><style>video{width:160px;height:90px;background:#000}</style></head>++<body><video id="a" muted playsinline preload="auto" src="/ov-sync-clock.webm"></video>++<video id="b" muted playsinline preload="auto" src="/ov-sync-clock.webm"></video>++<script src="/ov-sync.js"></script>++<script>++(function () {++ const ROOM = 'friday-film';++ const SERVER_OFFSET = 5000;++ const serverNow = () => Date.now() + SERVER_OFFSET;++ let seq = 1;++ const state = { room: ROOM, media: '/ov-sync-clock.webm', position_ms: 0, rate: 1, paused: false,++ at_ms: serverNow(), set_by: 'usr_ann', seq: seq };++ let lease = { thing: 'chat.room:' + ROOM, holder: { kind: 'person', subject: 'usr_ann' },++ since_ms: serverNow(), watchers: ['usr_ann', 'usr_bob'], seq: 1 };++ const sockets = [];++++ function broadcast(frame) { for (const s of sockets) s.emit(frame); }++ function makeSocket(subject) {++ const listeners = { message: new Set(), open: new Set() };++ const s = {++ readyState: 1, subject, sent: [],++ addEventListener(type, fn) { if (listeners[type]) listeners[type].add(fn); },++ removeEventListener(type, fn) { if (listeners[type]) listeners[type].delete(fn); },++ send(raw) {++ const msg = JSON.parse(raw);++ s.sent.push(msg);++ if (msg.type === 'time') {++ const t0 = msg.t0;++ setTimeout(() => s.emit({ type: 'time', id: msg.id, t0, t_server: serverNow() }), 2 + Math.random() * 8);++ } else if (msg.type === 'sync') {++ if (lease.holder.kind !== 'person' || lease.holder.subject !== subject) {++ s.emit({ type: 'sync_error', code: 'sync.not_holder', message: 'Only the holder may set the clock.' });++ return;++ }++ state.media = msg.media === undefined ? state.media : msg.media;++ state.position_ms = msg.position_ms;++ state.rate = msg.rate === undefined ? state.rate : msg.rate;++ state.paused = msg.paused === undefined ? state.paused : msg.paused;++ state.at_ms = serverNow();++ state.set_by = subject;++ state.seq = ++seq;++ broadcast({ type: 'sync', room: ROOM, state: state });++ }++ },++ emit(msg) { for (const fn of listeners.message) fn({ data: JSON.stringify(msg) }); },++ };++ sockets.push(s);++ return s;++ }++++ function currentPosition() {++ return state.paused ? state.position_ms : state.position_ms + (serverNow() - state.at_ms) * state.rate;++ }++ function change(patch) {++ Object.assign(state, patch);++ state.at_ms = serverNow();++ state.seq = ++seq;++ broadcast({ type: 'sync', room: ROOM, state: state });++ }++ window.__server = {++ state, lease, serverNow, currentPosition,++ pause() { change({ paused: true, position_ms: Math.max(0, Math.round(currentPosition())) }); },++ seek(position_ms) { change({ paused: true, position_ms: position_ms }); },++ play(rate) { change({ paused: false, rate: rate || 1, position_ms: Math.max(0, Math.round(currentPosition())) }); },++ setLease(next) { lease = next; broadcast({ type: 'lease', room: ROOM, state: lease }); },++ };++ window.__drifts = [];++ window.__start = function () {++ const a = document.getElementById('a');++ const b = document.getElementById('b');++ window.__a = OVSync.attach(a, { room: ROOM, socket: makeSocket('usr_ann'), self: 'usr_ann' });++ window.__b = OVSync.attach(b, { room: ROOM, socket: makeSocket('usr_bob'), self: 'usr_bob' });++ setTimeout(() => {++ broadcast({ type: 'lease', room: ROOM, state: lease });++ broadcast({ type: 'sync', room: ROOM, state: state });++ }, 50);++ setInterval(() => {++ if (!window.__a || !window.__b) return;++ window.__drifts.push({ a: window.__a.drift(), b: window.__b.drift(),++ ca: window.__a.clock().ready, cb: window.__b.clock().ready });++ }, 200);++ return true;++ };++})();++</script></body></html>`;++++const server = http.createServer((req, res) => {++ if (req.url === '/') {++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });++ return res.end(PAGE);++ }++ if (req.url === '/ov-sync.js') {++ res.writeHead(200, { 'content-type': 'application/javascript' });++ return res.end(fs.readFileSync(path.join(ROOT, 'ov-sync.js')));++ }++ if (req.url === '/ov-sync-clock.webm') {++ const body = fs.readFileSync(VIDEO);++ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');++ if (range) {++ const start = range[1] ? Number(range[1]) : 0;++ const end = range[2] ? Number(range[2]) : body.length - 1;++ res.writeHead(206, {++ 'content-type': 'video/webm', 'accept-ranges': 'bytes',++ 'content-range': `bytes ${start}-${end}/${body.length}`, 'content-length': end - start + 1,++ });++ return res.end(body.subarray(start, end + 1));++ }++ res.writeHead(200, { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-length': body.length });++ return res.end(body);++ }++ res.writeHead(404);++ res.end();++});++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sync-test-'));++ const browser = await h.launch({ tmpDir, args: ['--autoplay-policy=no-user-gesture-required'] });++ try {++ const context = await browser.newContext();++ const p = await h.openPage(browser, context, { width: 640 });++ await p.goto(`${base}/`, { maxMs: 10000 });++ assert.strictEqual(await p.evaluate('document.readyState'), 'complete');++ await p.evaluate('window.__start()');++++ const until = Date.now() + 8000;++ while (Date.now() < until) {++ const ready = await p.evaluate('window.__a.clock().ready && window.__b.clock().ready');++ if (ready) break;++ await sleep(100);++ }++ assert.ok(await p.evaluate('window.__a.clock().ready && window.__b.clock().ready'), 'both players measured the room clock');++ const clocks = await p.evaluate('[window.__a.clock(), window.__b.clock()]');++ for (const c of clocks) {++ assert.ok(Number.isFinite(c.offset) && Math.abs(c.offset - 5000) < 1500, `the 5 s server offset is measured (${JSON.stringify(c)})`);++ }++++ // Play from one timeline for 10 s and keep every sample.++ await sleep(1500);++ await p.evaluate('window.__drifts = []');++ await sleep(10000);++ const stats = await p.evaluate(`(() => {++ const ds = window.__drifts.filter((x) => x.ca && x.cb);++ return { n: ds.length, maxA: Math.max(...ds.map((x) => Math.abs(x.a))), maxB: Math.max(...ds.map((x) => Math.abs(x.b))) };++ })()`);++ assert.ok(stats.n >= 40, `sampled the 10 s (${stats.n} samples)`);++ assert.ok(stats.maxA <= 80 && stats.maxB <= 80, `both stay inside ±80 ms (${JSON.stringify(stats)})`);++++ // Pause: both elements stop and hold the room's position.++ await p.evaluate('window.__server.pause()');++ await sleep(500);++ const paused = await p.evaluate(`(() => ({ a: document.getElementById('a').paused, b: document.getElementById('b').paused,++ ta: document.getElementById('a').currentTime, tb: document.getElementById('b').currentTime, want: window.__server.currentPosition() }))()`);++ assert.ok(paused.a && paused.b, `both pause with the room: ${JSON.stringify(paused)}`);++ assert.ok(Math.abs(paused.ta * 1000 - paused.want) <= 80 && Math.abs(paused.tb * 1000 - paused.want) <= 80,++ `both hold the paused position: ${JSON.stringify(paused)}`);++++ // Seek: a paused seek lands on both.++ await p.evaluate('window.__server.seek(12000)');++ await sleep(600);++ const sought = await p.evaluate('[document.getElementById("a").currentTime, document.getElementById("b").currentTime]');++ for (const t of sought) assert.ok(Math.abs(t * 1000 - 12000) <= 80, `a seek reaches both (${JSON.stringify(sought)})`);++++ // Rate change: both play at the room's 1.25 (a nudge may ride on top of it).++ await p.evaluate('window.__server.play(1.25)');++ await sleep(2500);++ const rate = await p.evaluate(`(() => ({ a: document.getElementById('a').playbackRate, b: document.getElementById('b').playbackRate,++ pa: document.getElementById('a').paused, pb: document.getElementById('b').paused,++ da: window.__a.drift(), db: window.__b.drift() }))()`);++ assert.ok(!rate.pa && !rate.pb, `both play at the room's rate: ${JSON.stringify(rate)}`);++ for (const r of [rate.a, rate.b]) assert.ok(r >= 1.25 * 0.95 - 0.001 && r <= 1.25 * 1.05 + 0.001, `rate nudge stays 0.95–1.05 of 1.25 (${r})`);++ assert.ok(Math.abs(rate.da) <= 80 && Math.abs(rate.db) <= 80, `still in sync after the rate change: ${JSON.stringify(rate)}`);++++ // A non-holder's local seek snaps back, with a short notice.++ const before = await p.evaluate('({ want: window.__server.currentPosition(), seq: window.__server.state.seq })');++ await p.evaluate('document.getElementById("b").currentTime = 3');++ await sleep(700);++ const back = await p.evaluate(`(() => ({ t: document.getElementById('b').currentTime,++ want: window.__server.currentPosition(),++ notice: document.querySelector('[data-ov-sync-notice]') ? document.querySelector('[data-ov-sync-notice]').textContent : null,++ seq: window.__server.state.seq }))()`);++ assert.ok(Math.abs(back.t * 1000 - back.want) <= 150,++ `a non-holder's seek is put back (${JSON.stringify(back)})`);++ assert.match(back.notice || '', /Only the person with the remote/, 'the snap-back says why');++ assert.strictEqual(back.seq, before.seq, 'a non-holder cannot change the room');++++ // A holder's local seek becomes the room's new state and reaches the other player.++ await p.evaluate('document.getElementById("a").currentTime = 18');++ await sleep(700);++ const held = await p.evaluate(`(() => ({ set_by: window.__server.state.set_by, position: window.__server.state.position_ms,++ want: window.__server.currentPosition(),++ ta: document.getElementById('a').currentTime, tb: document.getElementById('b').currentTime }))()`);++ assert.strictEqual(held.set_by, 'usr_ann', 'the holder set the room');++ assert.ok(Math.abs(held.position - 18000) < 600, `the holder's seek is the timeline (${held.position})`);++ assert.ok(Math.abs(held.ta * 1000 - held.want) <= 200 && Math.abs(held.tb * 1000 - held.want) <= 300,++ `both follow the holder's seek (${JSON.stringify(held)})`);++++ assert.deepStrictEqual(p.state.errors, [], 'no page errors');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++ console.log('ov-sync-chrome: 2 players in sync for 10 s, through a pause, a seek and a rate change');++})().catch((err) => {++ server.close();++ console.error(err);++ process.exit(1);++});
@@ -1,5 +1,10 @@
@@ -1,5 +1,10 @@
# Changelog
# Changelog
+## 4.143.0 — 2026-11-07
+
+- Add `ov-sync.js` (seven domains plan B4): `OVSync.attach(mediaEl, { room, socket })` follows a `common.sync-clock@1` timeline over Chat's room socket. It measures the server clock with five `time` samples (best round trip, refreshed every 60 s), computes the room's position from `position_ms + (serverNow - at_ms) * rate`, holds the element within ±80 ms (a 0.95–1.05 playback-rate nudge between 80 and 400 ms, a seek past 400 ms), pauses and plays with the room, and puts a non-holder's own seek, play, pause or rate change back with a short notice while a holder's change becomes a `sync`. No dependencies; served as `/shared/ov-sync.js` and imported as `openvibe-shared/ov-sync`.
+- ov-sync follows an https source only; Chat already keeps a room's clock on OpenVibe's own hosts.
+
## 4.142.0 — 2026-11-07
## 4.142.0 — 2026-11-07
- **The menu has a close button on a phone** (the owner, 11-07: "This needs a close button"). Since 4.129.0 the menu
- **The menu has a close button on a phone** (the owner, 11-07: "This needs a close button"). Since 4.129.0 the menu
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.142.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.143.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.142.0",
+ "version": "4.143.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.312.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"features": [
"features": [
+ "one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
"the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)",
"the menu on a phone has a round close button at the end of the search pill: it covers the whole screen, burger too (4.142.0)",
"the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)",
"the forms kit loads when a form control is drawn, not merely present: a closed dialog's select costs nothing (4.141.0)",
"the Compose export maps Material 3's container roles (cards, the navigation bar and its selected pill, dialogs) to Vibe surfaces, so native apps never show Material's lavender (4.140.0)",
"the Compose export maps Material 3's container roles (cards, the navigation bar and its selected pill, dialogs) to Vibe surfaces, so native apps never show Material's lavender (4.140.0)",
@@ -54,6 +54,7 @@ const BROWSER = Object.freeze([
@@ -54,6 +54,7 @@ const BROWSER = Object.freeze([
'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile
'profile-panel.js', // a person's stuff on any site (worn items, badges, achievements), from the Network profile
'tooltip.js',
'tooltip.js',
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
+ 'ov-sync.js', // one timeline for many players (seven domains B4): OVSync.attach(media, { room, socket }); no dependencies
'vibes-pay.js', // Vibes Pay: OpenVibe.vibes.pay() and <button data-ov-vibes-pay> (MONEY.md, D12); 4.111.0
'vibes-pay.js', // Vibes Pay: OpenVibe.vibes.pay() and <button data-ov-vibes-pay> (MONEY.md, D12); 4.111.0
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
new file mode 100644
new file mode 100644
@@ -0,0 +1,402 @@
@@ -0,0 +1,402 @@
+/*
+ * OVSync — one timeline for many players (OpenVibe_Seven_Domains_Plan_2026-11-07 §1 B4, §7).
+ *
+ * const clock = OVSync.attach(video, { room: 'friday-film', socket, self: 'usr_…' });
+ * clock.destroy();
+ *
+ * One HTMLMediaElement follows a common.sync-clock@1 state that OpenVibe.Chat fans over /ws/chat:
+ *
+ * { room, media, position_ms, rate, paused, at_ms, set_by, seq }
+ *
+ * Where it should be is `position_ms + (serverNow() - at_ms) * rate` while the room plays, and exactly
+ * `position_ms` while it is paused. serverNow() is the local clock corrected by NTP-style `time`
+ * pings on the same socket: five samples, the one with the smallest round trip wins, refreshed every
+ * 60 s. Until the first sample arrives the element is not moved.
+ *
+ * Drift |media - timeline| is held inside ±80 ms: 80–400 ms nudges the playback rate by 0.95–1.05 of
+ * the room's rate until it is back, past 400 ms seeks. The element plays and pauses with the room.
+ * When the local person is not the common.control-lease@1 holder, their own play, pause, seek or rate
+ * change is put back with a short notice; the holder's own change is sent as a `sync` instead. `self`
+ * is the local subject; when it is left out it is learned from the socket's `room_joined` or `auth`
+ * frame (`subject`) or from `socket.subject`. `opts.holder: true|false` overrides the lease check.
+ *
+ * Media: a string is an https URL and is put on the element when it changes; a Media object is
+ * reported as an `ovsync:media` event (and to `opts.onMedia`) for the page to resolve. The script has
+ * no dependencies and works both as a browser classic script (`window.OVSync`, `window.OpenVibeSync`)
+ * and through `require('openvibe-shared/ov-sync')`.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) { module.exports = api; return; }
+ root.OVSync = api;
+ root.OpenVibeSync = api;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ const TARGET_MS = 80; // inside this the element is left alone
+ const SEEK_MS = 400; // past this a seek, not a nudge
+ const NUDGE = 0.05; // a nudge is 0.95–1.05 of the room's rate
+ const TICK_MS = 100;
+ const SAMPLES = 5;
+ const SAMPLE_GAP_MS = 120;
+ const CLOCK_TIMEOUT_MS = 2500;
+ const REFRESH_MS = 60000;
+
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
+
+ function attach(media, options) {
+ if (!media || typeof media.play !== 'function' || !('currentTime' in media)) {
+ throw new TypeError('OVSync.attach needs an HTMLMediaElement');
+ }
+ const opts = options || {};
+ const socket = opts.socket;
+ const room = String(opts.room || '');
+ if (!socket || typeof socket.send !== 'function') throw new TypeError('OVSync.attach needs the room socket');
+ if (!room) throw new TypeError('OVSync.attach needs a room');
+
+ let self = typeof opts.self === 'string' ? opts.self : (typeof opts.subject === 'string' ? opts.subject : null);
+ let state = opts.state && typeof opts.state === 'object' ? opts.state : null;
+ let lease = null;
+ let offset = 0;
+ let rtt = null;
+ let clockReady = false;
+ let generation = 0;
+ let batch = null;
+ const sentTimes = new Map();
+ let destroyed = false;
+ let tickTimer = null;
+ let refreshTimer = null;
+ let suppressUntil = 0;
+ let pendingSelfUntil = 0;
+ let lastDrift = 0;
+ let mediaKey = null;
+ let programmaticRate = null;
+ const outbox = [];
+ let noticeEl = null;
+ let noticeTimer = null;
+ let lastNoticeAt = -Infinity;
+
+ const perfNow = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
+ const serverNow = () => Date.now() + offset;
+ const quiet = () => perfNow() < suppressUntil;
+ const suppress = (ms) => { suppressUntil = Math.max(suppressUntil, perfNow() + ms); };
+
+ function emit(name, detail) {
+ try { media.dispatchEvent(new CustomEvent(name, { bubbles: true, detail })); } catch (e) { /* no DOM */ }
+ }
+
+ function send(frame) {
+ if (destroyed) return;
+ const ready = socket.readyState === undefined || socket.readyState === 1;
+ if (!ready) { outbox.push(frame); return; }
+ try { socket.send(JSON.stringify(frame)); } catch (e) { /* the socket went away */ }
+ }
+
+ function flush() {
+ while (outbox.length) {
+ const frame = outbox.shift();
+ try { socket.send(JSON.stringify(frame)); } catch (e) { return; }
+ }
+ }
+
+ // ── Clock: five samples, best round trip ─────────────────────────────
+ function sampleClock() {
+ if (destroyed) return;
+ const gen = ++generation;
+ const b = { gen, samples: [], timer: null };
+ batch = b;
+ for (let i = 0; i < SAMPLES; i++) {
+ setTimeout(() => {
+ if (destroyed || !batch || batch.gen !== gen) return;
+ const id = `t${gen}-${i}-${Math.random().toString(36).slice(2, 8)}`;
+ const t0 = Date.now();
+ sentTimes.set(id, { t0, gen });
+ send({ type: 'time', id, t0 });
+ }, i * SAMPLE_GAP_MS);
+ }
+ b.timer = setTimeout(() => finishClock(gen), (SAMPLES - 1) * SAMPLE_GAP_MS + CLOCK_TIMEOUT_MS);
+ }
+
+ function finishClock(gen) {
+ if (!batch || batch.gen !== gen) return;
+ clearTimeout(batch.timer);
+ const samples = batch.samples;
+ batch = null;
+ if (!samples.length) {
+ // Nothing came back (a sleeping tab, a lost socket): try again shortly, not in a minute.
+ if (!destroyed) setTimeout(sampleClock, 5000);
+ return;
+ }
+ const best = samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));
+ offset = best.offset;
+ rtt = best.rtt;
+ clockReady = true;
+ emit('ovsync:clock', { offset, rtt });
+ tick();
+ }
+
+ function onTime(msg) {
+ const sent = sentTimes.get(msg.id);
+ if (!sent || !batch || sent.gen !== batch.gen) return;
+ sentTimes.delete(msg.id);
+ const t0 = Number.isFinite(Number(msg.t0)) ? Number(msg.t0) : sent.t0;
+ const t1 = Number(msg.t_server);
+ const t3 = Date.now();
+ if (!Number.isFinite(t0) || !Number.isFinite(t1)) return;
+ const sampleRtt = t3 - t0;
+ if (sampleRtt < 0 || sampleRtt > 5000) return;
+ batch.samples.push({ offset: t1 - (t0 + t3) / 2, rtt: sampleRtt });
+ if (batch.samples.length >= SAMPLES) finishClock(batch.gen);
+ }
+
+ // ── The room's timeline ──────────────────────────────────────────────
+ function targetMs() {
+ if (!state) return null;
+ const position = Number(state.position_ms);
+ const at = Number(state.at_ms);
+ const rate = Number(state.rate);
+ if (!Number.isFinite(position) || !Number.isFinite(at) || !Number.isFinite(rate)) return null;
+ if (state.paused) return Math.max(0, position);
+ return Math.max(0, position + (serverNow() - at) * rate);
+ }
+
+ function isHolder() {
+ if (opts.holder === true) return true;
+ if (opts.holder === false) return false;
+ if (!self || !lease || !lease.holder) return false;
+ return lease.holder.kind === 'person' && lease.holder.subject === self;
+ }
+
+ function applyState(next, source) {
+ if (!next || typeof next !== 'object') return;
+ if (room && next.room && next.room !== room) return;
+ if (state && Number(state.seq) > 0 && Number(next.seq) > 0 && Number(next.seq) < Number(state.seq)) return;
+ state = next;
+ if (self && next.set_by === self) pendingSelfUntil = 0;
+ emit('ovsync:state', state);
+ if (source) emit('ovsync:' + source, state);
+ adoptMedia();
+ tick();
+ }
+
+ function adoptMedia() {
+ if (!state || opts.followMedia === false) return;
+ const m = state.media;
+ if (typeof m === 'string') {
+ let url;
+ try { url = new URL(m, typeof location !== 'undefined' ? location.href : undefined); } catch (e) { return; }
+ // Only an https source: Chat already refuses media off OpenVibe's own hosts, and the player never
+ // follows a clock to http, data: or blob: whatever a server says.
+ if (url.protocol !== 'https:') return;
+ url = url.href;
+ if (mediaKey === url) return;
+ mediaKey = url;
+ const current = media.currentSrc || media.src;
+ if (current === url) return;
+ suppress(600);
+ try { media.src = url; media.load(); } catch (e) { /* the page owns the source */ }
+ emit('ovsync:media', m);
+ return;
+ }
+ if (m && typeof m === 'object') {
+ const key = JSON.stringify(m);
+ if (mediaKey === key) return;
+ mediaKey = key;
+ emit('ovsync:media', m);
+ if (typeof opts.onMedia === 'function') { try { opts.onMedia(m); } catch (e) { /* the page's */ } }
+ }
+ }
+
+ function seek(ms) {
+ const t = Math.max(0, Number(ms) / 1000);
+ if (!Number.isFinite(t)) return;
+ if (Math.abs(media.currentTime - t) < 0.005) return;
+ suppress(600);
+ try { media.currentTime = t; } catch (e) { /* not seekable yet */ }
+ }
+
+ function setRate(rate) {
+ const r = Math.round(clamp(rate, 0.0625, 16) * 1000) / 1000;
+ if (Math.abs(media.playbackRate - r) < 0.001) return;
+ programmaticRate = r;
+ suppress(400);
+ try { media.playbackRate = r; } catch (e) { /* the browser refused */ }
+ }
+
+ function tick() {
+ if (destroyed || !clockReady || !state) return;
+ if (pendingSelfUntil && Date.now() < pendingSelfUntil) return;
+ if (media.readyState < 1) {
+ if (media.networkState === 0 && (media.src || media.currentSrc)) {
+ try { media.load(); } catch (e) { /* no source yet */ }
+ }
+ return;
+ }
+ const target = targetMs();
+ if (target == null) return;
+ const base = clamp(Number(state.rate) || 1, 0.0625, 16);
+ const drift = media.currentTime * 1000 - target;
+ lastDrift = drift;
+
+ if (state.paused) {
+ if (!media.paused) { suppress(500); try { media.pause(); } catch (e) { /* */ } }
+ setRate(base);
+ if (Math.abs(drift) > TARGET_MS) seek(target);
+ return;
+ }
+
+ if (media.paused) {
+ suppress(700);
+ const p = media.play();
+ if (p && typeof p.catch === 'function') p.catch(() => { /* autoplay waits for a tap */ });
+ }
+ if (Math.abs(drift) > SEEK_MS) {
+ seek(target);
+ setRate(base);
+ return;
+ }
+ if (Math.abs(drift) > TARGET_MS) {
+ // Positive drift is ahead of the room: slow down. 80 ms is a 2% nudge, 400 ms the 5% cap.
+ const factor = clamp(1 - drift / 5000, 1 - NUDGE, 1 + NUDGE);
+ setRate(base * factor);
+ } else {
+ setRate(base);
+ }
+ }
+
+ function notify(message) {
+ const now = perfNow();
+ if (now - lastNoticeAt < 1200) return;
+ lastNoticeAt = now;
+ emit('ovsync:notice', { message });
+ if (opts.notice === false || typeof document === 'undefined' || !document.body) return;
+ if (!noticeEl) {
+ noticeEl = document.createElement('div');
+ noticeEl.setAttribute('data-ov-sync-notice', '');
+ noticeEl.setAttribute('role', 'status');
+ noticeEl.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;'
+ + 'background:#10131a;color:#fff;font:14px/1.4 system-ui,sans-serif;padding:8px 14px;border-radius:999px;'
+ + 'box-shadow:0 6px 20px rgba(0,0,0,.35);pointer-events:none;max-width:calc(100vw - 32px)';
+ document.body.appendChild(noticeEl);
+ }
+ noticeEl.textContent = message;
+ noticeEl.style.display = 'block';
+ clearTimeout(noticeTimer);
+ noticeTimer = setTimeout(() => { if (noticeEl) noticeEl.style.display = 'none'; }, 2500);
+ }
+
+ function sendSelfSync() {
+ if (!state) return;
+ pendingSelfUntil = Date.now() + 1500;
+ send({
+ type: 'sync',
+ room,
+ media: state.media,
+ position_ms: Math.max(0, Math.round(media.currentTime * 1000)),
+ rate: media.paused ? (Number(state.rate) || 1) : (media.playbackRate || Number(state.rate) || 1),
+ paused: !!media.paused,
+ });
+ }
+
+ function localChange() {
+ if (destroyed || !state) return;
+ if (isHolder()) { sendSelfSync(); return; }
+ notify('Only the person with the remote can change the timeline.');
+ suppress(30);
+ setTimeout(tick, 40);
+ }
+
+ // ── The socket and the element ───────────────────────────────────────
+ function onMessage(event) {
+ let msg;
+ try { msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; } catch (e) { return; }
+ if (!msg || typeof msg !== 'object') return;
+ if ((msg.type === 'room_joined' || msg.type === 'auth') && !self && typeof msg.subject === 'string' && msg.subject) {
+ self = msg.subject;
+ tick();
+ }
+ if (msg.type === 'time') { onTime(msg); return; }
+ if (room && msg.room && msg.room !== room) return;
+ if (msg.type === 'sync') {
+ const next = msg.state || (msg.position_ms !== undefined ? msg : null);
+ if (next) applyState(next, 'sync');
+ return;
+ }
+ if (msg.type === 'lease') {
+ const next = msg.state || msg;
+ if (next && next.holder) { lease = next; emit('ovsync:lease', next); tick(); }
+ return;
+ }
+ if (msg.type === 'sync_error') {
+ pendingSelfUntil = 0;
+ notify(msg.message || 'The room did not accept that change.');
+ tick();
+ }
+ }
+
+ const onOpen = () => flush();
+ const onSeeking = () => { if (!quiet()) localChange(); };
+ const onPlay = () => { if (!quiet()) localChange(); };
+ const onPause = () => { if (!quiet()) localChange(); };
+ const onRate = () => {
+ if (programmaticRate != null && Math.abs(media.playbackRate - programmaticRate) < 0.002) return;
+ programmaticRate = null;
+ if (!quiet()) localChange();
+ };
+ const onReady = () => tick();
+
+ if (typeof socket.addEventListener === 'function') {
+ socket.addEventListener('message', onMessage);
+ socket.addEventListener('open', onOpen);
+ }
+ media.addEventListener('seeking', onSeeking);
+ media.addEventListener('play', onPlay);
+ media.addEventListener('pause', onPause);
+ media.addEventListener('ratechange', onRate);
+ media.addEventListener('loadedmetadata', onReady);
+ media.addEventListener('canplay', onReady);
+
+ if (!self && typeof socket.subject === 'string') self = socket.subject;
+ if (state) { adoptMedia(); tick(); }
+ tickTimer = setInterval(tick, TICK_MS);
+ refreshTimer = setInterval(sampleClock, REFRESH_MS);
+ sampleClock();
+
+ return {
+ get state() { return state; },
+ get lease() { return lease; },
+ isHolder,
+ clock: () => ({ offset, rtt, ready: clockReady }),
+ serverNow,
+ targetMs,
+ drift: () => lastDrift,
+ setSelf: (subject) => { self = typeof subject === 'string' ? subject : null; tick(); },
+ notify,
+ destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ clearInterval(tickTimer);
+ clearInterval(refreshTimer);
+ if (batch && batch.timer) clearTimeout(batch.timer);
+ batch = null;
+ sentTimes.clear();
+ outbox.length = 0;
+ if (typeof socket.removeEventListener === 'function') {
+ socket.removeEventListener('message', onMessage);
+ socket.removeEventListener('open', onOpen);
+ }
+ media.removeEventListener('seeking', onSeeking);
+ media.removeEventListener('play', onPlay);
+ media.removeEventListener('pause', onPause);
+ media.removeEventListener('ratechange', onRate);
+ media.removeEventListener('loadedmetadata', onReady);
+ media.removeEventListener('canplay', onReady);
+ if (noticeTimer) clearTimeout(noticeTimer);
+ if (noticeEl && noticeEl.parentNode) noticeEl.parentNode.removeChild(noticeEl);
+ noticeEl = null;
+ },
+ };
+ }
+
+ return { attach, TARGET_MS, SEEK_MS, NUDGE, clockSamples: SAMPLES };
+});
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.142.0",
+ "version": "4.143.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -60,6 +60,7 @@
@@ -60,6 +60,7 @@
"./ov-forms": "./ov-forms.js",
"./ov-forms": "./ov-forms.js",
"./ov-qr": "./ov-qr.js",
"./ov-qr": "./ov-qr.js",
"./ov-share": "./ov-share.js",
"./ov-share": "./ov-share.js",
+ "./ov-sync": "./ov-sync.js",
"./ov-locker": "./ov-locker.js",
"./ov-locker": "./ov-locker.js",
"./ov-locker-core": "./ov-locker-core.js",
"./ov-locker-core": "./ov-locker-core.js",
"./ov-labels": "./ov-labels.js",
"./ov-labels": "./ov-labels.js",
new file mode 100644
new file mode 100644
Binary files /dev/null and b/test/fixtures/ov-sync-clock.webm differ
Binary files /dev/null and b/test/fixtures/ov-sync-clock.webm differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+'use strict';
+// ov-sync.js in a real headless Chrome (seven domains B4): two <video> elements follow one
+// common.sync-clock@1 timeline over two mock room sockets. The clock is measured with the same
+// `time` ping/pong frames Chat sends; the test watches both players for 10 s, then pauses, seeks,
+// changes the room's rate, and checks that a non-holder's local seek is put back with a notice
+// while a holder's local seek becomes the room's new state. Skipped without Chrome.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('ov-sync-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const VIDEO = path.join(__dirname, 'fixtures', 'ov-sync-clock.webm');
+
+// The page is both ends of the room protocol: a fake socket per player, one server clock 5 s ahead
+// of the browser's, the same `time` and `sync` frames Chat sends, and a lease ann holds.
+const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>ov-sync</title>
+<link rel="icon" href="data:,"><style>video{width:160px;height:90px;background:#000}</style></head>
+<body><video id="a" muted playsinline preload="auto" src="/ov-sync-clock.webm"></video>
+<video id="b" muted playsinline preload="auto" src="/ov-sync-clock.webm"></video>
+<script src="/ov-sync.js"></script>
+<script>
+(function () {
+ const ROOM = 'friday-film';
+ const SERVER_OFFSET = 5000;
+ const serverNow = () => Date.now() + SERVER_OFFSET;
+ let seq = 1;
+ const state = { room: ROOM, media: '/ov-sync-clock.webm', position_ms: 0, rate: 1, paused: false,
+ at_ms: serverNow(), set_by: 'usr_ann', seq: seq };
+ let lease = { thing: 'chat.room:' + ROOM, holder: { kind: 'person', subject: 'usr_ann' },
+ since_ms: serverNow(), watchers: ['usr_ann', 'usr_bob'], seq: 1 };
+ const sockets = [];
+
+ function broadcast(frame) { for (const s of sockets) s.emit(frame); }
+ function makeSocket(subject) {
+ const listeners = { message: new Set(), open: new Set() };
+ const s = {
+ readyState: 1, subject, sent: [],
+ addEventListener(type, fn) { if (listeners[type]) listeners[type].add(fn); },
+ removeEventListener(type, fn) { if (listeners[type]) listeners[type].delete(fn); },
+ send(raw) {
+ const msg = JSON.parse(raw);
+ s.sent.push(msg);
+ if (msg.type === 'time') {
+ const t0 = msg.t0;
+ setTimeout(() => s.emit({ type: 'time', id: msg.id, t0, t_server: serverNow() }), 2 + Math.random() * 8);
+ } else if (msg.type === 'sync') {
+ if (lease.holder.kind !== 'person' || lease.holder.subject !== subject) {
+ s.emit({ type: 'sync_error', code: 'sync.not_holder', message: 'Only the holder may set the clock.' });
+ return;
+ }
+ state.media = msg.media === undefined ? state.media : msg.media;
+ state.position_ms = msg.position_ms;
+ state.rate = msg.rate === undefined ? state.rate : msg.rate;
+ state.paused = msg.paused === undefined ? state.paused : msg.paused;
+ state.at_ms = serverNow();
+ state.set_by = subject;
+ state.seq = ++seq;
+ broadcast({ type: 'sync', room: ROOM, state: state });
+ }
+ },
+ emit(msg) { for (const fn of listeners.message) fn({ data: JSON.stringify(msg) }); },
+ };
+ sockets.push(s);
+ return s;
+ }
+
+ function currentPosition() {
+ return state.paused ? state.position_ms : state.position_ms + (serverNow() - state.at_ms) * state.rate;
+ }
+ function change(patch) {
+ Object.assign(state, patch);
+ state.at_ms = serverNow();
+ state.seq = ++seq;
+ broadcast({ type: 'sync', room: ROOM, state: state });
+ }
+ window.__server = {
+ state, lease, serverNow, currentPosition,
+ pause() { change({ paused: true, position_ms: Math.max(0, Math.round(currentPosition())) }); },
+ seek(position_ms) { change({ paused: true, position_ms: position_ms }); },
+ play(rate) { change({ paused: false, rate: rate || 1, position_ms: Math.max(0, Math.round(currentPosition())) }); },
+ setLease(next) { lease = next; broadcast({ type: 'lease', room: ROOM, state: lease }); },
+ };
+ window.__drifts = [];
+ window.__start = function () {
+ const a = document.getElementById('a');
+ const b = document.getElementById('b');
+ window.__a = OVSync.attach(a, { room: ROOM, socket: makeSocket('usr_ann'), self: 'usr_ann' });
+ window.__b = OVSync.attach(b, { room: ROOM, socket: makeSocket('usr_bob'), self: 'usr_bob' });
+ setTimeout(() => {
+ broadcast({ type: 'lease', room: ROOM, state: lease });
+ broadcast({ type: 'sync', room: ROOM, state: state });
+ }, 50);
+ setInterval(() => {
+ if (!window.__a || !window.__b) return;
+ window.__drifts.push({ a: window.__a.drift(), b: window.__b.drift(),
+ ca: window.__a.clock().ready, cb: window.__b.clock().ready });
+ }, 200);
+ return true;
+ };
+})();
+</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ if (req.url === '/') {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
+ return res.end(PAGE);
+ }
+ if (req.url === '/ov-sync.js') {
+ res.writeHead(200, { 'content-type': 'application/javascript' });
+ return res.end(fs.readFileSync(path.join(ROOT, 'ov-sync.js')));
+ }
+ if (req.url === '/ov-sync-clock.webm') {
+ const body = fs.readFileSync(VIDEO);
+ const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
+ if (range) {
+ const start = range[1] ? Number(range[1]) : 0;
+ const end = range[2] ? Number(range[2]) : body.length - 1;
+ res.writeHead(206, {
+ 'content-type': 'video/webm', 'accept-ranges': 'bytes',
+ 'content-range': `bytes ${start}-${end}/${body.length}`, 'content-length': end - start + 1,
+ });
+ return res.end(body.subarray(start, end + 1));
+ }
+ res.writeHead(200, { 'content-type': 'video/webm', 'accept-ranges': 'bytes', 'content-length': body.length });
+ return res.end(body);
+ }
+ res.writeHead(404);
+ res.end();
+});
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-sync-test-'));
+ const browser = await h.launch({ tmpDir, args: ['--autoplay-policy=no-user-gesture-required'] });
+ try {
+ const context = await browser.newContext();
+ const p = await h.openPage(browser, context, { width: 640 });
+ await p.goto(`${base}/`, { maxMs: 10000 });
+ assert.strictEqual(await p.evaluate('document.readyState'), 'complete');
+ await p.evaluate('window.__start()');
+
+ const until = Date.now() + 8000;
+ while (Date.now() < until) {
+ const ready = await p.evaluate('window.__a.clock().ready && window.__b.clock().ready');
+ if (ready) break;
+ await sleep(100);
+ }
+ assert.ok(await p.evaluate('window.__a.clock().ready && window.__b.clock().ready'), 'both players measured the room clock');
+ const clocks = await p.evaluate('[window.__a.clock(), window.__b.clock()]');
+ for (const c of clocks) {
+ assert.ok(Number.isFinite(c.offset) && Math.abs(c.offset - 5000) < 1500, `the 5 s server offset is measured (${JSON.stringify(c)})`);
+ }
+
+ // Play from one timeline for 10 s and keep every sample.
+ await sleep(1500);
+ await p.evaluate('window.__drifts = []');
+ await sleep(10000);
+ const stats = await p.evaluate(`(() => {
+ const ds = window.__drifts.filter((x) => x.ca && x.cb);
+ return { n: ds.length, maxA: Math.max(...ds.map((x) => Math.abs(x.a))), maxB: Math.max(...ds.map((x) => Math.abs(x.b))) };
+ })()`);
+ assert.ok(stats.n >= 40, `sampled the 10 s (${stats.n} samples)`);
+ assert.ok(stats.maxA <= 80 && stats.maxB <= 80, `both stay inside ±80 ms (${JSON.stringify(stats)})`);
+
+ // Pause: both elements stop and hold the room's position.
+ await p.evaluate('window.__server.pause()');
+ await sleep(500);
+ const paused = await p.evaluate(`(() => ({ a: document.getElementById('a').paused, b: document.getElementById('b').paused,
+ ta: document.getElementById('a').currentTime, tb: document.getElementById('b').currentTime, want: window.__server.currentPosition() }))()`);
+ assert.ok(paused.a && paused.b, `both pause with the room: ${JSON.stringify(paused)}`);
+ assert.ok(Math.abs(paused.ta * 1000 - paused.want) <= 80 && Math.abs(paused.tb * 1000 - paused.want) <= 80,
+ `both hold the paused position: ${JSON.stringify(paused)}`);
+
+ // Seek: a paused seek lands on both.
+ await p.evaluate('window.__server.seek(12000)');
+ await sleep(600);
+ const sought = await p.evaluate('[document.getElementById("a").currentTime, document.getElementById("b").currentTime]');
+ for (const t of sought) assert.ok(Math.abs(t * 1000 - 12000) <= 80, `a seek reaches both (${JSON.stringify(sought)})`);
+
+ // Rate change: both play at the room's 1.25 (a nudge may ride on top of it).
+ await p.evaluate('window.__server.play(1.25)');
+ await sleep(2500);
+ const rate = await p.evaluate(`(() => ({ a: document.getElementById('a').playbackRate, b: document.getElementById('b').playbackRate,
+ pa: document.getElementById('a').paused, pb: document.getElementById('b').paused,
+ da: window.__a.drift(), db: window.__b.drift() }))()`);
+ assert.ok(!rate.pa && !rate.pb, `both play at the room's rate: ${JSON.stringify(rate)}`);
+ for (const r of [rate.a, rate.b]) assert.ok(r >= 1.25 * 0.95 - 0.001 && r <= 1.25 * 1.05 + 0.001, `rate nudge stays 0.95–1.05 of 1.25 (${r})`);
+ assert.ok(Math.abs(rate.da) <= 80 && Math.abs(rate.db) <= 80, `still in sync after the rate change: ${JSON.stringify(rate)}`);
+
+ // A non-holder's local seek snaps back, with a short notice.
+ const before = await p.evaluate('({ want: window.__server.currentPosition(), seq: window.__server.state.seq })');
+ await p.evaluate('document.getElementById("b").currentTime = 3');
+ await sleep(700);
+ const back = await p.evaluate(`(() => ({ t: document.getElementById('b').currentTime,
+ want: window.__server.currentPosition(),
+ notice: document.querySelector('[data-ov-sync-notice]') ? document.querySelector('[data-ov-sync-notice]').textContent : null,
+ seq: window.__server.state.seq }))()`);
+ assert.ok(Math.abs(back.t * 1000 - back.want) <= 150,
+ `a non-holder's seek is put back (${JSON.stringify(back)})`);
+ assert.match(back.notice || '', /Only the person with the remote/, 'the snap-back says why');
+ assert.strictEqual(back.seq, before.seq, 'a non-holder cannot change the room');
+
+ // A holder's local seek becomes the room's new state and reaches the other player.
+ await p.evaluate('document.getElementById("a").currentTime = 18');
+ await sleep(700);
+ const held = await p.evaluate(`(() => ({ set_by: window.__server.state.set_by, position: window.__server.state.position_ms,
+ want: window.__server.currentPosition(),
+ ta: document.getElementById('a').currentTime, tb: document.getElementById('b').currentTime }))()`);
+ assert.strictEqual(held.set_by, 'usr_ann', 'the holder set the room');
+ assert.ok(Math.abs(held.position - 18000) < 600, `the holder's seek is the timeline (${held.position})`);
+ assert.ok(Math.abs(held.ta * 1000 - held.want) <= 200 && Math.abs(held.tb * 1000 - held.want) <= 300,
+ `both follow the holder's seek (${JSON.stringify(held)})`);
+
+ assert.deepStrictEqual(p.state.errors, [], 'no page errors');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+ console.log('ov-sync-chrome: 2 players in sync for 10 s, through a pause, a seek and a rate change');
+})().catch((err) => {
+ server.close();
+ console.error(err);
+ process.exit(1);
+});