openvibers/openvibe.shared player.js

player.js

main 48.3 KB · 745 lines · javascript

1/*
2 * OpenVibe player: one look for every <video> across the network (owner, 2026-10-20: "override the default style of
3 * video players across all our sites and vods … for a more unified look").
4 *
5 * <link rel="stylesheet" href="/shared/player.css">
6 * <video data-ov-player controls src="…" poster="…" preload="none" playsinline></video>
7 * <script src="/shared/player.js" defer></script>
8 *
9 * Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
10 * in .ov-player and gets the network's controls: a scrubber (buffered, played, the time under the pointer), play,
11 * volume, time, audio description, captions, speed, picture-in-picture and fullscreen, hidden while playing and idle.
12 * Keys while the player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions,
13 * D audio description, 0–9 jump to that tenth. On touch a tap shows the controls and a double tap on either side seeks
14 * ±10 s.
15 *
16 * Audio description (X1, 2026-11-01): a <track kind="descriptions"> gets an AD button that turns it on, remembered
17 * per person in localStorage like the volume, with the same keyboard access (D) and a spoken screen-reader label. The
18 * player shows the active cue and speaks it through the browser's speech synthesis, so a sighted viewer reads it and a
19 * blind one hears it. A video without a descriptions track never shows the button.
20 *
21 * Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media
22 * volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to
23 * themselves, so no page can see those). Lock screens and headsets get play/pause/seek through the Media Session API.
24 * On a touch screen the volume slider shows, finger-sized, wherever the page can set the volume (Android; an iPhone
25 * leaves volume to its own buttons, so it keeps the mute button alone). A video with no sound track says so ("No sound
26 * in this video", a dimmed speaker) instead of offering a mute button that seems broken.
27 *
28 * Loading looks like it is already there (owner, 2026-10-25): data-ov-color paints the average colour at once,
29 * data-ov-thumb (a tiny inline image) shows blurred, the poster sharpens over it, and the first frame fades in.
30 * data-ov-ambient adds a soft glow of the picture around the player (YouTube's ambient mode: a low-resolution sample
31 * every 0.7 s, blurred and crossfaded; off for reduced motion and data saver).
32 *
33 * It only drives the media element (play, currentTime, volume, …) and listens to its events, so a page's own script
34 * (HLS attachment, chapters, next-up, view counts) keeps working on the same element. OpenVibePlayer.enhance(video)
35 * for a video added later; enhance() is idempotent.
36 */
37(function () {
38 'use strict';
39 if (typeof window === 'undefined' || window.OpenVibePlayer) return;
40
41 const SVG = (d) => `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path fill="currentColor" d="${d}"/></svg>`;
42 // Material Icons Round (Apache-2.0): one filled, rounded set, so every control reads at the same weight.
43 const ICON = {
44 play: SVG('M8 6.82v10.36c0 .79.87 1.27 1.54.84l8.14-5.18a1 1 0 0 0 0-1.69L9.54 5.98A.998.998 0 0 0 8 6.82z'),
45 pause: SVG('M8 19c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2v10c0 1.1.9 2 2 2zm6-12v10c0 1.1.9 2 2 2s2-.9 2-2V7c0-1.1-.9-2-2-2s-2 .9-2 2z'),
46 replay: SVG('M12 5V2.21c0-.45-.54-.67-.85-.35l-3.8 3.79c-.2.2-.2.51 0 .71l3.79 3.79c.32.31.86.09.86-.36V7c3.73 0 6.68 3.42 5.86 7.29-.47 2.27-2.31 4.1-4.57 4.57-3.57.75-6.75-1.7-7.23-5.01a1 1 0 0 0-.98-.85c-.6 0-1.08.53-1 1.13.62 4.39 4.8 7.64 9.53 6.72 3.12-.61 5.63-3.12 6.24-6.24C20.84 9.48 16.94 5 12 5z'),
47 volume: SVG('M3 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L7 9H4c-.55 0-1 .45-1 1zm13.5 2A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 4.45v.2c0 .38.25.71.6.85C17.18 6.53 19 9.06 19 12s-1.82 5.47-4.4 6.5c-.36.14-.6.47-.6.85v.2c0 .63.63 1.07 1.21.85C18.6 19.11 21 15.84 21 12s-2.4-7.11-5.79-8.4c-.58-.23-1.21.22-1.21.85z'),
48 low: SVG('M16.5 12A4.5 4.5 0 0 0 14 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 10v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71V6.41c0-.89-1.08-1.34-1.71-.71L9 9H6c-.55 0-1 .45-1 1z'),
49 muted: SVG('M3.63 3.63a.996.996 0 0 0 0 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l3.29 3.29c.63.63 1.71.18 1.71-.71v-4.17l4.18 4.18c-.49.37-1.02.68-1.6.91-.36.15-.58.53-.58.92 0 .72.73 1.18 1.39.91.8-.33 1.55-.77 2.22-1.31l1.34 1.34a.996.996 0 1 0 1.41-1.41L5.05 3.63c-.39-.39-1.02-.39-1.42 0zM19 12c0 .82-.15 1.61-.41 2.34l1.53 1.53c.56-1.17.88-2.48.88-3.87 0-3.83-2.4-7.11-5.78-8.4-.59-.23-1.22.23-1.22.86v.19c0 .38.25.71.61.85C17.18 6.54 19 9.06 19 12zm-8.71-6.29-.17.17L12 7.76V6.41c0-.89-1.08-1.33-1.71-.7zM16.5 12A4.5 4.5 0 0 0 14 7.97v1.79l2.48 2.48c.01-.08.02-.16.02-.24z'),
50 cc: SVG('M19 4H5a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm-8 6.5a.5.5 0 0 1-.5.5h-.5a.5.5 0 0 1-.5-.5h-2v3h2a.5.5 0 0 1 .5-.5h.5a.5.5 0 0 1 .5.5v.5a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5zm7 0a.5.5 0 0 1-.5.5h-.5a.5.5 0 0 1-.5-.5h-2v3h2a.5.5 0 0 1 .5-.5h.5a.5.5 0 0 1 .5.5v.5a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v.5z'),
51 pip: SVG('M18 11h-6c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-4c0-.55-.45-1-1-1zm5 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-3 .02H4c-.55 0-1-.45-1-1V5.97c0-.55.45-1 1-1h16c.55 0 1 .45 1 1v12.05c0 .55-.45 1-1 1z'),
52 full: SVG('M6 14c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1h3c.55 0 1-.45 1-1s-.45-1-1-1H7v-2c0-.55-.45-1-1-1zm0-4c.55 0 1-.45 1-1V7h2c.55 0 1-.45 1-1s-.45-1-1-1H6c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1zm11 7h-2c-.55 0-1 .45-1 1s.45 1 1 1h3c.55 0 1-.45 1-1v-3c0-.55-.45-1-1-1s-1 .45-1 1v2zM14 6c0 .55.45 1 1 1h2v2c0 .55.45 1 1 1s1-.45 1-1V6c0-.55-.45-1-1-1h-3c-.55 0-1 .45-1 1z'),
53 exit: SVG('M6 16h2v2c0 .55.45 1 1 1s1-.45 1-1v-3c0-.55-.45-1-1-1H6c-.55 0-1 .45-1 1s.45 1 1 1zm2-8H6c-.55 0-1 .45-1 1s.45 1 1 1h3c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1s-1 .45-1 1v2zm7 11c.55 0 1-.45 1-1v-2h2c.55 0 1-.45 1-1s-.45-1-1-1h-3c-.55 0-1 .45-1 1v3c0 .55.45 1 1 1zm1-11V6c0-.55-.45-1-1-1s-1 .45-1 1v3c0 .55.45 1 1 1h3c.55 0 1-.45 1-1s-.45-1-1-1h-2z'),
54 fwd: SVG('M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z'),
55 back: SVG('M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z'),
56 };
57 const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
58 const VOLUME_KEY = 'ov-player-volume';
59 const DESCRIPTIONS_KEY = 'ov-player-descriptions';
60
61 function fmt(s) {
62 if (!Number.isFinite(s) || s < 0) s = 0;
63 s = Math.floor(s);
64 const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
65 return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`;
66 }
67 function stored() {
68 try { const v = JSON.parse(localStorage.getItem(VOLUME_KEY) || 'null'); return v && Number.isFinite(v.volume) ? v : null; } catch { return null; }
69 }
70 function store(video) {
71 try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume: video.volume, muted: video.muted })); } catch { /* private window */ }
72 }
73 function descriptionsStored() {
74 try { return localStorage.getItem(DESCRIPTIONS_KEY) === 'on'; } catch { return false; }
75 }
76 function storeDescriptions(on) {
77 try { localStorage.setItem(DESCRIPTIONS_KEY, on ? 'on' : 'off'); } catch { /* private window */ }
78 }
79 function el(tag, cls, attrs) {
80 const e = document.createElement(tag);
81 if (cls) e.className = cls;
82 for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
83 return e;
84 }
85 function button(cls, label, icon) {
86 const b = el('button', `ov-player-btn ${cls}`, { type: 'button', 'aria-label': label, title: label });
87 b.innerHTML = icon;
88 return b;
89 }
90 /** Whether this browser lets a page set a media element's volume (an iPhone's Safari does not: only muted). */
91 function volumeSettable(video) {
92 const was = video.volume;
93 try {
94 video.volume = was > 0.5 ? was - 0.1 : was + 0.1;
95 const ok = Math.abs(video.volume - was) > 0.01;
96 video.volume = was;
97 return ok;
98 } catch { return false; }
99 }
100 const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
101 const descriptionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'descriptions') || null;
102 const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
103
104 /** Can this browser speak a description aloud? (Speech synthesis is optional; the cue text still shows without it.) */
105 function canSpeak() {
106 return typeof speechSynthesis !== 'undefined' && typeof SpeechSynthesisUtterance !== 'undefined';
107 }
108 /** Say one description cue at a time: a new cue replaces the old one instead of queueing behind it. */
109 function speakDescription(text) {
110 if (!text || !canSpeak()) return;
111 try {
112 speechSynthesis.cancel();
113 const u = new SpeechSynthesisUtterance(String(text).replace(/\s+/g, ' ').trim());
114 u.lang = 'en';
115 u.rate = 1;
116 speechSynthesis.speak(u);
117 } catch { /* a browser that refuses speech (no voices, autoplay rule) still shows the text */ }
118 }
119 function stopDescriptionSpeech() {
120 if (!canSpeak()) return;
121 try { speechSynthesis.cancel(); } catch { /* nothing queued */ }
122 }
123
124 // The media volume keys, where the browser passes them to the page (UI Events key names, and the older ones).
125 const VOLUME_KEYS = /^(AudioVolume(Up|Down|Mute)|Volume(Up|Down|Mute))$/;
126 let current = null; // the player last played or touched: the one the page-wide keys drive
127 function volumeKey(api, k) {
128 const v = api.video;
129 if (/Mute$/.test(k)) api.toggleMute();
130 else if (/Up$/.test(k)) { if (v.muted) api.unmute(); else api.setVolume(v.volume + 0.1); }
131 else api.setVolume(v.volume - 0.1);
132 }
133 document.addEventListener('keydown', (e) => {
134 if (!current || !VOLUME_KEYS.test(e.key) || current.root.contains(e.target)) return;
135 const t = e.target && e.target.tagName;
136 if (t === 'INPUT' || t === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
137 volumeKey(current, e.key);
138 });
139
140 /** An image, asked for with CORS so its pixels can be read; without CORS it still loads, only unread. */
141 function loadImage(src, done) {
142 if (typeof Image !== 'function') return;
143 const img = new Image();
144 img.decoding = 'async';
145 img.crossOrigin = 'anonymous';
146 img.onload = () => done(img);
147 img.onerror = () => { if (!img.crossOrigin) return; img.crossOrigin = null; img.removeAttribute('crossorigin'); img.src = src; };
148 img.src = src;
149 }
150
151 /** The average colour of a loaded image (a preview or a poster), when the browser lets the page read it. */
152 function averageColor(img, done) {
153 try {
154 const c = document.createElement('canvas'); c.width = 8; c.height = 8;
155 const g = c.getContext('2d');
156 g.drawImage(img, 0, 0, 8, 8);
157 const d = g.getImageData(0, 0, 8, 8).data;
158 let r = 0, gr = 0, b = 0;
159 for (let i = 0; i < d.length; i += 4) { r += d[i]; gr += d[i + 1]; b += d[i + 2]; }
160 const n = d.length / 4;
161 done(`rgb(${Math.round(r / n)}, ${Math.round(gr / n)}, ${Math.round(b / n)})`);
162 } catch { /* a poster from another origin without CORS: the default stays */ }
163 }
164
165 /**
166 * The ambient glow: two small canvases behind the player, painted from the video every 0.7 s while it plays and is
167 * on screen, blurred and crossfaded by player.css. A cross-origin video taints a canvas, which can still be shown.
168 */
169 function glow(root, video) {
170 const reduce = document.documentElement.getAttribute('data-ov-motion') === 'off'; // a slow colour fade moves nothing: Calm keeps it
171 const saver = typeof navigator !== 'undefined' && navigator.connection && navigator.connection.saveData;
172 const probe = document.createElement('canvas');
173 if (reduce || saver || typeof probe.getContext !== 'function' || !probe.getContext('2d')) return null;
174 const wrap = el('div', 'ov-player-ambient', { 'aria-hidden': 'true' });
175 const a = el('canvas'), b = el('canvas');
176 [a, b].forEach((c) => { c.width = 32; c.height = 18; });
177 wrap.append(a, b);
178 root.parentNode.insertBefore(wrap, root);
179 root.parentNode.classList.add('ov-player-host');
180 let front = a, timer = 0, visible = true;
181 const paint = (src) => {
182 const back = front === a ? b : a;
183 try { back.getContext('2d').drawImage(src, 0, 0, back.width, back.height); } catch { return; }
184 back.classList.add('is-front'); front.classList.remove('is-front'); front = back;
185 };
186 if (typeof IntersectionObserver === 'function') new IntersectionObserver((es) => { visible = es[0].isIntersecting; }).observe(root);
187 return {
188 still(img) { paint(img); wrap.classList.add('is-on'); },
189 start() { clearInterval(timer); wrap.classList.add('is-on'); timer = setInterval(() => { if (visible && !document.hidden && video.readyState >= 2) paint(video); }, 700); },
190 stop() { clearInterval(timer); },
191 };
192 }
193
194 /** Lock screen, headset and notification controls (Media Session) for the player that is playing. */
195 function session(api, options) {
196 const ms = typeof navigator !== 'undefined' && navigator.mediaSession;
197 if (!ms) return;
198 try {
199 const v = api.video;
200 const art = v.getAttribute('poster');
201 if (typeof MediaMetadata === 'function') ms.metadata = new MediaMetadata({ title: options.title || v.getAttribute('data-ov-title') || v.getAttribute('aria-label') || document.title, artist: options.artist || v.getAttribute('data-ov-artist') || '', artwork: art ? [{ src: art }] : [] });
202 ms.setActionHandler('play', () => api.toggle());
203 ms.setActionHandler('pause', () => api.toggle());
204 ms.setActionHandler('seekbackward', () => api.skip(-10));
205 ms.setActionHandler('seekforward', () => api.skip(10));
206 ms.setActionHandler('seekto', (d) => { if (d && Number.isFinite(d.seekTime)) api.seek(d.seekTime); });
207 } catch { /* an action this browser does not know */ }
208 }
209
210 // Startup policy is shared with pages that own their HLS attachment. Unknown connections use the fast policy.
211 const nav = typeof navigator === 'undefined' ? {} : navigator;
212 let battery = null;
213 if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => { battery = b; }).catch(() => {});
214 function playbackPolicy(connection = nav.connection || {}, power = battery) {
215 const slow = connection.saveData || /^(slow-)?2g$/.test(connection.effectiveType || '');
216 const limited = slow || connection.effectiveType === '3g';
217 const lowBattery = !!power && !power.charging && power.level <= 0.2;
218 return {
219 preload: slow ? 'none' : limited ? 'metadata' : 'auto',
220 warm: !limited && !lowBattery,
221 hls: { startLevel: 0, maxBufferLength: lowBattery ? 15 : limited ? 30 : 60,
222 maxMaxBufferLength: lowBattery ? 30 : limited ? 60 : 120,
223 backBufferLength: lowBattery || slow ? 0 : limited ? 15 : 30 },
224 };
225 }
226 /** Attach using the page's Hls constructor; native HLS keeps the same connection-aware preload. */
227 function attachHls(video, Hls, url, options = {}) {
228 const policy = playbackPolicy();
229 video.preload = policy.preload;
230 if (!Hls || !Hls.isSupported()) {
231 if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = url;
232 return null;
233 }
234 const hls = new Hls({ ...options, ...policy.hls, autoStartLoad: policy.preload === 'auto' });
235 let loading = policy.preload === 'auto';
236 const start = () => { if (!loading) { loading = true; hls.startLoad(); } };
237 const update = () => {
238 video.preload = playbackPolicy().preload;
239 Object.assign(hls.config, playbackPolicy().hls);
240 if (video.paused && video.preload !== 'auto' && loading) { hls.stopLoad(); loading = false; }
241 };
242 video.addEventListener('play', start);
243 if (nav.connection && nav.connection.addEventListener) nav.connection.addEventListener('change', update);
244 // getBattery is optional and async; register only while this attachment is alive.
245 let disposed = false, power = null;
246 if (nav.getBattery) Promise.resolve().then(() => nav.getBattery()).then((b) => {
247 if (disposed) return;
248 power = battery = b; update();
249 b.addEventListener('levelchange', update); b.addEventListener('chargingchange', update);
250 }).catch(() => {});
251 hls.on(Hls.Events.DESTROYING, () => {
252 disposed = true; video.removeEventListener('play', start);
253 if (nav.connection && nav.connection.removeEventListener) nav.connection.removeEventListener('change', update);
254 if (power) { power.removeEventListener('levelchange', update); power.removeEventListener('chargingchange', update); }
255 });
256 hls.loadSource(url); hls.attachMedia(video);
257 if (!video.paused) start();
258 return hls;
259 }
260 // Warm only explicit public HLS card sources, at most 20 per minute and 2 MiB per card. No whole-file prefetch.
261 const warmed = new Map();
262 async function warmHls(url, signal) {
263 if (!playbackPolicy().warm || typeof fetch !== 'function') return false;
264 const now = Date.now();
265 for (const [key, at] of warmed) if (now - at >= 60000) warmed.delete(key);
266 const safe = (value, base) => {
267 const u = new URL(value, base || undefined);
268 if (!/^https?:$/.test(u.protocol) || u.username || u.password || /(?:sig|token|key|exp|signature)/i.test(u.search)) throw new Error('Only public playlists are warmed');
269 return u.href;
270 };
271 let source;
272 try { source = safe(url, document.baseURI); } catch { return false; }
273 if (warmed.has(source) || warmed.size >= 20) return false;
274 warmed.set(source, now);
275 let remaining = 2 * 1024 * 1024;
276 const read = async (u, manifest = false) => {
277 const response = await fetch(u, { signal, credentials: 'same-origin', cache: 'force-cache' });
278 if (!response.ok || !response.body) throw new Error('Not cacheable');
279 const reader = response.body.getReader(), chunks = [];
280 let size = 0;
281 try {
282 for (;;) {
283 const { done, value } = await reader.read(); if (done) break;
284 size += value.byteLength; remaining -= value.byteLength;
285 if (remaining < 0 || (manifest && size > 65536)) throw new Error('Warm budget');
286 if (manifest) chunks.push(value);
287 }
288 } finally { await reader.cancel().catch(() => {}); }
289 if (!manifest) return '';
290 const bytes = new Uint8Array(size); let offset = 0;
291 for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
292 return new TextDecoder().decode(bytes);
293 };
294 try {
295 let body = await read(source, true);
296 if (!body.startsWith('#EXTM3U') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;
297 if (body.includes('#EXT-X-STREAM-INF:')) {
298 const lines = body.split(/\r?\n/), variants = [];
299 lines.forEach((line, i) => {
300 if (line.startsWith('#EXT-X-STREAM-INF:') && lines[i + 1] && !lines[i + 1].startsWith('#'))
301 variants.push({ bandwidth: Number((line.match(/(?:^|,)BANDWIDTH=(\d+)/) || line.match(/:BANDWIDTH=(\d+)/) || [])[1]) || Infinity, uri: lines[i + 1] });
302 });
303 variants.sort((a, b) => a.bandwidth - b.bandwidth);
304 if (!variants.length) return false;
305 source = safe(variants[0].uri, source); body = await read(source, true);
306 }
307 // Live playlists, encrypted media and byte-range playlists need their player's own loader.
308 if (!body.includes('#EXT-X-ENDLIST') || /#EXT-X-(?:KEY|SESSION-KEY):|BYTERANGE[=:]/.test(body)) return false;
309 const init = body.match(/#EXT-X-MAP:URI="([^"]+)"/);
310 const segment = body.split(/\r?\n/).find((line) => line && !line.startsWith('#'));
311 if (init) await read(safe(init[1], source));
312 if (segment) await read(safe(segment, source));
313 return !!segment;
314 } catch { return false; }
315 }
316 function warmCards(scope = document) {
317 scope.querySelectorAll('[data-ov-player-hls]').forEach((card) => {
318 if (card.__ovWarm) return; card.__ovWarm = true;
319 let controller = null, timer = null;
320 const cancel = () => { clearTimeout(timer); if (controller) controller.abort(); };
321 const begin = () => {
322 cancel();
323 if (!playbackPolicy().warm || typeof AbortController !== 'function') return;
324 controller = new AbortController();
325 timer = setTimeout(() => warmHls(card.getAttribute('data-ov-player-hls'), controller.signal), 120);
326 };
327 card.addEventListener('pointerenter', begin); card.addEventListener('focusin', begin);
328 card.addEventListener('pointerleave', cancel); card.addEventListener('focusout', cancel);
329 });
330 }
331
332 function enhance(video, options = {}) {
333 if (!video || video.__ovPlayer) return video && video.__ovPlayer;
334 video.preload = playbackPolicy().preload;
335 const idleMs = options.idleMs || 2500;
336 const root = el('div', 'ov-player is-paused is-start', { tabindex: '0', role: 'region', 'aria-label': options.label || video.getAttribute('aria-label') || 'Video player' });
337 video.parentNode.insertBefore(root, video);
338 root.appendChild(video);
339 video.controls = false;
340 video.removeAttribute('controls');
341 video.classList.add('ov-player-video');
342 // Loading: the average colour now, a tiny blurred preview, then the poster, then the first frame.
343 const color = video.getAttribute('data-ov-color');
344 if (/^#[0-9a-f]{3,8}$/i.test(color || '')) root.style.setProperty('--ovp-bg', color);
345 const thumb = video.getAttribute('data-ov-thumb');
346 const placeholder = el('div', 'ov-player-placeholder', { 'aria-hidden': 'true' });
347 if (/^(data:image\/|https:\/\/)/.test(thumb || '')) {
348 placeholder.style.backgroundImage = `url("${thumb.replace(/"/g, '%22')}")`;
349 root.classList.add('has-thumb');
350 // No colour given: the preview's average, which arrives first (a few hundred bytes).
351 if (!/^#[0-9a-f]{3,8}$/i.test(color || '')) loadImage(thumb, (img) => averageColor(img, (c) => root.style.setProperty('--ovp-bg', c)));
352 }
353 const posterLayer = el('div', 'ov-player-poster', { 'aria-hidden': 'true' });
354 // An audio-only recording (a browser broadcast with no camera; plan R4, 2026-11-01): no picture track, so the
355 // video element would be a black box. The cover shows the streamer's face (data-ov-avatar, else the poster)
356 // over a soft blur of it, and says "Audio only". Filled once the metadata says there is no picture.
357 const audioLayer = el('div', 'ov-player-audio', { 'aria-hidden': 'true' });
358 root.insertBefore(placeholder, video);
359 root.insertBefore(posterLayer, video);
360 root.insertBefore(audioLayer, video);
361 const shown = () => root.classList.add('has-frame');
362 if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);
363 else if (video.readyState >= 2) shown();
364 else video.addEventListener('loadeddata', shown, { once: true });
365 video.addEventListener('emptied', () => {
366 root.classList.remove('has-frame');
367 if (video.requestVideoFrameCallback) video.requestVideoFrameCallback(shown);
368 else video.addEventListener('loadeddata', shown, { once: true });
369 });
370
371 // ── Build ────────────────────────────────────────────
372 const big = button('ov-player-big', 'Play', ICON.play);
373 const spinner = el('div', 'ov-player-spinner', { 'aria-hidden': 'true' });
374 const message = el('div', 'ov-player-message', { role: 'status' });
375 const bar = el('div', 'ov-player-bar');
376 const scrub = el('div', 'ov-player-scrub', { role: 'slider', tabindex: '0', 'aria-label': 'Seek', 'aria-valuemin': '0', 'aria-valuemax': '0', 'aria-valuenow': '0', 'aria-valuetext': '0:00 of 0:00' });
377 const track = el('div', 'ov-player-track');
378 const buffered = el('div', 'ov-player-buffered');
379 const played = el('div', 'ov-player-played');
380 const hover = el('div', 'ov-player-hover', { 'aria-hidden': 'true' });
381 track.append(buffered, played);
382 scrub.append(track, hover);
383 const row = el('div', 'ov-player-row');
384 const playBtn = button('ov-player-play', 'Play (k)', ICON.play);
385 const volWrap = el('div', 'ov-player-volume');
386 const muteBtn = button('ov-player-mute', 'Mute (m)', ICON.volume);
387 const volSlider = el('input', 'ov-player-volume-slider', { type: 'range', min: '0', max: '1', step: '0.05', 'aria-label': 'Volume' });
388 volWrap.append(muteBtn, volSlider);
389 const time = el('div', 'ov-player-time', { 'aria-hidden': 'true' });
390 const spacer = el('div', 'ov-player-spacer');
391 // Audio description: a text button ("AD") because the letters say what it does in every language's player,
392 // and a screen reader hears the full label and whether it is on (aria-pressed).
393 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: '' });
394 adBtn.textContent = 'AD';
395 const ccBtn = button('ov-player-cc', 'Captions (c)', ICON.cc);
396 const speedBtn = el('button', 'ov-player-btn ov-player-speed', { type: 'button', 'aria-label': 'Playback speed: 1×', title: 'Playback speed' });
397 speedBtn.textContent = '1×';
398 const pipBtn = button('ov-player-pip', 'Picture in picture', ICON.pip);
399 const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);
400 row.append(playBtn, volWrap, time, spacer, adBtn, ccBtn, speedBtn, pipBtn, fullBtn);
401 bar.append(scrub, row);
402 const unmute = el('button', 'ov-player-unmute', { type: 'button', hidden: '' });
403 unmute.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;
404 const flash = el('div', 'ov-player-flash', { 'aria-hidden': 'true' });
405 // The active description, shown over the picture and given to assistive tech when this browser cannot speak.
406 const adLine = el('div', 'ov-player-ad-line', { role: 'status', 'aria-live': canSpeak() ? 'off' : 'polite' });
407 root.append(spinner, big, message, unmute, flash, adLine, bar);
408 const ambient = video.hasAttribute('data-ov-ambient') || options.ambient ? glow(root, video) : null;
409 const poster = video.getAttribute('poster');
410 if (poster) loadImage(poster, (img) => {
411 posterLayer.style.backgroundImage = `url("${poster.replace(/"/g, '%22')}")`;
412 root.classList.add('has-poster');
413 if (!color && !thumb) averageColor(img, (c) => root.style.setProperty('--ovp-bg', c));
414 if (ambient) ambient.still(img);
415 });
416
417 const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;
418 const comes = video.hasAttribute('data-ov-mini');
419 if (!pipOk && !comes) pipBtn.hidden = true;
420 if (comes) { pipBtn.setAttribute('aria-label', 'Keep watching while you browse'); pipBtn.title = 'Keep watching while you browse'; }
421 const fsOk = !!(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen);
422 if (!fsOk) fullBtn.hidden = true;
423
424 const saved = stored();
425 if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }
426 // Muted by the page (autoplay) rather than by the viewer: the first tap or a volume key brings the sound in.
427 let pageMuted = video.muted && (video.hasAttribute('muted') || video.autoplay || !saved || !saved.muted);
428 if (volumeSettable(video)) root.classList.add('can-volume');
429 // No sound track (a recording whose microphone never reached the recorder: VOD 5986, 2026-10-25). Only a browser
430 // that can tell says so: Firefox (mozHasAudio), Chrome and Safari (no audio decoded after 2 s of decoded video,
431 // which they count muted or not). A new source asks again.
432 let silent = false;
433 let soundKnown = false;
434 let playedFrom = null;
435
436 // ── State → UI ───────────────────────────────────────
437 const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
438 function paintTime() {
439 const d = duration(), t = video.currentTime || 0;
440 // The position, and the length in its own part (a phone shows the position alone: the scrubber has the length).
441 const now = fmt(t), len = d ? fmt(d) : '';
442 if (time.dataset.now !== now || time.dataset.len !== len) {
443 time.dataset.now = now; time.dataset.len = len;
444 const at = document.createElement('span'); at.textContent = now;
445 if (len) { const of = document.createElement('span'); of.className = 'ov-player-dur'; of.textContent = ` / ${len}`; time.replaceChildren(at, of); } else time.replaceChildren(at);
446 }
447 played.style.width = d ? `${Math.min(100, (t / d) * 100)}%` : '0%';
448 scrub.setAttribute('aria-valuemax', String(Math.floor(d)));
449 scrub.setAttribute('aria-valuenow', String(Math.floor(t)));
450 scrub.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(d)}`);
451 }
452 function paintBuffered() {
453 const d = duration(), b = video.buffered;
454 if (!d || !b || !b.length) { buffered.style.width = '0%'; return; }
455 let end = 0;
456 for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
457 buffered.style.width = `${Math.min(100, (end / d) * 100)}%`;
458 }
459 function paintPlay() {
460 const paused = video.paused || video.ended;
461 root.classList.toggle('is-paused', paused);
462 root.classList.toggle('is-ended', video.ended);
463 playBtn.innerHTML = video.ended ? ICON.replay : paused ? ICON.play : ICON.pause;
464 const label = video.ended ? 'Replay' : paused ? 'Play (k)' : 'Pause (k)';
465 playBtn.setAttribute('aria-label', label); playBtn.title = label;
466 big.innerHTML = video.ended ? ICON.replay : ICON.play;
467 big.setAttribute('aria-label', video.ended ? 'Replay' : 'Play');
468 if (paused) wake();
469 }
470 function paintVolume() {
471 const v = video.muted ? 0 : video.volume;
472 unmute.hidden = silent || !(video.muted && pageMuted && !video.paused);
473 root.classList.toggle('is-muted', video.muted || v === 0);
474 root.classList.toggle('is-silent', silent);
475 muteBtn.innerHTML = silent || v === 0 ? ICON.muted : v < 0.5 ? ICON.low : ICON.volume;
476 const label = silent ? 'No sound in this video' : video.muted || v === 0 ? 'Unmute (m)' : 'Mute (m)';
477 muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
478 volSlider.value = String(v);
479 volSlider.style.setProperty('--ovp-fill', `${v * 100}%`);
480 }
481 function paintCaptions() {
482 const t = captionTrack(video);
483 ccBtn.hidden = !t;
484 ccBtn.classList.toggle('is-on', !!t && t.mode === 'showing');
485 ccBtn.setAttribute('aria-pressed', t && t.mode === 'showing' ? 'true' : 'false');
486 }
487 // Audio description: the AD button only exists when the video has a descriptions track with cues. The track
488 // loads in hidden mode so the button (and the stored preference) can be decided before anyone presses play.
489 let describedText = null;
490 function paintDescriptions() {
491 const t = descriptionTrack(video);
492 if (t && !t.__ovAdWatched && t.addEventListener) {
493 t.__ovAdWatched = true;
494 t.addEventListener('cuechange', paintDescriptionCue);
495 }
496 const empty = !!t && (t.readyState === 2 || t.readyState === 3) && !(t.cues && t.cues.length);
497 adBtn.hidden = !t || empty;
498 const on = !!t && t.mode === 'showing';
499 adBtn.classList.toggle('is-on', on);
500 adBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
501 if (!on && describedText) { describedText = null; adLine.textContent = ''; stopDescriptionSpeech(); }
502 }
503 function paintDescriptionCue() {
504 const t = descriptionTrack(video);
505 const cue = t && t.mode === 'showing' && t.activeCues && t.activeCues[0];
506 const text = cue ? String(cue.text || '').replace(/\s+/g, ' ').trim() : '';
507 if (text === describedText) return;
508 describedText = text;
509 adLine.textContent = text;
510 if (text) speakDescription(text);
511 else stopDescriptionSpeech();
512 }
513 /** The person's remembered choice, applied only when the video actually has descriptions. */
514 function applyDescriptionsPreference() {
515 const t = descriptionTrack(video);
516 if (t && descriptionsStored()) t.mode = 'showing';
517 paintDescriptions();
518 }
519 function paintSpeed() {
520 const r = video.playbackRate || 1;
521 speedBtn.textContent = `${r}×`;
522 speedBtn.setAttribute('aria-label', `Playback speed: ${r}×`);
523 }
524 /** No picture track once the metadata is in: the audio cover, and no picture-in-picture (there is no picture). */
525 function paintAudioOnly() {
526 const audio = video.readyState >= 1 && !video.videoWidth && !video.videoHeight;
527 root.classList.toggle('is-audio', audio);
528 if (pipOk || comes) pipBtn.hidden = audio;
529 if (!audio || audioLayer.firstChild) return;
530 const src = [video.getAttribute('data-ov-avatar'), video.getAttribute('poster')].find((u) => /^(https:\/\/|\/(?!\/))/.test(u || ''));
531 if (src) {
532 const bg = el('img', 'ov-player-audio-bg', { src, alt: '', decoding: 'async' });
533 const face = el('img', 'ov-player-audio-face', { src, alt: '', decoding: 'async' });
534 audioLayer.append(bg, face);
535 } else {
536 audioLayer.append(el('span', 'ov-player-audio-glyph'));
537 audioLayer.lastChild.innerHTML = ICON.volume;
538 }
539 const tag = el('span', 'ov-player-audio-tag');
540 tag.textContent = 'Audio only';
541 audioLayer.append(tag);
542 }
543 function paintFull() {
544 const on = fullscreenElement() === root;
545 root.classList.toggle('is-fullscreen', on);
546 fullBtn.innerHTML = on ? ICON.exit : ICON.full;
547 const label = on ? 'Exit fullscreen (f)' : 'Fullscreen (f)';
548 fullBtn.setAttribute('aria-label', label); fullBtn.title = label;
549 }
550
551 // ── Idle: controls hide while playing and nothing moves ──
552 let idleTimer = 0;
553 function wake() {
554 root.classList.remove('is-idle');
555 clearTimeout(idleTimer);
556 // Not while a control in the bar has focus (keyboard use) or the pointer is scrubbing.
557 if (!video.paused && !video.ended) idleTimer = setTimeout(() => { if (!video.paused && !bar.contains(document.activeElement) && !dragging) root.classList.add('is-idle'); }, idleMs);
558 }
559
560 // ── Actions ──────────────────────────────────────────
561 const api = {
562 root, video,
563 toggle() {
564 if (video.paused || video.ended) { const p = video.play(); if (p && p.catch) p.catch(() => paintPlay()); } else video.pause();
565 },
566 seek(t) { const d = duration(); if (!d) return; video.currentTime = Math.min(d, Math.max(0, t)); paintTime(); },
567 skip(s) { api.seek((video.currentTime || 0) + s); show(s < 0 ? ICON.back : ICON.fwd, `${s > 0 ? '+' : ''}${s} s`, s < 0 ? 'left' : 'right'); },
568 setVolume(v) { pageMuted = false; video.volume = Math.min(1, Math.max(0, v)); video.muted = video.volume === 0; store(video); show(video.muted ? ICON.muted : video.volume < 0.5 ? ICON.low : ICON.volume, `${Math.round(video.volume * 100)}%`); },
569 toggleMute() { pageMuted = false; if (video.muted && video.volume === 0) video.volume = 0.5; video.muted = !video.muted; store(video); },
570 /** Sound on, at the remembered level (full when that was zero). */
571 unmute() { pageMuted = false; if (video.volume === 0) video.volume = 1; video.muted = false; store(video); show(ICON.volume, `${Math.round(video.volume * 100)}%`); },
572 toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },
573 /** Turn the descriptions track on or off, remember the choice, and stop talking when it goes off. */
574 toggleDescriptions() {
575 const t = descriptionTrack(video);
576 if (!t) return;
577 const on = t.mode !== 'showing';
578 t.mode = on ? 'showing' : 'hidden';
579 storeDescriptions(on);
580 if (!on) { stopDescriptionSpeech(); describedText = null; adLine.textContent = ''; }
581 paintDescriptions();
582 paintDescriptionCue();
583 },
584 nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
585 togglePip() {
586 // A video that may come along (data-ov-mini, mini.js) goes on in the small player while the person
587 // browses; without that the browser's own floating window, as before.
588 if (video.hasAttribute('data-ov-mini') && window.OpenVibeMini && window.OpenVibeMini.carry(video)) return;
589 if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});
590 else if (video.requestPictureInPicture) video.requestPictureInPicture().catch(() => {});
591 },
592 toggleFullscreen() {
593 if (fullscreenElement()) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
594 else if (root.requestFullscreen) root.requestFullscreen().catch(() => {});
595 else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen();
596 else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen();
597 },
598 };
599 video.__ovPlayer = api;
600
601 // A short sign in the middle: a seek, a volume level (the double tap and the keys).
602 let flashTimer = 0;
603 function show(icon, text, side, ms = 650) {
604 flash.className = `ov-player-flash is-on${side ? ` is-${side}` : ''}`;
605 flash.innerHTML = `${icon}<span>${text}</span>`;
606 clearTimeout(flashTimer);
607 flashTimer = setTimeout(() => { flash.className = 'ov-player-flash'; }, ms);
608 }
609 function checkSound() {
610 if (soundKnown || video.paused) return;
611 let none = null;
612 if (typeof video.mozHasAudio === 'boolean' && video.readyState >= 2) none = !video.mozHasAudio;
613 else if (typeof video.webkitAudioDecodedByteCount === 'number' && video.webkitVideoDecodedByteCount > 0
614 && playedFrom != null && (video.currentTime || 0) - playedFrom >= 2) none = video.webkitAudioDecodedByteCount === 0;
615 if (none == null) return;
616 soundKnown = true;
617 if (!none) return;
618 silent = true;
619 paintVolume();
620 show(ICON.muted, 'No sound in this video', null, 2600);
621 }
622 unmute.addEventListener('click', (e) => { e.stopPropagation(); api.unmute(); });
623 big.addEventListener('click', () => api.toggle());
624 playBtn.addEventListener('click', () => api.toggle());
625 muteBtn.addEventListener('click', () => { if (silent) show(ICON.muted, 'No sound in this video', null, 2600); else api.toggleMute(); });
626 volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));
627 ccBtn.addEventListener('click', () => api.toggleCaptions());
628 adBtn.addEventListener('click', () => api.toggleDescriptions());
629 speedBtn.addEventListener('click', () => api.nextSpeed());
630 pipBtn.addEventListener('click', () => api.togglePip());
631 fullBtn.addEventListener('click', () => api.toggleFullscreen());
632
633 // Scrubbing: click or drag; the time under the pointer shows above it.
634 const atPointer = (e) => {
635 const r = track.getBoundingClientRect();
636 return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))) * duration();
637 };
638 let dragging = false;
639 scrub.addEventListener('pointerdown', (e) => {
640 if (!duration()) return;
641 dragging = true;
642 root.classList.add('is-scrubbing');
643 if (scrub.setPointerCapture && e.pointerId != null) { try { scrub.setPointerCapture(e.pointerId); } catch { /* */ } }
644 api.seek(atPointer(e));
645 });
646 scrub.addEventListener('pointermove', (e) => {
647 const d = duration();
648 if (!d) return;
649 const t = atPointer(e);
650 const r = track.getBoundingClientRect();
651 hover.textContent = fmt(t);
652 hover.style.left = `${((t / d) * (r.width || 0))}px`;
653 if (dragging) api.seek(t);
654 });
655 const endDrag = () => { dragging = false; root.classList.remove('is-scrubbing'); };
656 scrub.addEventListener('pointerup', endDrag);
657 scrub.addEventListener('pointercancel', endDrag);
658
659 // The picture: a click plays or pauses (mouse); a tap shows the controls, a double tap seeks ±10 s (touch).
660 let lastTap = 0;
661 video.addEventListener('pointerup', (e) => {
662 if (e.pointerType !== 'touch') { api.toggle(); return; }
663 const now = Date.now();
664 if (now - lastTap < 300) {
665 const r = video.getBoundingClientRect();
666 const x = (e.clientX - r.left) / (r.width || 1);
667 if (x < 0.35) api.skip(-10); else if (x > 0.65) api.skip(10); else api.toggle();
668 lastTap = 0;
669 return;
670 }
671 lastTap = now;
672 if (video.muted && pageMuted && !video.paused) { api.unmute(); wake(); return; }
673 if (root.classList.contains('is-idle')) wake(); else if (!video.paused) root.classList.add('is-idle');
674 });
675 video.addEventListener('dblclick', () => api.toggleFullscreen());
676
677 root.addEventListener('pointerdown', () => { current = api; });
678 root.addEventListener('focusin', () => { current = api; });
679 root.addEventListener('pointermove', wake);
680 root.addEventListener('focusin', wake);
681 root.addEventListener('keydown', (e) => {
682 if (e.altKey || e.ctrlKey || e.metaKey) return;
683 const tag = e.target && e.target.tagName;
684 if (tag === 'INPUT' && e.target !== volSlider) return;
685 const k = e.key;
686 const onSlider = e.target === volSlider;
687 let handled = true;
688 if (k === ' ' || k === 'k' || k === 'K') { if (tag === 'BUTTON' && k === ' ') return; api.toggle(); }
689 else if (k === 'j' || k === 'J') api.skip(-10);
690 else if (k === 'l' || k === 'L') api.skip(10);
691 else if (k === 'ArrowLeft' && !onSlider) api.skip(-5);
692 else if (k === 'ArrowRight' && !onSlider) api.skip(5);
693 else if (k === 'ArrowUp' && !onSlider) api.setVolume(video.volume + 0.05);
694 else if (k === 'ArrowDown' && !onSlider) api.setVolume(video.volume - 0.05);
695 else if (k === 'Home' && e.target === scrub) api.seek(0);
696 else if (k === 'End' && e.target === scrub) api.seek(duration());
697 else if (k === 'm' || k === 'M') api.toggleMute();
698 else if (k === 'f' || k === 'F') api.toggleFullscreen();
699 else if (k === 'c' || k === 'C') api.toggleCaptions();
700 else if (k === 'd' || k === 'D') api.toggleDescriptions();
701 else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);
702 else if (VOLUME_KEYS.test(k)) volumeKey(api, k);
703 else handled = false;
704 if (handled) { e.preventDefault(); wake(); }
705 });
706
707 // ── Media events ─────────────────────────────────────
708 video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });
709 video.addEventListener('pause', () => { paintPlay(); paintVolume(); stopDescriptionSpeech(); if (ambient) ambient.stop(); });
710 video.addEventListener('ended', paintPlay);
711 video.addEventListener('timeupdate', () => { paintTime(); checkSound(); paintDescriptionCue(); });
712 video.addEventListener('emptied', () => { silent = false; soundKnown = false; playedFrom = null; describedText = null; adLine.textContent = ''; paintVolume(); root.classList.remove('is-audio'); });
713 video.addEventListener('durationchange', paintTime);
714 video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); paintAudioOnly(); applyDescriptionsPreference(); });
715 video.addEventListener('progress', paintBuffered);
716 video.addEventListener('volumechange', paintVolume);
717 video.addEventListener('ratechange', paintSpeed);
718 video.addEventListener('waiting', () => root.classList.add('is-waiting'));
719 video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; if (playedFrom == null) playedFrom = video.currentTime || 0; paintPlay(); });
720 video.addEventListener('seeked', () => { stopDescriptionSpeech(); describedText = null; adLine.textContent = ''; if (!soundKnown) playedFrom = video.currentTime || 0; });
721 video.addEventListener('canplay', () => root.classList.remove('is-waiting'));
722 video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
723 if (video.textTracks && video.textTracks.addEventListener) {
724 const onTracks = () => { paintCaptions(); paintDescriptions(); };
725 video.textTracks.addEventListener('addtrack', onTracks);
726 video.textTracks.addEventListener('change', onTracks);
727 }
728 document.addEventListener('fullscreenchange', paintFull);
729 document.addEventListener('webkitfullscreenchange', paintFull);
730
731 paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); applyDescriptionsPreference(); paintSpeed(); paintFull(); paintAudioOnly();
732 if (!video.paused) root.classList.remove('is-start');
733 return api;
734 }
735
736 function enhanceAll(scope) {
737 (scope || document).querySelectorAll('video[data-ov-player]').forEach((v) => enhance(v));
738 warmCards(scope || document);
739 }
740
741 window.OpenVibePlayer = { enhance, enhanceAll, format: fmt, playbackPolicy, attachHls, warmHls, warmCards };
742 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhanceAll());
743 else enhanceAll();
744})();
745