openvibers/openvibe.shared Commits
Player: tap for sound, volume keys, layered loading, ambient glow, new icons (#107)
Changes
@@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.13.0 — unreleased++++- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up and++ down button on mobile will actually unmute… those little color sample type s*** so that it loads that then the++ thumbnail… then the actual video… that background effect… the mute button looks a little weird".++ - **Sound:**++ - A video playing muted (autoplay) shows "Tap for sound", and the first tap on it unmutes.++ - The media volume keys unmute and set the level wherever a browser passes them to the page (`AudioVolumeUp`++ and the rest; phones keep their hardware buttons to themselves).++ - The viewer's own mute is remembered; a page's autoplay mute is not.++ - The Media Session API gives lock screens and headsets play, pause and seek.++ - **Loading in layers:**++ - `data-ov-color` paints the average colour at once, and `data-ov-thumb` (a tiny inline image) shows blurred.++ - The poster sharpens over them, then the first frame fades in.++ - Without `data-ov-color`, the poster's average colour is used when the browser allows it.++ - **Ambient glow:** `data-ov-ambient` adds a glow of the picture around the player, as in YouTube's ambient mode: a++ 32×18 sample every 0.7 s while the player plays and is on screen, blurred and crossfaded. It is off for reduced++ motion and data saver, and softer in light themes.++ - **Controls:**++ - One rounded, filled icon set (Material Icons Round). The mute icon was a crude cross.++ - Buttons press in; the big play button has a soft ring.++ - A short sign shows a seek (±10 s, on its side) or a volume level.++ - Touch screens drop the volume slider and keep the mute button; phones get 40 px targets.++## 4.12.0 — 2026-10-25- **Avatars show the picture the account has now.** The owner, 2026-10-25: their picture showed on Live but the@@@ -12,7 +12,7 @@display: block;width: 100%;height: 100%;-- background: #000;++ background: var(--ovp-bg, #000);color: var(--ovp-fg);border-radius: inherit;overflow: hidden;@@@ -22,7 +22,23 @@isolation: isolate;}.ov-player:focus-visible { box-shadow: 0 0 0 2px var(--ovp-accent) inset; }--.ov-player .ov-player-video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }++.ov-player .ov-player-video { position: relative; z-index: 1; display: block; width: 100%; height: 100%; object-fit: contain; background: transparent; opacity: 0; transition: opacity 0.35s ease; }++/* Loading in layers (player.js): the average colour (--ovp-bg), a tiny preview blurred, the poster, then the first frame. */++.ov-player.has-frame .ov-player-video, .ov-player:not(.has-thumb):not(.has-poster) .ov-player-video { opacity: 1; }++.ov-player-placeholder, .ov-player-poster { position: absolute; inset: 0; z-index: 0; background: center / contain no-repeat; pointer-events: none; }++.ov-player-placeholder { background-size: cover; filter: blur(18px) saturate(1.2); transform: scale(1.12); opacity: 0.9; }++.ov-player-poster { opacity: 0; transition: opacity 0.35s ease; }++.ov-player.has-poster .ov-player-poster { opacity: 1; }++.ov-player.has-frame .ov-player-placeholder, .ov-player.has-poster .ov-player-placeholder { opacity: 0; transition: opacity 0.5s ease; }++++/* Ambient glow (data-ov-ambient): the picture, blurred, behind and around the player. */++.ov-player-host { position: relative; isolation: isolate; }++.ov-player-host > .ov-player { z-index: 1; }++.ov-player-ambient { position: absolute; inset: -4% -3%; z-index: 0; pointer-events: none; opacity: 0; transition: opacity 0.8s ease; transform: translateZ(0); }++.ov-player-ambient.is-on { opacity: 0.62; }++:root[data-theme-mode="light"] .ov-player-ambient.is-on { opacity: 0.38; }++.ov-player-ambient canvas { position: absolute; inset: 0; width: 100%; height: 100%; filter: blur(48px) saturate(1.5); opacity: 0; transition: opacity 1.1s ease; border-radius: 24px; }++.ov-player-ambient canvas.is-front { opacity: 1; }.ov-player.is-idle { cursor: none; }/* The bar: a soft gradient from the bottom; hidden while playing and idle. */@@@ -72,6 +88,9 @@opacity: 0.92; transition: background 0.15s, opacity 0.15s;}.ov-player-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.14); }++.ov-player-btn:active { transform: scale(0.9); }++.ov-player-btn { transition: background 0.15s, opacity 0.15s, transform 0.08s ease; }++.ov-player-btn svg { width: 24px; height: 24px; }.ov-player-btn svg { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6)); }.ov-player-btn:focus-visible { outline: 2px solid var(--ovp-accent); outline-offset: 1px; opacity: 1; }.ov-player-btn[hidden] { display: none; }@@@ -104,15 +123,43 @@}.ov-player-volume-slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 0; }++/* Playing muted (autoplay): one tap brings the sound in. */++.ov-player-unmute {++ position: absolute; left: 12px; top: 12px; z-index: 5;++ display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 10px;++ border: 0; border-radius: 999px; background: rgba(0, 0, 0, 0.66); color: #fff;++ font: 650 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; cursor: pointer;++ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);++ animation: ov-player-pop 0.3s ease-out both;++}++.ov-player-unmute svg { width: 18px; height: 18px; }++.ov-player-unmute:hover, .ov-player-unmute:focus-visible { background: var(--ovp-accent); outline: none; }++.ov-player-unmute[hidden] { display: none; }++@keyframes ov-player-pop { from { opacity: 0; transform: translateY(-4px) scale(0.96); } }++++/* A seek or a volume level, shown for a moment (double tap, keys). */++.ov-player-flash {++ position: absolute; left: 50%; top: 50%; z-index: 4; transform: translate(-50%, -50%) scale(0.9);++ display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 16px; min-width: 76px;++ border-radius: 18px; background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 13px; font-variant-numeric: tabular-nums;++ opacity: 0; pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease;++}++.ov-player-flash.is-left { left: 22%; }++.ov-player-flash.is-right { left: 78%; }++.ov-player-flash.is-on { opacity: 1; transform: translate(-50%, -50%) scale(1); }++.ov-player-flash svg { width: 30px; height: 30px; }++/* Paused before the first play, or ended: one large button in the middle. */.ov-player-big {position: absolute; left: 50%; top: 50%; z-index: 4; /* above the bar on small players */width: 68px; height: 68px; border-radius: 50%;transform: translate(-50%, -50%);background: color-mix(in srgb, var(--ovp-accent) 88%, transparent);-- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);++ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45), 0 0 0 6px color-mix(in srgb, var(--ovp-accent) 22%, transparent);opacity: 1;++ transition: transform 0.15s ease, background 0.15s ease, opacity 0.2s ease;}++.ov-player-big:active { transform: translate(-50%, -50%) scale(0.94); }.ov-player-big svg { width: 32px; height: 32px; }.ov-player-big:hover { background: var(--ovp-accent); transform: translate(-50%, -50%) scale(1.06); }.ov-player:not(.is-start):not(.is-ended) .ov-player-big { opacity: 0; pointer-events: none; }@@@ -126,7 +173,9 @@}.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }@keyframes ov-player-spin { to { transform: rotate(360deg); } }--@media (prefers-reduced-motion: reduce) { .ov-player-spinner { animation-duration: 2.4s; } .ov-player-bar { transition: none; } }++@media (prefers-reduced-motion: reduce) { .ov-player-spinner { animation-duration: 2.4s; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }++/* Touch screens set the volume with the device: the mute button stays, the slider does not. */++@media (hover: none) { .ov-player-volume-slider { display: none; } }.ov-player-message:empty { display: none; }.ov-player-message {@@@ -140,6 +189,8 @@/* Small players and phones: fewer, larger targets. */@media (max-width: 520px) {.ov-player-speed, .ov-player-pip { display: none; }++ .ov-player-btn { min-width: 40px; height: 40px; }++ .ov-player-unmute { left: 8px; top: 8px; padding: 7px 12px 7px 9px; }.ov-player-time { padding: 0 4px; font-size: 12px; }.ov-player-big { width: 60px; height: 60px; }}@@@ -12,6 +12,15 @@* player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions, 0–9 jump to that* tenth. On touch a tap shows the controls and a double tap on either side seeks ±10 s.*++ * Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media++ * volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to++ * themselves, so no page can see those). Lock screens and headsets get play/pause/seek through the Media Session API.++ *++ * Loading looks like it is already there (owner, 2026-10-25): data-ov-color paints the average colour at once,++ * data-ov-thumb (a tiny inline image) shows blurred, the poster sharpens over it, and the first frame fades in.++ * data-ov-ambient adds a soft glow of the picture around the player (YouTube's ambient mode: a low-resolution sample++ * every 0.7 s, blurred and crossfaded; off for reduced motion and data saver).++ ** It only drives the media element (play, currentTime, volume, …) and listens to its events, so a page's own script* (HLS attachment, chapters, next-up, view counts) keeps working on the same element. OpenVibePlayer.enhance(video)* for a video added later; enhance() is idempotent.@@@ -21,17 +30,20 @@if (typeof window === 'undefined' || window.OpenVibePlayer) return;const SVG = (d) => `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path fill="currentColor" d="${d}"/></svg>`;++ // Material Icons Round (Apache-2.0): one filled, rounded set, so every control reads at the same weight.const ICON = {-- play: SVG('M8 5.14v13.72a1 1 0 0 0 1.52.85l10.9-6.86a1 1 0 0 0 0-1.7L9.52 4.29A1 1 0 0 0 8 5.14z'),-- pause: SVG('M7 5h3.5v14H7zM13.5 5H17v14h-3.5z'),-- replay: SVG('M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z'),-- volume: SVG('M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z'),-- low: SVG('M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12z'),-- muted: SVG('M4 9v6h4l5 4V5L8 9H4zm12.6 3 2.2-2.2-1.4-1.4-2.2 2.2-2.2-2.2-1.4 1.4 2.2 2.2-2.2 2.2 1.4 1.4 2.2-2.2 2.2 2.2 1.4-1.4z'),-- 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 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 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 1v1z'),-- pip: SVG('M19 11h-8v6h8v-6zm4 8V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 .02H3V4.97h18v14.05z'),-- full: SVG('M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z'),-- exit: SVG('M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),++ 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'),};const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];const VOLUME_KEY = 'ov-player-volume';@@@ -62,6 +74,92 @@const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;++ // The media volume keys, where the browser passes them to the page (UI Events key names, and the older ones).++ const VOLUME_KEYS = /^(AudioVolume(Up|Down|Mute)|Volume(Up|Down|Mute))$/;++ let current = null; // the player last played or touched: the one the page-wide keys drive++ function volumeKey(api, k) {++ const v = api.video;++ if (/Mute$/.test(k)) api.toggleMute();++ else if (/Up$/.test(k)) { if (v.muted) api.unmute(); else api.setVolume(v.volume + 0.1); }++ else api.setVolume(v.volume - 0.1);++ }++ document.addEventListener('keydown', (e) => {++ if (!current || !VOLUME_KEYS.test(e.key) || current.root.contains(e.target)) return;++ const t = e.target && e.target.tagName;++ if (t === 'INPUT' || t === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;++ volumeKey(current, e.key);++ });++++ /** An image, asked for with CORS so its pixels can be read; without CORS it still loads, only unread. */++ function loadImage(src, done) {++ if (typeof Image !== 'function') return;++ const img = new Image();++ img.decoding = 'async';++ img.crossOrigin = 'anonymous';++ img.onload = () => done(img);++ img.onerror = () => { if (!img.crossOrigin) return; img.crossOrigin = null; img.removeAttribute('crossorigin'); img.src = src; };++ img.src = src;++ }++++ /** The average colour of a loaded image (a preview or a poster), when the browser lets the page read it. */++ function averageColor(img, done) {++ try {++ const c = document.createElement('canvas'); c.width = 8; c.height = 8;++ const g = c.getContext('2d');++ g.drawImage(img, 0, 0, 8, 8);++ const d = g.getImageData(0, 0, 8, 8).data;++ let r = 0, gr = 0, b = 0;++ for (let i = 0; i < d.length; i += 4) { r += d[i]; gr += d[i + 1]; b += d[i + 2]; }++ const n = d.length / 4;++ done(`rgb(${Math.round(r / n)}, ${Math.round(gr / n)}, ${Math.round(b / n)})`);++ } catch { /* a poster from another origin without CORS: the default stays */ }++ }++++ /**++ * The ambient glow: two small canvases behind the player, painted from the video every 0.7 s while it plays and is++ * on screen, blurred and crossfaded by player.css. A cross-origin video taints a canvas, which can still be shown.++ */++ function glow(root, video) {++ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;++ const saver = typeof navigator !== 'undefined' && navigator.connection && navigator.connection.saveData;++ const probe = document.createElement('canvas');++ if (reduce || saver || typeof probe.getContext !== 'function' || !probe.getContext('2d')) return null;++ const wrap = el('div', 'ov-player-ambient', { 'aria-hidden': 'true' });++ const a = el('canvas'), b = el('canvas');++ [a, b].forEach((c) => { c.width = 32; c.height = 18; });++ wrap.append(a, b);++ root.parentNode.insertBefore(wrap, root);++ root.parentNode.classList.add('ov-player-host');++ let front = a, timer = 0, visible = true;++ const paint = (src) => {++ const back = front === a ? b : a;++ try { back.getContext('2d').drawImage(src, 0, 0, back.width, back.height); } catch { return; }++ back.classList.add('is-front'); front.classList.remove('is-front'); front = back;++ };++ if (typeof IntersectionObserver === 'function') new IntersectionObserver((es) => { visible = es[0].isIntersecting; }).observe(root);++ return {++ still(img) { paint(img); wrap.classList.add('is-on'); },++ start() { clearInterval(timer); wrap.classList.add('is-on'); timer = setInterval(() => { if (visible && !document.hidden && video.readyState >= 2) paint(video); }, 700); },++ stop() { clearInterval(timer); },++ };++ }++++ /** Lock screen, headset and notification controls (Media Session) for the player that is playing. */++ function session(api, options) {++ const ms = typeof navigator !== 'undefined' && navigator.mediaSession;++ if (!ms) return;++ try {++ const v = api.video;++ const art = v.getAttribute('poster');++ 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 }] : [] });++ ms.setActionHandler('play', () => api.toggle());++ ms.setActionHandler('pause', () => api.toggle());++ ms.setActionHandler('seekbackward', () => api.skip(-10));++ ms.setActionHandler('seekforward', () => api.skip(10));++ ms.setActionHandler('seekto', (d) => { if (d && Number.isFinite(d.seekTime)) api.seek(d.seekTime); });++ } catch { /* an action this browser does not know */ }++ }++function enhance(video, options = {}) {if (!video || video.__ovPlayer) return video && video.__ovPlayer;const idleMs = options.idleMs || 2500;@@@ -71,6 +169,22 @@video.controls = false;video.removeAttribute('controls');video.classList.add('ov-player-video');++ // Loading: the average colour now, a tiny blurred preview, then the poster, then the first frame.++ const color = video.getAttribute('data-ov-color');++ if (/^#[0-9a-f]{3,8}$/i.test(color || '')) root.style.setProperty('--ovp-bg', color);++ const thumb = video.getAttribute('data-ov-thumb');++ const placeholder = el('div', 'ov-player-placeholder', { 'aria-hidden': 'true' });++ if (/^(data:image\/|https:\/\/)/.test(thumb || '')) {++ placeholder.style.backgroundImage = `url("${thumb.replace(/"/g, '%22')}")`;++ root.classList.add('has-thumb');++ // No colour given: the preview's average, which arrives first (a few hundred bytes).++ if (!/^#[0-9a-f]{3,8}$/i.test(color || '')) loadImage(thumb, (img) => averageColor(img, (c) => root.style.setProperty('--ovp-bg', c)));++ }++ const posterLayer = el('div', 'ov-player-poster', { 'aria-hidden': 'true' });++ root.insertBefore(placeholder, video);++ root.insertBefore(posterLayer, video);++ const shown = () => root.classList.add('has-frame');++ if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });// ── Build ────────────────────────────────────────────const big = button('ov-player-big', 'Play', ICON.play);@@@ -99,7 +213,18 @@const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);row.append(playBtn, volWrap, time, spacer, ccBtn, speedBtn, pipBtn, fullBtn);bar.append(scrub, row);-- root.append(spinner, big, message, bar);++ const unmute = el('button', 'ov-player-unmute', { type: 'button', hidden: '' });++ unmute.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;++ const flash = el('div', 'ov-player-flash', { 'aria-hidden': 'true' });++ root.append(spinner, big, message, unmute, flash, bar);++ const ambient = video.hasAttribute('data-ov-ambient') || options.ambient ? glow(root, video) : null;++ const poster = video.getAttribute('poster');++ if (poster) loadImage(poster, (img) => {++ posterLayer.style.backgroundImage = `url("${poster.replace(/"/g, '%22')}")`;++ root.classList.add('has-poster');++ if (!color && !thumb) averageColor(img, (c) => root.style.setProperty('--ovp-bg', c));++ if (ambient) ambient.still(img);++ });const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;if (!pipOk) pipBtn.hidden = true;@@@ -108,6 +233,8 @@const saved = stored();if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }++ // Muted by the page (autoplay) rather than by the viewer: the first tap or a volume key brings the sound in.++ let pageMuted = video.muted && (video.hasAttribute('muted') || video.autoplay || !saved || !saved.muted);// ── State → UI ───────────────────────────────────────const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);@@@ -139,6 +266,8 @@}function paintVolume() {const v = video.muted ? 0 : video.volume;++ unmute.hidden = !(video.muted && pageMuted && !video.paused);++ root.classList.toggle('is-muted', video.muted || v === 0);muteBtn.innerHTML = v === 0 ? ICON.muted : v < 0.5 ? ICON.low : ICON.volume;const label = video.muted || v === 0 ? 'Unmute (m)' : 'Mute (m)';muteBtn.setAttribute('aria-label', label); muteBtn.title = label;@@@ -180,9 +309,11 @@if (video.paused || video.ended) { const p = video.play(); if (p && p.catch) p.catch(() => paintPlay()); } else video.pause();},seek(t) { const d = duration(); if (!d) return; video.currentTime = Math.min(d, Math.max(0, t)); paintTime(); },-- skip(s) { api.seek((video.currentTime || 0) + s); },-- setVolume(v) { video.volume = Math.min(1, Math.max(0, v)); video.muted = video.volume === 0; store(video); },-- toggleMute() { if (video.muted && video.volume === 0) video.volume = 0.5; video.muted = !video.muted; store(video); },++ skip(s) { api.seek((video.currentTime || 0) + s); show(s < 0 ? ICON.back : ICON.fwd, `${s > 0 ? '+' : ''}${s} s`, s < 0 ? 'left' : 'right'); },++ 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)}%`); },++ toggleMute() { pageMuted = false; if (video.muted && video.volume === 0) video.volume = 0.5; video.muted = !video.muted; store(video); },++ /** Sound on, at the remembered level (full when that was zero). */++ unmute() { pageMuted = false; if (video.volume === 0) video.volume = 1; video.muted = false; store(video); show(ICON.volume, `${Math.round(video.volume * 100)}%`); },toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },togglePip() {@@@ -198,6 +329,15 @@};video.__ovPlayer = api;++ // A short sign in the middle: a seek, a volume level (the double tap and the keys).++ let flashTimer = 0;++ function show(icon, text, side) {++ flash.className = `ov-player-flash is-on${side ? ` is-${side}` : ''}`;++ flash.innerHTML = `${icon}<span>${text}</span>`;++ clearTimeout(flashTimer);++ flashTimer = setTimeout(() => { flash.className = 'ov-player-flash'; }, 650);++ }++ unmute.addEventListener('click', (e) => { e.stopPropagation(); api.unmute(); });big.addEventListener('click', () => api.toggle());playBtn.addEventListener('click', () => api.toggle());muteBtn.addEventListener('click', () => api.toggleMute());@@@ -246,10 +386,13 @@return;}lastTap = now;++ if (video.muted && pageMuted && !video.paused) { api.unmute(); wake(); return; }if (root.classList.contains('is-idle')) wake(); else if (!video.paused) root.classList.add('is-idle');});video.addEventListener('dblclick', () => api.toggleFullscreen());++ root.addEventListener('pointerdown', () => { current = api; });++ root.addEventListener('focusin', () => { current = api; });root.addEventListener('pointermove', wake);root.addEventListener('focusin', wake);root.addEventListener('keydown', (e) => {@@@ -272,13 +415,14 @@else if (k === 'f' || k === 'F') api.toggleFullscreen();else if (k === 'c' || k === 'C') api.toggleCaptions();else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);++ else if (VOLUME_KEYS.test(k)) volumeKey(api, k);else handled = false;if (handled) { e.preventDefault(); wake(); }});// ── Media events ─────────────────────────────────────-- video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); });-- video.addEventListener('pause', paintPlay);++ video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });++ video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });video.addEventListener('ended', paintPlay);video.addEventListener('timeupdate', paintTime);video.addEventListener('durationchange', paintTime);@@@ -115,3 +115,60 @@ assert.match(css, /\.ov-player:not\(\.is-start\):not\(\.is-ended\) \.ov-player-bconst files = require('../files');assert.ok(files.isBrowserFile('player.js') && files.isBrowserFile('player.css') && files.BROWSER.includes('player.js') && files.STYLES.includes('player.css'));console.log('player: all checks passed');++++// ── 4.13: sound, loading layers, feedback (owner, 2026-10-25) ──++{++ const { window: w2, document: d2 } = parseHTML(`<!doctype html><html><body><div class="host"><video id="a" data-ov-player data-ov-color="#123456" data-ov-thumb="data:image/webp;base64,AAAA" data-ov-ambient poster="/p.jpg" autoplay muted src="/a.mp4"></video></div></body></html>`);++ const v = d2.getElementById('a');++ let p2 = true;++ const m2 = { currentTime: 0, duration: 120, volume: 0.8, muted: true, playbackRate: 1, ended: false, readyState: 0 };++ const f2 = (type) => v.dispatchEvent(new w2.Event(type));++ Object.defineProperties(v, {++ paused: { get: () => p2 }, ended: { get: () => m2.ended }, readyState: { get: () => m2.readyState },++ currentTime: { get: () => m2.currentTime, set: (x) => { m2.currentTime = x; f2('timeupdate'); } },++ duration: { get: () => m2.duration },++ volume: { get: () => m2.volume, set: (x) => { m2.volume = x; f2('volumechange'); } },++ muted: { get: () => m2.muted, set: (x) => { m2.muted = x; f2('volumechange'); } },++ playbackRate: { get: () => m2.playbackRate, set: (x) => { m2.playbackRate = x; f2('ratechange'); } },++ buffered: { get: () => ({ length: 0 }) },++ textTracks: { value: Object.assign([], { addEventListener() {} }) },++ autoplay: { get: () => true },++ });++ v.play = () => { p2 = false; f2('play'); f2('playing'); return Promise.resolve(); };++ v.pause = () => { p2 = true; f2('pause'); };++ const st2 = {};++ const c2 = { window: w2, document: d2, console, Promise, setTimeout, clearTimeout, setInterval, clearInterval, Date, Math, Number, String, JSON, Object, Array,++ localStorage: { getItem: (k) => (k in st2 ? st2[k] : null), setItem: (k, x) => { st2[k] = String(x); } } };++ w2.OpenVibePlayer = undefined;++ vm.createContext(c2);++ vm.runInContext(src, c2);++ const r2 = v.parentNode;++ const q = (sel) => r2.querySelector(sel);++ assert.strictEqual(r2.style.getPropertyValue('--ovp-bg'), '#123456', 'the average colour paints at once');++ assert.ok(q('.ov-player-placeholder').style.backgroundImage.includes('data:image/webp'), 'the tiny preview, blurred under the poster');++ assert.ok(r2.classList.contains('has-thumb') && !r2.classList.contains('has-frame'), 'no frame yet');++ m2.readyState = 2; f2('loadeddata');++ assert.ok(r2.classList.contains('has-frame'), 'the first frame fades in');++ // Autoplaying muted: "Tap for sound", and the pill unmutes at the remembered level.++ v.play();++ assert.strictEqual(q('.ov-player-unmute').hidden, false, '"Tap for sound" while playing muted');++ q('.ov-player-unmute').dispatchEvent(new w2.Event('click'));++ assert.strictEqual(m2.muted, false, 'one tap: sound on');++ assert.strictEqual(q('.ov-player-unmute').hidden, true);++ assert.ok(q('.ov-player-flash').className.includes('is-on'), 'the level shows briefly');++ // A media volume key anywhere on the page drives the player last played.++ m2.muted = true;++ const kd = new w2.Event('keydown', { bubbles: true }); kd.key = 'AudioVolumeUp'; d2.body.dispatchEvent(kd);++ assert.strictEqual(m2.muted, false, 'volume up unmutes a muted video');++ const kd2 = new w2.Event('keydown', { bubbles: true }); kd2.key = 'AudioVolumeDown'; d2.body.dispatchEvent(kd2);++ assert.ok(Math.abs(m2.volume - 0.7) < 1e-9, 'volume down lowers it');++ // Skips say what they did, on their side.++ v.__ovPlayer.skip(10);++ assert.ok(q('.ov-player-flash').className.includes('is-right') && q('.ov-player-flash').textContent.includes('+10 s'));++}++// The styles, and a mute icon from the one rounded set.++assert.match(css, /\.ov-player-unmute \{/);++assert.match(css, /@media \(hover: none\) \{ \.ov-player-volume-slider \{ display: none; \} \}/, 'no hidden slider on touch screens');++assert.match(css, /\.ov-player\.has-frame \.ov-player-video/);++assert.match(src, /Material Icons Round/);++console.log('player: all checks passed (4.13 sound, layers, feedback)');
@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,30 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.13.0 — unreleased
+
+- **The player sounds, loads and looks better** (`player.js`, `player.css`). The owner, 2026-10-25: "the volume up and
+ down button on mobile will actually unmute… those little color sample type s*** so that it loads that then the
+ thumbnail… then the actual video… that background effect… the mute button looks a little weird".
+ - **Sound:**
+ - A video playing muted (autoplay) shows "Tap for sound", and the first tap on it unmutes.
+ - The media volume keys unmute and set the level wherever a browser passes them to the page (`AudioVolumeUp`
+ and the rest; phones keep their hardware buttons to themselves).
+ - The viewer's own mute is remembered; a page's autoplay mute is not.
+ - The Media Session API gives lock screens and headsets play, pause and seek.
+ - **Loading in layers:**
+ - `data-ov-color` paints the average colour at once, and `data-ov-thumb` (a tiny inline image) shows blurred.
+ - The poster sharpens over them, then the first frame fades in.
+ - Without `data-ov-color`, the poster's average colour is used when the browser allows it.
+ - **Ambient glow:** `data-ov-ambient` adds a glow of the picture around the player, as in YouTube's ambient mode: a
+ 32×18 sample every 0.7 s while the player plays and is on screen, blurred and crossfaded. It is off for reduced
+ motion and data saver, and softer in light themes.
+ - **Controls:**
+ - One rounded, filled icon set (Material Icons Round). The mute icon was a crude cross.
+ - Buttons press in; the big play button has a soft ring.
+ - A short sign shows a seek (±10 s, on its side) or a volume level.
+ - Touch screens drop the volume slider and keep the mute button; phones get 40 px targets.
+
## 4.12.0 — 2026-10-25
## 4.12.0 — 2026-10-25
- **Avatars show the picture the account has now.** The owner, 2026-10-25: their picture showed on Live but the
- **Avatars show the picture the account has now.** The owner, 2026-10-25: their picture showed on Live but the
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
display: block;
display: block;
width: 100%;
width: 100%;
height: 100%;
height: 100%;
- background: #000;
+ background: var(--ovp-bg, #000);
color: var(--ovp-fg);
color: var(--ovp-fg);
border-radius: inherit;
border-radius: inherit;
overflow: hidden;
overflow: hidden;
@@ -22,7 +22,23 @@
@@ -22,7 +22,23 @@
isolation: isolate;
isolation: isolate;
}
}
.ov-player:focus-visible { box-shadow: 0 0 0 2px var(--ovp-accent) inset; }
.ov-player:focus-visible { box-shadow: 0 0 0 2px var(--ovp-accent) inset; }
-.ov-player .ov-player-video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
+.ov-player .ov-player-video { position: relative; z-index: 1; display: block; width: 100%; height: 100%; object-fit: contain; background: transparent; opacity: 0; transition: opacity 0.35s ease; }
+/* Loading in layers (player.js): the average colour (--ovp-bg), a tiny preview blurred, the poster, then the first frame. */
+.ov-player.has-frame .ov-player-video, .ov-player:not(.has-thumb):not(.has-poster) .ov-player-video { opacity: 1; }
+.ov-player-placeholder, .ov-player-poster { position: absolute; inset: 0; z-index: 0; background: center / contain no-repeat; pointer-events: none; }
+.ov-player-placeholder { background-size: cover; filter: blur(18px) saturate(1.2); transform: scale(1.12); opacity: 0.9; }
+.ov-player-poster { opacity: 0; transition: opacity 0.35s ease; }
+.ov-player.has-poster .ov-player-poster { opacity: 1; }
+.ov-player.has-frame .ov-player-placeholder, .ov-player.has-poster .ov-player-placeholder { opacity: 0; transition: opacity 0.5s ease; }
+
+/* Ambient glow (data-ov-ambient): the picture, blurred, behind and around the player. */
+.ov-player-host { position: relative; isolation: isolate; }
+.ov-player-host > .ov-player { z-index: 1; }
+.ov-player-ambient { position: absolute; inset: -4% -3%; z-index: 0; pointer-events: none; opacity: 0; transition: opacity 0.8s ease; transform: translateZ(0); }
+.ov-player-ambient.is-on { opacity: 0.62; }
+:root[data-theme-mode="light"] .ov-player-ambient.is-on { opacity: 0.38; }
+.ov-player-ambient canvas { position: absolute; inset: 0; width: 100%; height: 100%; filter: blur(48px) saturate(1.5); opacity: 0; transition: opacity 1.1s ease; border-radius: 24px; }
+.ov-player-ambient canvas.is-front { opacity: 1; }
.ov-player.is-idle { cursor: none; }
.ov-player.is-idle { cursor: none; }
/* The bar: a soft gradient from the bottom; hidden while playing and idle. */
/* The bar: a soft gradient from the bottom; hidden while playing and idle. */
@@ -72,6 +88,9 @@
@@ -72,6 +88,9 @@
opacity: 0.92; transition: background 0.15s, opacity 0.15s;
opacity: 0.92; transition: background 0.15s, opacity 0.15s;
}
}
.ov-player-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.14); }
.ov-player-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.14); }
+.ov-player-btn:active { transform: scale(0.9); }
+.ov-player-btn { transition: background 0.15s, opacity 0.15s, transform 0.08s ease; }
+.ov-player-btn svg { width: 24px; height: 24px; }
.ov-player-btn svg { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6)); }
.ov-player-btn svg { filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.6)); }
.ov-player-btn:focus-visible { outline: 2px solid var(--ovp-accent); outline-offset: 1px; opacity: 1; }
.ov-player-btn:focus-visible { outline: 2px solid var(--ovp-accent); outline-offset: 1px; opacity: 1; }
.ov-player-btn[hidden] { display: none; }
.ov-player-btn[hidden] { display: none; }
@@ -104,15 +123,43 @@
@@ -104,15 +123,43 @@
}
}
.ov-player-volume-slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 0; }
.ov-player-volume-slider::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; border: 0; }
+/* Playing muted (autoplay): one tap brings the sound in. */
+.ov-player-unmute {
+ position: absolute; left: 12px; top: 12px; z-index: 5;
+ display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 10px;
+ border: 0; border-radius: 999px; background: rgba(0, 0, 0, 0.66); color: #fff;
+ font: 650 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; cursor: pointer;
+ -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
+ animation: ov-player-pop 0.3s ease-out both;
+}
+.ov-player-unmute svg { width: 18px; height: 18px; }
+.ov-player-unmute:hover, .ov-player-unmute:focus-visible { background: var(--ovp-accent); outline: none; }
+.ov-player-unmute[hidden] { display: none; }
+@keyframes ov-player-pop { from { opacity: 0; transform: translateY(-4px) scale(0.96); } }
+
+/* A seek or a volume level, shown for a moment (double tap, keys). */
+.ov-player-flash {
+ position: absolute; left: 50%; top: 50%; z-index: 4; transform: translate(-50%, -50%) scale(0.9);
+ display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 16px; min-width: 76px;
+ border-radius: 18px; background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 13px; font-variant-numeric: tabular-nums;
+ opacity: 0; pointer-events: none; transition: opacity 0.18s ease, transform 0.18s ease;
+}
+.ov-player-flash.is-left { left: 22%; }
+.ov-player-flash.is-right { left: 78%; }
+.ov-player-flash.is-on { opacity: 1; transform: translate(-50%, -50%) scale(1); }
+.ov-player-flash svg { width: 30px; height: 30px; }
+
/* Paused before the first play, or ended: one large button in the middle. */
/* Paused before the first play, or ended: one large button in the middle. */
.ov-player-big {
.ov-player-big {
position: absolute; left: 50%; top: 50%; z-index: 4; /* above the bar on small players */
position: absolute; left: 50%; top: 50%; z-index: 4; /* above the bar on small players */
width: 68px; height: 68px; border-radius: 50%;
width: 68px; height: 68px; border-radius: 50%;
transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
background: color-mix(in srgb, var(--ovp-accent) 88%, transparent);
background: color-mix(in srgb, var(--ovp-accent) 88%, transparent);
- box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
+ box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45), 0 0 0 6px color-mix(in srgb, var(--ovp-accent) 22%, transparent);
opacity: 1;
opacity: 1;
+ transition: transform 0.15s ease, background 0.15s ease, opacity 0.2s ease;
}
}
+.ov-player-big:active { transform: translate(-50%, -50%) scale(0.94); }
.ov-player-big svg { width: 32px; height: 32px; }
.ov-player-big svg { width: 32px; height: 32px; }
.ov-player-big:hover { background: var(--ovp-accent); transform: translate(-50%, -50%) scale(1.06); }
.ov-player-big:hover { background: var(--ovp-accent); transform: translate(-50%, -50%) scale(1.06); }
.ov-player:not(.is-start):not(.is-ended) .ov-player-big { opacity: 0; pointer-events: none; }
.ov-player:not(.is-start):not(.is-ended) .ov-player-big { opacity: 0; pointer-events: none; }
@@ -126,7 +173,9 @@
@@ -126,7 +173,9 @@
}
}
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
.ov-player.is-waiting:not(.is-paused) .ov-player-spinner { display: block; }
@keyframes ov-player-spin { to { transform: rotate(360deg); } }
@keyframes ov-player-spin { to { transform: rotate(360deg); } }
-@media (prefers-reduced-motion: reduce) { .ov-player-spinner { animation-duration: 2.4s; } .ov-player-bar { transition: none; } }
+@media (prefers-reduced-motion: reduce) { .ov-player-spinner { animation-duration: 2.4s; } .ov-player-bar, .ov-player-video, .ov-player-poster, .ov-player-flash { transition: none; } .ov-player-unmute { animation: none; } }
+/* Touch screens set the volume with the device: the mute button stays, the slider does not. */
+@media (hover: none) { .ov-player-volume-slider { display: none; } }
.ov-player-message:empty { display: none; }
.ov-player-message:empty { display: none; }
.ov-player-message {
.ov-player-message {
@@ -140,6 +189,8 @@
@@ -140,6 +189,8 @@
/* Small players and phones: fewer, larger targets. */
/* Small players and phones: fewer, larger targets. */
@media (max-width: 520px) {
@media (max-width: 520px) {
.ov-player-speed, .ov-player-pip { display: none; }
.ov-player-speed, .ov-player-pip { display: none; }
+ .ov-player-btn { min-width: 40px; height: 40px; }
+ .ov-player-unmute { left: 8px; top: 8px; padding: 7px 12px 7px 9px; }
.ov-player-time { padding: 0 4px; font-size: 12px; }
.ov-player-time { padding: 0 4px; font-size: 12px; }
.ov-player-big { width: 60px; height: 60px; }
.ov-player-big { width: 60px; height: 60px; }
}
}
@@ -12,6 +12,15 @@
@@ -12,6 +12,15 @@
* player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions, 0–9 jump to that
* player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions, 0–9 jump to that
* tenth. On touch a tap shows the controls and a double tap on either side seeks ±10 s.
* tenth. On touch a tap shows the controls and a double tap on either side seeks ±10 s.
*
*
+ * Sound: a video playing muted (autoplay) shows "Tap for sound"; the first tap on it unmutes, and so do the media
+ * volume keys where a browser passes them to the page (desktop keyboards, TVs; phones keep their hardware buttons to
+ * themselves, so no page can see those). Lock screens and headsets get play/pause/seek through the Media Session API.
+ *
+ * Loading looks like it is already there (owner, 2026-10-25): data-ov-color paints the average colour at once,
+ * data-ov-thumb (a tiny inline image) shows blurred, the poster sharpens over it, and the first frame fades in.
+ * data-ov-ambient adds a soft glow of the picture around the player (YouTube's ambient mode: a low-resolution sample
+ * every 0.7 s, blurred and crossfaded; off for reduced motion and data saver).
+ *
* It only drives the media element (play, currentTime, volume, …) and listens to its events, so a page's own script
* It only drives the media element (play, currentTime, volume, …) and listens to its events, so a page's own script
* (HLS attachment, chapters, next-up, view counts) keeps working on the same element. OpenVibePlayer.enhance(video)
* (HLS attachment, chapters, next-up, view counts) keeps working on the same element. OpenVibePlayer.enhance(video)
* for a video added later; enhance() is idempotent.
* for a video added later; enhance() is idempotent.
@@ -21,17 +30,20 @@
@@ -21,17 +30,20 @@
if (typeof window === 'undefined' || window.OpenVibePlayer) return;
if (typeof window === 'undefined' || window.OpenVibePlayer) return;
const SVG = (d) => `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path fill="currentColor" d="${d}"/></svg>`;
const SVG = (d) => `<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" focusable="false"><path fill="currentColor" d="${d}"/></svg>`;
+ // Material Icons Round (Apache-2.0): one filled, rounded set, so every control reads at the same weight.
const ICON = {
const ICON = {
- play: SVG('M8 5.14v13.72a1 1 0 0 0 1.52.85l10.9-6.86a1 1 0 0 0 0-1.7L9.52 4.29A1 1 0 0 0 8 5.14z'),
- pause: SVG('M7 5h3.5v14H7zM13.5 5H17v14h-3.5z'),
- replay: SVG('M12 5V2L7 6l5 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z'),
- volume: SVG('M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12zM14 3.23v2.06a7 7 0 0 1 0 13.42v2.06a9 9 0 0 0 0-17.54z'),
- low: SVG('M4 9v6h4l5 4V5L8 9H4zm12.5 3a4.5 4.5 0 0 0-2.5-4.03v8.05A4.5 4.5 0 0 0 16.5 12z'),
- muted: SVG('M4 9v6h4l5 4V5L8 9H4zm12.6 3 2.2-2.2-1.4-1.4-2.2 2.2-2.2-2.2-1.4 1.4 2.2 2.2-2.2 2.2 1.4 1.4 2.2-2.2 2.2 2.2 1.4-1.4z'),
- 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 7H9.5v-.5h-2v3h2V13H11v1a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1zm7 0h-1.5v-.5h-2v3h2V13H18v1a1 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 1v1z'),
- pip: SVG('M19 11h-8v6h8v-6zm4 8V5a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2zm-2 .02H3V4.97h18v14.05z'),
- full: SVG('M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z'),
- exit: SVG('M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
+ 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'),
};
};
const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
const VOLUME_KEY = 'ov-player-volume';
const VOLUME_KEY = 'ov-player-volume';
@@ -62,6 +74,92 @@
@@ -62,6 +74,92 @@
const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
+ // The media volume keys, where the browser passes them to the page (UI Events key names, and the older ones).
+ const VOLUME_KEYS = /^(AudioVolume(Up|Down|Mute)|Volume(Up|Down|Mute))$/;
+ let current = null; // the player last played or touched: the one the page-wide keys drive
+ function volumeKey(api, k) {
+ const v = api.video;
+ if (/Mute$/.test(k)) api.toggleMute();
+ else if (/Up$/.test(k)) { if (v.muted) api.unmute(); else api.setVolume(v.volume + 0.1); }
+ else api.setVolume(v.volume - 0.1);
+ }
+ document.addEventListener('keydown', (e) => {
+ if (!current || !VOLUME_KEYS.test(e.key) || current.root.contains(e.target)) return;
+ const t = e.target && e.target.tagName;
+ if (t === 'INPUT' || t === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return;
+ volumeKey(current, e.key);
+ });
+
+ /** An image, asked for with CORS so its pixels can be read; without CORS it still loads, only unread. */
+ function loadImage(src, done) {
+ if (typeof Image !== 'function') return;
+ const img = new Image();
+ img.decoding = 'async';
+ img.crossOrigin = 'anonymous';
+ img.onload = () => done(img);
+ img.onerror = () => { if (!img.crossOrigin) return; img.crossOrigin = null; img.removeAttribute('crossorigin'); img.src = src; };
+ img.src = src;
+ }
+
+ /** The average colour of a loaded image (a preview or a poster), when the browser lets the page read it. */
+ function averageColor(img, done) {
+ try {
+ const c = document.createElement('canvas'); c.width = 8; c.height = 8;
+ const g = c.getContext('2d');
+ g.drawImage(img, 0, 0, 8, 8);
+ const d = g.getImageData(0, 0, 8, 8).data;
+ let r = 0, gr = 0, b = 0;
+ for (let i = 0; i < d.length; i += 4) { r += d[i]; gr += d[i + 1]; b += d[i + 2]; }
+ const n = d.length / 4;
+ done(`rgb(${Math.round(r / n)}, ${Math.round(gr / n)}, ${Math.round(b / n)})`);
+ } catch { /* a poster from another origin without CORS: the default stays */ }
+ }
+
+ /**
+ * The ambient glow: two small canvases behind the player, painted from the video every 0.7 s while it plays and is
+ * on screen, blurred and crossfaded by player.css. A cross-origin video taints a canvas, which can still be shown.
+ */
+ function glow(root, video) {
+ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const saver = typeof navigator !== 'undefined' && navigator.connection && navigator.connection.saveData;
+ const probe = document.createElement('canvas');
+ if (reduce || saver || typeof probe.getContext !== 'function' || !probe.getContext('2d')) return null;
+ const wrap = el('div', 'ov-player-ambient', { 'aria-hidden': 'true' });
+ const a = el('canvas'), b = el('canvas');
+ [a, b].forEach((c) => { c.width = 32; c.height = 18; });
+ wrap.append(a, b);
+ root.parentNode.insertBefore(wrap, root);
+ root.parentNode.classList.add('ov-player-host');
+ let front = a, timer = 0, visible = true;
+ const paint = (src) => {
+ const back = front === a ? b : a;
+ try { back.getContext('2d').drawImage(src, 0, 0, back.width, back.height); } catch { return; }
+ back.classList.add('is-front'); front.classList.remove('is-front'); front = back;
+ };
+ if (typeof IntersectionObserver === 'function') new IntersectionObserver((es) => { visible = es[0].isIntersecting; }).observe(root);
+ return {
+ still(img) { paint(img); wrap.classList.add('is-on'); },
+ start() { clearInterval(timer); wrap.classList.add('is-on'); timer = setInterval(() => { if (visible && !document.hidden && video.readyState >= 2) paint(video); }, 700); },
+ stop() { clearInterval(timer); },
+ };
+ }
+
+ /** Lock screen, headset and notification controls (Media Session) for the player that is playing. */
+ function session(api, options) {
+ const ms = typeof navigator !== 'undefined' && navigator.mediaSession;
+ if (!ms) return;
+ try {
+ const v = api.video;
+ const art = v.getAttribute('poster');
+ 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 }] : [] });
+ ms.setActionHandler('play', () => api.toggle());
+ ms.setActionHandler('pause', () => api.toggle());
+ ms.setActionHandler('seekbackward', () => api.skip(-10));
+ ms.setActionHandler('seekforward', () => api.skip(10));
+ ms.setActionHandler('seekto', (d) => { if (d && Number.isFinite(d.seekTime)) api.seek(d.seekTime); });
+ } catch { /* an action this browser does not know */ }
+ }
+
function enhance(video, options = {}) {
function enhance(video, options = {}) {
if (!video || video.__ovPlayer) return video && video.__ovPlayer;
if (!video || video.__ovPlayer) return video && video.__ovPlayer;
const idleMs = options.idleMs || 2500;
const idleMs = options.idleMs || 2500;
@@ -71,6 +169,22 @@
@@ -71,6 +169,22 @@
video.controls = false;
video.controls = false;
video.removeAttribute('controls');
video.removeAttribute('controls');
video.classList.add('ov-player-video');
video.classList.add('ov-player-video');
+ // Loading: the average colour now, a tiny blurred preview, then the poster, then the first frame.
+ const color = video.getAttribute('data-ov-color');
+ if (/^#[0-9a-f]{3,8}$/i.test(color || '')) root.style.setProperty('--ovp-bg', color);
+ const thumb = video.getAttribute('data-ov-thumb');
+ const placeholder = el('div', 'ov-player-placeholder', { 'aria-hidden': 'true' });
+ if (/^(data:image\/|https:\/\/)/.test(thumb || '')) {
+ placeholder.style.backgroundImage = `url("${thumb.replace(/"/g, '%22')}")`;
+ root.classList.add('has-thumb');
+ // No colour given: the preview's average, which arrives first (a few hundred bytes).
+ if (!/^#[0-9a-f]{3,8}$/i.test(color || '')) loadImage(thumb, (img) => averageColor(img, (c) => root.style.setProperty('--ovp-bg', c)));
+ }
+ const posterLayer = el('div', 'ov-player-poster', { 'aria-hidden': 'true' });
+ root.insertBefore(placeholder, video);
+ root.insertBefore(posterLayer, video);
+ const shown = () => root.classList.add('has-frame');
+ if (video.readyState >= 2) shown(); else video.addEventListener('loadeddata', shown, { once: true });
// ── Build ────────────────────────────────────────────
// ── Build ────────────────────────────────────────────
const big = button('ov-player-big', 'Play', ICON.play);
const big = button('ov-player-big', 'Play', ICON.play);
@@ -99,7 +213,18 @@
@@ -99,7 +213,18 @@
const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);
const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);
row.append(playBtn, volWrap, time, spacer, ccBtn, speedBtn, pipBtn, fullBtn);
row.append(playBtn, volWrap, time, spacer, ccBtn, speedBtn, pipBtn, fullBtn);
bar.append(scrub, row);
bar.append(scrub, row);
- root.append(spinner, big, message, bar);
+ const unmute = el('button', 'ov-player-unmute', { type: 'button', hidden: '' });
+ unmute.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;
+ const flash = el('div', 'ov-player-flash', { 'aria-hidden': 'true' });
+ root.append(spinner, big, message, unmute, flash, bar);
+ const ambient = video.hasAttribute('data-ov-ambient') || options.ambient ? glow(root, video) : null;
+ const poster = video.getAttribute('poster');
+ if (poster) loadImage(poster, (img) => {
+ posterLayer.style.backgroundImage = `url("${poster.replace(/"/g, '%22')}")`;
+ root.classList.add('has-poster');
+ if (!color && !thumb) averageColor(img, (c) => root.style.setProperty('--ovp-bg', c));
+ if (ambient) ambient.still(img);
+ });
const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;
const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;
if (!pipOk) pipBtn.hidden = true;
if (!pipOk) pipBtn.hidden = true;
@@ -108,6 +233,8 @@
@@ -108,6 +233,8 @@
const saved = stored();
const saved = stored();
if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }
if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }
+ // Muted by the page (autoplay) rather than by the viewer: the first tap or a volume key brings the sound in.
+ let pageMuted = video.muted && (video.hasAttribute('muted') || video.autoplay || !saved || !saved.muted);
// ── State → UI ───────────────────────────────────────
// ── State → UI ───────────────────────────────────────
const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
@@ -139,6 +266,8 @@
@@ -139,6 +266,8 @@
}
}
function paintVolume() {
function paintVolume() {
const v = video.muted ? 0 : video.volume;
const v = video.muted ? 0 : video.volume;
+ unmute.hidden = !(video.muted && pageMuted && !video.paused);
+ root.classList.toggle('is-muted', video.muted || v === 0);
muteBtn.innerHTML = v === 0 ? ICON.muted : v < 0.5 ? ICON.low : ICON.volume;
muteBtn.innerHTML = v === 0 ? ICON.muted : v < 0.5 ? ICON.low : ICON.volume;
const label = video.muted || v === 0 ? 'Unmute (m)' : 'Mute (m)';
const label = video.muted || v === 0 ? 'Unmute (m)' : 'Mute (m)';
muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
@@ -180,9 +309,11 @@
@@ -180,9 +309,11 @@
if (video.paused || video.ended) { const p = video.play(); if (p && p.catch) p.catch(() => paintPlay()); } else video.pause();
if (video.paused || video.ended) { const p = video.play(); if (p && p.catch) p.catch(() => paintPlay()); } else video.pause();
},
},
seek(t) { const d = duration(); if (!d) return; video.currentTime = Math.min(d, Math.max(0, t)); paintTime(); },
seek(t) { const d = duration(); if (!d) return; video.currentTime = Math.min(d, Math.max(0, t)); paintTime(); },
- skip(s) { api.seek((video.currentTime || 0) + s); },
- setVolume(v) { video.volume = Math.min(1, Math.max(0, v)); video.muted = video.volume === 0; store(video); },
- toggleMute() { if (video.muted && video.volume === 0) video.volume = 0.5; video.muted = !video.muted; store(video); },
+ skip(s) { api.seek((video.currentTime || 0) + s); show(s < 0 ? ICON.back : ICON.fwd, `${s > 0 ? '+' : ''}${s} s`, s < 0 ? 'left' : 'right'); },
+ 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)}%`); },
+ toggleMute() { pageMuted = false; if (video.muted && video.volume === 0) video.volume = 0.5; video.muted = !video.muted; store(video); },
+ /** Sound on, at the remembered level (full when that was zero). */
+ unmute() { pageMuted = false; if (video.volume === 0) video.volume = 1; video.muted = false; store(video); show(ICON.volume, `${Math.round(video.volume * 100)}%`); },
toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },
toggleCaptions() { const t = captionTrack(video); if (t) { t.mode = t.mode === 'showing' ? 'hidden' : 'showing'; paintCaptions(); } },
nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
togglePip() {
togglePip() {
@@ -198,6 +329,15 @@
@@ -198,6 +329,15 @@
};
};
video.__ovPlayer = api;
video.__ovPlayer = api;
+ // A short sign in the middle: a seek, a volume level (the double tap and the keys).
+ let flashTimer = 0;
+ function show(icon, text, side) {
+ flash.className = `ov-player-flash is-on${side ? ` is-${side}` : ''}`;
+ flash.innerHTML = `${icon}<span>${text}</span>`;
+ clearTimeout(flashTimer);
+ flashTimer = setTimeout(() => { flash.className = 'ov-player-flash'; }, 650);
+ }
+ unmute.addEventListener('click', (e) => { e.stopPropagation(); api.unmute(); });
big.addEventListener('click', () => api.toggle());
big.addEventListener('click', () => api.toggle());
playBtn.addEventListener('click', () => api.toggle());
playBtn.addEventListener('click', () => api.toggle());
muteBtn.addEventListener('click', () => api.toggleMute());
muteBtn.addEventListener('click', () => api.toggleMute());
@@ -246,10 +386,13 @@
@@ -246,10 +386,13 @@
return;
return;
}
}
lastTap = now;
lastTap = now;
+ if (video.muted && pageMuted && !video.paused) { api.unmute(); wake(); return; }
if (root.classList.contains('is-idle')) wake(); else if (!video.paused) root.classList.add('is-idle');
if (root.classList.contains('is-idle')) wake(); else if (!video.paused) root.classList.add('is-idle');
});
});
video.addEventListener('dblclick', () => api.toggleFullscreen());
video.addEventListener('dblclick', () => api.toggleFullscreen());
+ root.addEventListener('pointerdown', () => { current = api; });
+ root.addEventListener('focusin', () => { current = api; });
root.addEventListener('pointermove', wake);
root.addEventListener('pointermove', wake);
root.addEventListener('focusin', wake);
root.addEventListener('focusin', wake);
root.addEventListener('keydown', (e) => {
root.addEventListener('keydown', (e) => {
@@ -272,13 +415,14 @@
@@ -272,13 +415,14 @@
else if (k === 'f' || k === 'F') api.toggleFullscreen();
else if (k === 'f' || k === 'F') api.toggleFullscreen();
else if (k === 'c' || k === 'C') api.toggleCaptions();
else if (k === 'c' || k === 'C') api.toggleCaptions();
else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);
else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);
+ else if (VOLUME_KEYS.test(k)) volumeKey(api, k);
else handled = false;
else handled = false;
if (handled) { e.preventDefault(); wake(); }
if (handled) { e.preventDefault(); wake(); }
});
});
// ── Media events ─────────────────────────────────────
// ── Media events ─────────────────────────────────────
- video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); });
- video.addEventListener('pause', paintPlay);
+ video.addEventListener('play', () => { root.classList.remove('is-start'); paintPlay(); paintVolume(); current = api; session(api, options); if (ambient) ambient.start(); });
+ video.addEventListener('pause', () => { paintPlay(); paintVolume(); if (ambient) ambient.stop(); });
video.addEventListener('ended', paintPlay);
video.addEventListener('ended', paintPlay);
video.addEventListener('timeupdate', paintTime);
video.addEventListener('timeupdate', paintTime);
video.addEventListener('durationchange', paintTime);
video.addEventListener('durationchange', paintTime);
@@ -115,3 +115,60 @@ assert.match(css, /\.ov-player:not\(\.is-start\):not\(\.is-ended\) \.ov-player-b
@@ -115,3 +115,60 @@ assert.match(css, /\.ov-player:not\(\.is-start\):not\(\.is-ended\) \.ov-player-b
const files = require('../files');
const files = require('../files');
assert.ok(files.isBrowserFile('player.js') && files.isBrowserFile('player.css') && files.BROWSER.includes('player.js') && files.STYLES.includes('player.css'));
assert.ok(files.isBrowserFile('player.js') && files.isBrowserFile('player.css') && files.BROWSER.includes('player.js') && files.STYLES.includes('player.css'));
console.log('player: all checks passed');
console.log('player: all checks passed');
+
+// ── 4.13: sound, loading layers, feedback (owner, 2026-10-25) ──
+{
+ const { window: w2, document: d2 } = parseHTML(`<!doctype html><html><body><div class="host"><video id="a" data-ov-player data-ov-color="#123456" data-ov-thumb="data:image/webp;base64,AAAA" data-ov-ambient poster="/p.jpg" autoplay muted src="/a.mp4"></video></div></body></html>`);
+ const v = d2.getElementById('a');
+ let p2 = true;
+ const m2 = { currentTime: 0, duration: 120, volume: 0.8, muted: true, playbackRate: 1, ended: false, readyState: 0 };
+ const f2 = (type) => v.dispatchEvent(new w2.Event(type));
+ Object.defineProperties(v, {
+ paused: { get: () => p2 }, ended: { get: () => m2.ended }, readyState: { get: () => m2.readyState },
+ currentTime: { get: () => m2.currentTime, set: (x) => { m2.currentTime = x; f2('timeupdate'); } },
+ duration: { get: () => m2.duration },
+ volume: { get: () => m2.volume, set: (x) => { m2.volume = x; f2('volumechange'); } },
+ muted: { get: () => m2.muted, set: (x) => { m2.muted = x; f2('volumechange'); } },
+ playbackRate: { get: () => m2.playbackRate, set: (x) => { m2.playbackRate = x; f2('ratechange'); } },
+ buffered: { get: () => ({ length: 0 }) },
+ textTracks: { value: Object.assign([], { addEventListener() {} }) },
+ autoplay: { get: () => true },
+ });
+ v.play = () => { p2 = false; f2('play'); f2('playing'); return Promise.resolve(); };
+ v.pause = () => { p2 = true; f2('pause'); };
+ const st2 = {};
+ const c2 = { window: w2, document: d2, console, Promise, setTimeout, clearTimeout, setInterval, clearInterval, Date, Math, Number, String, JSON, Object, Array,
+ localStorage: { getItem: (k) => (k in st2 ? st2[k] : null), setItem: (k, x) => { st2[k] = String(x); } } };
+ w2.OpenVibePlayer = undefined;
+ vm.createContext(c2);
+ vm.runInContext(src, c2);
+ const r2 = v.parentNode;
+ const q = (sel) => r2.querySelector(sel);
+ assert.strictEqual(r2.style.getPropertyValue('--ovp-bg'), '#123456', 'the average colour paints at once');
+ assert.ok(q('.ov-player-placeholder').style.backgroundImage.includes('data:image/webp'), 'the tiny preview, blurred under the poster');
+ assert.ok(r2.classList.contains('has-thumb') && !r2.classList.contains('has-frame'), 'no frame yet');
+ m2.readyState = 2; f2('loadeddata');
+ assert.ok(r2.classList.contains('has-frame'), 'the first frame fades in');
+ // Autoplaying muted: "Tap for sound", and the pill unmutes at the remembered level.
+ v.play();
+ assert.strictEqual(q('.ov-player-unmute').hidden, false, '"Tap for sound" while playing muted');
+ q('.ov-player-unmute').dispatchEvent(new w2.Event('click'));
+ assert.strictEqual(m2.muted, false, 'one tap: sound on');
+ assert.strictEqual(q('.ov-player-unmute').hidden, true);
+ assert.ok(q('.ov-player-flash').className.includes('is-on'), 'the level shows briefly');
+ // A media volume key anywhere on the page drives the player last played.
+ m2.muted = true;
+ const kd = new w2.Event('keydown', { bubbles: true }); kd.key = 'AudioVolumeUp'; d2.body.dispatchEvent(kd);
+ assert.strictEqual(m2.muted, false, 'volume up unmutes a muted video');
+ const kd2 = new w2.Event('keydown', { bubbles: true }); kd2.key = 'AudioVolumeDown'; d2.body.dispatchEvent(kd2);
+ assert.ok(Math.abs(m2.volume - 0.7) < 1e-9, 'volume down lowers it');
+ // Skips say what they did, on their side.
+ v.__ovPlayer.skip(10);
+ assert.ok(q('.ov-player-flash').className.includes('is-right') && q('.ov-player-flash').textContent.includes('+10 s'));
+}
+// The styles, and a mute icon from the one rounded set.
+assert.match(css, /\.ov-player-unmute \{/);
+assert.match(css, /@media \(hover: none\) \{ \.ov-player-volume-slider \{ display: none; \} \}/, 'no hidden slider on touch screens');
+assert.match(css, /\.ov-player\.has-frame \.ov-player-video/);
+assert.match(src, /Material Icons Round/);
+console.log('player: all checks passed (4.13 sound, layers, feedback)');