openvibers/openvibe.shared Commits
4.126.0: audio description in the player (X1) (#312)
Changes
@@@ -1,5 +1,13 @@# Changelog++## 4.126.0 — 2026-11-07++++- **Audio description in the player (X1)**: a video with a `<track kind="descriptions">` gets an **AD** button beside++ Captions. It turns the descriptions track on and is remembered per person in localStorage, like the volume; **D**++ toggles it from the keyboard and its label says whether it is on. The active cue is shown over the picture and++ spoken through the browser's speech synthesis, so a blind viewer hears what is on screen between the speech. No++ descriptions track, no button. `player.js`, `player.css`, `test/player.test.js`.++## 4.125.0 — 2026-11-07- **The owner's Setup page and clear settings** (`openvibe-shared/config`; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07). New schema annotations, passed through to the summary: `x-owner-input { group, unlocks, get_it_at, get_it_how, test }` marks a field for Network's Setup tab (the groups are `config.OWNER_INPUT_GROUPS`), `x-required-for` makes an unset field **Needed**, `x-enum-labels` gives every option of a choice words and a line, `x-advanced` folds a field under a counted "Advanced settings (N)", and `x-example` is a placeholder. A secret-class property loses `x-example` and `x-enum-labels` in the summary, as it already lost its default, examples and enum.@@@ -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.125.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.126.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.125.0",++ "version": "4.126.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)",@@@ -20,6 +20,7 @@"showcase sections for product home pages (openvibe-shared/showcase)","share cards for every site and for its big pages (openvibe-shared/social)","the shared video player (player.js, player.css): one look for every <video>",++ "the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)","items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site","shipped pill, recent list and /updates log","server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.125.0",++ "version": "4.126.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -108,6 +108,9 @@.ov-player-btn[hidden] { display: none; }.ov-player-cc.is-on { color: var(--ovp-accent); }.ov-player-cc.is-on svg { filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ovp-accent) 60%, transparent)); }++/* Audio description: the two letters are the icon; on, it carries the same accent as captions. */++.ov-player-ad { min-width: 40px; font: 700 12.5px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: 0.02em; }++.ov-player-ad.is-on { color: var(--ovp-accent); background: color-mix(in srgb, var(--ovp-accent) 18%, transparent); }.ov-player-speed { font-size: 12.5px; font-variant-numeric: tabular-nums; min-width: 44px; }.ov-player-time { padding: 0 8px; color: rgba(255, 255, 255, 0.86); font-size: 12.5px; font-variant-numeric: tabular-nums; white-space: nowrap; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); }@@@ -183,7 +186,7 @@@keyframes ov-player-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-5px); opacity: 1; } }.ov-player-spinner { position: absolute; left: 50%; top: 50%; z-index: 2; margin: -4px 0 0 -4px; display: none; filter: drop-shadow(0 1px 6px rgba(0, 0, 0, .5)); }.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }--@media (prefers-reduced-motion: reduce) { .ov-player-spinner, .ov-player-spinner::before, .ov-player-spinner::after { animation: none; opacity: .8; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }++@media (prefers-reduced-motion: reduce) { .ov-player-spinner, .ov-player-spinner::before, .ov-player-spinner::after { animation: none; opacity: .8; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash, .ov-player-ad-line { transition: none; } .ov-player-unmute { animation: none; } }/* Touch screens: the slider shows, finger-sized, wherever the page can set the volume (.can-volume: Android). An iPhoneleaves volume to its own buttons, so there the mute button stays alone. */@media (hover: none) {@@@ -206,6 +209,18 @@/* Captions sit above the bar while it shows. */.ov-player .ov-player-video::cue { background: rgba(0, 0, 0, 0.72); color: #fff; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }++/* An audio description cue: shown over the picture, in its own band above the controls, while it is spoken. */++.ov-player-ad-line {++ position: absolute; left: 50%; bottom: 70px; z-index: 3; transform: translateX(-50%);++ max-width: min(86%, 720px); padding: 6px 12px; border-radius: 8px;++ background: rgba(0, 0, 0, 0.72); color: #fff; text-align: center;++ font: 500 13px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;++ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); pointer-events: none;++ opacity: 0; transition: opacity 0.15s ease;++}++.ov-player-ad-line:not(:empty) { opacity: 1; }++@media (max-width: 520px) { .ov-player-ad-line { bottom: 58px; font-size: 12px; } }++/* Small players and phones: fewer, larger targets. */@media (max-width: 520px) {.ov-player-speed, .ov-player-pip { display: none; }@@@ -8,9 +8,15 @@** Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped* in .ov-player and gets the network's controls: a scrubber (buffered, played, the time under the pointer), play,-- * volume, time, captions, speed, picture-in-picture and fullscreen, hidden while playing and idle. Keys while the-- * player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions, 0–9 jump to that-- * tenth. On touch a tap shows the controls and a double tap on either side seeks ±10 s.++ * volume, time, audio description, captions, speed, picture-in-picture and fullscreen, hidden while playing and idle.++ * Keys while the player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions,++ * D audio description, 0–9 jump to that tenth. On touch a tap shows the controls and a double tap on either side seeks++ * ±10 s.++ *++ * Audio description (X1, 2026-11-01): a <track kind="descriptions"> gets an AD button that turns it on, remembered++ * per person in localStorage like the volume, with the same keyboard access (D) and a spoken screen-reader label. The++ * player shows the active cue and speaks it through the browser's speech synthesis, so a sighted viewer reads it and a++ * blind one hears it. A video without a descriptions track never shows the button.** Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media* volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to@@@ -50,6 +56,7 @@};const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];const VOLUME_KEY = 'ov-player-volume';++ const DESCRIPTIONS_KEY = 'ov-player-descriptions';function fmt(s) {if (!Number.isFinite(s) || s < 0) s = 0;@@@ -63,6 +70,12 @@function store(video) {try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume: video.volume, muted: video.muted })); } catch { /* private window */ }}++ function descriptionsStored() {++ try { return localStorage.getItem(DESCRIPTIONS_KEY) === 'on'; } catch { return false; }++ }++ function storeDescriptions(on) {++ try { localStorage.setItem(DESCRIPTIONS_KEY, on ? 'on' : 'off'); } catch { /* private window */ }++ }function el(tag, cls, attrs) {const e = document.createElement(tag);if (cls) e.className = cls;@@@ -85,8 +98,29 @@} catch { return false; }}const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;++ const descriptionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'descriptions') || null;const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;++ /** Can this browser speak a description aloud? (Speech synthesis is optional; the cue text still shows without it.) */++ function canSpeak() {++ return typeof speechSynthesis !== 'undefined' && typeof SpeechSynthesisUtterance !== 'undefined';++ }++ /** Say one description cue at a time: a new cue replaces the old one instead of queueing behind it. */++ function speakDescription(text) {++ if (!text || !canSpeak()) return;++ try {++ speechSynthesis.cancel();++ const u = new SpeechSynthesisUtterance(String(text).replace(/\s+/g, ' ').trim());++ u.lang = 'en';++ u.rate = 1;++ speechSynthesis.speak(u);++ } catch { /* a browser that refuses speech (no voices, autoplay rule) still shows the text */ }++ }++ function stopDescriptionSpeech() {++ if (!canSpeak()) return;++ try { speechSynthesis.cancel(); } catch { /* nothing queued */ }++ }++// The media volume keys, where the browser passes them to the page (UI Events key names, and the older ones).const VOLUME_KEYS = /^(AudioVolume(Up|Down|Mute)|Volume(Up|Down|Mute))$/;let current = null; // the player last played or touched: the one the page-wide keys drive@@@ -224,17 +258,23 @@volWrap.append(muteBtn, volSlider);const time = el('div', 'ov-player-time', { 'aria-hidden': 'true' });const spacer = el('div', 'ov-player-spacer');++ // Audio description: a text button ("AD") because the letters say what it does in every language's player,++ // and a screen reader hears the full label and whether it is on (aria-pressed).++ const adBtn = el('button', 'ov-player-btn ov-player-ad', { type: 'button', 'aria-label': 'Audio description (d)', title: 'Audio description (d)', 'aria-pressed': 'false', hidden: '' });++ adBtn.textContent = 'AD';const ccBtn = button('ov-player-cc', 'Captions (c)', ICON.cc);const speedBtn = el('button', 'ov-player-btn ov-player-speed', { type: 'button', 'aria-label': 'Playback speed: 1×', title: 'Playback speed' });speedBtn.textContent = '1×';const pipBtn = button('ov-player-pip', 'Picture in picture', ICON.pip);const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);-- row.append(playBtn, volWrap, time, spacer, ccBtn, speedBtn, pipBtn, fullBtn);++ row.append(playBtn, volWrap, time, spacer, adBtn, ccBtn, speedBtn, pipBtn, fullBtn);bar.append(scrub, row);const unmute = el('button', 'ov-player-unmute', { type: 'button', hidden: '' });unmute.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;const flash = el('div', 'ov-player-flash', { 'aria-hidden': 'true' });-- root.append(spinner, big, message, unmute, flash, bar);++ // The active description, shown over the picture and given to assistive tech when this browser cannot speak.++ const adLine = el('div', 'ov-player-ad-line', { role: 'status', 'aria-live': canSpeak() ? 'off' : 'polite' });++ root.append(spinner, big, message, unmute, flash, adLine, bar);const ambient = video.hasAttribute('data-ov-ambient') || options.ambient ? glow(root, video) : null;const poster = video.getAttribute('poster');if (poster) loadImage(poster, (img) => {@@@ -306,6 +346,38 @@ccBtn.classList.toggle('is-on', !!t && t.mode === 'showing');ccBtn.setAttribute('aria-pressed', t && t.mode === 'showing' ? 'true' : 'false');}++ // Audio description: the AD button only exists when the video has a descriptions track with cues. The track++ // loads in hidden mode so the button (and the stored preference) can be decided before anyone presses play.++ let describedText = null;++ function paintDescriptions() {++ const t = descriptionTrack(video);++ if (t && !t.__ovAdWatched && t.addEventListener) {++ t.__ovAdWatched = true;++ t.addEventListener('cuechange', paintDescriptionCue);++ }++ const empty = !!t && (t.readyState === 2 || t.readyState === 3) && !(t.cues && t.cues.length);++ adBtn.hidden = !t || empty;++ const on = !!t && t.mode === 'showing';++ adBtn.classList.toggle('is-on', on);++ adBtn.setAttribute('aria-pressed', on ? 'true' : 'false');++ if (!on && describedText) { describedText = null; adLine.textContent = ''; stopDescriptionSpeech(); }++ }++ function paintDescriptionCue() {++ const t = descriptionTrack(video);++ const cue = t && t.mode === 'showing' && t.activeCues && t.activeCues[0];++ const text = cue ? String(cue.text || '').replace(/\s+/g, ' ').trim() : '';++ if (text === describedText) return;++ describedText = text;++ adLine.textContent = text;++ if (text) speakDescription(text);++ else stopDescriptionSpeech();++ }++ /** The person's remembered choice, applied only when the video actually has descriptions. */++ function applyDescriptionsPreference() {++ const t = descriptionTrack(video);++ if (t && descriptionsStored()) t.mode = 'showing';++ paintDescriptions();++ }function paintSpeed() {const r = video.playbackRate || 1;speedBtn.textContent = `${r}×`;@@@ -360,6 +432,17 @@/** Sound on, at the remembered level (full when that was zero). */unmute() { pageMuted = false; if (video.volume === 0) video.volume = 1; video.muted = false; store(video); show(ICON.volume, `${Math.round(video.volume * 100)}%`); },toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },++ /** Turn the descriptions track on or off, remember the choice, and stop talking when it goes off. */++ toggleDescriptions() {++ const t = descriptionTrack(video);++ if (!t) return;++ const on = t.mode !== 'showing';++ t.mode = on ? 'showing' : 'hidden';++ storeDescriptions(on);++ if (!on) { stopDescriptionSpeech(); describedText = null; adLine.textContent = ''; }++ paintDescriptions();++ paintDescriptionCue();++ },nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },togglePip() {if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});@@@ -401,6 +484,7 @@muteBtn.addEventListener('click', () => { if (silent) show(ICON.muted, 'No sound in this video', null, 2600); else api.toggleMute(); });volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));ccBtn.addEventListener('click', () => api.toggleCaptions());++ adBtn.addEventListener('click', () => api.toggleDescriptions());speedBtn.addEventListener('click', () => api.nextSpeed());pipBtn.addEventListener('click', () => api.togglePip());fullBtn.addEventListener('click', () => api.toggleFullscreen());@@@ -472,6 +556,7 @@else if (k === 'm' || k === 'M') api.toggleMute();else if (k === 'f' || k === 'F') api.toggleFullscreen();else if (k === 'c' || k === 'C') api.toggleCaptions();++ else if (k === 'd' || k === 'D') api.toggleDescriptions();else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);else if (VOLUME_KEYS.test(k)) volumeKey(api, k);else handled = false;@@@ -480,28 +565,29 @@// ── Media events ─────────────────────────────────────video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });-- video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });++ video.addEventListener('pause', () => { paintPlay(); paintVolume(); stopDescriptionSpeech(); if (ambient) ambient.stop(); });video.addEventListener('ended', paintPlay);-- video.addEventListener('timeupdate', () => { paintTime(); checkSound(); });-- video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; paintVolume(); root.classList.remove('is-audio'); });++ video.addEventListener('timeupdate', () => { paintTime(); checkSound(); paintDescriptionCue(); });++ video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; describedText = null; adLine.textContent = ''; paintVolume(); root.classList.remove('is-audio'); });video.addEventListener('durationchange', paintTime);-- video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); });++ video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); applyDescriptionsPreference(); });video.addEventListener('progress', paintBuffered);video.addEventListener('volumechange', paintVolume);video.addEventListener('ratechange', paintSpeed);video.addEventListener('waiting', () => root.classList.add('is-waiting'));video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; if (playedFrom == null) playedFrom = video.currentTime || 0; paintPlay(); });-- video.addEventListener('seeked', () => { if (!soundKnown) playedFrom = video.currentTime || 0; });++ video.addEventListener('seeked', () => { stopDescriptionSpeech(); describedText = null; adLine.textContent = ''; if (!soundKnown) playedFrom = video.currentTime || 0; });video.addEventListener('canplay', () => root.classList.remove('is-waiting'));video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });if (video.textTracks && video.textTracks.addEventListener) {-- video.textTracks.addEventListener('addtrack', paintCaptions);-- video.textTracks.addEventListener('change', paintCaptions);++ const onTracks = () => { paintCaptions(); paintDescriptions(); };++ video.textTracks.addEventListener('addtrack', onTracks);++ video.textTracks.addEventListener('change', onTracks);}document.addEventListener('fullscreenchange', paintFull);document.addEventListener('webkitfullscreenchange', paintFull);-- paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); paintSpeed(); paintFull(); paintAudioOnly();++ paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); applyDescriptionsPreference(); paintSpeed(); paintFull(); paintAudioOnly();if (!video.paused) root.classList.remove('is-start');return api;}@@@ -29,15 +29,22 @@ Object.defineProperties(video, {muted: { get: () => media.muted, set: (v) => { media.muted = v; fire('volumechange'); } },playbackRate: { get: () => media.playbackRate, set: (v) => { media.playbackRate = v; fire('ratechange'); } },buffered: { get: () => ({ length: 1, start: () => 0, end: () => 30 }) },-- textTracks: { value: Object.assign([{ kind: 'captions', mode: 'hidden' }], { addEventListener() {} }) },++ textTracks: { value: Object.assign([++ { kind: 'captions', mode: 'hidden' },++ { kind: 'descriptions', mode: 'hidden', cues: [{ text: 'A red door opens.' }], activeCues: [], readyState: 2, addEventListener() {} },++ ], { addEventListener() {} }) },});video.play = () => { paused = false; fire('play'); fire('playing'); return Promise.resolve(); };video.pause = () => { paused = true; fire('pause'); };const store = {};++const spoken = [];++class FakeUtterance { constructor(text) { this.text = text; } }const ctx = {window, document, console, Promise, setTimeout, clearTimeout, Date, Math, Number, String, JSON, Object, Array,localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },++ speechSynthesis: { cancel() {}, speak(u) { spoken.push(u.text); } },++ SpeechSynthesisUtterance: FakeUtterance,};ctx.window.OpenVibePlayer = undefined;vm.createContext(ctx);@@@ -83,6 +90,28 @@ key('m'); assert.strictEqual(media.muted, true, 'm mutes');assert.match($('.ov-player-mute').getAttribute('aria-label'), /^Unmute/);assert.deepStrictEqual(JSON.parse(store['ov-player-volume']), { volume: 0.95, muted: true }, 'volume remembered');++// Audio description: the AD button reads the descriptions track, remembers the choice, speaks the active cue,++// and the same choice is available from the keyboard (D).++const desc = video.textTracks[1];++const ad = $('.ov-player-ad');++assert.ok(ad && ad.hidden === false, 'AD button shown when the video has a descriptions track');++assert.strictEqual(ad.getAttribute('aria-label'), 'Audio description (d)');++assert.strictEqual(ad.getAttribute('aria-pressed'), 'false');++assert.strictEqual(desc.mode, 'hidden');++ad.dispatchEvent(new window.Event('click'));++assert.strictEqual(desc.mode, 'showing', 'AD turns the descriptions track on');++assert.strictEqual(ad.getAttribute('aria-pressed'), 'true');++assert.strictEqual(store['ov-player-descriptions'], 'on', 'the choice is remembered');++desc.activeCues = [{ text: 'A red door opens.' }];++fire('timeupdate');++assert.strictEqual($('.ov-player-ad-line').textContent, 'A red door opens.', 'the active description is shown');++assert.deepStrictEqual(spoken, ['A red door opens.'], 'and spoken through speech synthesis');++key('d');++assert.strictEqual(desc.mode, 'hidden', 'D turns it off');++assert.strictEqual(ad.getAttribute('aria-pressed'), 'false');++assert.strictEqual(store['ov-player-descriptions'], 'off');++assert.strictEqual($('.ov-player-ad-line').textContent, '');++// Captions, speed.key('c');assert.strictEqual(video.textTracks[0].mode, 'showing');@@@ -103,6 +132,21 @@ later.setAttribute('controls', '');document.body.appendChild(later);P.enhanceAll(document);assert.ok(later.parentNode.classList.contains('ov-player'));++assert.strictEqual(later.parentNode.querySelector('.ov-player-ad').hidden, true, 'no descriptions track, no AD button');++++// Remembered per person: a player created later applies the stored choice to its own descriptions track.++{++ store['ov-player-descriptions'] = 'on';++ const remembered = document.createElement('video');++ remembered.setAttribute('data-ov-player', '');++ remembered.setAttribute('controls', '');++ const track = { kind: 'descriptions', mode: 'hidden', cues: [{ text: 'A cat jumps onto the table.' }], activeCues: [], readyState: 2, addEventListener() {} };++ Object.defineProperty(remembered, 'textTracks', { value: Object.assign([track], { addEventListener() {} }) });++ document.body.appendChild(remembered);++ P.enhance(remembered);++ assert.strictEqual(track.mode, 'showing', 'a stored on turns the new video\'s descriptions on');++ assert.strictEqual(remembered.parentNode.querySelector('.ov-player-ad').getAttribute('aria-pressed'), 'true');++}// The stylesheet: the theme's accent, the idle state, and the large button only before play or at the end.assert.match(css, /--ovp-accent: var\(--accent, #3b82f6\);/);
@@ -1,5 +1,13 @@
@@ -1,5 +1,13 @@
# Changelog
# Changelog
+## 4.126.0 — 2026-11-07
+
+- **Audio description in the player (X1)**: a video with a `<track kind="descriptions">` gets an **AD** button beside
+ Captions. It turns the descriptions track on and is remembered per person in localStorage, like the volume; **D**
+ toggles it from the keyboard and its label says whether it is on. The active cue is shown over the picture and
+ spoken through the browser's speech synthesis, so a blind viewer hears what is on screen between the speech. No
+ descriptions track, no button. `player.js`, `player.css`, `test/player.test.js`.
+
## 4.125.0 — 2026-11-07
## 4.125.0 — 2026-11-07
- **The owner's Setup page and clear settings** (`openvibe-shared/config`; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07). New schema annotations, passed through to the summary: `x-owner-input { group, unlocks, get_it_at, get_it_how, test }` marks a field for Network's Setup tab (the groups are `config.OWNER_INPUT_GROUPS`), `x-required-for` makes an unset field **Needed**, `x-enum-labels` gives every option of a choice words and a line, `x-advanced` folds a field under a counted "Advanced settings (N)", and `x-example` is a placeholder. A secret-class property loses `x-example` and `x-enum-labels` in the summary, as it already lost its default, examples and enum.
- **The owner's Setup page and clear settings** (`openvibe-shared/config`; OWNER-SETUP-AND-ADMIN-CLARITY-DESIGN, 2026-11-07). New schema annotations, passed through to the summary: `x-owner-input { group, unlocks, get_it_at, get_it_how, test }` marks a field for Network's Setup tab (the groups are `config.OWNER_INPUT_GROUPS`), `x-required-for` makes an unset field **Needed**, `x-enum-labels` gives every option of a choice words and a line, `x-advanced` folds a field under a counted "Advanced settings (N)", and `x-example` is a placeholder. A secret-class property loses `x-example` and `x-enum-labels` in the summary, as it already lost its default, examples and enum.
@@ -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.125.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.126.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.125.0",
+ "version": "4.126.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)",
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"showcase sections for product home pages (openvibe-shared/showcase)",
"showcase sections for product home pages (openvibe-shared/showcase)",
"share cards for every site and for its big pages (openvibe-shared/social)",
"share cards for every site and for its big pages (openvibe-shared/social)",
"the shared video player (player.js, player.css): one look for every <video>",
"the shared video player (player.js, player.css): one look for every <video>",
+ "the player's AD control: a descriptions track is remembered per person, toggled with D and spoken through speech synthesis (4.126.0)",
"items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site",
"items.js: what people wear from OpenVibe.Inventory (name effects, hats, particles, community badges), drawn the same on every site",
"shipped pill, recent list and /updates log",
"shipped pill, recent list and /updates log",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
"server helpers: ready, metrics, trace, egress, config, analytics, legal, SEO (sitemaps with video entries, IndexNow in requests of 100 URLs), frame SSR",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.125.0",
+ "version": "4.126.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": {
@@ -108,6 +108,9 @@
@@ -108,6 +108,9 @@
.ov-player-btn[hidden] { display: none; }
.ov-player-btn[hidden] { display: none; }
.ov-player-cc.is-on { color: var(--ovp-accent); }
.ov-player-cc.is-on { color: var(--ovp-accent); }
.ov-player-cc.is-on svg { filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ovp-accent) 60%, transparent)); }
.ov-player-cc.is-on svg { filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ovp-accent) 60%, transparent)); }
+/* Audio description: the two letters are the icon; on, it carries the same accent as captions. */
+.ov-player-ad { min-width: 40px; font: 700 12.5px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; letter-spacing: 0.02em; }
+.ov-player-ad.is-on { color: var(--ovp-accent); background: color-mix(in srgb, var(--ovp-accent) 18%, transparent); }
.ov-player-speed { font-size: 12.5px; font-variant-numeric: tabular-nums; min-width: 44px; }
.ov-player-speed { font-size: 12.5px; font-variant-numeric: tabular-nums; min-width: 44px; }
.ov-player-time { padding: 0 8px; color: rgba(255, 255, 255, 0.86); font-size: 12.5px; font-variant-numeric: tabular-nums; white-space: nowrap; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); }
.ov-player-time { padding: 0 8px; color: rgba(255, 255, 255, 0.86); font-size: 12.5px; font-variant-numeric: tabular-nums; white-space: nowrap; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); }
@@ -183,7 +186,7 @@
@@ -183,7 +186,7 @@
@keyframes ov-player-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-5px); opacity: 1; } }
@keyframes ov-player-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-5px); opacity: 1; } }
.ov-player-spinner { position: absolute; left: 50%; top: 50%; z-index: 2; margin: -4px 0 0 -4px; display: none; filter: drop-shadow(0 1px 6px rgba(0, 0, 0, .5)); }
.ov-player-spinner { position: absolute; left: 50%; top: 50%; z-index: 2; margin: -4px 0 0 -4px; display: none; filter: drop-shadow(0 1px 6px rgba(0, 0, 0, .5)); }
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
-@media (prefers-reduced-motion: reduce) { .ov-player-spinner, .ov-player-spinner::before, .ov-player-spinner::after { animation: none; opacity: .8; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }
+@media (prefers-reduced-motion: reduce) { .ov-player-spinner, .ov-player-spinner::before, .ov-player-spinner::after { animation: none; opacity: .8; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash, .ov-player-ad-line { transition: none; } .ov-player-unmute { animation: none; } }
/* Touch screens: the slider shows, finger-sized, wherever the page can set the volume (.can-volume: Android). An iPhone
/* Touch screens: the slider shows, finger-sized, wherever the page can set the volume (.can-volume: Android). An iPhone
leaves volume to its own buttons, so there the mute button stays alone. */
leaves volume to its own buttons, so there the mute button stays alone. */
@media (hover: none) {
@media (hover: none) {
@@ -206,6 +209,18 @@
@@ -206,6 +209,18 @@
/* Captions sit above the bar while it shows. */
/* Captions sit above the bar while it shows. */
.ov-player .ov-player-video::cue { background: rgba(0, 0, 0, 0.72); color: #fff; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ov-player .ov-player-video::cue { background: rgba(0, 0, 0, 0.72); color: #fff; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
+/* An audio description cue: shown over the picture, in its own band above the controls, while it is spoken. */
+.ov-player-ad-line {
+ position: absolute; left: 50%; bottom: 70px; z-index: 3; transform: translateX(-50%);
+ max-width: min(86%, 720px); padding: 6px 12px; border-radius: 8px;
+ background: rgba(0, 0, 0, 0.72); color: #fff; text-align: center;
+ font: 500 13px/1.35 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); pointer-events: none;
+ opacity: 0; transition: opacity 0.15s ease;
+}
+.ov-player-ad-line:not(:empty) { opacity: 1; }
+@media (max-width: 520px) { .ov-player-ad-line { bottom: 58px; font-size: 12px; } }
+
/* Small players and phones: fewer, larger targets. */
/* Small players and phones: fewer, larger targets. */
@media (max-width: 520px) {
@media (max-width: 520px) {
.ov-player-speed, .ov-player-pip { display: none; }
.ov-player-speed, .ov-player-pip { display: none; }
@@ -8,9 +8,15 @@
@@ -8,9 +8,15 @@
*
*
* Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
* Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
* in .ov-player and gets the network's controls: a scrubber (buffered, played, the time under the pointer), play,
* in .ov-player and gets the network's controls: a scrubber (buffered, played, the time under the pointer), play,
- * volume, time, captions, speed, picture-in-picture and fullscreen, hidden while playing and idle. Keys while the
- * player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions, 0–9 jump to that
- * tenth. On touch a tap shows the controls and a double tap on either side seeks ±10 s.
+ * volume, time, audio description, captions, speed, picture-in-picture and fullscreen, hidden while playing and idle.
+ * Keys while the player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions,
+ * D audio description, 0–9 jump to that tenth. On touch a tap shows the controls and a double tap on either side seeks
+ * ±10 s.
+ *
+ * Audio description (X1, 2026-11-01): a <track kind="descriptions"> gets an AD button that turns it on, remembered
+ * per person in localStorage like the volume, with the same keyboard access (D) and a spoken screen-reader label. The
+ * player shows the active cue and speaks it through the browser's speech synthesis, so a sighted viewer reads it and a
+ * blind one hears it. A video without a descriptions track never shows the button.
*
*
* Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media
* Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media
* volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to
* volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to
@@ -50,6 +56,7 @@
@@ -50,6 +56,7 @@
};
};
const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
const VOLUME_KEY = 'ov-player-volume';
const VOLUME_KEY = 'ov-player-volume';
+ const DESCRIPTIONS_KEY = 'ov-player-descriptions';
function fmt(s) {
function fmt(s) {
if (!Number.isFinite(s) || s < 0) s = 0;
if (!Number.isFinite(s) || s < 0) s = 0;
@@ -63,6 +70,12 @@
@@ -63,6 +70,12 @@
function store(video) {
function store(video) {
try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume: video.volume, muted: video.muted })); } catch { /* private window */ }
try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume: video.volume, muted: video.muted })); } catch { /* private window */ }
}
}
+ function descriptionsStored() {
+ try { return localStorage.getItem(DESCRIPTIONS_KEY) === 'on'; } catch { return false; }
+ }
+ function storeDescriptions(on) {
+ try { localStorage.setItem(DESCRIPTIONS_KEY, on ? 'on' : 'off'); } catch { /* private window */ }
+ }
function el(tag, cls, attrs) {
function el(tag, cls, attrs) {
const e = document.createElement(tag);
const e = document.createElement(tag);
if (cls) e.className = cls;
if (cls) e.className = cls;
@@ -85,8 +98,29 @@
@@ -85,8 +98,29 @@
} catch { return false; }
} catch { return false; }
}
}
const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
+ const descriptionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'descriptions') || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
+ /** Can this browser speak a description aloud? (Speech synthesis is optional; the cue text still shows without it.) */
+ function canSpeak() {
+ return typeof speechSynthesis !== 'undefined' && typeof SpeechSynthesisUtterance !== 'undefined';
+ }
+ /** Say one description cue at a time: a new cue replaces the old one instead of queueing behind it. */
+ function speakDescription(text) {
+ if (!text || !canSpeak()) return;
+ try {
+ speechSynthesis.cancel();
+ const u = new SpeechSynthesisUtterance(String(text).replace(/\s+/g, ' ').trim());
+ u.lang = 'en';
+ u.rate = 1;
+ speechSynthesis.speak(u);
+ } catch { /* a browser that refuses speech (no voices, autoplay rule) still shows the text */ }
+ }
+ function stopDescriptionSpeech() {
+ if (!canSpeak()) return;
+ try { speechSynthesis.cancel(); } catch { /* nothing queued */ }
+ }
+
// The media volume keys, where the browser passes them to the page (UI Events key names, and the older ones).
// The media volume keys, where the browser passes them to the page (UI Events key names, and the older ones).
const VOLUME_KEYS = /^(AudioVolume(Up|Down|Mute)|Volume(Up|Down|Mute))$/;
const VOLUME_KEYS = /^(AudioVolume(Up|Down|Mute)|Volume(Up|Down|Mute))$/;
let current = null; // the player last played or touched: the one the page-wide keys drive
let current = null; // the player last played or touched: the one the page-wide keys drive
@@ -224,17 +258,23 @@
@@ -224,17 +258,23 @@
volWrap.append(muteBtn, volSlider);
volWrap.append(muteBtn, volSlider);
const time = el('div', 'ov-player-time', { 'aria-hidden': 'true' });
const time = el('div', 'ov-player-time', { 'aria-hidden': 'true' });
const spacer = el('div', 'ov-player-spacer');
const spacer = el('div', 'ov-player-spacer');
+ // Audio description: a text button ("AD") because the letters say what it does in every language's player,
+ // and a screen reader hears the full label and whether it is on (aria-pressed).
+ const adBtn = el('button', 'ov-player-btn ov-player-ad', { type: 'button', 'aria-label': 'Audio description (d)', title: 'Audio description (d)', 'aria-pressed': 'false', hidden: '' });
+ adBtn.textContent = 'AD';
const ccBtn = button('ov-player-cc', 'Captions (c)', ICON.cc);
const ccBtn = button('ov-player-cc', 'Captions (c)', ICON.cc);
const speedBtn = el('button', 'ov-player-btn ov-player-speed', { type: 'button', 'aria-label': 'Playback speed: 1×', title: 'Playback speed' });
const speedBtn = el('button', 'ov-player-btn ov-player-speed', { type: 'button', 'aria-label': 'Playback speed: 1×', title: 'Playback speed' });
speedBtn.textContent = '1×';
speedBtn.textContent = '1×';
const pipBtn = button('ov-player-pip', 'Picture in picture', ICON.pip);
const pipBtn = button('ov-player-pip', 'Picture in picture', ICON.pip);
const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);
const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);
- row.append(playBtn, volWrap, time, spacer, ccBtn, speedBtn, pipBtn, fullBtn);
+ row.append(playBtn, volWrap, time, spacer, adBtn, ccBtn, speedBtn, pipBtn, fullBtn);
bar.append(scrub, row);
bar.append(scrub, row);
const unmute = el('button', 'ov-player-unmute', { type: 'button', hidden: '' });
const unmute = el('button', 'ov-player-unmute', { type: 'button', hidden: '' });
unmute.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;
unmute.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;
const flash = el('div', 'ov-player-flash', { 'aria-hidden': 'true' });
const flash = el('div', 'ov-player-flash', { 'aria-hidden': 'true' });
- root.append(spinner, big, message, unmute, flash, bar);
+ // The active description, shown over the picture and given to assistive tech when this browser cannot speak.
+ const adLine = el('div', 'ov-player-ad-line', { role: 'status', 'aria-live': canSpeak() ? 'off' : 'polite' });
+ root.append(spinner, big, message, unmute, flash, adLine, bar);
const ambient = video.hasAttribute('data-ov-ambient') || options.ambient ? glow(root, video) : null;
const ambient = video.hasAttribute('data-ov-ambient') || options.ambient ? glow(root, video) : null;
const poster = video.getAttribute('poster');
const poster = video.getAttribute('poster');
if (poster) loadImage(poster, (img) => {
if (poster) loadImage(poster, (img) => {
@@ -306,6 +346,38 @@
@@ -306,6 +346,38 @@
ccBtn.classList.toggle('is-on', !!t && t.mode === 'showing');
ccBtn.classList.toggle('is-on', !!t && t.mode === 'showing');
ccBtn.setAttribute('aria-pressed', t && t.mode === 'showing' ? 'true' : 'false');
ccBtn.setAttribute('aria-pressed', t && t.mode === 'showing' ? 'true' : 'false');
}
}
+ // Audio description: the AD button only exists when the video has a descriptions track with cues. The track
+ // loads in hidden mode so the button (and the stored preference) can be decided before anyone presses play.
+ let describedText = null;
+ function paintDescriptions() {
+ const t = descriptionTrack(video);
+ if (t && !t.__ovAdWatched && t.addEventListener) {
+ t.__ovAdWatched = true;
+ t.addEventListener('cuechange', paintDescriptionCue);
+ }
+ const empty = !!t && (t.readyState === 2 || t.readyState === 3) && !(t.cues && t.cues.length);
+ adBtn.hidden = !t || empty;
+ const on = !!t && t.mode === 'showing';
+ adBtn.classList.toggle('is-on', on);
+ adBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
+ if (!on && describedText) { describedText = null; adLine.textContent = ''; stopDescriptionSpeech(); }
+ }
+ function paintDescriptionCue() {
+ const t = descriptionTrack(video);
+ const cue = t && t.mode === 'showing' && t.activeCues && t.activeCues[0];
+ const text = cue ? String(cue.text || '').replace(/\s+/g, ' ').trim() : '';
+ if (text === describedText) return;
+ describedText = text;
+ adLine.textContent = text;
+ if (text) speakDescription(text);
+ else stopDescriptionSpeech();
+ }
+ /** The person's remembered choice, applied only when the video actually has descriptions. */
+ function applyDescriptionsPreference() {
+ const t = descriptionTrack(video);
+ if (t && descriptionsStored()) t.mode = 'showing';
+ paintDescriptions();
+ }
function paintSpeed() {
function paintSpeed() {
const r = video.playbackRate || 1;
const r = video.playbackRate || 1;
speedBtn.textContent = `${r}×`;
speedBtn.textContent = `${r}×`;
@@ -360,6 +432,17 @@
@@ -360,6 +432,17 @@
/** Sound on, at the remembered level (full when that was zero). */
/** Sound on, at the remembered level (full when that was zero). */
unmute() { pageMuted = false; if (video.volume === 0) video.volume = 1; video.muted = false; store(video); show(ICON.volume, `${Math.round(video.volume * 100)}%`); },
unmute() { pageMuted = false; if (video.volume === 0) video.volume = 1; video.muted = false; store(video); show(ICON.volume, `${Math.round(video.volume * 100)}%`); },
toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },
toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },
+ /** Turn the descriptions track on or off, remember the choice, and stop talking when it goes off. */
+ toggleDescriptions() {
+ const t = descriptionTrack(video);
+ if (!t) return;
+ const on = t.mode !== 'showing';
+ t.mode = on ? 'showing' : 'hidden';
+ storeDescriptions(on);
+ if (!on) { stopDescriptionSpeech(); describedText = null; adLine.textContent = ''; }
+ paintDescriptions();
+ paintDescriptionCue();
+ },
nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
togglePip() {
togglePip() {
if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});
if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});
@@ -401,6 +484,7 @@
@@ -401,6 +484,7 @@
muteBtn.addEventListener('click', () => { if (silent) show(ICON.muted, 'No sound in this video', null, 2600); else api.toggleMute(); });
muteBtn.addEventListener('click', () => { if (silent) show(ICON.muted, 'No sound in this video', null, 2600); else api.toggleMute(); });
volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));
volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));
ccBtn.addEventListener('click', () => api.toggleCaptions());
ccBtn.addEventListener('click', () => api.toggleCaptions());
+ adBtn.addEventListener('click', () => api.toggleDescriptions());
speedBtn.addEventListener('click', () => api.nextSpeed());
speedBtn.addEventListener('click', () => api.nextSpeed());
pipBtn.addEventListener('click', () => api.togglePip());
pipBtn.addEventListener('click', () => api.togglePip());
fullBtn.addEventListener('click', () => api.toggleFullscreen());
fullBtn.addEventListener('click', () => api.toggleFullscreen());
@@ -472,6 +556,7 @@
@@ -472,6 +556,7 @@
else if (k === 'm' || k === 'M') api.toggleMute();
else if (k === 'm' || k === 'M') api.toggleMute();
else if (k === 'f' || k === 'F') api.toggleFullscreen();
else if (k === 'f' || k === 'F') api.toggleFullscreen();
else if (k === 'c' || k === 'C') api.toggleCaptions();
else if (k === 'c' || k === 'C') api.toggleCaptions();
+ else if (k === 'd' || k === 'D') api.toggleDescriptions();
else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);
else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);
else if (VOLUME_KEYS.test(k)) volumeKey(api, k);
else if (VOLUME_KEYS.test(k)) volumeKey(api, k);
else handled = false;
else handled = false;
@@ -480,28 +565,29 @@
@@ -480,28 +565,29 @@
// ── Media events ─────────────────────────────────────
// ── Media events ─────────────────────────────────────
video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });
video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });
- video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
+ video.addEventListener('pause', () => { paintPlay(); paintVolume(); stopDescriptionSpeech(); if (ambient) ambient.stop(); });
video.addEventListener('ended', paintPlay);
video.addEventListener('ended', paintPlay);
- video.addEventListener('timeupdate', () => { paintTime(); checkSound(); });
- video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; paintVolume(); root.classList.remove('is-audio'); });
+ video.addEventListener('timeupdate', () => { paintTime(); checkSound(); paintDescriptionCue(); });
+ video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; describedText = null; adLine.textContent = ''; paintVolume(); root.classList.remove('is-audio'); });
video.addEventListener('durationchange', paintTime);
video.addEventListener('durationchange', paintTime);
- video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); });
+ video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); applyDescriptionsPreference(); });
video.addEventListener('progress', paintBuffered);
video.addEventListener('progress', paintBuffered);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('volumechange', paintVolume);
video.addEventListener('ratechange', paintSpeed);
video.addEventListener('ratechange', paintSpeed);
video.addEventListener('waiting', () => root.classList.add('is-waiting'));
video.addEventListener('waiting', () => root.classList.add('is-waiting'));
video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; if (playedFrom == null) playedFrom = video.currentTime || 0; paintPlay(); });
video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; if (playedFrom == null) playedFrom = video.currentTime || 0; paintPlay(); });
- video.addEventListener('seeked', () => { if (!soundKnown) playedFrom = video.currentTime || 0; });
+ video.addEventListener('seeked', () => { stopDescriptionSpeech(); describedText = null; adLine.textContent = ''; if (!soundKnown) playedFrom = video.currentTime || 0; });
video.addEventListener('canplay', () => root.classList.remove('is-waiting'));
video.addEventListener('canplay', () => root.classList.remove('is-waiting'));
video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
if (video.textTracks && video.textTracks.addEventListener) {
if (video.textTracks && video.textTracks.addEventListener) {
- video.textTracks.addEventListener('addtrack', paintCaptions);
- video.textTracks.addEventListener('change', paintCaptions);
+ const onTracks = () => { paintCaptions(); paintDescriptions(); };
+ video.textTracks.addEventListener('addtrack', onTracks);
+ video.textTracks.addEventListener('change', onTracks);
}
}
document.addEventListener('fullscreenchange', paintFull);
document.addEventListener('fullscreenchange', paintFull);
document.addEventListener('webkitfullscreenchange', paintFull);
document.addEventListener('webkitfullscreenchange', paintFull);
- paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); paintSpeed(); paintFull(); paintAudioOnly();
+ paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); applyDescriptionsPreference(); paintSpeed(); paintFull(); paintAudioOnly();
if (!video.paused) root.classList.remove('is-start');
if (!video.paused) root.classList.remove('is-start');
return api;
return api;
}
}
@@ -29,15 +29,22 @@ Object.defineProperties(video, {
@@ -29,15 +29,22 @@ Object.defineProperties(video, {
muted: { get: () => media.muted, set: (v) => { media.muted = v; fire('volumechange'); } },
muted: { get: () => media.muted, set: (v) => { media.muted = v; fire('volumechange'); } },
playbackRate: { get: () => media.playbackRate, set: (v) => { media.playbackRate = v; fire('ratechange'); } },
playbackRate: { get: () => media.playbackRate, set: (v) => { media.playbackRate = v; fire('ratechange'); } },
buffered: { get: () => ({ length: 1, start: () => 0, end: () => 30 }) },
buffered: { get: () => ({ length: 1, start: () => 0, end: () => 30 }) },
- textTracks: { value: Object.assign([{ kind: 'captions', mode: 'hidden' }], { addEventListener() {} }) },
+ textTracks: { value: Object.assign([
+ { kind: 'captions', mode: 'hidden' },
+ { kind: 'descriptions', mode: 'hidden', cues: [{ text: 'A red door opens.' }], activeCues: [], readyState: 2, addEventListener() {} },
+ ], { addEventListener() {} }) },
});
});
video.play = () => { paused = false; fire('play'); fire('playing'); return Promise.resolve(); };
video.play = () => { paused = false; fire('play'); fire('playing'); return Promise.resolve(); };
video.pause = () => { paused = true; fire('pause'); };
video.pause = () => { paused = true; fire('pause'); };
const store = {};
const store = {};
+const spoken = [];
+class FakeUtterance { constructor(text) { this.text = text; } }
const ctx = {
const ctx = {
window, document, console, Promise, setTimeout, clearTimeout, Date, Math, Number, String, JSON, Object, Array,
window, document, console, Promise, setTimeout, clearTimeout, Date, Math, Number, String, JSON, Object, Array,
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
+ speechSynthesis: { cancel() {}, speak(u) { spoken.push(u.text); } },
+ SpeechSynthesisUtterance: FakeUtterance,
};
};
ctx.window.OpenVibePlayer = undefined;
ctx.window.OpenVibePlayer = undefined;
vm.createContext(ctx);
vm.createContext(ctx);
@@ -83,6 +90,28 @@ key('m'); assert.strictEqual(media.muted, true, 'm mutes');
@@ -83,6 +90,28 @@ key('m'); assert.strictEqual(media.muted, true, 'm mutes');
assert.match($('.ov-player-mute').getAttribute('aria-label'), /^Unmute/);
assert.match($('.ov-player-mute').getAttribute('aria-label'), /^Unmute/);
assert.deepStrictEqual(JSON.parse(store['ov-player-volume']), { volume: 0.95, muted: true }, 'volume remembered');
assert.deepStrictEqual(JSON.parse(store['ov-player-volume']), { volume: 0.95, muted: true }, 'volume remembered');
+// Audio description: the AD button reads the descriptions track, remembers the choice, speaks the active cue,
+// and the same choice is available from the keyboard (D).
+const desc = video.textTracks[1];
+const ad = $('.ov-player-ad');
+assert.ok(ad && ad.hidden === false, 'AD button shown when the video has a descriptions track');
+assert.strictEqual(ad.getAttribute('aria-label'), 'Audio description (d)');
+assert.strictEqual(ad.getAttribute('aria-pressed'), 'false');
+assert.strictEqual(desc.mode, 'hidden');
+ad.dispatchEvent(new window.Event('click'));
+assert.strictEqual(desc.mode, 'showing', 'AD turns the descriptions track on');
+assert.strictEqual(ad.getAttribute('aria-pressed'), 'true');
+assert.strictEqual(store['ov-player-descriptions'], 'on', 'the choice is remembered');
+desc.activeCues = [{ text: 'A red door opens.' }];
+fire('timeupdate');
+assert.strictEqual($('.ov-player-ad-line').textContent, 'A red door opens.', 'the active description is shown');
+assert.deepStrictEqual(spoken, ['A red door opens.'], 'and spoken through speech synthesis');
+key('d');
+assert.strictEqual(desc.mode, 'hidden', 'D turns it off');
+assert.strictEqual(ad.getAttribute('aria-pressed'), 'false');
+assert.strictEqual(store['ov-player-descriptions'], 'off');
+assert.strictEqual($('.ov-player-ad-line').textContent, '');
+
// Captions, speed.
// Captions, speed.
key('c');
key('c');
assert.strictEqual(video.textTracks[0].mode, 'showing');
assert.strictEqual(video.textTracks[0].mode, 'showing');
@@ -103,6 +132,21 @@ later.setAttribute('controls', '');
@@ -103,6 +132,21 @@ later.setAttribute('controls', '');
document.body.appendChild(later);
document.body.appendChild(later);
P.enhanceAll(document);
P.enhanceAll(document);
assert.ok(later.parentNode.classList.contains('ov-player'));
assert.ok(later.parentNode.classList.contains('ov-player'));
+assert.strictEqual(later.parentNode.querySelector('.ov-player-ad').hidden, true, 'no descriptions track, no AD button');
+
+// Remembered per person: a player created later applies the stored choice to its own descriptions track.
+{
+ store['ov-player-descriptions'] = 'on';
+ const remembered = document.createElement('video');
+ remembered.setAttribute('data-ov-player', '');
+ remembered.setAttribute('controls', '');
+ const track = { kind: 'descriptions', mode: 'hidden', cues: [{ text: 'A cat jumps onto the table.' }], activeCues: [], readyState: 2, addEventListener() {} };
+ Object.defineProperty(remembered, 'textTracks', { value: Object.assign([track], { addEventListener() {} }) });
+ document.body.appendChild(remembered);
+ P.enhance(remembered);
+ assert.strictEqual(track.mode, 'showing', 'a stored on turns the new video\'s descriptions on');
+ assert.strictEqual(remembered.parentNode.querySelector('.ov-player-ad').getAttribute('aria-pressed'), 'true');
+}
// The stylesheet: the theme's accent, the idle state, and the large button only before play or at the end.
// The stylesheet: the theme's accent, the idle state, and the large button only before play or at the end.
assert.match(css, /--ovp-accent: var\(--accent, #3b82f6\);/);
assert.match(css, /--ovp-accent: var\(--accent, #3b82f6\);/);