openvibers/openvibe.shared Commits

Player: tap for sound, volume keys, layered loading, ambient glow, new icons (#107)

OP OpenVibers committed 19 days ago · 11e63f3bcf · 4 files, +296 −20

* Player: tap for sound, media volume keys, lock-screen controls, colour → preview → poster → frame loading, an ambient glow, one rounded icon set and seek/volume feedback

* Player: the average colour comes from the tiny preview (CORS, with a plain retry when a host has none)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6844857..4579d22 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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
ddiff --git a/player.css b/player.css
iindex b761e2d..67ed414 100644
---- a/player.css
++++ b/player.css
@@@ -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; }
}
ddiff --git a/player.js b/player.js
iindex 2923487..286397c 100644
---- a/player.js
++++ b/player.js
@@@ -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);
ddiff --git a/test/player.test.js b/test/player.test.js
iindex efac849..eedae1d 100644
---- a/test/player.test.js
++++ b/test/player.test.js
@@@ -115,3 +115,60 @@ assert.match(css, /\.ov-player:not\(\.is-start\):not\(\.is-ended\) \.ov-player-b
const 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)');