openvibers/openvibe.shared mini.js
mini.js
| 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 | ; |
| 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; } } |
| 95 | html[data-ov-motion=reduced] .ovmini { animation: ovmini-fade .2s ease both; } |
| 96 | html[data-ov-motion=off] .ovmini, html[data-ov-motion=off] .ovmini.is-leaving { animation: none; } |
| 97 | html[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 |