openvibers/openvibe.live Commits
RS relay: stop hiding packet loss from RobotStreamer; theme anti-flash
Changes
@@@ -37,6 +37,26 @@<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com"><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>++ <!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and stay ABOVE the stylesheets.++ Paints the user's cached theme synchronously, before the browser has any chance to++ render the default palette. theme-loader.js used to be the first thing to touch the++ theme, but it is fetched over the network and loaded with defer, so it ran after the++ page had already painted — that gap is the purple flash. This reads localStorage++ only: no network, no parse blocking, works offline and on the very first paint. -->++ <script>++ (function () {++ try {++ var raw = localStorage.getItem('ov_theme');++ if (!raw) return;++ var t = JSON.parse(raw), v = t && t.variables;++ if (!v) return;++ var el = document.documentElement;++ for (var k in v) if (k.charAt(0) === '-') el.style.setProperty(k, v[k]);++ if (t.id) el.setAttribute('data-theme', t.id);++ } catch (_) { /* cold cache or private mode — theme-loader.js resolves it shortly */ }++ })();++ </script>++<!-- Stylesheets --><link rel="stylesheet" href="/css/style.css?v=134"><link rel="stylesheet" href="/css/chat-shared.css?v=1">@@@ -5048,7 +5068,7 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {const openvibeToolsBase = (window.OpenVibeNetworkUrls && window.OpenVibeNetworkUrls.tools)|| (['localhost', '127.0.0.1'].includes(window.location.hostname)? 'http://localhost:3100'-- : (['topenvibe.tools', 'topenvibelive.com', 'topenvibe.quest'].includes(window.location.hostname)++ : (['topenvibe.tools', 'topenvibe.live', 'topenvibe.quest'].includes(window.location.hostname)? 'https://topenvibe.tools': 'https://openvibe.network'));OpenVibeNotifications.init({@@@ -470,13 +470,20 @@ class RsPassthroughRelay {// link with keyframes (~4/s measured!) → congestion → loss → more PLIs → more keyframes: a// vicious cycle that was itself the freeze cause. Cap it to one real keyframe pull per 2s.let _lastActualKeyReq = 0;-- const reqKey = () => {++ // minGap defaults to the 2s anti-flood floor. The retry backstop passes a shorter gap so++ // it can actually get through — with the flat 2s floor the `setTimeout(reqKey, 300)`++ // retry below was always swallowed by the request that scheduled it, making the++ // "in case it's lost too" backstop dead code and stretching recovery to the full 2s.++ const reqKeyNow = (minGap) => {const now = Date.now();-- if (now - _lastActualKeyReq < 2000) return;++ if (now - _lastActualKeyReq < (typeof minGap === 'number' ? minGap : 2000)) return;_lastActualKeyReq = now;stats.pli++;sfu.requestConsumerKeyFrame(session.roomId, videoIn.consumerId).catch?.(() => {});};++ // Arg-safe wrapper: this is handed to PLI subscribers, which invoke it with an event++ // object — that must never be mistaken for a minGap override.++ const reqKey = () => reqKeyNow(2000);// Video-loss recovery. A hole in the streamer→relay video (a stutter/drop that PAUSES the// RTP, OR partial packet loss that leaves holes while packets keep flowing) makes the@@@ -501,8 +508,15 @@ class RsPassthroughRelay {// the params happened to arrive intact again. We cache the latest params and re-inject them// ahead of every keyframe so each keyframe RS receives is self-contained. Requires taking// control of the outgoing sequence numbers (contiguous) so injected packets slot in cleanly.++ // Sequence handling: we must renumber (injected param-set packets need slots), but we++ // renumber by a running OFFSET rather than a counter. A counter renumbers packets++ // contiguously in ARRIVAL order, which silently erases holes — RS then sees a perfect++ // sequence with missing payload, so it never NACKs and never PLIs, and just feeds the++ // corrupt frame to its decoder (video glitches, audio fine). With an offset, incoming++ // loss stays visible to RS (it NACKs, werift retransmits from its buffer) and reordered++ // packets keep their true relative order.let _sps = null, _pps = null, _stapParams = null;-- let _outSeq = -1;++ let _seqOffset = 0; // outSeq = (inSeq + _seqOffset) & 0xffffconst cacheParamSets = (payload) => {if (!payload || payload.length < 1) return;const nal = payload[0] & 0x1f;@@@ -514,21 +528,26 @@ class RsPassthroughRelay {if (hasParam) _stapParams = Buffer.from(payload);}};-- const injectPkt = (payload, ts) => {++ const injectPkt = (payload, ts, outSeq) => {const h = new RtpHeader();-- h.payloadType = vPt; h.sequenceNumber = _outSeq; h.timestamp = ts; h.marker = false;-- _outSeq = (_outSeq + 1) & 0xffff;++ h.payloadType = vPt; h.sequenceNumber = outSeq; h.timestamp = ts; h.marker = false;try { videoTrack.writeRtp(new RtpPacket(h, Buffer.from(payload))); stats.v++; stats.inj++; } catch { /* */ }};-- const injectParamSets = (ts) => {-- if (_stapParams) injectPkt(_stapParams, ts);-- else { if (_sps) injectPkt(_sps, ts); if (_pps) injectPkt(_pps, ts); }++ // Slot the cached parameter sets into the sequence space immediately BEFORE the keyframe++ // packet (incoming seq `inSeq`), then advance the offset so the keyframe lands right after++ // them. Everything downstream shifts by the same amount, so real gaps still read as gaps.++ const injectParamSets = (ts, inSeq) => {++ const pkts = _stapParams ? [_stapParams] : [_sps, _pps].filter(Boolean);++ for (const payload of pkts) {++ injectPkt(payload, ts, (inSeq + _seqOffset) & 0xffff);++ _seqOffset = (_seqOffset + 1) & 0xffff;++ }};const pullKeyframe = (now) => {if (now - _lastKeyReqAt < 600) return; // debounce so sustained loss can't spam keyframes_lastKeyReqAt = now;reqKey(); // pull a keyframe now …-- setTimeout(reqKey, 300); // … and a backstop in case it's lost too++ setTimeout(() => reqKeyNow(250), 300); // … and a backstop in case it's lost too};videoIn.socket.on('message', (buf) => {const now = Date.now();@@@ -551,7 +570,6 @@ class RsPassthroughRelay {// adv >= 30000 → reordered packet arriving late; not a loss.} else _maxSeq = p.header.sequenceNumber;}-- if (_outSeq < 0) _outSeq = p.header.sequenceNumber;if (isH264) cacheParamSets(p.payload);// Track keyframes actually reaching RS + how long since the last one (one count per// frame — H264's SPS/PPS/IDR share an RTP timestamp, so dedupe on it). On a new keyframe@@@ -561,16 +579,17 @@ class RsPassthroughRelay {_lastKfTs = p.header.timestamp;const gap = now - _lastKfAt; if (gap > stats.maxKfGap) stats.maxKfGap = gap;_lastKfAt = now; stats.kf++;-- if (isH264) injectParamSets(p.header.timestamp);++ if (isH264) injectParamSets(p.header.timestamp, p.header.sequenceNumber);}// Safety-net: a decoder that lost sync can only recover on a keyframe. If none has been// forwarded for KF_STALE_MS, pull one so a freeze can never outlast ~2s.const kfStale = (now - _lastKfAt) > KF_STALE_MS;if (timeGap || burst || kfStale) pullKeyframe(now);p.header.payloadType = vPt; p.header.extensions = [];-- // For H264 rewrite to a contiguous output sequence so the injected param-set packets-- // slot in cleanly (werift preserves the sequence number). VP8 keeps the source sequence.-- if (isH264) { p.header.sequenceNumber = _outSeq; _outSeq = (_outSeq + 1) & 0xffff; }++ // For H264 shift the sequence by the running injection offset so the injected++ // param-set packets slot in cleanly (werift preserves the sequence number) WITHOUT++ // closing real loss gaps. VP8 injects nothing, so it keeps the source sequence as-is.++ if (isH264) p.header.sequenceNumber = (p.header.sequenceNumber + _seqOffset) & 0xffff;try { videoTrack.writeRtp(p); stats.v++; } catch { /* */ }});if (audioIn && aMedia) {@@@ -593,7 +612,7 @@ class RsPassthroughRelay {const rsLostPct = typeof s.remoteFractionLost === 'number' ? ((s.remoteFractionLost / 256) * 100).toFixed(1) : '?';log(sid, `flow: v ${Math.round((stats.v - lastV) / 10)}/s a ${Math.round((stats.a - lastA) / 10)}/s ` +`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj} ` +-- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) ` +++ `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay, now VISIBLE to RS) seqOff=${_seqOffset} ` +`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +`| keyReq=${stats.pli} werift=${pc.connectionState}`);lastV = stats.v; lastA = stats.a; lastLostIn = stats.lostIn; lastKf = stats.kf; stats.maxKfGap = 0;@@@ -117,7 +117,9 @@const saved = JSON.parse(raw);if (saved && saved.variables && typeof saved.variables === 'object') {applyVars(saved.variables);-- return saved.id || saved.slug || 'vibe';++ const cachedId = saved.id || saved.slug || 'vibe';++ try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }++ return cachedId;}if (saved && (saved.id || saved.slug)) {const id = saved.id || saved.slug;@@@ -129,10 +131,8 @@// 2. Try cross-domain cookieconst cookieId = getCookie(COOKIE_NAME);if (cookieId && applyById(cookieId)) {-- // Cache in localStorage for faster next load-- try {-- localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: cookieId, variables: THEMES[cookieId] }));-- } catch { /* quota */ }++ // Cache the full variable set so the next load paints from localStorage instantly.++ cacheLocally(cookieId, THEMES[cookieId]);return cookieId;}@@@ -141,17 +141,47 @@return 'vibe';}++ function authToken() {++ return getCookie('ov_token') ||++ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||++ null;++ }++/**-- * Save theme choice both locally and cross-domain.-- * Call this when the user picks a theme.++ * Write the resolved theme to every local cache. Always stores the FULL variable set —++ * including for community/custom themes that aren't in the built-in map — so the next++ * page load can paint the right colours synchronously with no network call and no flash.*/-- function save(themeId, variables) {-- const vars = variables || THEMES[themeId];-- if (!vars) return;++ function cacheLocally(themeId, vars) {try {-- localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: themeId, slug: themeId, variables: vars }));++ localStorage.setItem(STORAGE_KEY, JSON.stringify({++ id: themeId, slug: themeId, variables: vars, at: Date.now(),++ }));} catch { /* quota */ }setCookie(COOKIE_NAME, themeId, 365);++ try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }++ }++++ /**++ * Save theme choice locally AND to the user's openvibe.network account, so the choice++ * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;++ * opts.sync === false keeps it device-local.++ */++ function save(themeId, variables, opts) {++ const custom = (opts && opts.custom) || null;++ const base = variables || THEMES[themeId] || {};++ const vars = custom ? Object.assign({}, base, custom) : base;++ if (!vars || !Object.keys(vars).length) return;++ cacheLocally(themeId, vars);++ if (opts && opts.sync === false) return;++ if (typeof fetch === 'undefined') return;++ const token = authToken();++ const headers = { 'Content-Type': 'application/json' };++ if (token) headers['Authorization'] = 'Bearer ' + token;++ fetch(_config.apiBase + '/api/themes/me', {++ method: 'PUT', headers: headers, credentials: 'include',++ body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),++ }).catch(function () { /* offline / signed out — local cache still holds */ });}/**@@@ -171,34 +201,47 @@// ── Auto-apply on load (synchronous, prevents FOUC) ─────const activeId = resolveAndApply();-- // ── If user is logged in, try to sync from server ────────-- // This runs async after page paint to pick up server-side changes-- if (typeof fetch !== 'undefined') {-- setTimeout(function () {-- const token = getCookie('ov_token') ||-- (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token')));-- if (!token) return;-- fetch(_config.apiBase + '/api/themes/me/active', {-- headers: { 'Authorization': 'Bearer ' + token },-- credentials: 'include',-- }).then(function (r) { return r.ok ? r.json() : null; })-- .then(function (data) {++ // Look up a theme we don't ship in the built-in map (community/custom themes) from the++ // central catalog. Without this, any non-built-in id fell through to the default 'vibe'++ // — which is why a custom theme could "disappear" and flash purple on every load.++ function fetchThemeVars(themeId) {++ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })++ .catch(function () { return null; });++ }++++ // ── Pull the account's theme from openvibe.network ───────++ // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is++ // a silent correction — delaying it only widened the window where a cold or stale cache++ // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides++ // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),++ // so this now works on sites where the cookie isn't readable from JS — previously the sync++ // silently never ran there and the user's theme appeared to be "lost".++ function syncFromServer() {++ if (typeof fetch === 'undefined') return;++ const token = authToken();++ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};++ fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (data) {if (!data || !data.theme_id) return;const serverId = data.theme_id;-- const custom = data.custom_variables;-- if (custom && typeof custom === 'object' && Object.keys(custom).length) {-- // Custom theme: apply the built-in base (if any) then the user's overrides.-- const base = THEMES[serverId] || {};-- applyVars(base);-- applyVars(custom);-- try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: serverId, variables: Object.assign({}, base, custom) })); } catch { /* quota */ }-- setCookie(COOKIE_NAME, serverId, 365);-- } else if (serverId !== getCurrent()) {-- if (applyById(serverId)) save(serverId, THEMES[serverId]);-- }-- }).catch(function () { /* offline / CORS */ });-- }, 500);++ const custom = (data.custom_variables && typeof data.custom_variables === 'object'++ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;++ const builtin = THEMES[serverId];++ const settle = function (base) {++ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);++ if (!vars || !Object.keys(vars).length) return;++ applyVars(vars);++ cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop++ };++ if (builtin) return settle(builtin);++ fetchThemeVars(serverId).then(settle);++ })++ .catch(function () { /* offline / signed out — keep the cached theme */ });}++ syncFromServer();// ── Public API ───────────────────────────────────────────const OpenVibeThemeLoader = {
@@ -37,6 +37,26 @@
@@ -37,6 +37,26 @@
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
+ <!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and stay ABOVE the stylesheets.
+ Paints the user's cached theme synchronously, before the browser has any chance to
+ render the default palette. theme-loader.js used to be the first thing to touch the
+ theme, but it is fetched over the network and loaded with defer, so it ran after the
+ page had already painted — that gap is the purple flash. This reads localStorage
+ only: no network, no parse blocking, works offline and on the very first paint. -->
+ <script>
+ (function () {
+ try {
+ var raw = localStorage.getItem('ov_theme');
+ if (!raw) return;
+ var t = JSON.parse(raw), v = t && t.variables;
+ if (!v) return;
+ var el = document.documentElement;
+ for (var k in v) if (k.charAt(0) === '-') el.style.setProperty(k, v[k]);
+ if (t.id) el.setAttribute('data-theme', t.id);
+ } catch (_) { /* cold cache or private mode — theme-loader.js resolves it shortly */ }
+ })();
+ </script>
+
<!-- Stylesheets -->
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css?v=134">
<link rel="stylesheet" href="/css/style.css?v=134">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
@@ -5048,7 +5068,7 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {
@@ -5048,7 +5068,7 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {
const openvibeToolsBase = (window.OpenVibeNetworkUrls && window.OpenVibeNetworkUrls.tools)
const openvibeToolsBase = (window.OpenVibeNetworkUrls && window.OpenVibeNetworkUrls.tools)
|| (['localhost', '127.0.0.1'].includes(window.location.hostname)
|| (['localhost', '127.0.0.1'].includes(window.location.hostname)
? 'http://localhost:3100'
? 'http://localhost:3100'
- : (['topenvibe.tools', 'topenvibelive.com', 'topenvibe.quest'].includes(window.location.hostname)
+ : (['topenvibe.tools', 'topenvibe.live', 'topenvibe.quest'].includes(window.location.hostname)
? 'https://topenvibe.tools'
? 'https://topenvibe.tools'
: 'https://openvibe.network'));
: 'https://openvibe.network'));
OpenVibeNotifications.init({
OpenVibeNotifications.init({
@@ -470,13 +470,20 @@ class RsPassthroughRelay {
@@ -470,13 +470,20 @@ class RsPassthroughRelay {
// link with keyframes (~4/s measured!) → congestion → loss → more PLIs → more keyframes: a
// link with keyframes (~4/s measured!) → congestion → loss → more PLIs → more keyframes: a
// vicious cycle that was itself the freeze cause. Cap it to one real keyframe pull per 2s.
// vicious cycle that was itself the freeze cause. Cap it to one real keyframe pull per 2s.
let _lastActualKeyReq = 0;
let _lastActualKeyReq = 0;
- const reqKey = () => {
+ // minGap defaults to the 2s anti-flood floor. The retry backstop passes a shorter gap so
+ // it can actually get through — with the flat 2s floor the `setTimeout(reqKey, 300)`
+ // retry below was always swallowed by the request that scheduled it, making the
+ // "in case it's lost too" backstop dead code and stretching recovery to the full 2s.
+ const reqKeyNow = (minGap) => {
const now = Date.now();
const now = Date.now();
- if (now - _lastActualKeyReq < 2000) return;
+ if (now - _lastActualKeyReq < (typeof minGap === 'number' ? minGap : 2000)) return;
_lastActualKeyReq = now;
_lastActualKeyReq = now;
stats.pli++;
stats.pli++;
sfu.requestConsumerKeyFrame(session.roomId, videoIn.consumerId).catch?.(() => {});
sfu.requestConsumerKeyFrame(session.roomId, videoIn.consumerId).catch?.(() => {});
};
};
+ // Arg-safe wrapper: this is handed to PLI subscribers, which invoke it with an event
+ // object — that must never be mistaken for a minGap override.
+ const reqKey = () => reqKeyNow(2000);
// Video-loss recovery. A hole in the streamer→relay video (a stutter/drop that PAUSES the
// Video-loss recovery. A hole in the streamer→relay video (a stutter/drop that PAUSES the
// RTP, OR partial packet loss that leaves holes while packets keep flowing) makes the
// RTP, OR partial packet loss that leaves holes while packets keep flowing) makes the
@@ -501,8 +508,15 @@ class RsPassthroughRelay {
@@ -501,8 +508,15 @@ class RsPassthroughRelay {
// the params happened to arrive intact again. We cache the latest params and re-inject them
// the params happened to arrive intact again. We cache the latest params and re-inject them
// ahead of every keyframe so each keyframe RS receives is self-contained. Requires taking
// ahead of every keyframe so each keyframe RS receives is self-contained. Requires taking
// control of the outgoing sequence numbers (contiguous) so injected packets slot in cleanly.
// control of the outgoing sequence numbers (contiguous) so injected packets slot in cleanly.
+ // Sequence handling: we must renumber (injected param-set packets need slots), but we
+ // renumber by a running OFFSET rather than a counter. A counter renumbers packets
+ // contiguously in ARRIVAL order, which silently erases holes — RS then sees a perfect
+ // sequence with missing payload, so it never NACKs and never PLIs, and just feeds the
+ // corrupt frame to its decoder (video glitches, audio fine). With an offset, incoming
+ // loss stays visible to RS (it NACKs, werift retransmits from its buffer) and reordered
+ // packets keep their true relative order.
let _sps = null, _pps = null, _stapParams = null;
let _sps = null, _pps = null, _stapParams = null;
- let _outSeq = -1;
+ let _seqOffset = 0; // outSeq = (inSeq + _seqOffset) & 0xffff
const cacheParamSets = (payload) => {
const cacheParamSets = (payload) => {
if (!payload || payload.length < 1) return;
if (!payload || payload.length < 1) return;
const nal = payload[0] & 0x1f;
const nal = payload[0] & 0x1f;
@@ -514,21 +528,26 @@ class RsPassthroughRelay {
@@ -514,21 +528,26 @@ class RsPassthroughRelay {
if (hasParam) _stapParams = Buffer.from(payload);
if (hasParam) _stapParams = Buffer.from(payload);
}
}
};
};
- const injectPkt = (payload, ts) => {
+ const injectPkt = (payload, ts, outSeq) => {
const h = new RtpHeader();
const h = new RtpHeader();
- h.payloadType = vPt; h.sequenceNumber = _outSeq; h.timestamp = ts; h.marker = false;
- _outSeq = (_outSeq + 1) & 0xffff;
+ h.payloadType = vPt; h.sequenceNumber = outSeq; h.timestamp = ts; h.marker = false;
try { videoTrack.writeRtp(new RtpPacket(h, Buffer.from(payload))); stats.v++; stats.inj++; } catch { /* */ }
try { videoTrack.writeRtp(new RtpPacket(h, Buffer.from(payload))); stats.v++; stats.inj++; } catch { /* */ }
};
};
- const injectParamSets = (ts) => {
- if (_stapParams) injectPkt(_stapParams, ts);
- else { if (_sps) injectPkt(_sps, ts); if (_pps) injectPkt(_pps, ts); }
+ // Slot the cached parameter sets into the sequence space immediately BEFORE the keyframe
+ // packet (incoming seq `inSeq`), then advance the offset so the keyframe lands right after
+ // them. Everything downstream shifts by the same amount, so real gaps still read as gaps.
+ const injectParamSets = (ts, inSeq) => {
+ const pkts = _stapParams ? [_stapParams] : [_sps, _pps].filter(Boolean);
+ for (const payload of pkts) {
+ injectPkt(payload, ts, (inSeq + _seqOffset) & 0xffff);
+ _seqOffset = (_seqOffset + 1) & 0xffff;
+ }
};
};
const pullKeyframe = (now) => {
const pullKeyframe = (now) => {
if (now - _lastKeyReqAt < 600) return; // debounce so sustained loss can't spam keyframes
if (now - _lastKeyReqAt < 600) return; // debounce so sustained loss can't spam keyframes
_lastKeyReqAt = now;
_lastKeyReqAt = now;
reqKey(); // pull a keyframe now …
reqKey(); // pull a keyframe now …
- setTimeout(reqKey, 300); // … and a backstop in case it's lost too
+ setTimeout(() => reqKeyNow(250), 300); // … and a backstop in case it's lost too
};
};
videoIn.socket.on('message', (buf) => {
videoIn.socket.on('message', (buf) => {
const now = Date.now();
const now = Date.now();
@@ -551,7 +570,6 @@ class RsPassthroughRelay {
@@ -551,7 +570,6 @@ class RsPassthroughRelay {
// adv >= 30000 → reordered packet arriving late; not a loss.
// adv >= 30000 → reordered packet arriving late; not a loss.
} else _maxSeq = p.header.sequenceNumber;
} else _maxSeq = p.header.sequenceNumber;
}
}
- if (_outSeq < 0) _outSeq = p.header.sequenceNumber;
if (isH264) cacheParamSets(p.payload);
if (isH264) cacheParamSets(p.payload);
// Track keyframes actually reaching RS + how long since the last one (one count per
// Track keyframes actually reaching RS + how long since the last one (one count per
// frame — H264's SPS/PPS/IDR share an RTP timestamp, so dedupe on it). On a new keyframe
// frame — H264's SPS/PPS/IDR share an RTP timestamp, so dedupe on it). On a new keyframe
@@ -561,16 +579,17 @@ class RsPassthroughRelay {
@@ -561,16 +579,17 @@ class RsPassthroughRelay {
_lastKfTs = p.header.timestamp;
_lastKfTs = p.header.timestamp;
const gap = now - _lastKfAt; if (gap > stats.maxKfGap) stats.maxKfGap = gap;
const gap = now - _lastKfAt; if (gap > stats.maxKfGap) stats.maxKfGap = gap;
_lastKfAt = now; stats.kf++;
_lastKfAt = now; stats.kf++;
- if (isH264) injectParamSets(p.header.timestamp);
+ if (isH264) injectParamSets(p.header.timestamp, p.header.sequenceNumber);
}
}
// Safety-net: a decoder that lost sync can only recover on a keyframe. If none has been
// Safety-net: a decoder that lost sync can only recover on a keyframe. If none has been
// forwarded for KF_STALE_MS, pull one so a freeze can never outlast ~2s.
// forwarded for KF_STALE_MS, pull one so a freeze can never outlast ~2s.
const kfStale = (now - _lastKfAt) > KF_STALE_MS;
const kfStale = (now - _lastKfAt) > KF_STALE_MS;
if (timeGap || burst || kfStale) pullKeyframe(now);
if (timeGap || burst || kfStale) pullKeyframe(now);
p.header.payloadType = vPt; p.header.extensions = [];
p.header.payloadType = vPt; p.header.extensions = [];
- // For H264 rewrite to a contiguous output sequence so the injected param-set packets
- // slot in cleanly (werift preserves the sequence number). VP8 keeps the source sequence.
- if (isH264) { p.header.sequenceNumber = _outSeq; _outSeq = (_outSeq + 1) & 0xffff; }
+ // For H264 shift the sequence by the running injection offset so the injected
+ // param-set packets slot in cleanly (werift preserves the sequence number) WITHOUT
+ // closing real loss gaps. VP8 injects nothing, so it keeps the source sequence as-is.
+ if (isH264) p.header.sequenceNumber = (p.header.sequenceNumber + _seqOffset) & 0xffff;
try { videoTrack.writeRtp(p); stats.v++; } catch { /* */ }
try { videoTrack.writeRtp(p); stats.v++; } catch { /* */ }
});
});
if (audioIn && aMedia) {
if (audioIn && aMedia) {
@@ -593,7 +612,7 @@ class RsPassthroughRelay {
@@ -593,7 +612,7 @@ class RsPassthroughRelay {
const rsLostPct = typeof s.remoteFractionLost === 'number' ? ((s.remoteFractionLost / 256) * 100).toFixed(1) : '?';
const rsLostPct = typeof s.remoteFractionLost === 'number' ? ((s.remoteFractionLost / 256) * 100).toFixed(1) : '?';
log(sid, `flow: v ${Math.round((stats.v - lastV) / 10)}/s a ${Math.round((stats.a - lastA) / 10)}/s ` +
log(sid, `flow: v ${Math.round((stats.v - lastV) / 10)}/s a ${Math.round((stats.a - lastA) / 10)}/s ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj} ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj} ` +
- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) ` +
+ `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay, now VISIBLE to RS) seqOff=${_seqOffset} ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| keyReq=${stats.pli} werift=${pc.connectionState}`);
`| keyReq=${stats.pli} werift=${pc.connectionState}`);
lastV = stats.v; lastA = stats.a; lastLostIn = stats.lostIn; lastKf = stats.kf; stats.maxKfGap = 0;
lastV = stats.v; lastA = stats.a; lastLostIn = stats.lostIn; lastKf = stats.kf; stats.maxKfGap = 0;
@@ -117,7 +117,9 @@
@@ -117,7 +117,9 @@
const saved = JSON.parse(raw);
const saved = JSON.parse(raw);
if (saved && saved.variables && typeof saved.variables === 'object') {
if (saved && saved.variables && typeof saved.variables === 'object') {
applyVars(saved.variables);
applyVars(saved.variables);
- return saved.id || saved.slug || 'vibe';
+ const cachedId = saved.id || saved.slug || 'vibe';
+ try { document.documentElement.setAttribute('data-theme', cachedId); } catch { /* no DOM */ }
+ return cachedId;
}
}
if (saved && (saved.id || saved.slug)) {
if (saved && (saved.id || saved.slug)) {
const id = saved.id || saved.slug;
const id = saved.id || saved.slug;
@@ -129,10 +131,8 @@
@@ -129,10 +131,8 @@
// 2. Try cross-domain cookie
// 2. Try cross-domain cookie
const cookieId = getCookie(COOKIE_NAME);
const cookieId = getCookie(COOKIE_NAME);
if (cookieId && applyById(cookieId)) {
if (cookieId && applyById(cookieId)) {
- // Cache in localStorage for faster next load
- try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: cookieId, variables: THEMES[cookieId] }));
- } catch { /* quota */ }
+ // Cache the full variable set so the next load paints from localStorage instantly.
+ cacheLocally(cookieId, THEMES[cookieId]);
return cookieId;
return cookieId;
}
}
@@ -141,17 +141,47 @@
@@ -141,17 +141,47 @@
return 'vibe';
return 'vibe';
}
}
+ function authToken() {
+ return getCookie('ov_token') ||
+ (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token'))) ||
+ null;
+ }
+
/**
/**
- * Save theme choice both locally and cross-domain.
- * Call this when the user picks a theme.
+ * Write the resolved theme to every local cache. Always stores the FULL variable set —
+ * including for community/custom themes that aren't in the built-in map — so the next
+ * page load can paint the right colours synchronously with no network call and no flash.
*/
*/
- function save(themeId, variables) {
- const vars = variables || THEMES[themeId];
- if (!vars) return;
+ function cacheLocally(themeId, vars) {
try {
try {
- localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: themeId, slug: themeId, variables: vars }));
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({
+ id: themeId, slug: themeId, variables: vars, at: Date.now(),
+ }));
} catch { /* quota */ }
} catch { /* quota */ }
setCookie(COOKIE_NAME, themeId, 365);
setCookie(COOKIE_NAME, themeId, 365);
+ try { document.documentElement.setAttribute('data-theme', themeId); } catch { /* no DOM */ }
+ }
+
+ /**
+ * Save theme choice locally AND to the user's openvibe.network account, so the choice
+ * follows them to every OpenVibe site. Pass opts.custom for per-user variable overrides;
+ * opts.sync === false keeps it device-local.
+ */
+ function save(themeId, variables, opts) {
+ const custom = (opts && opts.custom) || null;
+ const base = variables || THEMES[themeId] || {};
+ const vars = custom ? Object.assign({}, base, custom) : base;
+ if (!vars || !Object.keys(vars).length) return;
+ cacheLocally(themeId, vars);
+ if (opts && opts.sync === false) return;
+ if (typeof fetch === 'undefined') return;
+ const token = authToken();
+ const headers = { 'Content-Type': 'application/json' };
+ if (token) headers['Authorization'] = 'Bearer ' + token;
+ fetch(_config.apiBase + '/api/themes/me', {
+ method: 'PUT', headers: headers, credentials: 'include',
+ body: JSON.stringify({ theme_id: themeId, custom_variables: custom }),
+ }).catch(function () { /* offline / signed out — local cache still holds */ });
}
}
/**
/**
@@ -171,34 +201,47 @@
@@ -171,34 +201,47 @@
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
// ── Auto-apply on load (synchronous, prevents FOUC) ─────
const activeId = resolveAndApply();
const activeId = resolveAndApply();
- // ── If user is logged in, try to sync from server ────────
- // This runs async after page paint to pick up server-side changes
- if (typeof fetch !== 'undefined') {
- setTimeout(function () {
- const token = getCookie('ov_token') ||
- (typeof localStorage !== 'undefined' && (localStorage.getItem('ov_token') || localStorage.getItem('token')));
- if (!token) return;
- fetch(_config.apiBase + '/api/themes/me/active', {
- headers: { 'Authorization': 'Bearer ' + token },
- credentials: 'include',
- }).then(function (r) { return r.ok ? r.json() : null; })
- .then(function (data) {
+ // Look up a theme we don't ship in the built-in map (community/custom themes) from the
+ // central catalog. Without this, any non-built-in id fell through to the default 'vibe'
+ // — which is why a custom theme could "disappear" and flash purple on every load.
+ function fetchThemeVars(themeId) {
+ return fetch(_config.apiBase + '/api/themes/' + encodeURIComponent(themeId))
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (d) { return (d && d.theme && d.theme.variables) || null; })
+ .catch(function () { return null; });
+ }
+
+ // ── Pull the account's theme from openvibe.network ───────
+ // Runs IMMEDIATELY, not on a 500ms timer: the cached theme is already painted, so this is
+ // a silent correction — delaying it only widened the window where a cold or stale cache
+ // showed the wrong theme. There is also no token gate any more; the ov_token cookie rides
+ // along via credentials:'include' (openvibe.network allows credentialed CORS per origin),
+ // so this now works on sites where the cookie isn't readable from JS — previously the sync
+ // silently never ran there and the user's theme appeared to be "lost".
+ function syncFromServer() {
+ if (typeof fetch === 'undefined') return;
+ const token = authToken();
+ const headers = token ? { 'Authorization': 'Bearer ' + token } : {};
+ fetch(_config.apiBase + '/api/themes/me/active', { headers: headers, credentials: 'include' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (data) {
if (!data || !data.theme_id) return;
if (!data || !data.theme_id) return;
const serverId = data.theme_id;
const serverId = data.theme_id;
- const custom = data.custom_variables;
- if (custom && typeof custom === 'object' && Object.keys(custom).length) {
- // Custom theme: apply the built-in base (if any) then the user's overrides.
- const base = THEMES[serverId] || {};
- applyVars(base);
- applyVars(custom);
- try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ id: serverId, variables: Object.assign({}, base, custom) })); } catch { /* quota */ }
- setCookie(COOKIE_NAME, serverId, 365);
- } else if (serverId !== getCurrent()) {
- if (applyById(serverId)) save(serverId, THEMES[serverId]);
- }
- }).catch(function () { /* offline / CORS */ });
- }, 500);
+ const custom = (data.custom_variables && typeof data.custom_variables === 'object'
+ && Object.keys(data.custom_variables).length) ? data.custom_variables : null;
+ const builtin = THEMES[serverId];
+ const settle = function (base) {
+ const vars = custom ? Object.assign({}, base || {}, custom) : (base || null);
+ if (!vars || !Object.keys(vars).length) return;
+ applyVars(vars);
+ cacheLocally(serverId, vars); // cacheLocally, not save() — no write-back loop
+ };
+ if (builtin) return settle(builtin);
+ fetchThemeVars(serverId).then(settle);
+ })
+ .catch(function () { /* offline / signed out — keep the cached theme */ });
}
}
+ syncFromServer();
// ── Public API ───────────────────────────────────────────
// ── Public API ───────────────────────────────────────────
const OpenVibeThemeLoader = {
const OpenVibeThemeLoader = {