openvibers/openvibe.shared Commits

4.7.0: the video player (player.js, player.css): one look for every video across the network (#92)

OP OpenVibers committed 23 days ago · 58e8a3c798 · 8 files, +608 −4

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5970b95..8aa1faa 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,15 @@ 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.7.0 — 2026-10-20
++
++- **The video player** (`player.js`, `player.css`, served at `/shared/`): one look for every `<video>` across the
++ network (the owner asked to "override the default style of video players across all our sites and vods … for a
++ more unified look"). `video[data-ov-player]` gets the network's controls in place of the browser's: a scrubber with
++ the buffered range and the time under the pointer, play, volume, time, captions, speed, picture-in-picture and
++ fullscreen, hidden while playing and idle, with YouTube's keys and touch gestures. Progressive (no script, native
++ controls) and media-element only, so a page's HLS, captions and chapters keep working.
++
## 4.6.0 — 2026-10-20
- **Page cards.** A site's big pages get a share card of their own, `social/<service>-<page>.jpg`, drawn like the site
ddiff --git a/README.md b/README.md
iindex 3c17e1d..c9c621a 100644
---- a/README.md
++++ b/README.md
@@@ -19,7 +19,7 @@ were reconciled first.
| Kind | Files |
|---|---|
--| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
++| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `player.js`, `release-watch.js`, `release-update.js`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `app-icons` (every site's installable app icon set: `has(service)`, `url(service, size, { maskable })`, `isIcon(name)`, `path(name)`; the pre-rendered PNGs are served at `/shared/icons/<service>[-maskable]-<size>.png` and named by the web app manifest `serve.js` serves at `/shared/manifest/<service>.webmanifest`), `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js`, `scripts/build-icons.js`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
@@@ -657,6 +657,24 @@ Re-renders (each openvibe-shared/boost page move re-renders the bar) end the
Dropdowns under a top link (`children`) are fixed-positioned under their link when they show (hover, keyboard focus, a tap), because the links row scrolls sideways and would clip them.
++### The video player (`player.js`, `player.css`)
++
++One look for every `<video>` across the network. Mark the element and load both files; without the script the
++browser's own controls stay:
++
++```html
++<link rel="stylesheet" href="/shared/player.css">
++<video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>
++<script src="/shared/player.js" defer></script>
++```
++
++Each `video[data-ov-player]` is wrapped in `.ov-player` with a scrubber (buffered, played, the time under the
++pointer), play, volume (remembered), time, captions (when the video has a captions track), speed, picture-in-picture
++and fullscreen; the controls hide while playing and idle. Keys while the player has focus: Space/K, J/L ±10 s, ←/→ ±5 s,
++↑/↓ volume, M, F, C, 0–9. On touch, a tap shows the controls and a double tap on either side seeks ±10 s. It drives
++only the media element and listens to its events, so a page's own script (HLS, chapters, next-up, view counts) keeps
++working. Colours follow the site's `--accent`. `OpenVibePlayer.enhance(video)` for a video added later.
++
### What people wear (`items.js`, `items.css`)
The items a person has equipped in OpenVibe.Inventory, drawn the same on every site (plan T21). Mark each name with
ddiff --git a/files.js b/files.js
iindex 4c041b3..37419d3 100644
---- a/files.js
++++ b/files.js
@@@ -33,6 +33,7 @@ const BROWSER = Object.freeze([
'island.js', // activity island
'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css
'tooltip.js',
++ 'player.js', // the network's one video player look: enhances video[data-ov-player]; draw with player.css
'release-watch.js', // keeps open tabs on a supported release (ADR-016), loaded after first paint
'release-update.js', // plans a new release and applies style/content in place; release-watch loads it on demand
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
@@@ -43,6 +44,7 @@ const BROWSER = Object.freeze([
const STYLES = Object.freeze([
'showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)
'items.css', // Live's name effects for items.js (live.name_effect.css@1)
++ 'player.css', // player.js's controls
]);
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
ddiff --git a/package-lock.json b/package-lock.json
iindex 5a73ff5..fbfc4c3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.6.0",
++ "version": "4.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.6.0",
++ "version": "4.7.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 5c38aba..3b8469d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.6.0",
++ "version": "4.7.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -65,6 +65,7 @@
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
"./tooltip": "./tooltip.js",
++ "./player": "./player.js",
"./trace": "./trace.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
ddiff --git a/player.css b/player.css
nnew file mode 100644
iindex 0000000..b761e2d
---- /dev/null
++++ b/player.css
@@@ -0,0 +1,145 @@
++/*
++ * OpenVibe player (player.js): the network's one look for <video>. Colours follow the site's theme (--accent, the
++ * live red); everything else is the player's own, so it looks the same on every OpenVibe site.
++ */
++.ov-player {
++ --ovp-accent: var(--accent, #3b82f6);
++ --ovp-fg: #fff;
++ --ovp-muted: rgba(255, 255, 255, 0.72);
++ --ovp-track: rgba(255, 255, 255, 0.24);
++ --ovp-buffer: rgba(255, 255, 255, 0.38);
++ position: relative;
++ display: block;
++ width: 100%;
++ height: 100%;
++ background: #000;
++ color: var(--ovp-fg);
++ border-radius: inherit;
++ overflow: hidden;
++ font: 600 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
++ -webkit-tap-highlight-color: transparent;
++ outline: none;
++ 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.is-idle { cursor: none; }
++
++/* The bar: a soft gradient from the bottom; hidden while playing and idle. */
++.ov-player-bar {
++ position: absolute; left: 0; right: 0; bottom: 0; z-index: 3;
++ padding: 44px 10px 6px;
++ background: linear-gradient(to top, rgba(0, 0, 0, 0.88) 0%, rgba(0, 0, 0, 0.62) 42%, rgba(0, 0, 0, 0.22) 75%, transparent 100%);
++ transition: opacity 0.2s ease, transform 0.2s ease;
++ pointer-events: none; /* the gradient is backdrop: only the controls take the pointer */
++}
++.ov-player-bar > * { pointer-events: auto; }
++.ov-player.is-idle .ov-player-bar, .ov-player.is-idle .ov-player-bar > * { opacity: 0; pointer-events: none; }
++.ov-player.is-idle .ov-player-bar { transform: translateY(6px); }
++
++/* Scrubber: buffered, played and a thumb in the theme's accent; the time under the pointer above it. */
++.ov-player-scrub { position: relative; height: 16px; display: flex; align-items: center; cursor: pointer; touch-action: none; outline: none; }
++.ov-player-track { position: relative; width: 100%; height: 4px; border-radius: 2px; background: var(--ovp-track); transition: height 0.12s; }
++.ov-player-scrub:hover .ov-player-track, .ov-player.is-scrubbing .ov-player-track, .ov-player-scrub:focus-visible .ov-player-track { height: 6px; }
++.ov-player-buffered, .ov-player-played { position: absolute; left: 0; top: 0; bottom: 0; border-radius: inherit; width: 0; }
++.ov-player-buffered { background: var(--ovp-buffer); }
++.ov-player-played { background: var(--ovp-accent); }
++.ov-player-played::after {
++ content: ''; position: absolute; right: -6px; top: 50%;
++ width: 12px; height: 12px; border-radius: 50%;
++ background: var(--ovp-accent); border: 2px solid #fff; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
++ transform: translateY(-50%) scale(0); transition: transform 0.12s;
++}
++.ov-player-scrub:hover .ov-player-played::after, .ov-player.is-scrubbing .ov-player-played::after,
++.ov-player-scrub:focus-visible .ov-player-played::after { transform: translateY(-50%) scale(1); }
++.ov-player-scrub:focus-visible .ov-player-track { box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5); }
++.ov-player-hover {
++ position: absolute; bottom: 18px; transform: translateX(-50%);
++ padding: 4px 7px; border-radius: 6px; background: rgba(0, 0, 0, 0.85);
++ font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap;
++ opacity: 0; pointer-events: none; transition: opacity 0.1s;
++}
++.ov-player-scrub:hover .ov-player-hover, .ov-player.is-scrubbing .ov-player-hover { opacity: 1; }
++
++/* The row of controls. */
++.ov-player-row { display: flex; align-items: center; gap: 2px; margin-top: 2px; }
++.ov-player-spacer { flex: 1; }
++.ov-player-btn {
++ display: inline-flex; align-items: center; justify-content: center;
++ min-width: 38px; height: 38px; padding: 0 6px;
++ background: none; border: 0; border-radius: 8px;
++ color: var(--ovp-fg); font: inherit; cursor: pointer;
++ 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 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; }
++.ov-player-cc.is-on { color: var(--ovp-accent); }
++.ov-player-cc.is-on svg { filter: drop-shadow(0 0 4px color-mix(in srgb, var(--ovp-accent) 60%, transparent)); }
++.ov-player-speed { font-size: 12.5px; font-variant-numeric: tabular-nums; min-width: 44px; }
++.ov-player-time { padding: 0 8px; color: rgba(255, 255, 255, 0.86); font-size: 12.5px; font-variant-numeric: tabular-nums; white-space: nowrap; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); }
++
++/* Volume: the slider opens beside the button on hover or focus. */
++.ov-player-volume { display: flex; align-items: center; }
++.ov-player-volume-slider {
++ -webkit-appearance: none; appearance: none;
++ width: 0; height: 22px; margin: 0; padding: 0; opacity: 0;
++ background: transparent; cursor: pointer;
++ transition: width 0.2s ease, opacity 0.2s ease, margin 0.2s ease;
++ --ovp-fill: 100%;
++}
++.ov-player-volume:hover .ov-player-volume-slider, .ov-player-volume-slider:focus-visible, .ov-player-volume:focus-within .ov-player-volume-slider {
++ width: 76px; opacity: 1; margin: 0 6px 0 2px;
++}
++.ov-player-volume-slider::-webkit-slider-runnable-track {
++ height: 4px; border-radius: 2px;
++ background: linear-gradient(to right, #fff var(--ovp-fill), var(--ovp-track) var(--ovp-fill));
++}
++.ov-player-volume-slider::-moz-range-track { height: 4px; border-radius: 2px; background: var(--ovp-track); }
++.ov-player-volume-slider::-moz-range-progress { height: 4px; border-radius: 2px; background: #fff; }
++.ov-player-volume-slider::-webkit-slider-thumb {
++ -webkit-appearance: none; appearance: none; margin-top: -4px;
++ 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; }
++
++/* 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);
++ opacity: 1;
++}
++.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; }
++
++/* Buffering. */
++.ov-player-spinner {
++ position: absolute; left: 50%; top: 50%; z-index: 2;
++ width: 44px; height: 44px; margin: -22px 0 0 -22px;
++ border-radius: 50%; border: 3px solid rgba(255, 255, 255, 0.25); border-top-color: #fff;
++ animation: ov-player-spin 0.8s linear infinite; display: none;
++}
++.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; } }
++
++.ov-player-message:empty { display: none; }
++.ov-player-message {
++ position: absolute; left: 50%; top: 50%; z-index: 2; transform: translate(-50%, -50%);
++ padding: 10px 14px; border-radius: 10px; background: rgba(0, 0, 0, 0.8); font-weight: 500;
++}
++
++/* Captions sit above the bar while it shows. */
++.ov-player .ov-player-video::cue { background: rgba(0, 0, 0, 0.72); color: #fff; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++
++/* Small players and phones: fewer, larger targets. */
++@media (max-width: 520px) {
++ .ov-player-speed, .ov-player-pip { display: none; }
++ .ov-player-time { padding: 0 4px; font-size: 12px; }
++ .ov-player-big { width: 60px; height: 60px; }
++}
ddiff --git a/player.js b/player.js
nnew file mode 100644
iindex 0000000..2923487
---- /dev/null
++++ b/player.js
@@@ -0,0 +1,312 @@
++/*
++ * OpenVibe player: one look for every <video> across the network (owner, 2026-10-20: "override the default style of
++ * video players across all our sites and vods … for a more unified look").
++ *
++ * <link rel="stylesheet" href="/shared/player.css">
++ * <video data-ov-player controls src="…" poster="…" preload="metadata" playsinline></video>
++ * <script src="/shared/player.js" defer></script>
++ *
++ * Progressive: without this script the browser's own controls stay. With it, each video[data-ov-player] is wrapped
++ * in .ov-player and gets the network's controls: a scrubber (buffered, played, the time under the pointer), play,
++ * volume, time, captions, speed, picture-in-picture and fullscreen, hidden while playing and idle. Keys while the
++ * player has focus: Space/K play, J/L ±10 s, ←/→ ±5 s, ↑/↓ volume, M mute, F fullscreen, C captions, 0–9 jump to that
++ * tenth. On touch a tap shows the controls and a double tap on either side seeks ±10 s.
++ *
++ * 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.
++ */
++(function () {
++ 'use strict';
++ 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 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'),
++ };
++ const SPEEDS = [1, 1.25, 1.5, 2, 0.5, 0.75];
++ const VOLUME_KEY = 'ov-player-volume';
++
++ function fmt(s) {
++ if (!Number.isFinite(s) || s < 0) s = 0;
++ s = Math.floor(s);
++ const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`;
++ }
++ function stored() {
++ try { const v = JSON.parse(localStorage.getItem(VOLUME_KEY) || 'null'); return v && Number.isFinite(v.volume) ? v : null; } catch { return null; }
++ }
++ function store(video) {
++ try { localStorage.setItem(VOLUME_KEY, JSON.stringify({ volume: video.volume, muted: video.muted })); } catch { /* private window */ }
++ }
++ function el(tag, cls, attrs) {
++ const e = document.createElement(tag);
++ if (cls) e.className = cls;
++ for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
++ return e;
++ }
++ function button(cls, label, icon) {
++ const b = el('button', `ov-player-btn ${cls}`, { type: 'button', 'aria-label': label, title: label });
++ b.innerHTML = icon;
++ return b;
++ }
++ const captionTrack = (video) => Array.from(video.textTracks || []).find((t) => t.kind === 'captions' || t.kind === 'subtitles') || null;
++ const fullscreenElement = () => document.fullscreenElement || document.webkitFullscreenElement || null;
++
++ function enhance(video, options = {}) {
++ if (!video || video.__ovPlayer) return video && video.__ovPlayer;
++ const idleMs = options.idleMs || 2500;
++ const root = el('div', 'ov-player is-paused is-start', { tabindex: '0', role: 'region', 'aria-label': options.label || video.getAttribute('aria-label') || 'Video player' });
++ video.parentNode.insertBefore(root, video);
++ root.appendChild(video);
++ video.controls = false;
++ video.removeAttribute('controls');
++ video.classList.add('ov-player-video');
++
++ // ── Build ────────────────────────────────────────────
++ const big = button('ov-player-big', 'Play', ICON.play);
++ const spinner = el('div', 'ov-player-spinner', { 'aria-hidden': 'true' });
++ const message = el('div', 'ov-player-message', { role: 'status' });
++ const bar = el('div', 'ov-player-bar');
++ const scrub = el('div', 'ov-player-scrub', { role: 'slider', tabindex: '0', 'aria-label': 'Seek', 'aria-valuemin': '0', 'aria-valuemax': '0', 'aria-valuenow': '0', 'aria-valuetext': '0:00 of 0:00' });
++ const track = el('div', 'ov-player-track');
++ const buffered = el('div', 'ov-player-buffered');
++ const played = el('div', 'ov-player-played');
++ const hover = el('div', 'ov-player-hover', { 'aria-hidden': 'true' });
++ track.append(buffered, played);
++ scrub.append(track, hover);
++ const row = el('div', 'ov-player-row');
++ const playBtn = button('ov-player-play', 'Play (k)', ICON.play);
++ const volWrap = el('div', 'ov-player-volume');
++ const muteBtn = button('ov-player-mute', 'Mute (m)', ICON.volume);
++ const volSlider = el('input', 'ov-player-volume-slider', { type: 'range', min: '0', max: '1', step: '0.05', 'aria-label': 'Volume' });
++ volWrap.append(muteBtn, volSlider);
++ const time = el('div', 'ov-player-time', { 'aria-hidden': 'true' });
++ const spacer = el('div', 'ov-player-spacer');
++ const ccBtn = button('ov-player-cc', 'Captions (c)', ICON.cc);
++ const speedBtn = el('button', 'ov-player-btn ov-player-speed', { type: 'button', 'aria-label': 'Playback speed: 1×', title: 'Playback speed' });
++ speedBtn.textContent = '1×';
++ const pipBtn = button('ov-player-pip', 'Picture in picture', ICON.pip);
++ const fullBtn = button('ov-player-full', 'Fullscreen (f)', ICON.full);
++ row.append(playBtn, volWrap, time, spacer, ccBtn, speedBtn, pipBtn, fullBtn);
++ bar.append(scrub, row);
++ root.append(spinner, big, message, bar);
++
++ const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;
++ if (!pipOk) pipBtn.hidden = true;
++ const fsOk = !!(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen);
++ if (!fsOk) fullBtn.hidden = true;
++
++ const saved = stored();
++ if (saved && !video.hasAttribute('muted')) { video.volume = Math.min(1, Math.max(0, saved.volume)); video.muted = !!saved.muted; }
++
++ // ── State → UI ───────────────────────────────────────
++ const duration = () => (Number.isFinite(video.duration) ? video.duration : 0);
++ function paintTime() {
++ const d = duration(), t = video.currentTime || 0;
++ time.textContent = d ? `${fmt(t)} / ${fmt(d)}` : fmt(t);
++ played.style.width = d ? `${Math.min(100, (t / d) * 100)}%` : '0%';
++ scrub.setAttribute('aria-valuemax', String(Math.floor(d)));
++ scrub.setAttribute('aria-valuenow', String(Math.floor(t)));
++ scrub.setAttribute('aria-valuetext', `${fmt(t)} of ${fmt(d)}`);
++ }
++ function paintBuffered() {
++ const d = duration(), b = video.buffered;
++ if (!d || !b || !b.length) { buffered.style.width = '0%'; return; }
++ let end = 0;
++ for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
++ buffered.style.width = `${Math.min(100, (end / d) * 100)}%`;
++ }
++ function paintPlay() {
++ const paused = video.paused || video.ended;
++ root.classList.toggle('is-paused', paused);
++ root.classList.toggle('is-ended', video.ended);
++ playBtn.innerHTML = video.ended ? ICON.replay : paused ? ICON.play : ICON.pause;
++ const label = video.ended ? 'Replay' : paused ? 'Play (k)' : 'Pause (k)';
++ playBtn.setAttribute('aria-label', label); playBtn.title = label;
++ big.innerHTML = video.ended ? ICON.replay : ICON.play;
++ big.setAttribute('aria-label', video.ended ? 'Replay' : 'Play');
++ if (paused) wake();
++ }
++ function paintVolume() {
++ const v = video.muted ? 0 : video.volume;
++ 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;
++ volSlider.value = String(v);
++ volSlider.style.setProperty('--ovp-fill', `${v * 100}%`);
++ }
++ function paintCaptions() {
++ const t = captionTrack(video);
++ ccBtn.hidden = !t;
++ ccBtn.classList.toggle('is-on', !!t && t.mode === 'showing');
++ ccBtn.setAttribute('aria-pressed', t && t.mode === 'showing' ? 'true' : 'false');
++ }
++ function paintSpeed() {
++ const r = video.playbackRate || 1;
++ speedBtn.textContent = `${r}×`;
++ speedBtn.setAttribute('aria-label', `Playback speed: ${r}×`);
++ }
++ function paintFull() {
++ const on = fullscreenElement() === root;
++ root.classList.toggle('is-fullscreen', on);
++ fullBtn.innerHTML = on ? ICON.exit : ICON.full;
++ const label = on ? 'Exit fullscreen (f)' : 'Fullscreen (f)';
++ fullBtn.setAttribute('aria-label', label); fullBtn.title = label;
++ }
++
++ // ── Idle: controls hide while playing and nothing moves ──
++ let idleTimer = 0;
++ function wake() {
++ root.classList.remove('is-idle');
++ clearTimeout(idleTimer);
++ // Not while a control in the bar has focus (keyboard use) or the pointer is scrubbing.
++ if (!video.paused && !video.ended) idleTimer = setTimeout(() => { if (!video.paused && !bar.contains(document.activeElement) && !dragging) root.classList.add('is-idle'); }, idleMs);
++ }
++
++ // ── Actions ──────────────────────────────────────────
++ const api = {
++ root, video,
++ toggle() {
++ 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); },
++ 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() {
++ if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});
++ else if (video.requestPictureInPicture) video.requestPictureInPicture().catch(() => {});
++ },
++ toggleFullscreen() {
++ if (fullscreenElement()) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
++ else if (root.requestFullscreen) root.requestFullscreen().catch(() => {});
++ else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen();
++ else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen();
++ },
++ };
++ video.__ovPlayer = api;
++
++ big.addEventListener('click', () => api.toggle());
++ playBtn.addEventListener('click', () => api.toggle());
++ muteBtn.addEventListener('click', () => api.toggleMute());
++ volSlider.addEventListener('input', () => api.setVolume(Number(volSlider.value)));
++ ccBtn.addEventListener('click', () => api.toggleCaptions());
++ speedBtn.addEventListener('click', () => api.nextSpeed());
++ pipBtn.addEventListener('click', () => api.togglePip());
++ fullBtn.addEventListener('click', () => api.toggleFullscreen());
++
++ // Scrubbing: click or drag; the time under the pointer shows above it.
++ const atPointer = (e) => {
++ const r = track.getBoundingClientRect();
++ return Math.min(1, Math.max(0, (e.clientX - r.left) / (r.width || 1))) * duration();
++ };
++ let dragging = false;
++ scrub.addEventListener('pointerdown', (e) => {
++ if (!duration()) return;
++ dragging = true;
++ root.classList.add('is-scrubbing');
++ if (scrub.setPointerCapture && e.pointerId != null) { try { scrub.setPointerCapture(e.pointerId); } catch { /* */ } }
++ api.seek(atPointer(e));
++ });
++ scrub.addEventListener('pointermove', (e) => {
++ const d = duration();
++ if (!d) return;
++ const t = atPointer(e);
++ const r = track.getBoundingClientRect();
++ hover.textContent = fmt(t);
++ hover.style.left = `${((t / d) * (r.width || 0))}px`;
++ if (dragging) api.seek(t);
++ });
++ const endDrag = () => { dragging = false; root.classList.remove('is-scrubbing'); };
++ scrub.addEventListener('pointerup', endDrag);
++ scrub.addEventListener('pointercancel', endDrag);
++
++ // The picture: a click plays or pauses (mouse); a tap shows the controls, a double tap seeks ±10 s (touch).
++ let lastTap = 0;
++ video.addEventListener('pointerup', (e) => {
++ if (e.pointerType !== 'touch') { api.toggle(); return; }
++ const now = Date.now();
++ if (now - lastTap < 300) {
++ const r = video.getBoundingClientRect();
++ const x = (e.clientX - r.left) / (r.width || 1);
++ if (x < 0.35) api.skip(-10); else if (x > 0.65) api.skip(10); else api.toggle();
++ lastTap = 0;
++ return;
++ }
++ lastTap = now;
++ if (root.classList.contains('is-idle')) wake(); else if (!video.paused) root.classList.add('is-idle');
++ });
++ video.addEventListener('dblclick', () => api.toggleFullscreen());
++
++ root.addEventListener('pointermove', wake);
++ root.addEventListener('focusin', wake);
++ root.addEventListener('keydown', (e) => {
++ if (e.altKey || e.ctrlKey || e.metaKey) return;
++ const tag = e.target && e.target.tagName;
++ if (tag === 'INPUT' && e.target !== volSlider) return;
++ const k = e.key;
++ const onSlider = e.target === volSlider;
++ let handled = true;
++ if (k === ' ' || k === 'k' || k === 'K') { if (tag === 'BUTTON' && k === ' ') return; api.toggle(); }
++ else if (k === 'j' || k === 'J') api.skip(-10);
++ else if (k === 'l' || k === 'L') api.skip(10);
++ else if (k === 'ArrowLeft' && !onSlider) api.skip(-5);
++ else if (k === 'ArrowRight' && !onSlider) api.skip(5);
++ else if (k === 'ArrowUp' && !onSlider) api.setVolume(video.volume + 0.05);
++ else if (k === 'ArrowDown' && !onSlider) api.setVolume(video.volume - 0.05);
++ else if (k === 'Home' && e.target === scrub) api.seek(0);
++ else if (k === 'End' && e.target === scrub) api.seek(duration());
++ else if (k === 'm' || k === 'M') api.toggleMute();
++ else if (k === 'f' || k === 'F') api.toggleFullscreen();
++ else if (k === 'c' || k === 'C') api.toggleCaptions();
++ else if (/^[0-9]$/.test(k)) api.seek(duration() * Number(k) / 10);
++ 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('ended', paintPlay);
++ video.addEventListener('timeupdate', paintTime);
++ video.addEventListener('durationchange', paintTime);
++ video.addEventListener('loadedmetadata', () => { paintTime(); paintCaptions(); });
++ video.addEventListener('progress', paintBuffered);
++ video.addEventListener('volumechange', paintVolume);
++ video.addEventListener('ratechange', paintSpeed);
++ video.addEventListener('waiting', () => root.classList.add('is-waiting'));
++ video.addEventListener('playing', () => { root.classList.remove('is-waiting'); message.textContent = ''; paintPlay(); });
++ video.addEventListener('canplay', () => root.classList.remove('is-waiting'));
++ video.addEventListener('error', () => { root.classList.remove('is-waiting'); message.textContent = 'This video could not be played.'; });
++ if (video.textTracks && video.textTracks.addEventListener) {
++ video.textTracks.addEventListener('addtrack', paintCaptions);
++ video.textTracks.addEventListener('change', paintCaptions);
++ }
++ document.addEventListener('fullscreenchange', paintFull);
++ document.addEventListener('webkitfullscreenchange', paintFull);
++
++ paintTime(); paintBuffered(); paintPlay(); paintVolume(); paintCaptions(); paintSpeed(); paintFull();
++ if (!video.paused) root.classList.remove('is-start');
++ return api;
++ }
++
++ function enhanceAll(scope) {
++ (scope || document).querySelectorAll('video[data-ov-player]').forEach((v) => enhance(v));
++ }
++
++ window.OpenVibePlayer = { enhance, enhanceAll, format: fmt };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhanceAll());
++ else enhanceAll();
++})();
ddiff --git a/test/player.test.js b/test/player.test.js
nnew file mode 100644
iindex 0000000..efac849
---- /dev/null
++++ b/test/player.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++// player.js (+ player.css): the network's one video look. Runs the real script in a linkedom document with a <video>
++// whose media behaviour is faked (play/pause, time, duration, volume, events), and checks it the way a viewer uses it.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const { parseHTML } = require('linkedom');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'player.js'), 'utf8');
++const css = fs.readFileSync(path.join(__dirname, '..', 'player.css'), 'utf8');
++
++const { window, document } = parseHTML(`<!doctype html><html><body>
++ <div class="wrap"><video id="v" data-ov-player controls preload="metadata" src="/v.mp4"><track kind="captions" src="/c.vtt"></video></div>
++ <video id="plain" controls src="/x.mp4"></video>
++</body></html>`);
++
++// The media model linkedom does not have.
++const video = document.getElementById('v');
++let paused = true;
++const media = { currentTime: 0, duration: NaN, volume: 1, muted: false, playbackRate: 1, ended: false };
++const fire = (type) => video.dispatchEvent(new window.Event(type));
++Object.defineProperties(video, {
++ paused: { get: () => paused },
++ ended: { get: () => media.ended },
++ currentTime: { get: () => media.currentTime, set: (v) => { media.currentTime = v; fire('timeupdate'); } },
++ duration: { get: () => media.duration },
++ volume: { get: () => media.volume, set: (v) => { media.volume = v; fire('volumechange'); } },
++ muted: { get: () => media.muted, set: (v) => { media.muted = v; fire('volumechange'); } },
++ playbackRate: { get: () => media.playbackRate, set: (v) => { media.playbackRate = v; fire('ratechange'); } },
++ buffered: { get: () => ({ length: 1, start: () => 0, end: () => 30 }) },
++ textTracks: { value: Object.assign([{ kind: 'captions', mode: 'hidden' }], { addEventListener() {} }) },
++});
++video.play = () => { paused = false; fire('play'); fire('playing'); return Promise.resolve(); };
++video.pause = () => { paused = true; fire('pause'); };
++
++const store = {};
++const ctx = {
++ window, document, console, Promise, setTimeout, clearTimeout, Date, Math, Number, String, JSON, Object, Array,
++ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
++};
++ctx.window.OpenVibePlayer = undefined;
++vm.createContext(ctx);
++vm.runInContext(src, ctx);
++const P = window.OpenVibePlayer;
++assert.ok(P && typeof P.enhance === 'function', 'OpenVibePlayer.enhance');
++
++// Enhanced on load: wrapped, the browser's controls off, the network's on; a plain <video> is left alone.
++const root = video.parentNode;
++assert.ok(root.classList.contains('ov-player'), 'wrapped in .ov-player');
++assert.strictEqual(root.parentNode.className, 'wrap', 'in the page where the video was');
++assert.strictEqual(video.hasAttribute('controls'), false, "the browser's controls are off");
++assert.strictEqual(document.getElementById('plain').hasAttribute('controls'), true, 'only video[data-ov-player]');
++assert.strictEqual(P.enhance(video), video.__ovPlayer, 'enhance() is idempotent');
++assert.strictEqual(root.querySelectorAll('.ov-player-bar').length, 1);
++const $ = (sel) => root.querySelector(sel);
++assert.ok($('.ov-player-big') && root.classList.contains('is-start'), 'a large play button before the first play');
++
++// Time: "0:00", then "m:ss / m:ss" once the duration is known, with the scrubber's value for assistive tech.
++media.duration = 754; fire('durationchange');
++media.currentTime = 65; fire('timeupdate');
++assert.strictEqual($('.ov-player-time').textContent, '1:05 / 12:34');
++assert.strictEqual($('.ov-player-scrub').getAttribute('aria-valuetext'), '1:05 of 12:34');
++assert.match($('.ov-player-played').style.width, /^8\.6\d*%$/);
++fire('progress');
++assert.match($('.ov-player-buffered').style.width, /^3\.9\d*%$/, 'buffered to 30 s of 754');
++assert.strictEqual(P.format(3725), '1:02:05');
++
++// Play and pause from the button, the big button and the keyboard.
++$('.ov-player-big').dispatchEvent(new window.Event('click'));
++assert.strictEqual(paused, false);
++assert.ok(!root.classList.contains('is-start') && !root.classList.contains('is-paused'));
++assert.strictEqual($('.ov-player-play').getAttribute('aria-label'), 'Pause (k)');
++const key = (k, target = root) => { const e = new window.Event('keydown', { bubbles: true }); e.key = k; target.dispatchEvent(e); return e; };
++key('k');
++assert.strictEqual(paused, true, 'k pauses');
++key('l'); assert.strictEqual(media.currentTime, 75, 'l: +10 s');
++key('j'); key('j'); assert.strictEqual(media.currentTime, 55, 'j: -10 s');
++key('ArrowRight'); assert.strictEqual(media.currentTime, 60, '→: +5 s');
++key('5'); assert.strictEqual(media.currentTime, 377, '5: half way');
++key('ArrowDown'); assert.ok(Math.abs(media.volume - 0.95) < 1e-9, '↓: quieter');
++key('m'); assert.strictEqual(media.muted, true, 'm mutes');
++assert.match($('.ov-player-mute').getAttribute('aria-label'), /^Unmute/);
++assert.deepStrictEqual(JSON.parse(store['ov-player-volume']), { volume: 0.95, muted: true }, 'volume remembered');
++
++// Captions, speed.
++key('c');
++assert.strictEqual(video.textTracks[0].mode, 'showing');
++assert.ok($('.ov-player-cc').classList.contains('is-on'));
++$('.ov-player-speed').dispatchEvent(new window.Event('click'));
++assert.strictEqual(media.playbackRate, 1.25);
++assert.strictEqual($('.ov-player-speed').textContent, '1.25×');
++
++// Ended: a replay button.
++media.ended = true; paused = true; fire('ended');
++assert.ok(root.classList.contains('is-ended'));
++assert.strictEqual($('.ov-player-big').getAttribute('aria-label'), 'Replay');
++
++// A video added later is enhanced on request.
++const later = document.createElement('video');
++later.setAttribute('data-ov-player', '');
++later.setAttribute('controls', '');
++document.body.appendChild(later);
++P.enhanceAll(document);
++assert.ok(later.parentNode.classList.contains('ov-player'));
++
++// The stylesheet: the theme's accent, the idle state, and the large button only before play or at the end.
++assert.match(css, /--ovp-accent: var\(--accent, #3b82f6\);/);
++assert.match(css, /\.ov-player\.is-idle \.ov-player-bar, \.ov-player\.is-idle \.ov-player-bar > \* \{ opacity: 0; pointer-events: none; \}/);
++assert.match(css, /\.ov-player-bar \{[^}]*pointer-events: none;/, 'the gradient never blocks the big button or the picture');
++assert.match(css, /\.ov-player-big \{[^}]*z-index: 4;/);
++assert.match(css, /\.ov-player:not\(\.is-start\):not\(\.is-ended\) \.ov-player-big \{ opacity: 0; pointer-events: none; \}/);
++
++// Served beside the other browser 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'));
++console.log('player: all checks passed');