openvibers/openvibe.shared Commits

4.192.0: the video that comes with you (mini.js) (#420)

OP OpenVibers committed 3 days ago · 27ddb3eb3b · 9 files, +375 −8

The owner, 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".

- mini.js (OpenVibeMini): a watch page says its video may come along (data-ov-mini, with its title and who made it).
  The shared player's picture-in-picture button writes down what is 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, one bar above the app bar on a phone. The video's own page takes the second back.
  A page's own video starting closes it. The record is the tab's own (sessionStorage).
- player.js: on such a video the button is "Keep watching while you browse", shown also where the browser has no
  floating window of its own.
- A site turns it on by loading mini.js on its pages; the bar does not load it (navbar.js is at its size budget).
- test/mini-chrome.test.js; files.js, package.json exports.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4892311..f860278 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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).
ddiff --git a/README.md b/README.md
iindex 0414397..2cc152e 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex f8094d5..4114d6b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/files.js b/files.js
iindex 616ae3e..5b1e204 100644
---- a/files.js
++++ b/files.js
@@@ -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 sent
ddiff --git a/mini.js b/mini.js
nnew file mode 100644
iindex 0000000..1281cd5
---- /dev/null
++++ b/mini.js
@@@ -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));
ddiff --git a/package-lock.json b/package-lock.json
iindex 69f26eb..5d9ff60 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 4b1717c..854d34c 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/player.js b/player.js
iindex 359d6d7..037833d 100644
---- a/player.js
++++ b/player.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(() => {});
},
ddiff --git a/test/mini-chrome.test.js b/test/mini-chrome.test.js
nnew file mode 100644
iindex 0000000..637f782
---- /dev/null
++++ b/test/mini-chrome.test.js
@@@ -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); });