openvibers/openvibe.shared overlay.js
overlay.js
| 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 | ; |
| 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)}} |
| 113 | html[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} |
| 147 | html[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 |