openvibers/openvibe.shared Commits

1.2.1: release-watch treats playing media and live capture as protected

OP OpenVibers committed 23 Sept 2026 · ed8f5d4601 · 4 files, +30 −5


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex fec3046..be9b849 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,11 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.2.1 — 2026-09-23
++
++- `release-watch.js` never reloads a tab that is playing `<video>`/`<audio>` or holds a live
++ camera/microphone stream (viewers look idle while watching; broadcasters must never be cut).
++
## 1.2.0 — 2026-09-23
- `navbar.js` loads `release-watch.js` (from the same place it was loaded from) four seconds after
ddiff --git a/package.json b/package.json
iindex 224ff94..d56d5c7 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.2.0",
++ "version": "1.2.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/release-watch.js b/release-watch.js
iindex 355d8dd..2894e43 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -9,8 +9,8 @@
* - the server requires it (min_client_release is the running release) or the new release is older
* than the mixed-version window: reload automatically, but ONLY when it is safe — the tab is
* hidden or idle for 2 minutes, no text field has focus, and nothing on the page is protected
-- * (a form marked data-dirty="true", an element with data-ov-protected, or window.OVProtected()
-- * returning true for uploads, calls, broadcasts and recordings).
++ * (a form marked data-dirty="true", an element with data-ov-protected, a playing <video>/<audio>
++ * or live camera/mic stream, or window.OVProtected() returning true for uploads and calls).
*/
(function (root) {
if (typeof document === 'undefined' || root.OVRelease) return;
@@@ -33,6 +33,14 @@
const a = document.activeElement;
if (a && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName || ''))) return true;
if (document.querySelector('form[data-dirty="true"], [data-ov-protected]')) return true;
++ // Someone watching or broadcasting: a playing <video>/<audio>, or a live camera/mic stream.
++ try {
++ for (const m of document.querySelectorAll('video, audio')) {
++ if (!m.paused && !m.ended) return true;
++ const so = m.srcObject;
++ if (so && typeof so.getTracks === 'function' && so.getTracks().some((t) => t.readyState === 'live')) return true;
++ }
++ } catch { return true; }
try { if (typeof root.OVProtected === 'function' && root.OVProtected()) return true; } catch { return true; }
return !document.hidden && Date.now() - lastInput < IDLE_MS;
}
ddiff --git a/test/release.test.js b/test/release.test.js
iindex 2e6945c..7db4b6b 100644
---- a/test/release.test.js
++++ b/test/release.test.js
@@@ -35,6 +35,7 @@ const { createRelease } = require('../release');
hidden,
activeElement: active,
querySelector: (sel) => (sel.startsWith('meta') ? { content: 'aaaaaaa', getAttribute: () => '/release.json' } : null),
++ querySelectorAll: () => [],
addEventListener: (t, f) => { docListeners[t] = f; },
},
};
@@@ -75,6 +76,17 @@ const { createRelease } = require('../release');
await p.ctx.OVRelease.check();
assert.strictEqual(p.reloads(), 0, 'never during a protected session (upload, call, broadcast)');
++ {
++ const playing = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });
++ playing.ctx.document.querySelectorAll = (sel) => (sel === 'video, audio' ? [{ paused: false, ended: false }] : []);
++ await playing.ctx.OVRelease.check();
++ assert.strictEqual(playing.reloads(), 0, 'never while a stream is playing');
++ const capture = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: true });
++ capture.ctx.document.querySelectorAll = () => [{ paused: true, ended: false, srcObject: { getTracks: () => [{ readyState: 'live' }] } }];
++ await capture.ctx.OVRelease.check();
++ assert.strictEqual(capture.reloads(), 0, 'never while the camera/mic is live');
++ }
++
p = page({ serverRelease: 'bbbbbbb', releasedAt: stale, hidden: false });
await p.ctx.OVRelease.check();
assert.strictEqual(p.reloads(), 0, 'a visible tab someone just used is not reloaded');
@@@ -88,7 +100,7 @@ const { createRelease } = require('../release');
location: { reload: () => {} }, addEventListener: (t, f) => { listeners[t] = f; },
OpenVibeUI: { toast: (msg, o) => toasts.push({ msg, o }) },
fetch: async () => ({ ok: true, json: async () => ({ release: served, released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 }) }),
-- document: { hidden: true, activeElement: null, querySelector: () => null, addEventListener: () => {} },
++ document: { hidden: true, activeElement: null, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {} },
};
ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);
@@@ -105,7 +117,7 @@ const { createRelease } = require('../release');
const ctx = {
console, Date, JSON, Promise, setInterval: () => 1, clearInterval: () => {}, location: { reload: () => {} }, addEventListener: () => {},
fetch: async () => { calls++; return { ok: false, json: async () => null }; },
-- document: { hidden: true, activeElement: null, querySelector: () => null, addEventListener: () => {} },
++ document: { hidden: true, activeElement: null, querySelector: () => null, querySelectorAll: () => [], addEventListener: () => {} },
};
ctx.window = ctx; ctx.globalThis = ctx; vm.createContext(ctx);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'release-watch.js'), 'utf8'), ctx);