openvibers/openvibe.live Commits

Home live grid on Events realtime (WS-F task 1): public live.stream.started/ended from events.openvibe.network/realtime/stream refresh the grid the moment a stream starts or ends; the 12 s poll stays as the fallback and, while realtime is connected, only runs once a minute; a native EventSource (the SDK's browser transport) instead of the SDK's 128 KB bundle; CSP connect-src allows events.openvibe.network

OP OpenVibers committed 25 Sept 2026 · 6b2f7c4757 · 4 files, +64 −2


Changes

ddiff --git a/public/features.json b/public/features.json
iindex e40b96a3..5bb25335 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -9,6 +9,7 @@
],
"js": [
"/js/ov-rail.js",
++ "/js/ov-live-realtime.js",
"/js/app-home.js",
"/js/ov-odometer.js",
"/js/ov-density.js",
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex ffd60ce3..0d1ac78d 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -1910,15 +1910,31 @@ function refreshHomeSections() {
if (typeof loadHomeLeaderboards === 'function') loadHomeLeaderboards();
}
++// The live grid follows OpenVibe.Events realtime (public/js/ov-live-realtime.js): a stream that
++// starts or ends refreshes it at once. The 12 s poll stays as the fallback, and while realtime is
++// connected it only runs once a minute as a safety net.
++let _homeRtOff = null, _homeRtDebounce = null, _homeLastLiveRefresh = 0;
++function _homeRealtimeRefresh() {
++ clearTimeout(_homeRtDebounce);
++ _homeRtDebounce = setTimeout(() => { _homeLastLiveRefresh = Date.now(); refreshHomeLive(); }, 400);
++}
++function _homeLiveTick() {
++ if (window.OVLiveRealtime && OVLiveRealtime.connected && Date.now() - _homeLastLiveRefresh < 60000) return;
++ _homeLastLiveRefresh = Date.now();
++ refreshHomeLive();
++}
function startHomeRefresh() {
stopHomeRefresh();
_initUptimeTooltip();
-- _homeLiveTimer = setInterval(refreshHomeLive, 12000);
++ _homeLiveTimer = setInterval(_homeLiveTick, 12000);
_homeSectionsTimer = setInterval(refreshHomeSections, 60000);
++ if (window.OVLiveRealtime) _homeRtOff = OVLiveRealtime.on((kind) => { if (kind !== 'error') _homeRealtimeRefresh(); });
}
function stopHomeRefresh() {
if (_homeLiveTimer) { clearInterval(_homeLiveTimer); _homeLiveTimer = null; }
if (_homeSectionsTimer) { clearInterval(_homeSectionsTimer); _homeSectionsTimer = null; }
++ if (_homeRtOff) { _homeRtOff(); _homeRtOff = null; }
++ clearTimeout(_homeRtDebounce);
} // populated on channel load, used for filter bar
// Homepage pagination state
// The home page used to open with 46 cards across four list sections, which made it enormous and
ddiff --git a/public/js/ov-live-realtime.js b/public/js/ov-live-realtime.js
nnew file mode 100644
iindex 00000000..7583cf73
---- /dev/null
++++ b/public/js/ov-live-realtime.js
@@@ -0,0 +1,45 @@
++/**
++ * Live's stream lifecycle over OpenVibe.Events realtime (roadmap WS-F task 1): the public
++ * live.stream.started / live.stream.ended events on events.openvibe.network/realtime/stream (SSE).
++ * The home page's live grid refreshes the moment a stream starts or ends, and polls only as a
++ * safety net while this is connected (public/js/app-home.js startHomeRefresh).
++ *
++ * A native EventSource, the same transport openvibe-sdk/realtime uses in a browser, without the SDK's
++ * 128 KB browser bundle on the home page. Resume needs no bookkeeping here: every event, reconnect
++ * or gap just refetches the grid.
++ *
++ * const off = OVLiveRealtime.on((kind, event) => { … }); // kind: 'open' | 'event' | 'gap' | 'error'
++ * OVLiveRealtime.connected // true while the stream is open
++ */
++(function () {
++ 'use strict';
++ const URL_ = 'https://events.openvibe.network/realtime/stream?topics=live.stream.started,live.stream.ended';
++ const listeners = new Set();
++ let es = null;
++ let connected = false;
++
++ const emit = (kind, event) => { for (const fn of listeners) { try { fn(kind, event); } catch { /* a listener's problem */ } } };
++ function start() {
++ if (es || typeof EventSource === 'undefined') return;
++ es = new EventSource(URL_);
++ es.onopen = () => { connected = true; emit('open'); };
++ // EventSource reconnects by itself (with Last-Event-ID); until then the page polls.
++ es.onerror = () => { connected = false; emit('error'); };
++ es.onmessage = (m) => {
++ let d = null;
++ try { d = JSON.parse(m.data); } catch { return; }
++ if (d && d.event && /^live\.stream\.(started|ended)$/.test(d.event.event_type)) emit('event', d.event);
++ };
++ es.addEventListener('gap', () => emit('gap'));
++ }
++ function stop() { if (es) { es.close(); es = null; } connected = false; }
++
++ window.OVLiveRealtime = {
++ on(fn) {
++ listeners.add(fn);
++ start();
++ return () => { listeners.delete(fn); if (!listeners.size) stop(); };
++ },
++ get connected() { return connected; },
++ };
++})();
ddiff --git a/server/index.js b/server/index.js
iindex ba0ef78c..e5988ade 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -290,7 +290,7 @@ app.use(helmet({
// every queued item renders as a broken image, which is what the media
// request tab was doing for every YouTube link.
imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],
-- connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", whipConnectSrc],
++ connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://events.openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com", whipConnectSrc],
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
// or the browser blocks the media element.