openvibers/openvibe.shared overlay.js

overlay.js

main 34.7 KB · 535 lines · javascript

1/**
2 * OpenVibe overlays (Shared 4.152.0): small floating surfaces that stay with a person across pages and across OpenVibe
3 * sites: a live channel's picture, a chat, an agent at work, a Codes task. The owner, 11-08: "move and resize it like
4 * YouTube on Android", "a chat button for the room", "make it work across sites", "an SDK any site, agent, Vibe chat
5 * or Codes can use".
6 *
7 * OVOverlay.open({ id, src, title, href, live, side: { title, src }, aspect, w, corner, portable }) → handle
8 * src a first-party https URL drawn in a sandboxed frame (Live's /embed/<name>, a chat room, an agent)
9 * title shown on the card; href: where "open" goes (the full page); live: the LIVE badge
10 * side a second frame the card's chat button opens beside it (a sheet above the app bar on a phone)
11 * portable (default true) it follows the person to another OpenVibe site they open from a link
12 * OVOverlay.close(id) · OVOverlay.list() · OVOverlay.declare(id, descriptor | null)
13 * declare: a page that draws its own floating player (Live's mini player moves its <video>) says what to open on
14 * the next site, without this file drawing anything here.
15 * window.OVOverlayHere = (card) => boolean: a page that already shows what a carried card would (Live's channel page
16 * for its own embed) answers true, and that card is not opened on arrival.
17 *
18 * Handling (one gesture set for every card): drag anywhere and it settles in the nearest corner, a flick carries it;
19 * more than half off the side, or a sideways throw past the edge, closes it; the grip on its inner corner (mouse),
20 * two fingers or a double tap resize it; a tap shows its controls (a mouse hover always does). It sits between the
21 * navbar and the phone's app bar, never under either. Corner and size are kept per card kind on this device.
22 *
23 * Across sites: while a portable card is open (or declared), a same-tab click on a link to another OpenVibe site adds
24 * `#ovo=<data>` to it; the next site's navbar sees it, loads this file and opens the same cards (the hash never
25 * reaches a server, and is removed from the address at once). Only first-party https sources are ever opened: the
26 * hosts below, checked again on arrival. Within one site the cards come back after a reload (sessionStorage).
27 */
28(function (root) {
29 'use strict';
30 if (!root || root.OVOverlay || typeof document === 'undefined') return;
31
32 // Every source a card may frame, and every site a card may follow the person to.
33 // Link shares are user-run apps; only its apex and Connect control panel are first-party.
34 const FIRST_PARTY = /^(?:(?:connect\.)?openvibe\.link|(?:[a-z0-9-]+\.)*openvibe\.(?:live|chat|actor|codes|network|games|video|design|report|my|computer|tools|services|space|community|news|wiki|blog|mail|email|events|help|quest|rent|work|food|host|deals|coupons|trade|tips|vip|zone|run|search|today|surf|meme|vision|exchange|fun|cam|casino|tours|shoes|connect|media|pics|download|domains|studio|party|locker|watch|bot|inventory))$/;
35 const KEY = 'ov-overlay';
36 const MAX_CARDS = 3;
37 const cards = new Map(); // id → card
38 const declared = new Map(); // id → descriptor (drawn by the page itself)
39 let styleEl = null, clicksWired = false;
40 const changed = (reason, card) => { try { root.dispatchEvent(new CustomEvent('openvibe:player', { detail: { reason, card: card || current() } })); } catch { /* */ } };
41 function current() {
42 const live = [...declared.values(), ...cards.values()].map((c) => c.d || c).find((c) => c.live);
43 return live ? { ...live } : null;
44 }
45
46 // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
47 const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off';
48 const phone = () => { try { return root.matchMedia('(max-width: 759px)').matches; } catch { return false; } };
49 const fine = () => { try { return root.matchMedia('(hover: hover) and (pointer: fine)').matches; } catch { return false; } };
50
51 /** A first-party https URL, or null. */
52 function safeSrc(u) {
53 try {
54 const url = new URL(String(u || ''), root.location.href);
55 if (/--[^.]+\.openvibe\.(?:link|website)$/.test(url.hostname)) return null;
56 if (url.protocol !== 'https:' && !(url.origin === root.location.origin && /^https?:$/.test(url.protocol))) return null;
57 if (url.origin !== root.location.origin && !FIRST_PARTY.test(url.hostname)) return null;
58 return url.href;
59 } catch { return null; }
60 }
61 const clean = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u001f]/g, '').slice(0, n);
62
63 /** A descriptor as it may travel: only these fields, every URL checked. */
64 function portableOf(d) {
65 const src = safeSrc(d && d.src);
66 if (!src) return null;
67 const out = { id: clean(d.id || 'card', 40).replace(/[^a-z0-9_-]/gi, '') || 'card', src, title: clean(d.title, 80), live: !!d.live, sound: !!d.sound };
68 const href = safeSrc(d.href); if (href) out.href = href;
69 const side = d.side && safeSrc(d.side.src); if (side) out.side = { src: side, title: clean(d.side.title, 80) };
70 if (Number(d.aspect) > 0.3 && Number(d.aspect) < 4) out.aspect = Number(d.aspect);
71 return out;
72 }
73
74 const ICON = {
75 chat: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H9l-5 4V6a1 1 0 0 1 1-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>',
76 open: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14 4h6v6M20 4l-8.5 8.5M18 14v4.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 4 18.5v-11A1.5 1.5 0 0 1 5.5 6H10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>',
77 sound: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z" fill="currentColor"/><path d="M16.5 8.5a5 5 0 0 1 0 7M19 6a8.5 8.5 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
78 muted: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9v6h4l5 4V5L8 9H4z" fill="currentColor"/><path d="M17 9.5l5 5M22 9.5l-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>',
79 close: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/></svg>',
80 grip: '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13L13 3M8 13l5-5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>',
81 };
82
83 function styles() {
84 if (styleEl) return;
85 styleEl = document.createElement('style');
86 styleEl.id = 'ov-overlay-style';
87 styleEl.textContent = `
88.ovo-card{position:fixed;left:0;top:0;z-index:9980;width:var(--ovo-w,320px);aspect-ratio:var(--ovo-ar,16/9);border-radius:14px;overflow:hidden;background:#000;
89 transform:translate3d(var(--ovo-x,0px),var(--ovo-y,0px),0);box-shadow:0 18px 44px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.1);
90 touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;cursor:grab;will-change:transform}
91.ovo-card.is-settling{transition:transform .34s cubic-bezier(.2,.8,.2,1),width .34s cubic-bezier(.2,.8,.2,1),opacity .25s}
92.ovo-card.is-dragging{cursor:grabbing;box-shadow:0 26px 60px -14px rgba(0,0,0,.7),0 0 0 1px rgba(255,255,255,.16)}
93.ovo-card.is-leaving{opacity:.35}
94.ovo-card.is-entering{opacity:0;transform:translate3d(var(--ovo-x,0px),calc(var(--ovo-y,0px) + 16px),0) scale(.96)}
95.ovo-frame{position:absolute;inset:0;width:100%;height:100%;border:0;background:#000;pointer-events:none}
96.ovo-card.is-live-input .ovo-frame{pointer-events:auto}
97.ovo-ui{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:space-between;opacity:0;transition:opacity .2s;background:linear-gradient(rgba(0,0,0,.62),rgba(0,0,0,0) 42%,rgba(0,0,0,0) 58%,rgba(0,0,0,.55))}
98.ovo-card.is-ui .ovo-ui{opacity:1}
99@media (hover:hover) and (pointer:fine){.ovo-card:hover .ovo-ui{opacity:1}}
100.ovo-top,.ovo-row{display:flex;align-items:center;gap:6px;padding:7px 8px}
101.ovo-live{flex:none;display:inline-flex;align-items:center;gap:5px;padding:2px 7px;border-radius:999px;background:#e5484d;color:#fff;font:800 10px/1.6 system-ui,sans-serif;letter-spacing:.06em}
102.ovo-live::before{content:'';width:6px;height:6px;border-radius:50%;background:#fff}
103/* At rest (controls hidden) a live card keeps a small red dot in its corner, so it reads as live without a tap (4.213.0). */
104.ovo-dot{position:absolute;left:9px;top:9px;width:9px;height:9px;border-radius:50%;background:#e5484d;box-shadow:0 0 0 2px rgba(255,255,255,.9),0 1px 4px rgba(0,0,0,.5);pointer-events:none;transition:opacity .2s}
105.ovo-card.is-ui .ovo-dot{opacity:0}
106@media (hover:hover) and (pointer:fine){.ovo-card:hover .ovo-dot{opacity:0}}
107/* Playing without sound (a browser blocked carried sound): one clear chip says so and takes the tap, at rest too. */
108.ovo-unmute{position:absolute;left:8px;bottom:8px;z-index:1;display:none;align-items:center;gap:6px;height:30px;padding:0 11px 0 8px;border:0;border-radius:999px;background:rgba(10,12,18,.72);color:#fff;font:700 12px/1 system-ui,sans-serif;cursor:pointer;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);box-shadow:0 0 0 1px rgba(255,255,255,.18)}
109.ovo-unmute svg{width:16px;height:16px;flex:none}
110.ovo-card.is-muted .ovo-unmute{display:inline-flex}
111.ovo-card.is-muted .ovo-unmute.is-new{animation:ovo-call 1.6s cubic-bezier(.2,.8,.2,1) .4s 2}
112@keyframes ovo-call{0%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 0 rgba(255,255,255,.45)}70%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 calc(10px * var(--ov-move, 1)) rgba(255,255,255,0)}100%{box-shadow:0 0 0 1px rgba(255,255,255,.18),0 0 0 0 rgba(255,255,255,0)}}
113html[data-ov-motion="off"] .ovo-unmute.is-new{animation:none}
114.ovo-unmute:hover{background:rgba(10,12,18,.86)}
115.ovo-unmute:focus-visible{outline:2px solid #fff;outline-offset:2px}
116.ovo-card.is-muted .ovo-row [data-ovo="sound"]{display:none}
117.ovo-card.is-narrow .ovo-unmute span{display:none}
118.ovo-card.is-narrow .ovo-unmute{padding:0 7px}
119.ovo-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#fff;font:700 12.5px/1.3 system-ui,sans-serif;text-shadow:0 1px 2px rgba(0,0,0,.5)}
120.ovo-row{justify-content:flex-end}
121.ovo-b{flex:none;display:inline-grid;place-items:center;width:34px;height:34px;border:0;border-radius:999px;background:rgba(20,22,30,.55);color:#fff;cursor:pointer;
122 -webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);transition:background-color .15s,transform .15s}
123.ovo-b svg{width:18px;height:18px}
124.ovo-b:hover{background:rgba(255,255,255,.24)}
125.ovo-b:active{transform:scale(.9)}
126.ovo-b:focus-visible{outline:2px solid #fff;outline-offset:1px}
127.ovo-b[aria-pressed="true"]{background:#fff;color:#0b0f19}
128.ovo-x{width:28px;height:28px}
129.ovo-x svg{width:15px;height:15px}
130.ovo-grip{position:absolute;width:26px;height:26px;display:grid;place-items:center;color:rgba(255,255,255,.85);cursor:nwse-resize;opacity:0;transition:opacity .2s}
131.ovo-grip svg{width:14px;height:14px}
132.ovo-card:hover .ovo-grip{opacity:1}
133.ovo-card[data-corner="br"] .ovo-grip{left:0;top:0;transform:rotate(180deg)}
134.ovo-card[data-corner="bl"] .ovo-grip{right:0;top:0;transform:rotate(270deg);cursor:nesw-resize}
135.ovo-card[data-corner="tr"] .ovo-grip{left:0;bottom:0;transform:rotate(90deg);cursor:nesw-resize}
136.ovo-card[data-corner="tl"] .ovo-grip{right:0;bottom:0}
137@media (hover:none){.ovo-grip{display:none}}
138.ovo-side{position:fixed;z-index:9979;display:flex;flex-direction:column;overflow:hidden;border-radius:16px;background:var(--bg-card,#101624);
139 box-shadow:0 20px 48px -14px rgba(0,0,0,.65),0 0 0 1px var(--border,rgba(255,255,255,.12));opacity:0;transform:translateY(10px) scale(.98);transform-origin:var(--ovo-origin,100% 100%);
140 transition:opacity .22s,transform .3s cubic-bezier(.2,.8,.2,1)}
141.ovo-side.is-open{opacity:1;transform:none}
142.ovo-side-h{display:flex;align-items:center;gap:8px;padding:8px 8px 8px 14px;border-bottom:1px solid var(--border,rgba(255,255,255,.1));color:var(--text-primary,#e6edf7);font:700 13px/1.3 system-ui,sans-serif}
143.ovo-side-h span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
144.ovo-side-h .ovo-b{background:transparent;color:inherit;width:30px;height:30px}
145.ovo-side-h .ovo-b:hover{background:rgba(127,127,127,.18)}
146.ovo-side iframe{flex:1;width:100%;border:0;background:transparent}
147html[data-ov-motion=off] .ovo-card.is-entering{transform:translate3d(var(--ovo-x,0px),var(--ovo-y,0px),0)}
148`;
149 (document.head || document.documentElement).appendChild(styleEl);
150 }
151
152 // ── Where a card may sit: between the navbar and the phone's app bar ──
153 function bounds() {
154 const nav = document.querySelector('.openvibe-navbar');
155 const nr = nav ? nav.getBoundingClientRect() : null;
156 const top = Math.max(8, nr && nr.bottom > 0 ? nr.bottom + 8 : 64);
157 const bar = document.querySelector('.ovnav-appbar');
158 const barH = bar ? bar.getBoundingClientRect().height : 0;
159 const m = phone() ? 10 : 16;
160 return { left: m, right: root.innerWidth - m, top, bottom: root.innerHeight - barH - m };
161 }
162 const heightOf = (c, w) => Math.round(w / c.aspect);
163 function clampW(w) {
164 const min = phone() ? 160 : 220, max = Math.min(phone() ? root.innerWidth - 20 : 640, root.innerWidth * (phone() ? 0.94 : 0.6));
165 return Math.round(Math.max(min, Math.min(max, w)));
166 }
167 function cornerXY(c, corner, w) {
168 const b = bounds(), h = heightOf(c, w);
169 return { x: corner[1] === 'l' ? b.left : b.right - w, y: corner[0] === 't' ? b.top : Math.max(b.top, b.bottom - h) };
170 }
171 function place(c, x, y, w, settle) {
172 c.x = x; c.y = y; c.w = w;
173 c.el.classList.toggle('is-settling', !!settle && !reduced());
174 c.el.style.setProperty('--ovo-w', `${w}px`);
175 c.el.style.setProperty('--ovo-x', `${Math.round(x)}px`);
176 c.el.style.setProperty('--ovo-y', `${Math.round(y)}px`);
177 placeSide(c);
178 }
179 function settle(c, corner, w) {
180 c.corner = corner;
181 c.el.setAttribute('data-corner', corner);
182 const p = cornerXY(c, corner, w);
183 place(c, p.x, p.y, w, true);
184 try { localStorage.setItem(`${KEY}:${c.kindKey}`, JSON.stringify({ corner, w })); } catch { /* private mode */ }
185 c.el.classList.toggle('is-narrow', w < 200); // the muted chip keeps only its icon on a small card
186 }
187 function nearest(c, x, y, w) {
188 const b = bounds(), h = heightOf(c, w);
189 return `${y + h / 2 < (b.top + b.bottom) / 2 ? 't' : 'b'}${x + w / 2 < (b.left + b.right) / 2 ? 'l' : 'r'}`;
190 }
191 function freeCorner(want, self) {
192 const used = new Set([...cards.values()].filter((c) => c !== self).map((c) => c.corner));
193 if (!used.has(want)) return want;
194 return ['br', 'bl', 'tr', 'tl'].find((k) => !used.has(k)) || want;
195 }
196 function showUi(c) {
197 c.el.classList.add('is-ui');
198 clearTimeout(c.idle);
199 c.idle = setTimeout(() => { if (!c.el.matches(':focus-within')) c.el.classList.remove('is-ui'); }, 3200);
200 }
201
202 // ── One gesture set: drag (a flick carries it, off the side closes it), pinch and the grip resize, double tap ──
203 function wire(c) {
204 const box = c.el, pts = new Map(), vel = [];
205 const capture = (id) => { try { box.setPointerCapture(id); } catch { /* a pointer the browser no longer tracks */ } };
206 let drag = null, pinch = null, grip = null, lastTap = 0;
207 box.addEventListener('pointerdown', (e) => {
208 if (e.target.closest('.ovo-b')) return;
209 if (e.target.closest('.ovo-grip')) { grip = { x: e.clientX, w: c.w }; capture(e.pointerId); e.preventDefault(); return; }
210 pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
211 capture(e.pointerId);
212 if (pts.size === 2) { const [a, b] = [...pts.values()]; pinch = { d: Math.hypot(a.x - b.x, a.y - b.y) || 1, w: c.w }; drag = null; return; }
213 drag = { sx: e.clientX, sy: e.clientY, x: c.x, y: c.y, moved: false };
214 vel.length = 0;
215 });
216 box.addEventListener('pointermove', (e) => {
217 if (grip) { const w = clampW(grip.w + (e.clientX - grip.x) * (c.corner[1] === 'r' ? -1 : 1)); const p = cornerXY(c, c.corner, w); place(c, p.x, p.y, w, false); return; }
218 if (!pts.has(e.pointerId)) return;
219 pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
220 if (pinch && pts.size === 2) {
221 const [a, b] = [...pts.values()];
222 const w = clampW(pinch.w * (Math.hypot(a.x - b.x, a.y - b.y) / pinch.d));
223 const cx = c.x + c.w / 2, cy = c.y + heightOf(c, c.w) / 2;
224 place(c, cx - w / 2, cy - heightOf(c, w) / 2, w, false);
225 return;
226 }
227 if (!drag) return;
228 const dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
229 if (!drag.moved && Math.hypot(dx, dy) < 6) return;
230 if (!drag.moved) { drag.moved = true; box.classList.add('is-dragging'); }
231 vel.push({ x: e.clientX, y: e.clientY, t: performance.now() }); if (vel.length > 6) vel.shift();
232 place(c, drag.x + dx, drag.y + dy, c.w, false);
233 const b = bounds();
234 box.classList.toggle('is-leaving', c.x + c.w < b.left + c.w * 0.45 || c.x > b.right - c.w * 0.55);
235 });
236 const end = (e) => {
237 if (grip) { grip = null; settle(c, c.corner, c.w); return; }
238 pts.delete(e.pointerId);
239 if (pinch) { if (pts.size < 2) { pinch = null; settle(c, nearest(c, c.x, c.y, c.w), c.w); } return; }
240 if (!drag) return;
241 const d = drag; drag = null;
242 box.classList.remove('is-dragging');
243 if (!d.moved) {
244 const now = performance.now();
245 if (now - lastTap < 300) { lastTap = 0; const big = c.w < clampW(phone() ? root.innerWidth : 520) * 0.8; settle(c, c.corner, clampW(big ? (phone() ? root.innerWidth - 20 : 520) : (phone() ? 200 : 320))); return; }
246 lastTap = now;
247 if (box.classList.contains('is-ui') && !fine()) { box.classList.remove('is-ui'); clearTimeout(c.idle); } else showUi(c);
248 return;
249 }
250 let vx = 0, vy = 0;
251 if (vel.length > 1) { const a = vel[0], z = vel[vel.length - 1]; const dt = Math.max(1, z.t - a.t); vx = (z.x - a.x) / dt; vy = (z.y - a.y) / dt; }
252 const b = bounds(), tdx = c.x - d.x, tdy = c.y - d.y;
253 const sideways = Math.abs(vx) > 1.2 && Math.abs(vx) > Math.abs(vy) * 1.5 && Math.abs(tdx) > Math.abs(tdy) * 2;
254 const off = c.x + c.w < b.left + c.w * 0.45 || c.x > b.right - c.w * 0.55
255 || (sideways && vx > 0 && c.x + c.w > b.right + c.w * 0.15) || (sideways && vx < 0 && c.x < b.left - c.w * 0.15);
256 if (off) { flyOff(c, vx >= 0 || c.x > b.right - c.w ? 1 : -1); return; }
257 box.classList.remove('is-leaving');
258 settle(c, nearest(c, c.x + vx * 180, c.y + vy * 180, c.w), c.w);
259 };
260 box.addEventListener('pointerup', end);
261 box.addEventListener('pointercancel', end);
262 box.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(c.id); });
263 }
264 function flyOff(c, dir) {
265 c.el.classList.remove('is-dragging');
266 if (reduced()) { close(c.id); return; }
267 c.el.classList.add('is-settling', 'is-leaving');
268 c.el.style.setProperty('--ovo-x', `${dir > 0 ? root.innerWidth + 20 : -c.w - 20}px`);
269 setTimeout(() => close(c.id), 300);
270 }
271
272 // ── The side frame (a chat beside the picture; a sheet above the app bar on a phone) ──
273 function toggleSide(c, force) {
274 const open = force != null ? force : !c.side;
275 const btn = c.el.querySelector('[data-ovo="side"]');
276 if (!open) {
277 const s = c.side; c.side = null;
278 if (c.sideSurface) { c.sideSurface.destroy(); c.sideSurface = null; }
279 if (btn) btn.setAttribute('aria-pressed', 'false');
280 if (s) { s.classList.remove('is-open'); setTimeout(() => s.remove(), reduced() ? 0 : 260); if (cards.has(c.id)) settle(c, c.corner, c.w); }
281 return;
282 }
283 if (c.side || !c.d.side) return;
284 const s = document.createElement('section');
285 s.className = 'ovo-side';
286 s.setAttribute('aria-label', c.d.side.title || `${c.d.title} chat`);
287 const h = document.createElement('div'); h.className = 'ovo-side-h';
288 const t = document.createElement('span'); t.textContent = c.d.side.title || `${c.d.title} · chat`;
289 const x = button('Close', ICON.close, () => toggleSide(c, false));
290 h.append(t, x);
291 const f = frame(c.d.side.src, c.d.side.title || 'Chat');
292 f.style.pointerEvents = 'auto';
293 s.append(h, f);
294 document.body.appendChild(s);
295 c.side = s;
296 if (btn) btn.setAttribute('aria-pressed', 'true');
297 placeSide(c);
298 if (phone()) { const r = s.getBoundingClientRect(), b = bounds(); place(c, c.corner[1] === 'l' ? b.left : b.right - c.w, Math.max(b.top, r.top - heightOf(c, c.w) - 8), c.w, true); }
299 requestAnimationFrame(() => requestAnimationFrame(() => s.classList.add('is-open')));
300 }
301 function placeSide(c) {
302 const s = c.side; if (!s) return;
303 if (phone() && root.OVSurfaces && !c.sideSurface) {
304 c.sideSurface = root.OVSurfaces.sheet({ el: s, opener: c.el.querySelector('[data-ovo="side"]'),
305 header: s.querySelector('.ovo-side-h'), body: s.querySelector('iframe'), close: () => toggleSide(c, false) });
306 c.sideSurface.open();
307 } else if (!phone() && c.sideSurface) { c.sideSurface.destroy(); c.sideSurface = null; }
308 const b = bounds(), h = heightOf(c, c.w);
309 if (phone()) {
310 const sh = Math.round(Math.min(root.innerHeight * 0.55, b.bottom - b.top - h - 12));
311 Object.assign(s.style, { left: '8px', right: '8px', width: 'auto', top: 'auto', bottom: `${root.innerHeight - b.bottom}px`, height: `${sh}px` });
312 s.style.setProperty('--ovo-origin', '50% 100%');
313 return;
314 }
315 const w = Math.max(320, c.w), sh = Math.min(460, b.bottom - b.top - 10);
316 const left = Math.min(Math.max(b.left, c.corner[1] === 'r' ? c.x + c.w - w : c.x), b.right - w);
317 const above = c.corner[0] === 'b';
318 const top = above ? Math.max(b.top, c.y - sh - 10) : Math.min(c.y + h + 10, b.bottom - sh);
319 Object.assign(s.style, { left: `${left}px`, top: `${top}px`, width: `${w}px`, height: `${sh}px`, right: 'auto', bottom: 'auto' });
320 s.style.setProperty('--ovo-origin', `${c.corner[1] === 'r' ? '100%' : '0%'} ${above ? '100%' : '0%'}`);
321 }
322
323 function button(label, icon, fn, cls) {
324 const b = document.createElement('button');
325 b.type = 'button'; b.className = `ovo-b${cls ? ` ${cls}` : ''}`;
326 b.setAttribute('aria-label', label); b.title = label; b.innerHTML = icon;
327 b.addEventListener('click', (e) => { e.stopPropagation(); fn(b); });
328 return b;
329 }
330 function frame(src, title) {
331 const f = document.createElement('iframe');
332 f.className = 'ovo-frame';
333 f.src = frameUrl(src);
334 f.title = title || 'OpenVibe';
335 f.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-popups allow-forms');
336 f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
337 f.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
338 return f;
339 }
340 function frameUrl(src) {
341 try {
342 const url = new URL(src);
343 if (url.origin === root.location.origin || url.origin !== 'https://openvibe.live') return src;
344 const match = /^\/embed\/([^/]+)$/.exec(url.pathname);
345 if (!match) return src;
346 const username = decodeURIComponent(match[1]);
347 if (!/^[A-Za-z0-9_-]{1,40}$/.test(username)) return src;
348 // All sites already allow Network frames; its player bridge frames Live.
349 return `https://openvibe.network/frame/live/${encodeURIComponent(username)}?from=${encodeURIComponent(root.location.origin)}`;
350 } catch { return src; }
351 }
352
353 // ── Opening and closing ──
354 function open(desc) {
355 const d = portableOf(desc);
356 if (!d) return null;
357 d.portable = desc.portable !== false;
358 if (cards.has(d.id)) { const c = cards.get(d.id); showUi(c); return handle(c); }
359 if (cards.size >= MAX_CARDS) close(cards.keys().next().value);
360 styles();
361 wireClicks();
362 const el = document.createElement('div');
363 el.className = 'ovo-card is-entering';
364 el.id = `ovo-${d.id}`;
365 el.setAttribute('role', 'region');
366 el.setAttribute('aria-label', `${d.title || 'OpenVibe'}${d.live ? ', live' : ''}, floating`);
367 const c = { id: d.id, d, el, x: 0, y: 0, w: 0, corner: 'br', side: null, idle: 0, aspect: d.aspect || 16 / 9, kindKey: new URL(d.src).hostname.split('.').slice(-2).join('.') };
368 el.style.setProperty('--ovo-ar', String(c.aspect));
369 const ui = document.createElement('div'); ui.className = 'ovo-ui';
370 const top = document.createElement('div'); top.className = 'ovo-top';
371 if (d.live) { const l = document.createElement('span'); l.className = 'ovo-live'; l.textContent = 'LIVE'; top.appendChild(l); }
372 const dot = d.live ? Object.assign(document.createElement('span'), { className: 'ovo-dot' }) : null;
373 if (dot) dot.setAttribute('aria-hidden', 'true');
374 const name = document.createElement('span'); name.className = 'ovo-name'; name.textContent = d.title || '';
375 top.append(name, button('Close', ICON.close, () => close(c.id), 'ovo-x'));
376 const row = document.createElement('div'); row.className = 'ovo-row';
377 // A player that speaks the embed messages (Live's /embed/<name>): its sound, and its state back (title, live, muted).
378 const embed = /^\/embed\//.test(new URL(d.src).pathname);
379 if (embed) {
380 const snd = button('Turn the sound on', ICON.muted, () => {
381 const f = el.querySelector('.ovo-frame');
382 try { f.contentWindow.postMessage({ type: 'ov-embed', cmd: c.muted === false ? 'mute' : 'unmute' }, new URL(f.src).origin); } catch { /* */ }
383 showUi(c);
384 });
385 snd.setAttribute('data-ovo', 'sound');
386 row.appendChild(snd);
387 // Muted (the browser would not carry the sound over): the chip that turns it on, at rest as well.
388 const chip = document.createElement('button');
389 chip.type = 'button'; chip.className = 'ovo-unmute is-new';
390 chip.innerHTML = `${ICON.muted}<span>Tap for sound</span>`;
391 chip.setAttribute('aria-label', 'Turn the sound on');
392 chip.addEventListener('click', (e) => { e.stopPropagation(); chip.classList.remove('is-new'); snd.click(); });
393 chip.addEventListener('pointerdown', (e) => e.stopPropagation());
394 c.unmute = chip;
395 }
396 if (d.side) { const s = button('Chat', ICON.chat, () => { toggleSide(c); showUi(c); }); s.setAttribute('data-ovo', 'side'); s.setAttribute('aria-pressed', 'false'); row.appendChild(s); }
397 if (d.href) row.appendChild(button('Open the full page', ICON.open, () => { root.location.href = d.href; }));
398 ui.append(top, row);
399 const grip = document.createElement('span'); grip.className = 'ovo-grip'; grip.innerHTML = ICON.grip; grip.setAttribute('aria-hidden', 'true');
400 const mediaFrame = frame(d.src, d.title);
401 if (d.sound && embed) mediaFrame.addEventListener('load', () => {
402 try { mediaFrame.contentWindow.postMessage({ type: 'ov-embed', cmd: 'unmute' }, new URL(mediaFrame.src).origin); } catch { /* browser may require a new tap */ }
403 });
404 el.append(mediaFrame, ...(dot ? [dot] : []), ui, ...(c.unmute ? [c.unmute] : []), grip);
405 document.body.appendChild(el);
406 cards.set(c.id, c);
407 let pref = null; try { pref = JSON.parse(localStorage.getItem(`${KEY}:${c.kindKey}`) || 'null'); } catch { /* */ }
408 const corner = freeCorner(desc.corner || (pref && /^[tb][lr]$/.test(pref.corner) ? pref.corner : 'br'), c);
409 const w = clampW(desc.w || (pref && pref.w) || (phone() ? Math.min(240, root.innerWidth * 0.56) : 360));
410 const p = cornerXY(c, corner, w);
411 c.corner = corner; el.setAttribute('data-corner', corner);
412 place(c, p.x, p.y, w, false);
413 void el.offsetWidth; // the entering style is drawn once; dropping it now plays the entrance
414 el.classList.add('is-settling');
415 el.classList.remove('is-entering');
416 wire(c);
417 if (!fine()) showUi(c);
418 remember();
419 changed('open', d);
420 return handle(c);
421 }
422 function handle(c) {
423 return { id: c.id, close: () => close(c.id), el: c.el, update: (u) => { if (u && u.title != null) { c.d.title = clean(u.title, 80); const n = c.el.querySelector('.ovo-name'); if (n) n.textContent = c.d.title; remember(); } } };
424 }
425 function close(id) {
426 const c = cards.get(id); if (!c) return;
427 toggleSide(c, false);
428 cards.delete(id);
429 clearTimeout(c.idle);
430 try { c.el.remove(); } catch { /* */ }
431 remember();
432 changed('close');
433 }
434 function list() { return [...cards.values()].map((c) => ({ ...c.d })); }
435 function declare(id, desc) {
436 const key = clean(id, 40);
437 if (!desc) declared.delete(key); else { const d = portableOf({ ...desc, id: key }); if (d) declared.set(key, d); }
438 if (declared.size) wireClicks();
439 changed('declare');
440 }
441
442 // ── Within a site: the cards come back after a reload ──
443 function remember() {
444 try {
445 const keep = [...cards.values()].filter((c) => c.d.portable).map((c) => portableOf(c.d)).filter(Boolean);
446 if (keep.length) sessionStorage.setItem(KEY, JSON.stringify(keep)); else sessionStorage.removeItem(KEY);
447 } catch { /* private mode */ }
448 }
449
450 // ── Across sites: a link to another OpenVibe site carries the cards in its hash ──
451 const b64 = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
452 const unb64 = (s) => decodeURIComponent(escape(atob(String(s).replace(/-/g, '+').replace(/_/g, '/'))));
453 function travelling() {
454 const out = [...cards.values()].filter((c) => c.d.portable).map((c) => portableOf(c.d));
455 for (const d of declared.values()) out.push(d);
456 return out.filter(Boolean).slice(0, MAX_CARDS);
457 }
458 function handoffUrl(href) {
459 const list = travelling();
460 if (!list.length) return href;
461 try {
462 const u = new URL(href, root.location.href);
463 if (u.origin === root.location.origin || u.protocol !== 'https:' || !FIRST_PARTY.test(u.hostname)) return href;
464 const data = b64(JSON.stringify(list));
465 if (data.length > 3000) return href;
466 const existing = u.hash.slice(1);
467 u.hash = existing ? `${existing}&ovo=${data}` : `ovo=${data}`;
468 changed('handoff');
469 return u.href;
470 } catch { return href; }
471 }
472 function wireClicks() {
473 if (clicksWired) return;
474 clicksWired = true;
475 document.addEventListener('click', (e) => {
476 if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
477 const a = e.target && e.target.closest && e.target.closest('a[href]');
478 if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download')) return;
479 const next = handoffUrl(a.href);
480 if (next !== a.href) a.href = next; // the browser follows the link as it was going to, with the cards
481 }, true);
482 }
483 /** On arrival: open the cards a link carried here, then take the hash out of the address. */
484 function restore() {
485 const m = /(?:^#|&)ovo=([A-Za-z0-9_-]{1,3000})/.exec(root.location.hash || '');
486 let list = null;
487 if (m) {
488 try { list = JSON.parse(unb64(m[1])); } catch { list = null; }
489 try {
490 const u = new URL(root.location.href);
491 const keep = u.hash.slice(1).split('&').filter((part) => !/^ovo=/.test(part)).join('&');
492 u.hash = keep;
493 root.history.replaceState(root.history.state, '', u.href.replace(/#$/, ''));
494 } catch { /* */ }
495 } else {
496 try { list = JSON.parse(sessionStorage.getItem(KEY) || 'null'); } catch { list = null; }
497 }
498 if (!Array.isArray(list)) return 0;
499 // A page that plays the same thing itself says so (window.OVOverlayHere = (card) => true): Live's channel page
500 // for a card that frames its own embed, so one stream never plays twice.
501 const here = typeof root.OVOverlayHere === 'function' ? root.OVOverlayHere : null;
502 let n = 0;
503 for (const d of list.slice(0, MAX_CARDS)) {
504 let skip = false; try { skip = !!(here && here({ ...d })); } catch { skip = false; }
505 if (!skip && open(d)) n += 1;
506 }
507 return n;
508 }
509
510 root.addEventListener('message', (e) => {
511 const m = e.data;
512 if (!m || m.type !== 'ov-embed' || m.event !== 'state') return;
513 for (const c of cards.values()) {
514 const f = c.el.querySelector('.ovo-frame');
515 if (!f || e.source !== f.contentWindow || e.origin !== new URL(f.src).origin) continue;
516 c.muted = !!m.muted;
517 c.d.sound = !c.muted;
518 c.d.playing = !!m.playing;
519 const b = c.el.querySelector('[data-ovo="sound"]');
520 if (b) { b.innerHTML = c.muted ? ICON.muted : ICON.sound; b.setAttribute('aria-label', c.muted ? 'Turn the sound on' : 'Mute'); b.title = b.getAttribute('aria-label'); }
521 c.el.classList.toggle('is-muted', c.muted && !!m.playing);
522 c.el.classList.toggle('is-narrow', c.el.getBoundingClientRect().width < 200);
523 if (typeof m.title === 'string' && m.title && !c.d.title) handle(c).update({ title: m.title });
524 const live = c.el.querySelector('.ovo-live'); if (live) live.hidden = m.live === false;
525 remember();
526 changed('state', { ...c.d });
527 }
528 });
529 root.addEventListener('resize', () => { for (const c of cards.values()) settle(c, c.corner, clampW(c.w)); });
530 root.OVOverlay = { open, close, list, current, declare, handoffUrl, restore, safeSrc, _travelling: travelling };
531 // After the page's own start-up (its scripts may set OVOverlayHere on DOMContentLoaded).
532 const later = () => setTimeout(restore, 0);
533 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', later, { once: true }); else later();
534})(typeof window !== 'undefined' ? window : null);
535