openvibers/openvibe.shared Commits
4.7.0: the video player (player.js, player.css): one look for every video across the network (#92)
Changes
@@@ -4,6 +4,15 @@ 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.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@@@ -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 theDropdowns 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@@@ -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));@@@ -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"@@@ -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",nnew file mode 100644@@@ -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; }++}nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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');
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 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
- **Page cards.** A site's big pages get a share card of their own, `social/<service>-<page>.jpg`, drawn like the site
@@ -19,7 +19,7 @@ were reconciled first.
@@ -19,7 +19,7 @@ were reconciled first.
| Kind | Files |
| 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()` |
| 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`) |
| 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) |
| 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
@@ -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.
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`)
### 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
The items a person has equipped in OpenVibe.Inventory, drawn the same on every site (plan T21). Mark each name with
@@ -33,6 +33,7 @@ const BROWSER = Object.freeze([
@@ -33,6 +33,7 @@ const BROWSER = Object.freeze([
'island.js', // activity island
'island.js', // activity island
'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css
'items.js', // what people wear (OpenVibe.Inventory name effects, plan T21); draw with items.css
'tooltip.js',
'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-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
'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
'web-runtime.js', // feature loader and route lifecycle (OVWebRuntime), from Live's ov-loader
@@ -43,6 +44,7 @@ const BROWSER = Object.freeze([
@@ -43,6 +44,7 @@ const BROWSER = Object.freeze([
const STYLES = Object.freeze([
const STYLES = Object.freeze([
'showcase.css', // openvibe-shared/showcase's sections + the ring icons (generated: scripts/build-styles.js)
'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)
'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));
const served = (name) => BROWSER.includes(String(name)) || STYLES.includes(String(name));
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.6.0",
+ "version": "4.7.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.6.0",
+ "version": "4.7.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -65,6 +65,7 @@
@@ -65,6 +65,7 @@
"./theme-loader": "./theme-loader.js",
"./theme-loader": "./theme-loader.js",
"./theme-sync": "./theme-sync.js",
"./theme-sync": "./theme-sync.js",
"./tooltip": "./tooltip.js",
"./tooltip": "./tooltip.js",
+ "./player": "./player.js",
"./trace": "./trace.js",
"./trace": "./trace.js",
"./ui": "./ui.js",
"./ui": "./ui.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,312 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -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');