openvibers/openvibe.shared Commits
4.192.0: the video that comes with you (mini.js) (#420)
Changes
@@@ -1,5 +1,33 @@# Changelog++## 4.192.0 — 2026-11-11++++**The video that comes with you.** The owner, 2026-11-10, with the browser's floating picture-in-picture window over a++watch page: "should … actually [be] like YouTube where it moves that video. Now you go to the search again or the index++… the homepage … it's just kind of confusing".++++- **New `mini.js` (`OpenVibeMini`): a small player of our own.** A watch page says its video may come along++ (`<video data-ov-player data-ov-mini data-ov-mini-title data-ov-mini-by>`). The shared player's picture-in-picture++ button then writes down what is playing (the page, the file, the title, who made it, the poster, the second, the++ speed, the volume, whether it was playing) and takes the person to the site's front page. Every page of the site++ that finds the record draws the small player and goes on from the same second: a card at the bottom left on a++ computer (the picture, "Back to the video" on hover, a line of progress, the title, who and the time, play and++ close), one bar above the app bar on a phone. The video's own page takes the second back and draws nothing. A page's++ own video starting closes it: one sound at a time. Where a new page may not start with sound, it plays silent with++ "Turn the sound on". The record is the tab's own (`sessionStorage`), so a private file's signed address never++ leaves the site. `OpenVibeMini.carry(video, { to })`, `.open(record)`, `.resume()`, `.close()`, `.record()`.++- **`player.js`:** on a video with `data-ov-mini` the picture-in-picture button is "Keep watching while you browse"++ and is shown also where the browser has no floating window of its own (it was hidden there).++- **A site turns it on** by loading `mini.js` on its pages (`ovServe.url('mini.js')`, deferred): the file starts++ itself and does nothing without a record. The bar does not load it: `navbar.js` is at its size budget, and the loader++ moves into the bar with the bar's diet.++- Motion: the card rises in 280 ms; Calm fades; Off does not move.++- `test/mini-chrome.test.js`: from the player's button to the front page at the same second, pause kept across pages,++ closing for the page's own video, the second handed back, Close forgets, the bar on a phone.++++Across sites the record will travel another way (storage is each site's own); gapless moves need pages changed in++place. Both are researched first (plan, ask 265).++## 4.191.0 — 2026-11-10- **A guest's page view counts at once again.** The beacon waits for the shared sign-in state only for a browser that has a token and no user yet (at most 2.5 s, so a signed-in person is not also counted as a guest); a page that knows its person, and a browser with no token at all, send it as they always did. The navbar's brotli budget follows (49.9 KB).@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.191.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.192.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.191.0",++ "version": "4.192.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -25,7 +25,7 @@ const BROWSER = Object.freeze(['dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents-- 'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person++ 'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'mini.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sentnnew file mode 100644@@@ -0,0 +1,224 @@++/*++ * openvibe-shared/mini.js — the video that comes with you (the owner, 2026-11-10, on the browser's floating++ * picture-in-picture window over a watch page: "should … actually [be] like YouTube where it moves that video. Now you++ * go to the search again or the index … the homepage").++ *++ * A watch page says its video may come along:++ *++ * <video data-ov-player data-ov-mini data-ov-mini-title="…" data-ov-mini-by="…" [data-ov-mini-page="/watch?v=…"]++ * [data-ov-mini-to="/"] [data-ov-mini-src="https://…/file.mp4"] src="…">++ *++ * The shared player's picture-in-picture button then calls OpenVibeMini.carry(video): what is playing is written down++ * (the page, the file, the title, who made it, the poster, the second, the speed, the volume, whether it was playing)++ * and the person goes to the site's front page. Every page of the site that finds the record draws the small player and++ * goes on from the same second: bottom left on a computer, a bar above the app bar on a phone. (A site turns this++ * on by loading this file on its pages, deferred: it starts itself and does nothing without a record.) It has play and pause,++ * a way back to the video's own page at that second, and a close. The page the video came from takes the second back++ * and the small player is not drawn there. Starting another video on a page closes it: one sound at a time.++ *++ * The record is this tab's own (sessionStorage): it never leaves the site, so a private file's signed address is safe++ * in it. Across sites it will travel another way (plan, ask 265).++ *++ * OpenVibeMini.carry(video, { to }) → true when the video was taken along++ * OpenVibeMini.open(record) draw the small player for a record++ * OpenVibeMini.resume() what a page does on load (this file does it by itself)++ * OpenVibeMini.close() stop and forget++ * OpenVibeMini.record() what is written down, or null++ */++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeMini) return;++ const KEY = 'ov-mini';++ const OLD = 6 * 3600 * 1000; // a record older than this is forgotten: nobody is still watching it++ let el = null, video = null, saved = 0;++ let live = false; // true once the record's second, speed and state have been given to the element++++ const store = () => { try { return root.sessionStorage; } catch { return null; } };++ const record = () => { try { const r = JSON.parse(store().getItem(KEY) || 'null'); return r && r.src && r.page ? r : null; } catch { return null; } };++ const save = (r) => { try { store().setItem(KEY, JSON.stringify(r)); } catch { /* a full or closed store: the video just does not come along */ } };++ const forget = () => { try { store().removeItem(KEY); } catch { /* nothing to forget */ } };++ const samePage = (page) => {++ try { const a = new URL(page, root.location.href); const b = root.location; const strip = (s) => s.replace(/([?&])t=[^&]*&?/, '$1').replace(/[?&]$/, ''); return a.origin === b.origin && a.pathname === b.pathname && strip(a.search) === strip(b.search); } catch { return false; }++ };++ const clock = (s) => { const n = Math.max(0, Math.floor(s || 0)); const h = Math.floor(n / 3600); const m = Math.floor((n % 3600) / 60); return (h ? `${h}:${String(m).padStart(2, '0')}` : String(m)) + ':' + String(n % 60).padStart(2, '0'); };++ const I = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="${d}" fill="currentColor"/></svg>`;++ const ICON = {++ play: I('M8 5.6v12.8a1 1 0 0 0 1.5.86l10.6-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6z'),++ pause: I('M7 5h3.2a.8.8 0 0 1 .8.8v12.4a.8.8 0 0 1-.8.8H7a.8.8 0 0 1-.8-.8V5.800A.8.8 0 0 1 7 5zm6.800 0H17a.8.8 0 0 1 .8.8v12.4a.8.8 0 0 1-.8.8h-3.200a.8.8 0 0 1-.8-.8V5.800a.8.8 0 0 1 .8-.8z'),++ close: I('M6.700 5.300a1 1 0 0 0-1.400 1.400L10.600 12l-5.300 5.300a1 1 0 1 0 1.400 1.400L12 13.400l5.300 5.300a1 1 0 0 0 1.400-1.400L13.400 12l5.300-5.300a1 1 0 0 0-1.400-1.400L12 10.600z'),++ back: I('M5 4h6a1 1 0 1 1 0 2H7.400l4.300 4.300a1 1 0 0 1-1.400 1.400L6 7.400V11a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1zm8 9a1 1 0 0 1 1.700-.7L19 16.600V13a1 1 0 1 1 2 0v6a1 1 0 0 1-1 1h-6a1 1 0 1 1 0-2h3.600l-4.300-4.300A1 1 0 0 1 13 13z'),++ sound: I('M4 9.500h3l4.300-3.600a.8.8 0 0 1 1.300.6v11a.8.8 0 0 1-1.300.6L7 14.500H4a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1zm12.300-.2a1 1 0 0 1 1.400 0l1.300 1.300 1.300-1.300a1 1 0 1 1 1.400 1.400L20.400 12l1.300 1.300a1 1 0 0 1-1.400 1.400L19 13.400l-1.300 1.300a1 1 0 0 1-1.400-1.400l1.300-1.300-1.300-1.300a1 1 0 0 1 0-1.400z'),++ };++++ function style() {++ if (document.getElementById('ov-mini-style')) return;++ const st = document.createElement('style');++ st.id = 'ov-mini-style';++ st.textContent = `++.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: 9990; left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }++.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }++.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }++.ovmini-back { position: absolute; left: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px 0 8px; border: 0; border-radius: 999px; background: rgba(5, 8, 15, .72); color: #fff; font: 600 .76rem/1 var(--font-sans, system-ui, sans-serif); text-decoration: none; opacity: 0; transition: opacity .16s ease; }++.ovmini:hover .ovmini-back, .ovmini-back:focus-visible { opacity: 1; }++.ovmini-sound { position: absolute; right: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px 0 8px; border: 0; border-radius: 999px; background: var(--mc); color: var(--on-accent, #fff); font: 700 .76rem/1 var(--font-sans, system-ui, sans-serif); cursor: pointer; }++.ovmini-sound[hidden] { display: none; }++.ovmini-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .22); }++.ovmini-bar i { display: block; height: 100%; width: 0; background: var(--mc); transition: width .25s linear; }++.ovmini-text { min-width: 0; padding: 9px 4px 10px 14px; color: inherit; text-decoration: none; }++.ovmini-title { display: block; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.ovmini-by { display: block; font-size: .76rem; color: var(--text-secondary, #96a7c2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++.ovmini-btn { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 12px; background: none; color: inherit; cursor: pointer; transition: background-color .15s ease, transform .12s ease; }++.ovmini-btn:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }++.ovmini-btn:active { transform: scale(.92); }++.ovmini-btn:focus-visible, .ovmini-back:focus-visible, .ovmini-sound:focus-visible, .ovmini-text:focus-visible { outline: 2px solid var(--mc); outline-offset: 2px; }++.ovmini-btn:last-child { margin-right: 4px; }++.ovmini.is-leaving { animation: ovmini-out .18s ease both; }++@keyframes ovmini-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }++@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }++@media (max-width: 640px) {++ /* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */++ .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }++ .ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }++ .ovmini-back { display: none; }++ .ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }++ .ovmini-sound span { display: none; }++ .ovmini-bar { position: static; grid-column: 1 / -1; grid-row: 2; }++ .ovmini-pic .ovmini-bar { display: none; }++ .ovmini-text { padding: 6px 2px 6px 10px; }++}++@media (min-width: 641px) { .ovmini > .ovmini-bar { display: none; } }++html[data-ov-motion=reduced] .ovmini { animation: ovmini-fade .2s ease both; }++html[data-ov-motion=off] .ovmini, html[data-ov-motion=off] .ovmini.is-leaving { animation: none; }++html[data-ov-motion=off] .ovmini-bar i { transition: none; }++@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ovmini { animation: ovmini-fade .2s ease both; } }++@keyframes ovmini-fade { from { opacity: 0; } }++@media print { .ovmini { display: none; } }`;++ document.head.appendChild(st);++ }++++ function note(r) {++ // Not before the element has taken the record's second: its first events (a volume set, a duration known) would++ // write second 0 and "paused" over what came along.++ if (!video || !live) return;++ r.t = video.currentTime || 0;++ r.playing = !video.paused && !video.ended;++ r.vol = video.volume; r.muted = video.muted; r.rate = video.playbackRate;++ if (isFinite(video.duration)) r.dur = video.duration;++ r.at = Date.now();++ save(r);++ }++++ /** Draw the small player for a record and go on from its second. */++ function open(r) {++ if (!r || !r.src) return null;++ close(true);++ live = false;++ style();++ const back = () => { const u = new URL(r.page, root.location.href); return u.pathname + u.search + u.hash; };++ el = document.createElement('aside');++ el.className = 'ovmini';++ el.setAttribute('aria-label', `Still playing: ${r.title || 'a video'}`);++ el.innerHTML = `<a class="ovmini-pic" data-m="back" aria-label="Back to the video"><video playsinline preload="auto"></video><span class="ovmini-back">${ICON.back}Back to the video</span><button type="button" class="ovmini-sound" data-m="sound" hidden>${ICON.sound}<span>Turn the sound on</span></button><span class="ovmini-bar"><i></i></span></a>`++ + '<a class="ovmini-text" data-m="back"><span class="ovmini-title"></span><span class="ovmini-by"></span></a>'++ + `<button type="button" class="ovmini-btn" data-m="play" aria-label="Play">${ICON.play}</button><button type="button" class="ovmini-btn" data-m="close" aria-label="Close the video">${ICON.close}</button><span class="ovmini-bar"><i></i></span>`;++ el.querySelector('.ovmini-title').textContent = r.title || 'Video';++ const by = el.querySelector('.ovmini-by');++ video = el.querySelector('video');++ if (r.poster) video.poster = r.poster;++ const playBtn = el.querySelector('[data-m="play"]'); const soundBtn = el.querySelector('[data-m="sound"]');++ const bars = el.querySelectorAll('.ovmini-bar i');++ const paint = () => {++ if (!video) return;++ const on = !video.paused && !video.ended;++ playBtn.innerHTML = on ? ICON.pause : ICON.play;++ playBtn.setAttribute('aria-label', on ? 'Pause' : 'Play');++ const d = isFinite(video.duration) && video.duration > 0 ? video.duration : (r.dur || 0);++ const pct = d ? Math.min(100, (video.currentTime / d) * 100) : 0;++ bars.forEach((b) => { b.style.width = pct.toFixed(2) + '%'; });++ by.textContent = [r.by, d ? `${clock(video.currentTime)} / ${clock(d)}` : clock(video.currentTime)].filter(Boolean).join(' · ');++ soundBtn.hidden = !(video.muted && on && r.wantSound);++ };++ el.querySelectorAll('[data-m="back"]').forEach((a) => { a.href = back(); });++ el.addEventListener('click', (e) => {++ const t = e.target.closest('[data-m]'); if (!t) return;++ const m = t.getAttribute('data-m');++ if (m === 'play') { if (video.paused) video.play().catch(() => {}); else video.pause(); }++ else if (m === 'sound') { e.preventDefault(); e.stopPropagation(); video.muted = false; r.wantSound = false; paint(); }++ else if (m === 'close') close();++ else if (m === 'back') note(r); // the page it came from takes the second back (resume())++ });++ video.addEventListener('timeupdate', () => { paint(); if (Date.now() - saved > 1000) { saved = Date.now(); note(r); } });++ ['play', 'pause', 'ended', 'volumechange', 'ratechange', 'durationchange'].forEach((t) => video.addEventListener(t, () => { paint(); note(r); }));++ video.addEventListener('error', () => { close(); });++ video.addEventListener('loadedmetadata', () => {++ try { if (r.t > 0 && (!isFinite(video.duration) || r.t < video.duration - 0.25)) video.currentTime = r.t; } catch { /* not seekable yet: it starts where it can */ }++ video.playbackRate = r.rate || 1;++ const was = r.playing;++ live = true;++ if (!was) { paint(); return; }++ // With its sound where the browser lets a new page make sound; else silent, with the way to turn it on.++ video.play().catch(() => { r.wantSound = !r.muted; video.muted = true; return video.play(); }).catch(() => {}).then(paint);++ }, { once: true });++ video.volume = typeof r.vol === 'number' ? r.vol : 1;++ video.muted = !!r.muted;++ video.src = r.src;++ document.body.appendChild(el);++ root.addEventListener('pagehide', leave);++ document.addEventListener('play', other, true);++ paint();++ return el;++ function leave() { note(r); }++ function other(e) { if (e.target !== video && e.target && e.target.matches && e.target.matches('video[data-ov-player], audio[data-ov-player]')) close(); }++ }++++ /** Stop and forget. `quiet` keeps the record (a redraw). */++ function close(quiet) {++ if (!el) { if (!quiet) forget(); return; }++ const node = el; const v = video;++ el = null; video = null; live = false;++ if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }++ if (!quiet) forget();++ const off = () => node.remove();++ if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();++ else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }++ }++++ /** Take a page's video along: write down what is playing and go to the site's front page (or `to`). */++ function carry(v, o = {}) {++ if (!v || !v.getAttribute) return false;++ const d = v.dataset || {};++ let src = d.ovMiniSrc || v.getAttribute('src') || '';++ if (!src || /^blob:/.test(src)) src = /^blob:/.test(v.currentSrc || '') ? '' : (v.currentSrc || '');++ if (!src) return false;++ save({++ page: d.ovMiniPage || (root.location.pathname + root.location.search), src: new URL(src, root.location.href).href,++ title: d.ovMiniTitle || document.title, by: d.ovMiniBy || '', poster: v.poster || '',++ t: v.currentTime || 0, dur: isFinite(v.duration) ? v.duration : 0, rate: v.playbackRate || 1, vol: v.volume, muted: v.muted,++ playing: !v.paused && !v.ended, at: Date.now(),++ });++ if (!record()) return false; // no store in this browser: the browser's own window is still there++ try { v.pause(); } catch { /* it stops with the page */ }++ root.location.assign(o.to || d.ovMiniTo || '/');++ return true;++ }++++ /** On a page's load: the video's own page takes its second back; any other page of the site goes on playing it. */++ function resume() {++ const r = record();++ if (!r) return null;++ if (Date.now() - (r.at || 0) > OLD) { forget(); return null; }++ if (samePage(r.page)) {++ forget();++ const v = document.querySelector('video[data-ov-mini]');++ if (v) {++ const go = () => { try { v.currentTime = r.t || 0; } catch { /* it starts at the beginning */ } if (r.playing) v.play().catch(() => {}); };++ if (v.readyState >= 1) go(); else v.addEventListener('loadedmetadata', go, { once: true });++ }++ return null;++ }++ return open(r);++ }++++ root.OpenVibeMini = { carry, open, resume, close, record };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', resume, { once: true }); else resume();++}(typeof window !== 'undefined' ? window : globalThis));@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.190.0",++ "version": "4.192.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.190.0",++ "version": "4.192.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.191.0",++ "version": "4.192.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -51,6 +51,7 @@"./presence": "./presence.js","./purse": "./purse.js","./drop": "./ov-drop.js",++ "./mini": "./mini.js","./machines": "./machines.js","./nav-slots": "./nav-slots.js","./overlay": "./overlay.js",@@@ -285,7 +285,9 @@});const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;-- if (!pipOk) pipBtn.hidden = true;++ const comes = video.hasAttribute('data-ov-mini');++ if (!pipOk && !comes) pipBtn.hidden = true;++ if (comes) { pipBtn.setAttribute('aria-label', 'Keep watching while you browse'); pipBtn.title = 'Keep watching while you browse'; }const fsOk = !!(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen);if (!fsOk) fullBtn.hidden = true;@@@ -387,7 +389,7 @@function paintAudioOnly() {const audio = video.readyState >= 1 && !video.videoWidth && !video.videoHeight;root.classList.toggle('is-audio', audio);-- if (pipOk) pipBtn.hidden = audio;++ if (pipOk || comes) pipBtn.hidden = audio;if (!audio || audioLayer.firstChild) return;const src = [video.getAttribute('data-ov-avatar'), video.getAttribute('poster')].find((u) => /^(https:\/\/|\/(?!\/))/.test(u || ''));if (src) {@@@ -445,6 +447,9 @@},nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },togglePip() {++ // A video that may come along (data-ov-mini, mini.js) goes on in the small player while the person++ // browses; without that the browser's own floating window, as before.++ if (video.hasAttribute('data-ov-mini') && window.OpenVibeMini && window.OpenVibeMini.carry(video)) return;if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});else if (video.requestPictureInPicture) video.requestPictureInPicture().catch(() => {});},nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++// The video that comes with you (mini.js, 4.192.0) in a real headless Chrome: the shared player's picture-in-picture++// button on a video that may come along takes the person to the front page, where the small player goes on from the++// same second with the video's title; it has the way back, and going back hands the second to the page's own player and++// draws nothing there; Close stops and forgets; on a phone it is a bar across the bottom; a page's own video starting++// closes it.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('mini-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const root = path.join(__dirname, '..');++const file = (res, name, type) => { res.writeHead(200, { 'content-type': type, 'accept-ranges': 'bytes' }); res.end(fs.readFileSync(path.join(root, name))); };++const page0 = (body) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Test</title><body style="margin:0;min-height:900px;background:#0b1120;color:#fff">${body}`;++const server = http.createServer((req, res) => {++ const u = req.url.split('?')[0];++ if (u === '/mini.js' || u === '/player.js') return file(res, u.slice(1), 'application/javascript');++ if (u === '/player.css') return file(res, 'player.css', 'text/css');++ if (u === '/clip.webm') return file(res, 'test/fixtures/ov-sync-clock.webm', 'video/webm');++ res.writeHead(200, { 'content-type': 'text/html' });++ if (u === '/watch') return res.end(page0(`<link rel="stylesheet" href="/player.css"><div style="width:640px"><video id="v" data-ov-player data-ov-mini data-ov-mini-title="The harbour at night" data-ov-mini-by="Kim" controls muted playsinline preload="auto" src="/clip.webm"></video></div><script src="/player.js"></script><script src="/mini.js"></script>`));++ if (u === '/second') return res.end(page0(`<video id="other" data-ov-player muted playsinline preload="auto" src="/clip.webm" style="width:320px"></video><script src="/mini.js"></script>`));++ return res.end(page0('<h1>Front page</h1><script src="/mini.js"></script>'));++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mini-'));++ const browser = await h.launch({ tmpDir });++ try {++ const page = await h.openPage(browser, await browser.newContext(), { width: 1100, height: 800 });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return v; await wait(60); } return false; };++ const take = async () => {++ await page.goto(`${base}/watch`, { maxMs: 6000 });++ assert.ok(await until(`!!document.querySelector('.ov-player-pip') && document.getElementById('v').readyState >= 1`), 'the watch page has its player');++ const dur = await page.evaluate(`document.getElementById('v').duration`);++ const at = Math.min(1.2, dur / 2);++ await page.evaluate(`(() => { const v = document.getElementById('v'); v.currentTime = ${at}; return v.play().catch(() => {}); })()`);++ assert.ok(await until(`document.getElementById('v').currentTime >= ${at}`), 'it plays');++ return at;++ };++++ // The player's button, on a video that may come along: its label says what it does, and it leaves for the front page.++ let at = await take();++ const label = await page.evaluate(`(() => { const b = document.querySelector('.ov-player-pip'); return { hidden: b.hidden, label: b.getAttribute('aria-label') }; })()`);++ assert.deepEqual(label, { hidden: false, label: 'Keep watching while you browse' }, 'the button says what it does');++ await page.evaluate(`document.querySelector('.ov-player-pip').click()`);++ assert.ok(await until(`location.pathname === '/' && !!document.querySelector('.ovmini video')`), 'the front page, with the small player');++ const same = await until(`document.querySelector('.ovmini video').readyState >= 2 && document.querySelector('.ovmini video').currentTime >= ${at - 0.05}`);++ if (!same) console.error('mini video:', await page.evaluate(`(() => { const v = document.querySelector('.ovmini video'); return JSON.stringify({ ready: v.readyState, t: v.currentTime, dur: v.duration, paused: v.paused, err: v.error && v.error.code, rec: OpenVibeMini.record() && OpenVibeMini.record().t, at: ${at} }); })()`));++ assert.ok(same, 'it goes on from the same second');++ await wait(450); // its entrance is over: it is measured where it rests++ const m = await page.evaluate(`(() => { const e = document.querySelector('.ovmini'), r = e.getBoundingClientRect(), v = e.querySelector('video');++ return { title: e.querySelector('.ovmini-title').textContent, by: e.querySelector('.ovmini-by').textContent, back: e.querySelector('.ovmini-pic').getAttribute('href'), left: r.left, bottom: innerHeight - r.bottom, width: r.width,++ label: e.getAttribute('aria-label'), playing: !v.paused, pause: e.querySelector('[data-m="play"]').getAttribute('aria-label'), rec: !!OpenVibeMini.record(), fixed: getComputedStyle(e).position }; })()`);++ assert.equal(m.title, 'The harbour at night'); assert.match(m.by, /^Kim · \d+:\d\d/);++ assert.deepEqual([m.back, m.label, m.fixed, m.rec], ['/watch', 'Still playing: The harbour at night', 'fixed', true]);++ assert.ok(m.left >= 16 && m.left <= 20 && m.bottom >= 16 && m.bottom <= 20 && Math.round(m.width) === 340, `bottom left on a computer, 340 wide (${JSON.stringify([m.left, m.bottom, m.width])})`);++ assert.deepEqual([m.playing, m.pause], [true, 'Pause'], 'it was playing, so it plays, and its button says Pause');++ // Pause from its button; the record keeps the second.++ await page.evaluate(`document.querySelector('.ovmini [data-m="play"]').click()`);++ assert.ok(await until(`document.querySelector('.ovmini video').paused && document.querySelector('.ovmini [data-m="play"]').getAttribute('aria-label') === 'Play' && OpenVibeMini.record().playing === false`), 'paused, and written down');++++ // Another page of the site: it is there again, still paused, at its second.++ const second0 = await page.evaluate(`OpenVibeMini.record().t`);++ await page.goto(`${base}/second`, { maxMs: 6000 });++ assert.ok(await until(`!!document.querySelector('.ovmini video') && document.querySelector('.ovmini video').readyState >= 1`), 'it follows to the next page');++ assert.ok(await until(`Math.abs(document.querySelector('.ovmini video').currentTime - ${second0}) < 0.3 && document.querySelector('.ovmini video').paused`), 'at its second, paused as it was left');++ // That page's own video starting closes it: one sound at a time.++ await page.evaluate(`document.getElementById('other').play().catch(() => {})`);++ assert.ok(await until(`!document.querySelector('.ovmini:not(.is-leaving)') && OpenVibeMini.record() === null`), "the page's own video starting closes the small player and forgets it");++++ // The way back: the video's own page takes the second, and draws no small player.++ at = await take();++ await page.evaluate(`OpenVibeMini.carry(document.getElementById('v'))`);++ assert.ok(await until(`location.pathname === '/' && !!document.querySelector('.ovmini video') && document.querySelector('.ovmini video').readyState >= 2`));++ await page.evaluate(`document.querySelector('.ovmini-text').click()`);++ assert.ok(await until(`location.pathname === '/watch' && !!document.getElementById('v') && document.getElementById('v').readyState >= 1`), 'back on its page');++ assert.ok(await until(`document.getElementById('v').currentTime >= ${at - 0.05}`), 'the page takes the second back');++ assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down');++++ // Close stops and forgets.++ await take();++ await page.evaluate(`OpenVibeMini.carry(document.getElementById('v'))`);++ assert.ok(await until(`location.pathname === '/' && !!document.querySelector('.ovmini')`));++ await page.evaluate(`document.querySelector('.ovmini [data-m="close"]').click()`);++ assert.ok(await until(`!document.querySelector('.ovmini') && OpenVibeMini.record() === null`), 'closed and forgotten');++ await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);++ assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page');++++ // A phone: one bar across the bottom, the picture at its left, 44 px buttons.++ const phone = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780 });++ const untilP = async (expr) => { const end = Date.now() + 6000; while (Date.now() < end) { let v = false; try { v = await phone.evaluate(expr); } catch { v = false; } if (v) return v; await wait(60); } return false; };++ await phone.goto(`${base}/watch`, { maxMs: 6000 });++ assert.ok(await untilP(`document.getElementById('v').readyState >= 1`));++ await phone.evaluate(`OpenVibeMini.carry(document.getElementById('v'))`);++ assert.ok(await untilP(`location.pathname === '/' && !!document.querySelector('.ovmini video')`));++ await wait(450);++ const p = await phone.evaluate(`(() => { const e = document.querySelector('.ovmini'), r = e.getBoundingClientRect(), pic = e.querySelector('.ovmini-pic').getBoundingClientRect(), b = e.querySelector('[data-m="close"]').getBoundingClientRect();++ return { left: r.left, right: innerWidth - r.right, height: r.height, picLeft: pic.left - r.left, picW: pic.width, btn: [b.width, b.height], over: document.documentElement.scrollWidth - innerWidth }; })()`);++ assert.ok(p.left === 8 && p.right === 8 && p.height < 80, `a bar from edge to edge, low (${JSON.stringify(p)})`);++ assert.ok(p.picLeft <= 2 && p.picW === 112, 'the picture at its left');++ assert.deepEqual(p.btn, [44, 44], 'buttons a thumb can press'); assert.equal(p.over, 0, 'the page does not scroll sideways');++ console.log('mini-chrome: comes along from the player\'s button, same second, title and way back, pause kept across pages, closes for the page\'s own video, hands the second back, Close forgets, a bar on a phone');++ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,5 +1,33 @@
@@ -1,5 +1,33 @@
# Changelog
# Changelog
+## 4.192.0 — 2026-11-11
+
+**The video that comes with you.** The owner, 2026-11-10, with the browser's floating picture-in-picture window over a
+watch page: "should … actually [be] like YouTube where it moves that video. Now you go to the search again or the index
+… the homepage … it's just kind of confusing".
+
+- **New `mini.js` (`OpenVibeMini`): a small player of our own.** A watch page says its video may come along
+ (`<video data-ov-player data-ov-mini data-ov-mini-title data-ov-mini-by>`). The shared player's picture-in-picture
+ button then writes down what is playing (the page, the file, the title, who made it, the poster, the second, the
+ speed, the volume, whether it was playing) and takes the person to the site's front page. Every page of the site
+ that finds the record draws the small player and goes on from the same second: a card at the bottom left on a
+ computer (the picture, "Back to the video" on hover, a line of progress, the title, who and the time, play and
+ close), one bar above the app bar on a phone. The video's own page takes the second back and draws nothing. A page's
+ own video starting closes it: one sound at a time. Where a new page may not start with sound, it plays silent with
+ "Turn the sound on". The record is the tab's own (`sessionStorage`), so a private file's signed address never
+ leaves the site. `OpenVibeMini.carry(video, { to })`, `.open(record)`, `.resume()`, `.close()`, `.record()`.
+- **`player.js`:** on a video with `data-ov-mini` the picture-in-picture button is "Keep watching while you browse"
+ and is shown also where the browser has no floating window of its own (it was hidden there).
+- **A site turns it on** by loading `mini.js` on its pages (`ovServe.url('mini.js')`, deferred): the file starts
+ itself and does nothing without a record. The bar does not load it: `navbar.js` is at its size budget, and the loader
+ moves into the bar with the bar's diet.
+- Motion: the card rises in 280 ms; Calm fades; Off does not move.
+- `test/mini-chrome.test.js`: from the player's button to the front page at the same second, pause kept across pages,
+ closing for the page's own video, the second handed back, Close forgets, the bar on a phone.
+
+Across sites the record will travel another way (storage is each site's own); gapless moves need pages changed in
+place. Both are researched first (plan, ask 265).
+
## 4.191.0 — 2026-11-10
## 4.191.0 — 2026-11-10
- **A guest's page view counts at once again.** The beacon waits for the shared sign-in state only for a browser that has a token and no user yet (at most 2.5 s, so a signed-in person is not also counted as a guest); a page that knows its person, and a browser with no token at all, send it as they always did. The navbar's brotli budget follows (49.9 KB).
- **A guest's page view counts at once again.** The beacon waits for the shared sign-in state only for a browser that has a token and no user yet (at most 2.5 s, so a signed-in person is not also counted as a guest); a page that knows its person, and a browser with no token at all, send it as they always did. The navbar's brotli budget follows (49.9 KB).
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.191.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.192.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.191.0",
+ "version": "4.192.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
@@ -25,7 +25,7 @@ const BROWSER = Object.freeze([
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
- 'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
+ 'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'mini.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
'dock-share.js', // the Dock's Send to… (4.144.0); dock.js loads it the first time something is sent
new file mode 100644
new file mode 100644
@@ -0,0 +1,224 @@
@@ -0,0 +1,224 @@
+/*
+ * openvibe-shared/mini.js — the video that comes with you (the owner, 2026-11-10, on the browser's floating
+ * picture-in-picture window over a watch page: "should … actually [be] like YouTube where it moves that video. Now you
+ * go to the search again or the index … the homepage").
+ *
+ * A watch page says its video may come along:
+ *
+ * <video data-ov-player data-ov-mini data-ov-mini-title="…" data-ov-mini-by="…" [data-ov-mini-page="/watch?v=…"]
+ * [data-ov-mini-to="/"] [data-ov-mini-src="https://…/file.mp4"] src="…">
+ *
+ * The shared player's picture-in-picture button then calls OpenVibeMini.carry(video): what is playing is written down
+ * (the page, the file, the title, who made it, the poster, the second, the speed, the volume, whether it was playing)
+ * and the person goes to the site's front page. Every page of the site that finds the record draws the small player and
+ * goes on from the same second: bottom left on a computer, a bar above the app bar on a phone. (A site turns this
+ * on by loading this file on its pages, deferred: it starts itself and does nothing without a record.) It has play and pause,
+ * a way back to the video's own page at that second, and a close. The page the video came from takes the second back
+ * and the small player is not drawn there. Starting another video on a page closes it: one sound at a time.
+ *
+ * The record is this tab's own (sessionStorage): it never leaves the site, so a private file's signed address is safe
+ * in it. Across sites it will travel another way (plan, ask 265).
+ *
+ * OpenVibeMini.carry(video, { to }) → true when the video was taken along
+ * OpenVibeMini.open(record) draw the small player for a record
+ * OpenVibeMini.resume() what a page does on load (this file does it by itself)
+ * OpenVibeMini.close() stop and forget
+ * OpenVibeMini.record() what is written down, or null
+ */
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeMini) return;
+ const KEY = 'ov-mini';
+ const OLD = 6 * 3600 * 1000; // a record older than this is forgotten: nobody is still watching it
+ let el = null, video = null, saved = 0;
+ let live = false; // true once the record's second, speed and state have been given to the element
+
+ const store = () => { try { return root.sessionStorage; } catch { return null; } };
+ const record = () => { try { const r = JSON.parse(store().getItem(KEY) || 'null'); return r && r.src && r.page ? r : null; } catch { return null; } };
+ const save = (r) => { try { store().setItem(KEY, JSON.stringify(r)); } catch { /* a full or closed store: the video just does not come along */ } };
+ const forget = () => { try { store().removeItem(KEY); } catch { /* nothing to forget */ } };
+ const samePage = (page) => {
+ try { const a = new URL(page, root.location.href); const b = root.location; const strip = (s) => s.replace(/([?&])t=[^&]*&?/, '$1').replace(/[?&]$/, ''); return a.origin === b.origin && a.pathname === b.pathname && strip(a.search) === strip(b.search); } catch { return false; }
+ };
+ const clock = (s) => { const n = Math.max(0, Math.floor(s || 0)); const h = Math.floor(n / 3600); const m = Math.floor((n % 3600) / 60); return (h ? `${h}:${String(m).padStart(2, '0')}` : String(m)) + ':' + String(n % 60).padStart(2, '0'); };
+ const I = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="${d}" fill="currentColor"/></svg>`;
+ const ICON = {
+ play: I('M8 5.6v12.8a1 1 0 0 0 1.5.86l10.6-6.4a1 1 0 0 0 0-1.72L9.5 4.74A1 1 0 0 0 8 5.6z'),
+ pause: I('M7 5h3.2a.8.8 0 0 1 .8.8v12.4a.8.8 0 0 1-.8.8H7a.8.8 0 0 1-.8-.8V5.800A.8.8 0 0 1 7 5zm6.800 0H17a.8.8 0 0 1 .8.8v12.4a.8.8 0 0 1-.8.8h-3.200a.8.8 0 0 1-.8-.8V5.800a.8.8 0 0 1 .8-.8z'),
+ close: I('M6.700 5.300a1 1 0 0 0-1.400 1.400L10.600 12l-5.300 5.300a1 1 0 1 0 1.400 1.400L12 13.400l5.300 5.300a1 1 0 0 0 1.400-1.400L13.400 12l5.300-5.300a1 1 0 0 0-1.400-1.400L12 10.600z'),
+ back: I('M5 4h6a1 1 0 1 1 0 2H7.400l4.300 4.300a1 1 0 0 1-1.400 1.400L6 7.400V11a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1zm8 9a1 1 0 0 1 1.700-.7L19 16.600V13a1 1 0 1 1 2 0v6a1 1 0 0 1-1 1h-6a1 1 0 1 1 0-2h3.600l-4.300-4.300A1 1 0 0 1 13 13z'),
+ sound: I('M4 9.500h3l4.300-3.600a.8.8 0 0 1 1.300.6v11a.8.8 0 0 1-1.300.6L7 14.500H4a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1zm12.300-.2a1 1 0 0 1 1.400 0l1.300 1.300 1.300-1.300a1 1 0 1 1 1.400 1.400L20.400 12l1.300 1.300a1 1 0 0 1-1.400 1.400L19 13.400l-1.300 1.300a1 1 0 0 1-1.400-1.400l1.300-1.300-1.300-1.300a1 1 0 0 1 0-1.400z'),
+ };
+
+ function style() {
+ if (document.getElementById('ov-mini-style')) return;
+ const st = document.createElement('style');
+ st.id = 'ov-mini-style';
+ st.textContent = `
+.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: 9990; left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovnav-appbar-h, 0px)); width: 340px; max-width: calc(100vw - 32px); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; column-gap: 2px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #101828)); color: var(--text-primary, #e6edf7); box-shadow: 0 18px 44px -18px rgba(0, 0, 0, .7), 0 2px 8px rgba(0, 0, 0, .25); overflow: hidden; font: 500 .88rem/1.3 var(--font-sans, system-ui, sans-serif); animation: ovmini-in .28s cubic-bezier(.2, .7, .2, 1) both; }
+.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }
+.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
+.ovmini-back { position: absolute; left: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 10px 0 8px; border: 0; border-radius: 999px; background: rgba(5, 8, 15, .72); color: #fff; font: 600 .76rem/1 var(--font-sans, system-ui, sans-serif); text-decoration: none; opacity: 0; transition: opacity .16s ease; }
+.ovmini:hover .ovmini-back, .ovmini-back:focus-visible { opacity: 1; }
+.ovmini-sound { position: absolute; right: 8px; top: 8px; display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 12px 0 8px; border: 0; border-radius: 999px; background: var(--mc); color: var(--on-accent, #fff); font: 700 .76rem/1 var(--font-sans, system-ui, sans-serif); cursor: pointer; }
+.ovmini-sound[hidden] { display: none; }
+.ovmini-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .22); }
+.ovmini-bar i { display: block; height: 100%; width: 0; background: var(--mc); transition: width .25s linear; }
+.ovmini-text { min-width: 0; padding: 9px 4px 10px 14px; color: inherit; text-decoration: none; }
+.ovmini-title { display: block; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ovmini-by { display: block; font-size: .76rem; color: var(--text-secondary, #96a7c2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.ovmini-btn { display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 12px; background: none; color: inherit; cursor: pointer; transition: background-color .15s ease, transform .12s ease; }
+.ovmini-btn:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
+.ovmini-btn:active { transform: scale(.92); }
+.ovmini-btn:focus-visible, .ovmini-back:focus-visible, .ovmini-sound:focus-visible, .ovmini-text:focus-visible { outline: 2px solid var(--mc); outline-offset: 2px; }
+.ovmini-btn:last-child { margin-right: 4px; }
+.ovmini.is-leaving { animation: ovmini-out .18s ease both; }
+@keyframes ovmini-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }
+@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
+@media (max-width: 640px) {
+ /* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */
+ .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }
+ .ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }
+ .ovmini-back { display: none; }
+ .ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }
+ .ovmini-sound span { display: none; }
+ .ovmini-bar { position: static; grid-column: 1 / -1; grid-row: 2; }
+ .ovmini-pic .ovmini-bar { display: none; }
+ .ovmini-text { padding: 6px 2px 6px 10px; }
+}
+@media (min-width: 641px) { .ovmini > .ovmini-bar { display: none; } }
+html[data-ov-motion=reduced] .ovmini { animation: ovmini-fade .2s ease both; }
+html[data-ov-motion=off] .ovmini, html[data-ov-motion=off] .ovmini.is-leaving { animation: none; }
+html[data-ov-motion=off] .ovmini-bar i { transition: none; }
+@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ovmini { animation: ovmini-fade .2s ease both; } }
+@keyframes ovmini-fade { from { opacity: 0; } }
+@media print { .ovmini { display: none; } }`;
+ document.head.appendChild(st);
+ }
+
+ function note(r) {
+ // Not before the element has taken the record's second: its first events (a volume set, a duration known) would
+ // write second 0 and "paused" over what came along.
+ if (!video || !live) return;
+ r.t = video.currentTime || 0;
+ r.playing = !video.paused && !video.ended;
+ r.vol = video.volume; r.muted = video.muted; r.rate = video.playbackRate;
+ if (isFinite(video.duration)) r.dur = video.duration;
+ r.at = Date.now();
+ save(r);
+ }
+
+ /** Draw the small player for a record and go on from its second. */
+ function open(r) {
+ if (!r || !r.src) return null;
+ close(true);
+ live = false;
+ style();
+ const back = () => { const u = new URL(r.page, root.location.href); return u.pathname + u.search + u.hash; };
+ el = document.createElement('aside');
+ el.className = 'ovmini';
+ el.setAttribute('aria-label', `Still playing: ${r.title || 'a video'}`);
+ el.innerHTML = `<a class="ovmini-pic" data-m="back" aria-label="Back to the video"><video playsinline preload="auto"></video><span class="ovmini-back">${ICON.back}Back to the video</span><button type="button" class="ovmini-sound" data-m="sound" hidden>${ICON.sound}<span>Turn the sound on</span></button><span class="ovmini-bar"><i></i></span></a>`
+ + '<a class="ovmini-text" data-m="back"><span class="ovmini-title"></span><span class="ovmini-by"></span></a>'
+ + `<button type="button" class="ovmini-btn" data-m="play" aria-label="Play">${ICON.play}</button><button type="button" class="ovmini-btn" data-m="close" aria-label="Close the video">${ICON.close}</button><span class="ovmini-bar"><i></i></span>`;
+ el.querySelector('.ovmini-title').textContent = r.title || 'Video';
+ const by = el.querySelector('.ovmini-by');
+ video = el.querySelector('video');
+ if (r.poster) video.poster = r.poster;
+ const playBtn = el.querySelector('[data-m="play"]'); const soundBtn = el.querySelector('[data-m="sound"]');
+ const bars = el.querySelectorAll('.ovmini-bar i');
+ const paint = () => {
+ if (!video) return;
+ const on = !video.paused && !video.ended;
+ playBtn.innerHTML = on ? ICON.pause : ICON.play;
+ playBtn.setAttribute('aria-label', on ? 'Pause' : 'Play');
+ const d = isFinite(video.duration) && video.duration > 0 ? video.duration : (r.dur || 0);
+ const pct = d ? Math.min(100, (video.currentTime / d) * 100) : 0;
+ bars.forEach((b) => { b.style.width = pct.toFixed(2) + '%'; });
+ by.textContent = [r.by, d ? `${clock(video.currentTime)} / ${clock(d)}` : clock(video.currentTime)].filter(Boolean).join(' · ');
+ soundBtn.hidden = !(video.muted && on && r.wantSound);
+ };
+ el.querySelectorAll('[data-m="back"]').forEach((a) => { a.href = back(); });
+ el.addEventListener('click', (e) => {
+ const t = e.target.closest('[data-m]'); if (!t) return;
+ const m = t.getAttribute('data-m');
+ if (m === 'play') { if (video.paused) video.play().catch(() => {}); else video.pause(); }
+ else if (m === 'sound') { e.preventDefault(); e.stopPropagation(); video.muted = false; r.wantSound = false; paint(); }
+ else if (m === 'close') close();
+ else if (m === 'back') note(r); // the page it came from takes the second back (resume())
+ });
+ video.addEventListener('timeupdate', () => { paint(); if (Date.now() - saved > 1000) { saved = Date.now(); note(r); } });
+ ['play', 'pause', 'ended', 'volumechange', 'ratechange', 'durationchange'].forEach((t) => video.addEventListener(t, () => { paint(); note(r); }));
+ video.addEventListener('error', () => { close(); });
+ video.addEventListener('loadedmetadata', () => {
+ try { if (r.t > 0 && (!isFinite(video.duration) || r.t < video.duration - 0.25)) video.currentTime = r.t; } catch { /* not seekable yet: it starts where it can */ }
+ video.playbackRate = r.rate || 1;
+ const was = r.playing;
+ live = true;
+ if (!was) { paint(); return; }
+ // With its sound where the browser lets a new page make sound; else silent, with the way to turn it on.
+ video.play().catch(() => { r.wantSound = !r.muted; video.muted = true; return video.play(); }).catch(() => {}).then(paint);
+ }, { once: true });
+ video.volume = typeof r.vol === 'number' ? r.vol : 1;
+ video.muted = !!r.muted;
+ video.src = r.src;
+ document.body.appendChild(el);
+ root.addEventListener('pagehide', leave);
+ document.addEventListener('play', other, true);
+ paint();
+ return el;
+ function leave() { note(r); }
+ function other(e) { if (e.target !== video && e.target && e.target.matches && e.target.matches('video[data-ov-player], audio[data-ov-player]')) close(); }
+ }
+
+ /** Stop and forget. `quiet` keeps the record (a redraw). */
+ function close(quiet) {
+ if (!el) { if (!quiet) forget(); return; }
+ const node = el; const v = video;
+ el = null; video = null; live = false;
+ if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }
+ if (!quiet) forget();
+ const off = () => node.remove();
+ if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();
+ else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }
+ }
+
+ /** Take a page's video along: write down what is playing and go to the site's front page (or `to`). */
+ function carry(v, o = {}) {
+ if (!v || !v.getAttribute) return false;
+ const d = v.dataset || {};
+ let src = d.ovMiniSrc || v.getAttribute('src') || '';
+ if (!src || /^blob:/.test(src)) src = /^blob:/.test(v.currentSrc || '') ? '' : (v.currentSrc || '');
+ if (!src) return false;
+ save({
+ page: d.ovMiniPage || (root.location.pathname + root.location.search), src: new URL(src, root.location.href).href,
+ title: d.ovMiniTitle || document.title, by: d.ovMiniBy || '', poster: v.poster || '',
+ t: v.currentTime || 0, dur: isFinite(v.duration) ? v.duration : 0, rate: v.playbackRate || 1, vol: v.volume, muted: v.muted,
+ playing: !v.paused && !v.ended, at: Date.now(),
+ });
+ if (!record()) return false; // no store in this browser: the browser's own window is still there
+ try { v.pause(); } catch { /* it stops with the page */ }
+ root.location.assign(o.to || d.ovMiniTo || '/');
+ return true;
+ }
+
+ /** On a page's load: the video's own page takes its second back; any other page of the site goes on playing it. */
+ function resume() {
+ const r = record();
+ if (!r) return null;
+ if (Date.now() - (r.at || 0) > OLD) { forget(); return null; }
+ if (samePage(r.page)) {
+ forget();
+ const v = document.querySelector('video[data-ov-mini]');
+ if (v) {
+ const go = () => { try { v.currentTime = r.t || 0; } catch { /* it starts at the beginning */ } if (r.playing) v.play().catch(() => {}); };
+ if (v.readyState >= 1) go(); else v.addEventListener('loadedmetadata', go, { once: true });
+ }
+ return null;
+ }
+ return open(r);
+ }
+
+ root.OpenVibeMini = { carry, open, resume, close, record };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', resume, { once: true }); else resume();
+}(typeof window !== 'undefined' ? window : globalThis));
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.190.0",
+ "version": "4.192.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.190.0",
+ "version": "4.192.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.191.0",
+ "version": "4.192.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": {
@@ -51,6 +51,7 @@
@@ -51,6 +51,7 @@
"./presence": "./presence.js",
"./presence": "./presence.js",
"./purse": "./purse.js",
"./purse": "./purse.js",
"./drop": "./ov-drop.js",
"./drop": "./ov-drop.js",
+ "./mini": "./mini.js",
"./machines": "./machines.js",
"./machines": "./machines.js",
"./nav-slots": "./nav-slots.js",
"./nav-slots": "./nav-slots.js",
"./overlay": "./overlay.js",
"./overlay": "./overlay.js",
@@ -285,7 +285,9 @@
@@ -285,7 +285,9 @@
});
});
const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;
const pipOk = typeof document !== 'undefined' && document.pictureInPictureEnabled && !video.disablePictureInPicture;
- if (!pipOk) pipBtn.hidden = true;
+ const comes = video.hasAttribute('data-ov-mini');
+ if (!pipOk && !comes) pipBtn.hidden = true;
+ if (comes) { pipBtn.setAttribute('aria-label', 'Keep watching while you browse'); pipBtn.title = 'Keep watching while you browse'; }
const fsOk = !!(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen);
const fsOk = !!(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen);
if (!fsOk) fullBtn.hidden = true;
if (!fsOk) fullBtn.hidden = true;
@@ -387,7 +389,7 @@
@@ -387,7 +389,7 @@
function paintAudioOnly() {
function paintAudioOnly() {
const audio = video.readyState >= 1 && !video.videoWidth && !video.videoHeight;
const audio = video.readyState >= 1 && !video.videoWidth && !video.videoHeight;
root.classList.toggle('is-audio', audio);
root.classList.toggle('is-audio', audio);
- if (pipOk) pipBtn.hidden = audio;
+ if (pipOk || comes) pipBtn.hidden = audio;
if (!audio || audioLayer.firstChild) return;
if (!audio || audioLayer.firstChild) return;
const src = [video.getAttribute('data-ov-avatar'), video.getAttribute('poster')].find((u) => /^(https:\/\/|\/(?!\/))/.test(u || ''));
const src = [video.getAttribute('data-ov-avatar'), video.getAttribute('poster')].find((u) => /^(https:\/\/|\/(?!\/))/.test(u || ''));
if (src) {
if (src) {
@@ -445,6 +447,9 @@
@@ -445,6 +447,9 @@
},
},
nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
nextSpeed() { const i = SPEEDS.indexOf(video.playbackRate); video.playbackRate = SPEEDS[(i + 1) % SPEEDS.length]; paintSpeed(); },
togglePip() {
togglePip() {
+ // A video that may come along (data-ov-mini, mini.js) goes on in the small player while the person
+ // browses; without that the browser's own floating window, as before.
+ if (video.hasAttribute('data-ov-mini') && window.OpenVibeMini && window.OpenVibeMini.carry(video)) return;
if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});
if (document.pictureInPictureElement === video) document.exitPictureInPicture().catch(() => {});
else if (video.requestPictureInPicture) video.requestPictureInPicture().catch(() => {});
else if (video.requestPictureInPicture) video.requestPictureInPicture().catch(() => {});
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+// The video that comes with you (mini.js, 4.192.0) in a real headless Chrome: the shared player's picture-in-picture
+// button on a video that may come along takes the person to the front page, where the small player goes on from the
+// same second with the video's title; it has the way back, and going back hands the second to the page's own player and
+// draws nothing there; Close stops and forgets; on a phone it is a bar across the bottom; a page's own video starting
+// closes it.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('mini-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const root = path.join(__dirname, '..');
+const file = (res, name, type) => { res.writeHead(200, { 'content-type': type, 'accept-ranges': 'bytes' }); res.end(fs.readFileSync(path.join(root, name))); };
+const page0 = (body) => `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Test</title><body style="margin:0;min-height:900px;background:#0b1120;color:#fff">${body}`;
+const server = http.createServer((req, res) => {
+ const u = req.url.split('?')[0];
+ if (u === '/mini.js' || u === '/player.js') return file(res, u.slice(1), 'application/javascript');
+ if (u === '/player.css') return file(res, 'player.css', 'text/css');
+ if (u === '/clip.webm') return file(res, 'test/fixtures/ov-sync-clock.webm', 'video/webm');
+ res.writeHead(200, { 'content-type': 'text/html' });
+ if (u === '/watch') return res.end(page0(`<link rel="stylesheet" href="/player.css"><div style="width:640px"><video id="v" data-ov-player data-ov-mini data-ov-mini-title="The harbour at night" data-ov-mini-by="Kim" controls muted playsinline preload="auto" src="/clip.webm"></video></div><script src="/player.js"></script><script src="/mini.js"></script>`));
+ if (u === '/second') return res.end(page0(`<video id="other" data-ov-player muted playsinline preload="auto" src="/clip.webm" style="width:320px"></video><script src="/mini.js"></script>`));
+ return res.end(page0('<h1>Front page</h1><script src="/mini.js"></script>'));
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-mini-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const page = await h.openPage(browser, await browser.newContext(), { width: 1100, height: 800 });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const until = async (expr, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { let v = false; try { v = await page.evaluate(expr); } catch { v = false; } if (v) return v; await wait(60); } return false; };
+ const take = async () => {
+ await page.goto(`${base}/watch`, { maxMs: 6000 });
+ assert.ok(await until(`!!document.querySelector('.ov-player-pip') && document.getElementById('v').readyState >= 1`), 'the watch page has its player');
+ const dur = await page.evaluate(`document.getElementById('v').duration`);
+ const at = Math.min(1.2, dur / 2);
+ await page.evaluate(`(() => { const v = document.getElementById('v'); v.currentTime = ${at}; return v.play().catch(() => {}); })()`);
+ assert.ok(await until(`document.getElementById('v').currentTime >= ${at}`), 'it plays');
+ return at;
+ };
+
+ // The player's button, on a video that may come along: its label says what it does, and it leaves for the front page.
+ let at = await take();
+ const label = await page.evaluate(`(() => { const b = document.querySelector('.ov-player-pip'); return { hidden: b.hidden, label: b.getAttribute('aria-label') }; })()`);
+ assert.deepEqual(label, { hidden: false, label: 'Keep watching while you browse' }, 'the button says what it does');
+ await page.evaluate(`document.querySelector('.ov-player-pip').click()`);
+ assert.ok(await until(`location.pathname === '/' && !!document.querySelector('.ovmini video')`), 'the front page, with the small player');
+ const same = await until(`document.querySelector('.ovmini video').readyState >= 2 && document.querySelector('.ovmini video').currentTime >= ${at - 0.05}`);
+ if (!same) console.error('mini video:', await page.evaluate(`(() => { const v = document.querySelector('.ovmini video'); return JSON.stringify({ ready: v.readyState, t: v.currentTime, dur: v.duration, paused: v.paused, err: v.error && v.error.code, rec: OpenVibeMini.record() && OpenVibeMini.record().t, at: ${at} }); })()`));
+ assert.ok(same, 'it goes on from the same second');
+ await wait(450); // its entrance is over: it is measured where it rests
+ const m = await page.evaluate(`(() => { const e = document.querySelector('.ovmini'), r = e.getBoundingClientRect(), v = e.querySelector('video');
+ return { title: e.querySelector('.ovmini-title').textContent, by: e.querySelector('.ovmini-by').textContent, back: e.querySelector('.ovmini-pic').getAttribute('href'), left: r.left, bottom: innerHeight - r.bottom, width: r.width,
+ label: e.getAttribute('aria-label'), playing: !v.paused, pause: e.querySelector('[data-m="play"]').getAttribute('aria-label'), rec: !!OpenVibeMini.record(), fixed: getComputedStyle(e).position }; })()`);
+ assert.equal(m.title, 'The harbour at night'); assert.match(m.by, /^Kim · \d+:\d\d/);
+ assert.deepEqual([m.back, m.label, m.fixed, m.rec], ['/watch', 'Still playing: The harbour at night', 'fixed', true]);
+ assert.ok(m.left >= 16 && m.left <= 20 && m.bottom >= 16 && m.bottom <= 20 && Math.round(m.width) === 340, `bottom left on a computer, 340 wide (${JSON.stringify([m.left, m.bottom, m.width])})`);
+ assert.deepEqual([m.playing, m.pause], [true, 'Pause'], 'it was playing, so it plays, and its button says Pause');
+ // Pause from its button; the record keeps the second.
+ await page.evaluate(`document.querySelector('.ovmini [data-m="play"]').click()`);
+ assert.ok(await until(`document.querySelector('.ovmini video').paused && document.querySelector('.ovmini [data-m="play"]').getAttribute('aria-label') === 'Play' && OpenVibeMini.record().playing === false`), 'paused, and written down');
+
+ // Another page of the site: it is there again, still paused, at its second.
+ const second0 = await page.evaluate(`OpenVibeMini.record().t`);
+ await page.goto(`${base}/second`, { maxMs: 6000 });
+ assert.ok(await until(`!!document.querySelector('.ovmini video') && document.querySelector('.ovmini video').readyState >= 1`), 'it follows to the next page');
+ assert.ok(await until(`Math.abs(document.querySelector('.ovmini video').currentTime - ${second0}) < 0.3 && document.querySelector('.ovmini video').paused`), 'at its second, paused as it was left');
+ // That page's own video starting closes it: one sound at a time.
+ await page.evaluate(`document.getElementById('other').play().catch(() => {})`);
+ assert.ok(await until(`!document.querySelector('.ovmini:not(.is-leaving)') && OpenVibeMini.record() === null`), "the page's own video starting closes the small player and forgets it");
+
+ // The way back: the video's own page takes the second, and draws no small player.
+ at = await take();
+ await page.evaluate(`OpenVibeMini.carry(document.getElementById('v'))`);
+ assert.ok(await until(`location.pathname === '/' && !!document.querySelector('.ovmini video') && document.querySelector('.ovmini video').readyState >= 2`));
+ await page.evaluate(`document.querySelector('.ovmini-text').click()`);
+ assert.ok(await until(`location.pathname === '/watch' && !!document.getElementById('v') && document.getElementById('v').readyState >= 1`), 'back on its page');
+ assert.ok(await until(`document.getElementById('v').currentTime >= ${at - 0.05}`), 'the page takes the second back');
+ assert.deepEqual(await page.evaluate(`[!!document.querySelector('.ovmini'), OpenVibeMini.record()]`), [false, null], 'no small player on the video\'s own page, and nothing left written down');
+
+ // Close stops and forgets.
+ await take();
+ await page.evaluate(`OpenVibeMini.carry(document.getElementById('v'))`);
+ assert.ok(await until(`location.pathname === '/' && !!document.querySelector('.ovmini')`));
+ await page.evaluate(`document.querySelector('.ovmini [data-m="close"]').click()`);
+ assert.ok(await until(`!document.querySelector('.ovmini') && OpenVibeMini.record() === null`), 'closed and forgotten');
+ await page.goto(`${base}/second`, { maxMs: 6000 }); await wait(300);
+ assert.equal(await page.evaluate(`!!document.querySelector('.ovmini')`), false, 'and it does not come back on the next page');
+
+ // A phone: one bar across the bottom, the picture at its left, 44 px buttons.
+ const phone = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780 });
+ const untilP = async (expr) => { const end = Date.now() + 6000; while (Date.now() < end) { let v = false; try { v = await phone.evaluate(expr); } catch { v = false; } if (v) return v; await wait(60); } return false; };
+ await phone.goto(`${base}/watch`, { maxMs: 6000 });
+ assert.ok(await untilP(`document.getElementById('v').readyState >= 1`));
+ await phone.evaluate(`OpenVibeMini.carry(document.getElementById('v'))`);
+ assert.ok(await untilP(`location.pathname === '/' && !!document.querySelector('.ovmini video')`));
+ await wait(450);
+ const p = await phone.evaluate(`(() => { const e = document.querySelector('.ovmini'), r = e.getBoundingClientRect(), pic = e.querySelector('.ovmini-pic').getBoundingClientRect(), b = e.querySelector('[data-m="close"]').getBoundingClientRect();
+ return { left: r.left, right: innerWidth - r.right, height: r.height, picLeft: pic.left - r.left, picW: pic.width, btn: [b.width, b.height], over: document.documentElement.scrollWidth - innerWidth }; })()`);
+ assert.ok(p.left === 8 && p.right === 8 && p.height < 80, `a bar from edge to edge, low (${JSON.stringify(p)})`);
+ assert.ok(p.picLeft <= 2 && p.picW === 112, 'the picture at its left');
+ assert.deepEqual(p.btn, [44, 44], 'buttons a thumb can press'); assert.equal(p.over, 0, 'the page does not scroll sideways');
+ console.log('mini-chrome: comes along from the player\'s button, same second, title and way back, pause kept across pages, closes for the page\'s own video, hands the second back, Close forgets, a bar on a phone');
+ } finally { await browser.close(); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
+})().catch((err) => { console.error(err); process.exit(1); });