openvibers/openvibe.shared ov-sync.js

ov-sync.js

main 17.9 KB · 403 lines · javascript

1/*
2 * OVSync — one timeline for many players (OpenVibe_Seven_Domains_Plan_2026-11-07 §1 B4, §7).
3 *
4 * const clock = OVSync.attach(video, { room: 'friday-film', socket, self: 'usr_…' });
5 * clock.destroy();
6 *
7 * One HTMLMediaElement follows a common.sync-clock@1 state that OpenVibe.Chat fans over /ws/chat:
8 *
9 * { room, media, position_ms, rate, paused, at_ms, set_by, seq }
10 *
11 * Where it should be is `position_ms + (serverNow() - at_ms) * rate` while the room plays, and exactly
12 * `position_ms` while it is paused. serverNow() is the local clock corrected by NTP-style `time`
13 * pings on the same socket: five samples, the one with the smallest round trip wins, refreshed every
14 * 60 s. Until the first sample arrives the element is not moved.
15 *
16 * Drift |media - timeline| is held inside ±80 ms: 80–400 ms nudges the playback rate by 0.95–1.05 of
17 * the room's rate until it is back, past 400 ms seeks. The element plays and pauses with the room.
18 * When the local person is not the common.control-lease@1 holder, their own play, pause, seek or rate
19 * change is put back with a short notice; the holder's own change is sent as a `sync` instead. `self`
20 * is the local subject; when it is left out it is learned from the socket's `room_joined` or `auth`
21 * frame (`subject`) or from `socket.subject`. `opts.holder: true|false` overrides the lease check.
22 *
23 * Media: a string is an https URL and is put on the element when it changes; a Media object is
24 * reported as an `ovsync:media` event (and to `opts.onMedia`) for the page to resolve. The script has
25 * no dependencies and works both as a browser classic script (`window.OVSync`, `window.OpenVibeSync`)
26 * and through `require('openvibe-shared/ov-sync')`.
27 */
28(function (root, factory) {
29 const api = factory();
30 if (typeof module === 'object' && module.exports) { module.exports = api; return; }
31 root.OVSync = api;
32 root.OpenVibeSync = api;
33})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
34 'use strict';
35
36 const TARGET_MS = 80; // inside this the element is left alone
37 const SEEK_MS = 400; // past this a seek, not a nudge
38 const NUDGE = 0.05; // a nudge is 0.95–1.05 of the room's rate
39 const TICK_MS = 100;
40 const SAMPLES = 5;
41 const SAMPLE_GAP_MS = 120;
42 const CLOCK_TIMEOUT_MS = 2500;
43 const REFRESH_MS = 60000;
44
45 const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
46
47 function attach(media, options) {
48 if (!media || typeof media.play !== 'function' || !('currentTime' in media)) {
49 throw new TypeError('OVSync.attach needs an HTMLMediaElement');
50 }
51 const opts = options || {};
52 const socket = opts.socket;
53 const room = String(opts.room || '');
54 if (!socket || typeof socket.send !== 'function') throw new TypeError('OVSync.attach needs the room socket');
55 if (!room) throw new TypeError('OVSync.attach needs a room');
56
57 let self = typeof opts.self === 'string' ? opts.self : (typeof opts.subject === 'string' ? opts.subject : null);
58 let state = opts.state && typeof opts.state === 'object' ? opts.state : null;
59 let lease = null;
60 let offset = 0;
61 let rtt = null;
62 let clockReady = false;
63 let generation = 0;
64 let batch = null;
65 const sentTimes = new Map();
66 let destroyed = false;
67 let tickTimer = null;
68 let refreshTimer = null;
69 let suppressUntil = 0;
70 let pendingSelfUntil = 0;
71 let lastDrift = 0;
72 let mediaKey = null;
73 let programmaticRate = null;
74 const outbox = [];
75 let noticeEl = null;
76 let noticeTimer = null;
77 let lastNoticeAt = -Infinity;
78
79 const perfNow = () => (typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now());
80 const serverNow = () => Date.now() + offset;
81 const quiet = () => perfNow() < suppressUntil;
82 const suppress = (ms) => { suppressUntil = Math.max(suppressUntil, perfNow() + ms); };
83
84 function emit(name, detail) {
85 try { media.dispatchEvent(new CustomEvent(name, { bubbles: true, detail })); } catch (e) { /* no DOM */ }
86 }
87
88 function send(frame) {
89 if (destroyed) return;
90 const ready = socket.readyState === undefined || socket.readyState === 1;
91 if (!ready) { outbox.push(frame); return; }
92 try { socket.send(JSON.stringify(frame)); } catch (e) { /* the socket went away */ }
93 }
94
95 function flush() {
96 while (outbox.length) {
97 const frame = outbox.shift();
98 try { socket.send(JSON.stringify(frame)); } catch (e) { return; }
99 }
100 }
101
102 // ── Clock: five samples, best round trip ─────────────────────────────
103 function sampleClock() {
104 if (destroyed) return;
105 const gen = ++generation;
106 const b = { gen, samples: [], timer: null };
107 batch = b;
108 for (let i = 0; i < SAMPLES; i++) {
109 setTimeout(() => {
110 if (destroyed || !batch || batch.gen !== gen) return;
111 const id = `t${gen}-${i}-${Math.random().toString(36).slice(2, 8)}`;
112 const t0 = Date.now();
113 sentTimes.set(id, { t0, gen });
114 send({ type: 'time', id, t0 });
115 }, i * SAMPLE_GAP_MS);
116 }
117 b.timer = setTimeout(() => finishClock(gen), (SAMPLES - 1) * SAMPLE_GAP_MS + CLOCK_TIMEOUT_MS);
118 }
119
120 function finishClock(gen) {
121 if (!batch || batch.gen !== gen) return;
122 clearTimeout(batch.timer);
123 const samples = batch.samples;
124 batch = null;
125 if (!samples.length) {
126 // Nothing came back (a sleeping tab, a lost socket): try again shortly, not in a minute.
127 if (!destroyed) setTimeout(sampleClock, 5000);
128 return;
129 }
130 const best = samples.reduce((a, b) => (b.rtt < a.rtt ? b : a));
131 offset = best.offset;
132 rtt = best.rtt;
133 clockReady = true;
134 emit('ovsync:clock', { offset, rtt });
135 tick();
136 }
137
138 function onTime(msg) {
139 const sent = sentTimes.get(msg.id);
140 if (!sent || !batch || sent.gen !== batch.gen) return;
141 sentTimes.delete(msg.id);
142 const t0 = Number.isFinite(Number(msg.t0)) ? Number(msg.t0) : sent.t0;
143 const t1 = Number(msg.t_server);
144 const t3 = Date.now();
145 if (!Number.isFinite(t0) || !Number.isFinite(t1)) return;
146 const sampleRtt = t3 - t0;
147 if (sampleRtt < 0 || sampleRtt > 5000) return;
148 batch.samples.push({ offset: t1 - (t0 + t3) / 2, rtt: sampleRtt });
149 if (batch.samples.length >= SAMPLES) finishClock(batch.gen);
150 }
151
152 // ── The room's timeline ──────────────────────────────────────────────
153 function targetMs() {
154 if (!state) return null;
155 const position = Number(state.position_ms);
156 const at = Number(state.at_ms);
157 const rate = Number(state.rate);
158 if (!Number.isFinite(position) || !Number.isFinite(at) || !Number.isFinite(rate)) return null;
159 if (state.paused) return Math.max(0, position);
160 return Math.max(0, position + (serverNow() - at) * rate);
161 }
162
163 function isHolder() {
164 if (opts.holder === true) return true;
165 if (opts.holder === false) return false;
166 if (!self || !lease || !lease.holder) return false;
167 return lease.holder.kind === 'person' && lease.holder.subject === self;
168 }
169
170 function applyState(next, source) {
171 if (!next || typeof next !== 'object') return;
172 if (room && next.room && next.room !== room) return;
173 if (state && Number(state.seq) > 0 && Number(next.seq) > 0 && Number(next.seq) < Number(state.seq)) return;
174 state = next;
175 if (self && next.set_by === self) pendingSelfUntil = 0;
176 emit('ovsync:state', state);
177 if (source) emit('ovsync:' + source, state);
178 adoptMedia();
179 tick();
180 }
181
182 function adoptMedia() {
183 if (!state || opts.followMedia === false) return;
184 const m = state.media;
185 if (typeof m === 'string') {
186 let url;
187 try { url = new URL(m, typeof location !== 'undefined' ? location.href : undefined); } catch (e) { return; }
188 // Only an https source: Chat already refuses media off OpenVibe's own hosts, and the player never
189 // follows a clock to http, data: or blob: whatever a server says.
190 if (url.protocol !== 'https:') return;
191 url = url.href;
192 if (mediaKey === url) return;
193 mediaKey = url;
194 const current = media.currentSrc || media.src;
195 if (current === url) return;
196 suppress(600);
197 try { media.src = url; media.load(); } catch (e) { /* the page owns the source */ }
198 emit('ovsync:media', m);
199 return;
200 }
201 if (m && typeof m === 'object') {
202 const key = JSON.stringify(m);
203 if (mediaKey === key) return;
204 mediaKey = key;
205 emit('ovsync:media', m);
206 if (typeof opts.onMedia === 'function') { try { opts.onMedia(m); } catch (e) { /* the page's */ } }
207 }
208 }
209
210 function seek(ms) {
211 const t = Math.max(0, Number(ms) / 1000);
212 if (!Number.isFinite(t)) return;
213 if (Math.abs(media.currentTime - t) < 0.005) return;
214 suppress(600);
215 try { media.currentTime = t; } catch (e) { /* not seekable yet */ }
216 }
217
218 function setRate(rate) {
219 const r = Math.round(clamp(rate, 0.0625, 16) * 1000) / 1000;
220 if (Math.abs(media.playbackRate - r) < 0.001) return;
221 programmaticRate = r;
222 suppress(400);
223 try { media.playbackRate = r; } catch (e) { /* the browser refused */ }
224 }
225
226 function tick() {
227 if (destroyed || !clockReady || !state) return;
228 if (pendingSelfUntil && Date.now() < pendingSelfUntil) return;
229 if (media.readyState < 1) {
230 if (media.networkState === 0 && (media.src || media.currentSrc)) {
231 try { media.load(); } catch (e) { /* no source yet */ }
232 }
233 return;
234 }
235 const target = targetMs();
236 if (target == null) return;
237 const base = clamp(Number(state.rate) || 1, 0.0625, 16);
238 const drift = media.currentTime * 1000 - target;
239 lastDrift = drift;
240
241 if (state.paused) {
242 if (!media.paused) { suppress(500); try { media.pause(); } catch (e) { /* */ } }
243 setRate(base);
244 if (Math.abs(drift) > TARGET_MS) seek(target);
245 return;
246 }
247
248 if (media.paused) {
249 suppress(700);
250 const p = media.play();
251 if (p && typeof p.catch === 'function') p.catch(() => { /* autoplay waits for a tap */ });
252 }
253 if (Math.abs(drift) > SEEK_MS) {
254 seek(target);
255 setRate(base);
256 return;
257 }
258 if (Math.abs(drift) > TARGET_MS) {
259 // Positive drift is ahead of the room: slow down. 80 ms is a 2% nudge, 400 ms the 5% cap.
260 const factor = clamp(1 - drift / 5000, 1 - NUDGE, 1 + NUDGE);
261 setRate(base * factor);
262 } else {
263 setRate(base);
264 }
265 }
266
267 function notify(message) {
268 const now = perfNow();
269 if (now - lastNoticeAt < 1200) return;
270 lastNoticeAt = now;
271 emit('ovsync:notice', { message });
272 if (opts.notice === false || typeof document === 'undefined' || !document.body) return;
273 if (!noticeEl) {
274 noticeEl = document.createElement('div');
275 noticeEl.setAttribute('data-ov-sync-notice', '');
276 noticeEl.setAttribute('role', 'status');
277 noticeEl.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;'
278 + 'background:#10131a;color:#fff;font:14px/1.4 system-ui,sans-serif;padding:8px 14px;border-radius:999px;'
279 + 'box-shadow:0 6px 20px rgba(0,0,0,.35);pointer-events:none;max-width:calc(100vw - 32px)';
280 document.body.appendChild(noticeEl);
281 }
282 noticeEl.textContent = message;
283 noticeEl.style.display = 'block';
284 clearTimeout(noticeTimer);
285 noticeTimer = setTimeout(() => { if (noticeEl) noticeEl.style.display = 'none'; }, 2500);
286 }
287
288 function sendSelfSync() {
289 if (!state) return;
290 pendingSelfUntil = Date.now() + 1500;
291 send({
292 type: 'sync',
293 room,
294 media: state.media,
295 position_ms: Math.max(0, Math.round(media.currentTime * 1000)),
296 rate: media.paused ? (Number(state.rate) || 1) : (media.playbackRate || Number(state.rate) || 1),
297 paused: !!media.paused,
298 });
299 }
300
301 function localChange() {
302 if (destroyed || !state) return;
303 if (isHolder()) { sendSelfSync(); return; }
304 notify('Only the person with the remote can change the timeline.');
305 suppress(30);
306 setTimeout(tick, 40);
307 }
308
309 // ── The socket and the element ───────────────────────────────────────
310 function onMessage(event) {
311 let msg;
312 try { msg = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; } catch (e) { return; }
313 if (!msg || typeof msg !== 'object') return;
314 if ((msg.type === 'room_joined' || msg.type === 'auth') && !self && typeof msg.subject === 'string' && msg.subject) {
315 self = msg.subject;
316 tick();
317 }
318 if (msg.type === 'time') { onTime(msg); return; }
319 if (room && msg.room && msg.room !== room) return;
320 if (msg.type === 'sync') {
321 const next = msg.state || (msg.position_ms !== undefined ? msg : null);
322 if (next) applyState(next, 'sync');
323 return;
324 }
325 if (msg.type === 'lease') {
326 const next = msg.state || msg;
327 if (next && next.holder) { lease = next; emit('ovsync:lease', next); tick(); }
328 return;
329 }
330 if (msg.type === 'sync_error') {
331 pendingSelfUntil = 0;
332 notify(msg.message || 'The room did not accept that change.');
333 tick();
334 }
335 }
336
337 const onOpen = () => flush();
338 const onSeeking = () => { if (!quiet()) localChange(); };
339 const onPlay = () => { if (!quiet()) localChange(); };
340 const onPause = () => { if (!quiet()) localChange(); };
341 const onRate = () => {
342 if (programmaticRate != null && Math.abs(media.playbackRate - programmaticRate) < 0.002) return;
343 programmaticRate = null;
344 if (!quiet()) localChange();
345 };
346 const onReady = () => tick();
347
348 if (typeof socket.addEventListener === 'function') {
349 socket.addEventListener('message', onMessage);
350 socket.addEventListener('open', onOpen);
351 }
352 media.addEventListener('seeking', onSeeking);
353 media.addEventListener('play', onPlay);
354 media.addEventListener('pause', onPause);
355 media.addEventListener('ratechange', onRate);
356 media.addEventListener('loadedmetadata', onReady);
357 media.addEventListener('canplay', onReady);
358
359 if (!self && typeof socket.subject === 'string') self = socket.subject;
360 if (state) { adoptMedia(); tick(); }
361 tickTimer = setInterval(tick, TICK_MS);
362 refreshTimer = setInterval(sampleClock, REFRESH_MS);
363 sampleClock();
364
365 return {
366 get state() { return state; },
367 get lease() { return lease; },
368 isHolder,
369 clock: () => ({ offset, rtt, ready: clockReady }),
370 serverNow,
371 targetMs,
372 drift: () => lastDrift,
373 setSelf: (subject) => { self = typeof subject === 'string' ? subject : null; tick(); },
374 notify,
375 destroy() {
376 if (destroyed) return;
377 destroyed = true;
378 clearInterval(tickTimer);
379 clearInterval(refreshTimer);
380 if (batch && batch.timer) clearTimeout(batch.timer);
381 batch = null;
382 sentTimes.clear();
383 outbox.length = 0;
384 if (typeof socket.removeEventListener === 'function') {
385 socket.removeEventListener('message', onMessage);
386 socket.removeEventListener('open', onOpen);
387 }
388 media.removeEventListener('seeking', onSeeking);
389 media.removeEventListener('play', onPlay);
390 media.removeEventListener('pause', onPause);
391 media.removeEventListener('ratechange', onRate);
392 media.removeEventListener('loadedmetadata', onReady);
393 media.removeEventListener('canplay', onReady);
394 if (noticeTimer) clearTimeout(noticeTimer);
395 if (noticeEl && noticeEl.parentNode) noticeEl.parentNode.removeChild(noticeEl);
396 noticeEl = null;
397 },
398 };
399 }
400
401 return { attach, TARGET_MS, SEEK_MS, NUDGE, clockSamples: SAMPLES };
402});
403