openvibers/openvibe.shared mini.js

mini.js

main 18.7 KB · 244 lines · javascript

1/*
2 * openvibe-shared/mini.js — the video that comes with you (the owner, 2026-11-10, on the browser's floating
3 * picture-in-picture window over a watch page: "should … actually [be] like YouTube where it moves that video. Now you
4 * go to the search again or the index … the homepage").
5 *
6 * A watch page says its video may come along:
7 *
8 * <video data-ov-player data-ov-mini data-ov-mini-title="…" data-ov-mini-by="…" [data-ov-mini-page="/watch?v=…"]
9 * [data-ov-mini-to="/"] [data-ov-mini-src="https://…/file.mp4"] [data-ov-mini-hls="https://…/index.m3u8"] src="…">
10 *
11 * The shared player's picture-in-picture button then calls OpenVibeMini.carry(video): what is playing is written down
12 * (the page, the file, the title, who made it, the poster, the second, the speed, the volume, whether it was playing)
13 * and the person goes to the site's front page. Every page of the site that finds the record draws the small player and
14 * goes on from the same second: bottom left on a computer, a bar above the app bar on a phone. (A site turns this
15 * on by loading this file on its pages, deferred: it starts itself and does nothing without a record.) It has play and pause,
16 * 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
17 * and the small player is not drawn there. Starting another video on a page closes it: one sound at a time.
18 * It plays what the page plays: the streaming playlist (data-ov-mini-hls) where the browser plays one by itself, in the
19 * same cross-origin mode as the page, and the plain file otherwise; a playlist that fails gives way to the file at the
20 * same second. A file that cannot be played either is said, with the way back.
21 *
22 * The record is this tab's own (sessionStorage): it never leaves the site, so a private file's signed address is safe
23 * in it. Across sites it will travel another way (plan, ask 265).
24 *
25 * OpenVibeMini.carry(video, { to }) → true when the video was taken along
26 * OpenVibeMini.open(record) draw the small player for a record
27 * OpenVibeMini.resume() what a page does on load (this file does it by itself)
28 * OpenVibeMini.close() stop and forget
29 * OpenVibeMini.record() what is written down, or null
30 */
31(function (root) {
32 'use strict';
33 if (typeof document === 'undefined' || root.OpenVibeMini) return;
34 const KEY = 'ov-mini';
35 const OLD = 6 * 3600 * 1000; // a record older than this is forgotten: nobody is still watching it
36 let el = null, video = null, saved = 0;
37 let live = false; // true once the record's second, speed and state have been given to the element
38
39 const store = () => { try { return root.sessionStorage; } catch { return null; } };
40 const record = () => { try { const r = JSON.parse(store().getItem(KEY) || 'null'); return r && r.src && r.page ? r : null; } catch { return null; } };
41 const save = (r) => { try { store().setItem(KEY, JSON.stringify(r)); } catch { /* a full or closed store: the video just does not come along */ } };
42 const forget = () => { try { store().removeItem(KEY); } catch { /* nothing to forget */ } };
43 const samePage = (page) => {
44 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; }
45 };
46 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'); };
47 const I = (d) => `<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"><path d="${d}" fill="currentColor"/></svg>`;
48 const ICON = {
49 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'),
50 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'),
51 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'),
52 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'),
53 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'),
54 };
55
56 function style() {
57 if (document.getElementById('ov-mini-style')) return;
58 const st = document.createElement('style');
59 st.id = 'ov-mini-style';
60 st.textContent = `
61.ovmini { --mc: var(--accent, #3b82f6); box-sizing: border-box; position: fixed; z-index: var(--ovb-z-mini, 9991); left: max(16px, env(safe-area-inset-left, 0px)); bottom: calc(max(16px, env(safe-area-inset-bottom, 0px)) + var(--ovb-bar, var(--ovnav-appbar-h, 0px))); translate: 0 calc(-1 * var(--ovb-lift, 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; }
62.ovmini-pic { position: relative; grid-column: 1 / -1; display: block; aspect-ratio: 16 / 9; background: #000; cursor: pointer; }
63.ovmini-pic video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
64.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; }
65.ovmini:hover .ovmini-back, .ovmini-back:focus-visible { opacity: 1; }
66.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; }
67.ovmini-sound[hidden] { display: none; }
68.ovmini-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .22); }
69.ovmini-bar i { display: block; height: 100%; width: 0; background: var(--mc); transition: width .25s linear; }
70.ovmini-text { min-width: 0; padding: 9px 4px 10px 14px; color: inherit; text-decoration: none; }
71.ovmini-title { display: block; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
72.ovmini-by { display: block; font-size: .76rem; color: var(--text-secondary, #96a7c2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
73.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; }
74.ovmini-btn:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
75.ovmini-btn:active { transform: scale(.92); }
76.ovmini-btn:focus-visible, .ovmini-back:focus-visible, .ovmini-sound:focus-visible, .ovmini-text:focus-visible { outline: 2px solid var(--mc); outline-offset: 2px; }
77.ovmini-btn:last-child { margin-right: 4px; }
78.ovmini-btn[hidden] { display: none; }
79.ovmini.is-broken .ovmini-bar { visibility: hidden; }
80.ovmini.is-leaving { animation: ovmini-out .18s ease both; }
81@keyframes ovmini-in { from { opacity: 0; transform: translateY(14px) scale(.98); } }
82@keyframes ovmini-out { to { opacity: 0; transform: translateY(10px); } }
83@media (max-width: 640px) {
84 /* A phone: one bar above the app bar. The picture at its left, then what is playing, then play and close. */
85 .ovmini { left: 8px; right: 8px; width: auto; max-width: none; bottom: calc(8px + var(--ovb-base, var(--ovnav-appbar-h, env(safe-area-inset-bottom, 0px)))); grid-template-columns: auto minmax(0, 1fr) auto auto; border-radius: 14px; }
86 .ovmini-pic { grid-column: 1; width: 112px; aspect-ratio: 16 / 9; }
87 .ovmini-back { display: none; }
88 .ovmini-sound { left: 4px; right: auto; top: auto; bottom: 7px; min-height: 26px; padding: 0 8px 0 5px; font-size: .68rem; }
89 .ovmini-sound span { display: none; }
90 .ovmini-bar { position: static; grid-column: 1 / -1; grid-row: 2; }
91 .ovmini-pic .ovmini-bar { display: none; }
92 .ovmini-text { padding: 6px 2px 6px 10px; }
93}
94@media (min-width: 641px) { .ovmini > .ovmini-bar { display: none; } }
95html[data-ov-motion=reduced] .ovmini { animation: ovmini-fade .2s ease both; }
96html[data-ov-motion=off] .ovmini, html[data-ov-motion=off] .ovmini.is-leaving { animation: none; }
97html[data-ov-motion=off] .ovmini-bar i { transition: none; }
98@media (prefers-reduced-motion: reduce) { html:not([data-ov-motion]) .ovmini { animation: ovmini-fade .2s ease both; } }
99@keyframes ovmini-fade { from { opacity: 0; } }
100@media print { .ovmini { display: none; } }`;
101 document.head.appendChild(st);
102 }
103
104 function note(r) {
105 // Not before the element has taken the record's second: its first events (a volume set, a duration known) would
106 // write second 0 and "paused" over what came along.
107 if (!video || !live) return;
108 r.t = video.currentTime || 0;
109 r.playing = !video.paused && !video.ended;
110 r.vol = video.volume; r.muted = video.muted; r.rate = video.playbackRate;
111 if (isFinite(video.duration)) r.dur = video.duration;
112 r.at = Date.now();
113 save(r);
114 }
115
116 /** Draw the small player for a record and go on from its second. */
117 function open(r) {
118 if (!r || !r.src) return null;
119 close(true);
120 live = false;
121 style();
122 const back = () => { const u = new URL(r.page, root.location.href); return u.pathname + u.search + u.hash; };
123 el = document.createElement('aside');
124 el.className = 'ovmini';
125 el.setAttribute('aria-label', `Still playing: ${r.title || 'a video'}`);
126 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>`
127 + '<a class="ovmini-text" data-m="back"><span class="ovmini-title"></span><span class="ovmini-by"></span></a>'
128 + `<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>`;
129 el.querySelector('.ovmini-title').textContent = r.title || 'Video';
130 const by = el.querySelector('.ovmini-by');
131 video = el.querySelector('video');
132 if (r.poster) video.poster = r.poster;
133 const playBtn = el.querySelector('[data-m="play"]'); const soundBtn = el.querySelector('[data-m="sound"]');
134 const bars = el.querySelectorAll('.ovmini-bar i');
135 const paint = () => {
136 if (!video) return;
137 const on = !video.paused && !video.ended;
138 playBtn.innerHTML = on ? ICON.pause : ICON.play;
139 playBtn.setAttribute('aria-label', on ? 'Pause' : 'Play');
140 const d = isFinite(video.duration) && video.duration > 0 ? video.duration : (r.dur || 0);
141 const pct = d ? Math.min(100, (video.currentTime / d) * 100) : 0;
142 bars.forEach((b) => { b.style.width = pct.toFixed(2) + '%'; });
143 if (!el.classList.contains('is-broken')) by.textContent = [r.by, d ? `${clock(video.currentTime)} / ${clock(d)}` : clock(video.currentTime)].filter(Boolean).join(' · ');
144 soundBtn.hidden = !(video.muted && on && r.wantSound);
145 };
146 el.querySelectorAll('[data-m="back"]').forEach((a) => { a.href = back(); });
147 el.addEventListener('click', (e) => {
148 const t = e.target.closest('[data-m]'); if (!t) return;
149 const m = t.getAttribute('data-m');
150 if (m === 'play') { if (video.paused) video.play().catch(() => {}); else video.pause(); }
151 else if (m === 'sound') { e.preventDefault(); e.stopPropagation(); video.muted = false; r.wantSound = false; paint(); }
152 else if (m === 'close') close();
153 else if (m === 'back') note(r); // the page it came from takes the second back (resume())
154 });
155 video.addEventListener('timeupdate', () => { paint(); if (Date.now() - saved > 1000) { saved = Date.now(); note(r); } });
156 ['play', 'pause', 'ended', 'volumechange', 'ratechange', 'durationchange'].forEach((t) => video.addEventListener(t, () => { paint(); note(r); }));
157 // A file that cannot be played here: say so, keep the way back, and do not follow the person to the next page.
158 video.addEventListener('error', () => {
159 if (!video) return;
160 // The playlist failed: the file, from the same second, once.
161 if (onPlaylist && r.src) { onPlaylist = false; if (live && video.currentTime > 0) r.t = video.currentTime; live = false; arm(); source(); return; }
162 live = false; forget(); playBtn.hidden = true; soundBtn.hidden = true; by.textContent = 'Open its page to keep watching'; el.classList.add('is-broken');
163 });
164 const arm = () => video.addEventListener('loadedmetadata', () => {
165 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 */ }
166 video.playbackRate = r.rate || 1;
167 const was = r.playing;
168 live = true;
169 if (!was) { paint(); return; }
170 // With its sound where the browser lets a new page make sound; else silent, with the way to turn it on.
171 video.play().catch(() => { r.wantSound = !r.muted; video.muted = true; return video.play(); }).catch(() => {}).then(paint);
172 }, { once: true });
173 // What the page plays: its playlist where the browser plays one by itself (a signed playlist is served without
174 // CORS, as on the page), else the file.
175 let onPlaylist = !!(r.hls && video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl'));
176 const source = () => {
177 if (onPlaylist) { if (!/[?&]sig=/.test(r.hls)) video.crossOrigin = 'anonymous'; video.src = r.hls; }
178 else { video.removeAttribute('crossorigin'); video.src = r.src; }
179 };
180 video.volume = typeof r.vol === 'number' ? r.vol : 1;
181 video.muted = !!r.muted;
182 arm(); source();
183 document.body.appendChild(el);
184 if (root.OpenVibeBottom) root.OpenVibeBottom.refresh();
185 root.addEventListener('pagehide', leave);
186 document.addEventListener('play', other, true);
187 paint();
188 return el;
189 function leave() { note(r); }
190 function other(e) { if (e.target !== video && e.target && e.target.matches && e.target.matches('video[data-ov-player], audio[data-ov-player]')) close(); }
191 }
192
193 /** Stop and forget. `quiet` keeps the record (a redraw). */
194 function close(quiet) {
195 if (!el) { if (!quiet) forget(); return; }
196 const node = el; const v = video;
197 el = null; video = null; live = false;
198 if (v) { try { v.pause(); v.removeAttribute('src'); v.load(); } catch { /* already gone */ } }
199 if (!quiet) forget();
200 const off = () => { node.remove(); if (root.OpenVibeBottom) root.OpenVibeBottom.refresh(); };
201 if (quiet || document.documentElement.getAttribute('data-ov-motion') === 'off') off();
202 else { node.classList.add('is-leaving'); node.addEventListener('animationend', off, { once: true }); root.setTimeout(off, 260); }
203 }
204
205 /** Take a page's video along: write down what is playing and go to the site's front page (or `to`). */
206 function carry(v, o = {}) {
207 if (!v || !v.getAttribute) return false;
208 const d = v.dataset || {};
209 let src = d.ovMiniSrc || v.getAttribute('src') || '';
210 if (!src || /^blob:/.test(src)) src = /^blob:/.test(v.currentSrc || '') ? '' : (v.currentSrc || '');
211 if (!src) return false;
212 save({
213 page: d.ovMiniPage || (root.location.pathname + root.location.search), src: new URL(src, root.location.href).href, hls: d.ovMiniHls ? new URL(d.ovMiniHls, root.location.href).href : '',
214 title: d.ovMiniTitle || document.title, by: d.ovMiniBy || '', poster: v.poster || '',
215 t: v.currentTime || 0, dur: isFinite(v.duration) ? v.duration : 0, rate: v.playbackRate || 1, vol: v.volume, muted: v.muted,
216 playing: !v.paused && !v.ended, at: Date.now(),
217 });
218 if (!record()) return false; // no store in this browser: the browser's own window is still there
219 try { v.pause(); } catch { /* it stops with the page */ }
220 root.location.assign(o.to || d.ovMiniTo || '/');
221 return true;
222 }
223
224 /** On a page's load: the video's own page takes its second back; any other page of the site goes on playing it. */
225 function resume() {
226 const r = record();
227 if (!r) return null;
228 if (Date.now() - (r.at || 0) > OLD) { forget(); return null; }
229 if (samePage(r.page)) {
230 forget();
231 const v = document.querySelector('video[data-ov-mini]');
232 if (v) {
233 const go = () => { try { v.currentTime = r.t || 0; } catch { /* it starts at the beginning */ } if (r.playing) v.play().catch(() => {}); };
234 if (v.readyState >= 1) go(); else v.addEventListener('loadedmetadata', go, { once: true });
235 }
236 return null;
237 }
238 return open(r);
239 }
240
241 root.OpenVibeMini = { carry, open, resume, close, record };
242 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', resume, { once: true }); else resume();
243}(typeof window !== 'undefined' ? window : globalThis));
244