/** * openvibe-shared/loading.js — the network's one loading look (window.OpenVibeLoading). The owner, 2026-10-28, of Live's * stream loader: "old and outdated… awkward… consider the latest frames of the stream… more integrated, modern and * sleek… no borders and wasted space and padding… minimalistic… a better animation… reusable and shared across the * sites… a loading system you can use in the SDK and API." * * It covers what is loading edge to edge, with no card: the thing's own picture (a stream's latest frame, a video's * poster) blurred and drifting behind, the OpenVibe mark at work (the V bobs and the O breathes; nothing spins), one word of status with one quiet line under * it, and a two-pixel line along the bottom that fills as the steps go by (or sweeps while the progress is unknown). * Only when it is slow does a hint and its actions appear; a failure says what happened and offers Retry. Done, it fades. * * const l = OpenVibeLoading.mount(el, { poster, label: 'Connecting', sub: 'Getting your viewer ready', steps: 5 }) * l.update({ label, sub, step, progress }) step: 0-based of steps (or progress 0-1); omit both to keep it * l.slow({ hint, actions: [{ label, onClick }] }) a hint and actions under the status (it keeps loading) * l.fail({ title, detail, actions: [{ label, onClick }] }) stops the mark, says what happened * l.done() fades out and removes itself (the picture underneath shows through as it goes) * l.destroy() removes it at once * OpenVibeLoading.skeleton(el, { rows: 3, mark: true }) shimmering rows while a list loads → { done() } * * The element it mounts into should be positioned (relative, absolute or fixed); it fills it. Text is set with * textContent; the poster must be an https URL, a same-site path or a blob/data image. Motion stops under * the Motion setting (Calm slows its loops, Off stops them). Works without the rest of the OpenVibe Frame; colours follow the site's theme. */ (function (root) { 'use strict'; // The V's two strokes, and the "!" it becomes when loading fails (openvibe-shared/mark shapes('pair').v and .err). const V = ['M71.13 70L54.17 30', 'M71.13 70L88.62 30'], BANG = ['M71.4 30L71.4 53.36', 'M71.4 70L71.4 70.01']; // The tick it becomes when the picture arrives (mark.js shapes('pair').ok). const TICK = ['M70.02 70L59.68 50.8', 'M70.02 70L90.69 30']; // Actions carry an icon (by name, or guessed from the word): Try again turns, Copy copies (11-09, the owner: "icons // for the buttons"). const ACT_ICONS = { retry: 'M4 12a8 8 0 0 1 14-5.3M20 4v4.5h-4.5M20 12a8 8 0 0 1-14 5.3M4 20v-4.5h4.5', copy: 'M9 9h10v10H9zM5 15V5h10', report: 'M5 4h10l4 4v12H5zM9 12h6M9 16h4', }; let marks = 0; const CSS = ` .ovld { position: absolute; inset: 0; z-index: 5; display: grid; place-items: center; overflow: hidden; background: #05070c; color: #f4f6fb; font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; transition: opacity .38s ease; contain: strict; } .ovld.is-done { opacity: 0; pointer-events: none; } .ovld-poster { position: absolute; inset: -8%; background-size: cover; background-position: center; filter: blur(22px) saturate(1.25) brightness(.5); transform: scale(1.06); opacity: 0; transition: opacity .6s ease; animation: ovld-drift 16s ease-in-out infinite alternate; } .ovld-poster.is-in { opacity: 1; } .ovld::after { content: ''; position: absolute; inset: 0; background: radial-gradient(ellipse at 50% 45%, rgba(5,7,12,0) 0%, rgba(5,7,12,.35) 55%, rgba(5,7,12,.75) 100%); pointer-events: none; } .ovld { container-type: size; } .ovld-core { position: relative; z-index: 1; display: grid; justify-items: center; gap: 10px; max-width: min(86%, 420px); text-align: center; text-shadow: 0 1px 12px rgba(0,0,0,.45); animation: ovld-in .42s cubic-bezier(.2, .9, .3, 1) both; } @keyframes ovld-in { from { opacity: 0; transform: translateY(6px) scale(.97); } } .ovld-hint { max-width: 34ch; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .ovld-acts button { display: inline-flex; align-items: center; gap: 7px; } .ovld-acts button svg { width: 16px; height: 16px; flex: none; } .ovld-acts button.is-icon { width: 38px; padding: 0; justify-content: center; } .ovld.is-ok .ovld-ring { --ovm-o: var(--success, #22c55e); --ovm-v: var(--success, #22c55e); filter: drop-shadow(0 0 14px rgba(34, 197, 94, .45)); } .ovld.is-ok .ovld-ring .og, .ovld.is-ok .ovld-ring .vg { animation: none; } .ovld.is-ok .ovld-line i { background: var(--success, #22c55e); box-shadow: 0 0 10px rgba(34, 197, 94, .6); } @container (max-height: 300px) { .ovld-ring { width: 44px; height: 44px; } .ovld-core { gap: 6px; } .ovld-label { font-size: 14px; } .ovld-sub { font-size: 12px; } .ovld-hint { display: none; } .ovld-acts button { min-height: 34px; } } @container (max-height: 210px) { .ovld-ring { display: none; } .ovld-sub { display: none; } } .ovld-ring { position: relative; width: 60px; height: 60px; --ovm-o: #4f7cff; --ovm-v: #8b5cf6; filter: drop-shadow(0 0 12px rgba(124, 108, 255, .4)); } .ovld-ring svg { width: 100%; height: 100%; overflow: visible; } .ovld-ring .o { fill: none; stroke: var(--ovm-o); stroke-width: 13; transition: stroke .35s; } .ovld-ring .va, .ovld-ring .vb { fill: none; stroke: var(--ovm-v); stroke-width: 13; stroke-linecap: round; transition: stroke .35s; } .ovld-ring .ha, .ovld-ring .hb { fill: none; stroke: #000; stroke-width: 20; stroke-linecap: round; } .ovld-ring .og, .ovld-ring .vg { transform-box: view-box; } .ovld-ring .og { transform-origin: 31.38px 50px; animation: ovld-pop .56s cubic-bezier(.34,1.56,.64,1) both, ovld-breathe .9s ease-in-out 1.05s infinite; } .ovld-ring .vg { transform-origin: 71.13px 70px; animation: ovld-pop .52s cubic-bezier(.34,1.56,.64,1) .18s both, ovld-bob .9s ease-in-out .6s infinite; } .ovld-label { font-size: 15px; font-weight: 650; letter-spacing: .01em; transition: opacity .2s ease, transform .2s ease; } .ovld-sub { font-size: 12.5px; line-height: 1.45; color: rgba(244,246,251,.72); min-height: 1.45em; transition: opacity .2s ease; } .ovld-swap .ovld-label, .ovld-swap .ovld-sub { opacity: 0; transform: translateY(3px); } .ovld-help { display: none; gap: 10px; justify-items: center; margin-top: 2px; font-size: 12.5px; line-height: 1.45; color: rgba(244,246,251,.8); } .ovld.is-slow .ovld-help, .ovld.is-failed .ovld-help { display: grid; } .ovld-acts { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } .ovld-acts button { min-height: 36px; padding: 6px 14px; border: 0; border-radius: 999px; background: rgba(255,255,255,.14); color: inherit; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); } .ovld-acts button:hover, .ovld-acts button:focus-visible { background: rgba(255,255,255,.24); outline: none; } .ovld-acts button:first-child { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); } .ovld-line { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; height: 2px; background: rgba(255,255,255,.08); overflow: hidden; } .ovld-line i { position: absolute; left: 0; top: 0; bottom: 0; width: var(--ovld-p, 0%); background: linear-gradient(90deg, var(--accent, #3b82f6), var(--accent-light, #9cc0ff)); box-shadow: 0 0 10px var(--accent-light, #9cc0ff); transition: width .7s cubic-bezier(.2,.7,.2,1); } .ovld.is-unknown .ovld-line i { width: 30%; animation: ovld-sweep 1.4s ease-in-out infinite; } .ovld.is-failed .ovld-ring { --ovm-o: var(--danger, #ef4444); --ovm-v: var(--danger, #ef4444); filter: none; animation: ovld-shake .56s ease-in-out 1; } .ovld.is-failed .ovld-ring .og, .ovld.is-failed .ovld-ring .vg { animation: none; } .ovld.is-failed .ovld-line i { background: var(--danger, #ef4444); box-shadow: none; animation: none; width: 100%; } .ovld-skel { display: grid; gap: 10px; padding: 4px 0; } .ovld-skel-row { display: flex; align-items: center; gap: 12px; } .ovld-skel-mark { flex: none; width: 40px; height: 40px; border-radius: 50%; } .ovld-skel-lines { flex: 1; display: grid; gap: 7px; } .ovld-skel-line { height: 11px; border-radius: 6px; } .ovld-skel-line:last-child { width: 62%; } .ovld-skel-mark, .ovld-skel-line { background: linear-gradient(90deg, var(--bg-hover, rgba(255,255,255,.06)) 25%, rgba(255,255,255,.12) 50%, var(--bg-hover, rgba(255,255,255,.06)) 75%); background-size: 300% 100%; animation: ovld-shimmer 1.3s ease-in-out infinite; } @keyframes ovld-pop { from { transform: scale(0); } to { transform: scale(1); } } @keyframes ovld-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } } @keyframes ovld-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(.88); } } @keyframes ovld-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(2px); } } @keyframes ovld-breathe { 0%, 100% { transform: scale(.72); opacity: .75; } 50% { transform: scale(1); opacity: 1; } } @keyframes ovld-drift { from { transform: scale(1.06) translate3d(-1.5%, -1%, 0); } to { transform: scale(1.14) translate3d(1.5%, 1%, 0); } } @keyframes ovld-sweep { from { left: -30%; } to { left: 100%; } } @keyframes ovld-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } } `; function styles() { if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return; const st = document.createElement('style'); st.id = 'openvibe-loading-styles'; st.textContent = CSS; (document.head || document.documentElement).appendChild(st); } const posterUrl = (u) => { const s = String(u || ''); if (/^(blob:|data:image\/)/.test(s)) return s; try { const x = new URL(s, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } }; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it. const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off'; function mount(host, opts = {}) { styles(); const box = el('div', 'ovld is-unknown'); box.setAttribute('role', 'status'); box.setAttribute('aria-live', 'polite'); const poster = el('div', 'ovld-poster'); const core = el('div', 'ovld-core'); const ring = el('div', 'ovld-ring'); ring.setAttribute('aria-hidden', 'true'); // The OpenVibe mark (openvibe-shared/mark, the pair form): the O pops in, the V grows out of its point, then they // work in turn. The gap around the V is a mask, so it sits on the blurred picture. const mid = `ovld${++marks}`; ring.innerHTML = `` + ``; const label = el('div', 'ovld-label', opts.label || 'Loading'); const sub = el('div', 'ovld-sub', opts.sub || ''); const help = el('div', 'ovld-help'); const hint = el('div', 'ovld-hint'); const acts = el('div', 'ovld-acts'); help.append(hint, acts); core.append(ring, label, sub, help); const line = el('div', 'ovld-line'); const fill = el('i'); line.appendChild(fill); box.append(poster, core, line); host.appendChild(box); let steps = Number(opts.steps) || 0; let gone = false; function setPoster(u) { const url = posterUrl(u); if (!url) return; const img = new Image(); img.decoding = 'async'; img.onload = () => { if (gone) return; poster.style.backgroundImage = `url("${url.replace(/"/g, '%22')}")`; poster.classList.add('is-in'); }; img.src = url; } function setText(l, s) { const change = (l != null && l !== label.textContent) || (s != null && s !== sub.textContent); if (!change) return; if (reduced()) { if (l != null) label.textContent = l; if (s != null) sub.textContent = s; return; } core.classList.add('ovld-swap'); setTimeout(() => { if (l != null) label.textContent = l; if (s != null) sub.textContent = s; core.classList.remove('ovld-swap'); }, 160); } function setActions(list) { acts.textContent = ''; (list || []).slice(0, 3).forEach((a, i) => { if (!a || !a.label) return; const name = a.icon || (/again|retry|reload/i.test(a.label) ? 'retry' : /copy/i.test(a.label) ? 'copy' : /report|details|log/i.test(a.label) ? 'report' : ''); const b = el('button', ''); b.type = 'button'; if (ACT_ICONS[name]) b.insertAdjacentHTML('beforeend', ``); // The first action is the button with its word; the others are icon buttons whose word is the tooltip. if (i === 0 || !ACT_ICONS[name]) b.appendChild(el('span', '', a.label)); else { b.classList.add('is-icon'); b.setAttribute('aria-label', a.label); b.title = a.title || a.label; } if (typeof a.onClick === 'function') b.addEventListener('click', (e) => a.onClick(e)); acts.appendChild(b); }); } if (opts.poster) setPoster(opts.poster); const api = { update({ label: l, sub: s, step, progress, steps: n, poster: p } = {}) { if (gone) return api; if (n != null) steps = Number(n) || 0; let pct = null; if (progress != null && Number.isFinite(Number(progress))) pct = Math.max(0, Math.min(1, Number(progress))); else if (step != null && steps > 0) pct = Math.max(0, Math.min(1, (Number(step) + 1) / (steps + 1))); if (pct != null) { box.classList.remove('is-unknown'); box.style.setProperty('--ovld-p', `${Math.round(pct * 100)}%`); } if (l != null || s != null) setText(l, s); if (p) setPoster(p); box.classList.remove('is-slow'); return api; }, slow({ hint: h = '', actions = [] } = {}) { if (gone) return api; hint.textContent = h; setActions(actions); box.classList.add('is-slow'); return api; }, fail({ title = 'Something went wrong', detail = '', actions = [] } = {}) { if (gone) return api; box.classList.remove('is-slow', 'is-unknown'); box.classList.add('is-failed'); box.setAttribute('role', 'alert'); ring.querySelectorAll('.va, .ha').forEach((p) => p.setAttribute('d', BANG[0])); ring.querySelectorAll('.vb, .hb').forEach((p) => p.setAttribute('d', BANG[1])); setText(title, detail); hint.textContent = ''; setActions(actions); return api; }, done() { if (gone) return; gone = true; box.style.setProperty('--ovld-p', '100%'); // The V becomes a tick and turns green, then the screen fades (Calm keeps it short). if (!box.classList.contains('is-failed')) { box.classList.add('is-ok'); ring.querySelectorAll('.va, .ha').forEach((p) => p.setAttribute('d', TICK[0])); ring.querySelectorAll('.vb, .hb').forEach((p) => p.setAttribute('d', TICK[1])); } setTimeout(() => { box.classList.add('is-done'); setTimeout(() => box.remove(), 400); }, reduced() ? 160 : 420); }, destroy() { gone = true; box.remove(); }, get element() { return box; }, }; return api; } function skeleton(host, { rows = 3, mark = true } = {}) { styles(); const wrap = el('div', 'ovld-skel'); wrap.setAttribute('aria-busy', 'true'); wrap.setAttribute('aria-label', 'Loading'); for (let i = 0; i < Math.max(1, Math.min(20, rows)); i++) { const r = el('div', 'ovld-skel-row'); if (mark) r.appendChild(el('div', 'ovld-skel-mark')); const lines = el('div', 'ovld-skel-lines'); lines.append(el('div', 'ovld-skel-line'), el('div', 'ovld-skel-line')); r.appendChild(lines); wrap.appendChild(r); } host.appendChild(wrap); return { done() { wrap.remove(); }, element: wrap }; } const api = { mount, skeleton, CSS }; root.OpenVibeLoading = api; if (typeof module === 'object' && module.exports) module.exports = api; })(typeof globalThis !== 'undefined' ? globalThis : this);