openvibers/openvibe.shared Commits

4.90.0: an audio-only recording shows the streamer, not a black box (#245)

OP OpenVibers committed 11 days ago · ad98ce6d60 · 7 files, +91 −7

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5e2281e..6d7324d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ 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).
++## 4.90.0 — 2026-11-01
++
++- **An audio-only recording shows the streamer, not a black box** (`player.js`, `player.css`; plan R4).
++ - A browser broadcast with no camera is recorded as sound only. When the metadata says there is no picture track, the
++ player shows a cover: the face from `data-ov-avatar` (else the poster) in a circle over a soft blur of it, with an
++ "Audio only" tag. With no usable picture, a speaker glyph. The sound plays under it with the usual controls.
++ - The player keeps a 16:9 shape (a sound file has no picture size, and the browser drew it 150 px tall), and hides
++ picture-in-picture. A new source decides again.
++ - Pages: put the streamer's avatar on the video as `data-ov-avatar="…"`.
++
## 4.89.0 — 2026-11-01
- **A card with no picture but a person shows their face** (`kit.card`, `room.css`).
ddiff --git a/README.md b/README.md
iindex 1be0c7a..d87b5d9 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.89.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.90.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex f63e978..a7614ce 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.89.0",
++ "version": "4.90.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/package.json b/package.json
iindex 7e27076..7cbc617 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.89.0",
++ "version": "4.90.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/player.css b/player.css
iindex 2f8dec5..3e09f28 100644
---- a/player.css
++++ b/player.css
@@@ -30,6 +30,18 @@
.ov-player-poster { opacity: 0; transition: opacity 0.35s ease; }
.ov-player.has-poster .ov-player-poster { opacity: 1; }
.ov-player.has-frame .ov-player-placeholder, .ov-player.has-poster .ov-player-placeholder { opacity: 0; transition: opacity 0.5s ease; }
++/* An audio-only recording (no picture track; plan R4, 2026-11-01): the streamer's face over a soft blur of it, never a
++ black box. The video element stays (it plays the sound) but shows nothing; the controls sit above as always. A sound
++ file has no picture size, so the player keeps the 16:9 a video would have (the browser's default is 150 px tall). */
++.ov-player-audio { position: absolute; inset: 0; z-index: 1; display: none; place-items: center; overflow: hidden; background: var(--ovp-bg, #0b1220); pointer-events: none; }
++.ov-player.is-audio .ov-player-audio { display: grid; }
++.ov-player.is-audio { aspect-ratio: 16 / 9; }
++.ov-player.is-audio .ov-player-video { position: absolute; inset: 0; height: 100%; opacity: 0 !important; }
++.ov-player-audio-bg { position: absolute; inset: -15%; width: 130%; height: 130%; object-fit: cover; filter: blur(28px) saturate(1.15); opacity: .5; }
++.ov-player-audio-face { position: relative; width: clamp(72px, 22%, 168px); aspect-ratio: 1; border-radius: 50%; object-fit: cover; box-shadow: 0 0 0 4px rgb(255 255 255 / .12), 0 12px 32px rgb(0 0 0 / .4); }
++.ov-player-audio-glyph { position: relative; display: grid; place-items: center; width: clamp(64px, 16%, 112px); aspect-ratio: 1; border-radius: 50%; background: rgb(255 255 255 / .1); color: #fff; }
++.ov-player-audio-glyph svg { width: 44%; height: 44%; }
++.ov-player-audio-tag { position: absolute; top: 12px; right: 12px; padding: 4px 10px; border-radius: 999px; background: rgb(0 0 0 / .55); color: #fff; font: 600 12px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; letter-spacing: .02em; }
/* Ambient glow (data-ov-ambient): the picture, blurred, behind and around the player. */
.ov-player-host { position: relative; isolation: isolate; }
ddiff --git a/player.js b/player.js
iindex afd4d26..0ab8ce0 100644
---- a/player.js
++++ b/player.js
@@@ -194,8 +194,13 @@
if (!/^#[0-9a-f]{3,8}$/i.test(color || '')) loadImage(thumb, (img) => averageColor(img, (c) => root.style.setProperty('--ovp-bg', c)));
}
const posterLayer = el('div', 'ov-player-poster', { 'aria-hidden': 'true' });
++ // An audio-only recording (a browser broadcast with no camera; plan R4, 2026-11-01): no picture track, so the
++ // video element would be a black box. The cover shows the streamer's face (data-ov-avatar, else the poster)
++ // over a soft blur of it, and says "Audio only". Filled once the metadata says there is no picture.
++ const audioLayer = el('div', 'ov-player-audio', { 'aria-hidden': 'true' });
root.insertBefore(placeholder, video);
root.insertBefore(posterLayer, video);
++ root.insertBefore(audioLayer, video);
const shown = () => root.classList.add('has-frame');
if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });
@@@ -306,6 +311,25 @@
speedBtn.textContent = `${r}×`;
speedBtn.setAttribute('aria-label', `Playback speed: ${r}×`);
}
++ /** No picture track once the metadata is in: the audio cover, and no picture-in-picture (there is no picture). */
++ function paintAudioOnly() {
++ const audio = video.readyState >= 1 && !video.videoWidth && !video.videoHeight;
++ root.classList.toggle('is-audio', audio);
++ if (pipOk) pipBtn.hidden = audio;
++ if (!audio || audioLayer.firstChild) return;
++ const src = [video.getAttribute('data-ov-avatar'), video.getAttribute('poster')].find((u) => /^(https:\/\/|\/(?!\/))/.test(u || ''));
++ if (src) {
++ const bg = el('img', 'ov-player-audio-bg', { src, alt: '', decoding: 'async' });
++ const face = el('img', 'ov-player-audio-face', { src, alt: '', decoding: 'async' });
++ audioLayer.append(bg, face);
++ } else {
++ audioLayer.append(el('span', 'ov-player-audio-glyph'));
++ audioLayer.lastChild.innerHTML = ICON.volume;
++ }
++ const tag = el('span', 'ov-player-audio-tag');
++ tag.textContent = 'Audio only';
++ audioLayer.append(tag);
++ }
function paintFull() {
const on = fullscreenElement() === root;
root.classList.toggle('is-fullscreen', on);
@@@ -459,9 +483,9 @@
video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
video.addEventListener('ended', paintPlay);
video.addEventListener('timeupdate', () => { paintTime(); checkSound(); });
-- video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; paintVolume(); });
++ video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; paintVolume(); root.classList.remove('is-audio'); });
video.addEventListener('durationchange', paintTime);
-- video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); });
++ video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); });
video.addEventListener('progress', paintBuffered);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('ratechange', paintSpeed);
@@@ -477,7 +501,7 @@
document.addEventListener('fullscreenchange', paintFull);
document.addEventListener('webkitfullscreenchange', paintFull);
-- paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); paintSpeed(); paintFull();
++ paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); paintSpeed(); paintFull(); paintAudioOnly();
if (!video.paused) root.classList.remove('is-start');
return api;
}
ddiff --git a/test/player.test.js b/test/player.test.js
iindex ed20e05..5c51513 100644
---- a/test/player.test.js
++++ b/test/player.test.js
@@@ -235,4 +235,42 @@ assert.match(css, /@media \(hover: none\) \{\s*\.ov-player-volume-slider \{ disp
assert.match(css, /\.ov-player\.is-silent \.ov-player-volume-slider \{ display: none; \}/);
assert.match(css, /\.ov-player\.has-frame \.ov-player-video/);
assert.match(src, /Material Icons Round/);
--console.log('player: all checks passed (4.13 sound, layers, feedback; 4.14 volume on phones, no-sound videos)');
++// ── 4.90: an audio-only recording shows the streamer's face, not a black box (plan R4, 2026-11-01) ──
++{
++ const make = (attrs, width) => {
++ const { window: w3, document: d3 } = parseHTML(`<!doctype html><html><body><div class="host"><video id="a" data-ov-player ${attrs} src="/a.webm"></video></div></body></html>`);
++ const v = d3.getElementById('a');
++ const m = { readyState: 0 };
++ Object.defineProperties(v, {
++ paused: { get: () => true }, ended: { get: () => false }, readyState: { get: () => m.readyState },
++ currentTime: { get: () => 0, set() {} }, duration: { get: () => 60 }, volume: { get: () => 1, set() {} },
++ muted: { get: () => false, set() {} }, playbackRate: { get: () => 1, set() {} }, buffered: { get: () => ({ length: 0 }) },
++ textTracks: { value: Object.assign([], { addEventListener() {} }) },
++ videoWidth: { get: () => (m.readyState >= 1 ? width : 0) }, videoHeight: { get: () => (m.readyState >= 1 ? Math.round(width * 9 / 16) : 0) },
++ });
++ v.play = () => Promise.resolve(); v.pause = () => {};
++ const c3 = { window: w3, document: d3, console, Promise, setTimeout, clearTimeout, setInterval, clearInterval, Date, Math, Number, String, JSON, Object, Array,
++ localStorage: { getItem: () => null, setItem() {} } };
++ w3.OpenVibePlayer = undefined;
++ vm.createContext(c3);
++ vm.runInContext(src, c3);
++ const root = v.parentNode;
++ assert.ok(!root.classList.contains('is-audio'), 'nothing is decided before the metadata');
++ m.readyState = 1; v.dispatchEvent(new w3.Event('loadedmetadata'));
++ return { root, v, w3 };
++ };
++ const a = make('data-ov-avatar="https://openvibe.media/o/med_01FACE"', 0);
++ assert.ok(a.root.classList.contains('is-audio'), 'no picture track: the audio cover');
++ assert.strictEqual(a.root.querySelector('.ov-player-audio-face').getAttribute('src'), 'https://openvibe.media/o/med_01FACE', 'the streamer\'s face');
++ assert.ok(a.root.querySelector('.ov-player-audio-bg'), 'over a blur of it');
++ assert.strictEqual(a.root.querySelector('.ov-player-audio-tag').textContent, 'Audio only');
++ assert.strictEqual(a.root.querySelector('.ov-player-audio').getAttribute('aria-hidden'), 'true');
++ a.v.dispatchEvent(new a.w3.Event('emptied'));
++ assert.ok(!a.root.classList.contains('is-audio'), 'a new source decides again');
++ const g = make('data-ov-avatar="javascript:alert(1)"', 0);
++ assert.ok(g.root.querySelector('.ov-player-audio-glyph') && !g.root.querySelector('.ov-player-audio-face'), 'no usable picture: a speaker glyph, never a script URL');
++ const v = make('data-ov-avatar="https://openvibe.media/o/med_01FACE"', 1280);
++ assert.ok(!v.root.classList.contains('is-audio') && !v.root.querySelector('.ov-player-audio-face'), 'a video with pictures is a video');
++}
++assert.match(css, /\.ov-player\.is-audio \.ov-player-audio \{ display: grid; \}/);
++console.log('player: all checks passed (4.13 sound, layers, feedback; 4.14 volume on phones, no-sound videos; 4.90 audio-only cover)');