openvibers/openvibe.shared player.js
player.js
| 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 | ; |
| 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 |