openvibers/openvibe.shared ov-sync.js
ov-sync.js
| 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 | ; |
| 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 |