openvibers/openvibe.shared Commits
4.90.0: an audio-only recording shows the streamer, not a black box (#245)
Changes
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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`).@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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": {@@@ -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; }@@@ -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;}@@@ -235,4 +235,42 @@ assert.match(css, /@media \(hover: none\) \{\s*\.ov-player-volume-slider \{ dispassert.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)');
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 4.89.0 — 2026-11-01
- **A card with no picture but a person shows their face** (`kit.card`, `room.css`).
- **A card with no picture but a person shows their face** (`kit.card`, `room.css`).
@@ -2,7 +2,7 @@
@@ -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.
> 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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.89.0",
+ "version": "4.90.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -30,6 +30,18 @@
@@ -30,6 +30,18 @@
.ov-player-poster { opacity: 0; transition: opacity 0.35s ease; }
.ov-player-poster { opacity: 0; transition: opacity 0.35s ease; }
.ov-player.has-poster .ov-player-poster { opacity: 1; }
.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; }
.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. */
/* Ambient glow (data-ov-ambient): the picture, blurred, behind and around the player. */
.ov-player-host { position: relative; isolation: isolate; }
.ov-player-host { position: relative; isolation: isolate; }
@@ -194,8 +194,13 @@
@@ -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)));
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' });
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(placeholder, video);
root.insertBefore(posterLayer, video);
root.insertBefore(posterLayer, video);
+ root.insertBefore(audioLayer, video);
const shown = () => root.classList.add('has-frame');
const shown = () => root.classList.add('has-frame');
if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });
if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });
@@ -306,6 +311,25 @@
@@ -306,6 +311,25 @@
speedBtn.textContent = `${r}×`;
speedBtn.textContent = `${r}×`;
speedBtn.setAttribute('aria-label', `Playback speed: ${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() {
function paintFull() {
const on = fullscreenElement() === root;
const on = fullscreenElement() === root;
root.classList.toggle('is-fullscreen', on);
root.classList.toggle('is-fullscreen', on);
@@ -459,9 +483,9 @@
@@ -459,9 +483,9 @@
video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
video.addEventListener('ended', paintPlay);
video.addEventListener('ended', paintPlay);
video.addEventListener('timeupdate', () => { paintTime(); checkSound(); });
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('durationchange', paintTime);
- video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); });
+ video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); });
video.addEventListener('progress', paintBuffered);
video.addEventListener('progress', paintBuffered);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('ratechange', paintSpeed);
video.addEventListener('ratechange', paintSpeed);
@@ -477,7 +501,7 @@
@@ -477,7 +501,7 @@
document.addEventListener('fullscreenchange', paintFull);
document.addEventListener('fullscreenchange', paintFull);
document.addEventListener('webkitfullscreenchange', 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');
if (!video.paused) root.classList.remove('is-start');
return api;
return api;
}
}
@@ -235,4 +235,42 @@ assert.match(css, /@media \(hover: none\) \{\s*\.ov-player-volume-slider \{ disp
@@ -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\.is-silent \.ov-player-volume-slider \{ display: none; \}/);
assert.match(css, /\.ov-player\.has-frame \.ov-player-video/);
assert.match(css, /\.ov-player\.has-frame \.ov-player-video/);
assert.match(src, /Material Icons Round/);
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)');