openvibers/openvibe.shared Commits

4.126.0: audio description in the player (X1) (#312)

OP OpenVibers committed 6 days ago · 3091ec9aeb · 7 files, +172 −18

* 4.125.0: audio description in the player (X1)

* 4.125.0 in STATUS and README

* Test the remembered AD choice on a later player

* Merge origin/main; audio description is 4.126.0

* README: 4.125.0 features stay 4.125.0

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b7dfff8..ed5a500 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex b33cecd..bec0187 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.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
([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 e046f57..e7f5f93 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 91aa9b0..1bcd86e 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/player.css b/player.css
iindex 3e09f28..a0bba1a 100644
---- a/player.css
++++ b/player.css
@@@ -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 iPhone
leaves 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; }
ddiff --git a/player.js b/player.js
iindex 0ab8ce0..2538d40 100644
---- a/player.js
++++ b/player.js
@@@ -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;
}
ddiff --git a/test/player.test.js b/test/player.test.js
iindex 5c51513..ec345b4 100644
---- a/test/player.test.js
++++ b/test/player.test.js
@@@ -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\);/);