openvibers/openvibe.shared loading.js
loading.js
| 1 | /** |
| 2 | * openvibe-shared/loading.js — the network's one loading look (window.OpenVibeLoading). The owner, 2026-10-28, of Live's |
| 3 | * stream loader: "old and outdated… awkward… consider the latest frames of the stream… more integrated, modern and |
| 4 | * sleek… no borders and wasted space and padding… minimalistic… a better animation… reusable and shared across the |
| 5 | * sites… a loading system you can use in the SDK and API." |
| 6 | * |
| 7 | * It covers what is loading edge to edge, with no card: the thing's own picture (a stream's latest frame, a video's |
| 8 | * 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 |
| 9 | * it, and a two-pixel line along the bottom that fills as the steps go by (or sweeps while the progress is unknown). |
| 10 | * Only when it is slow does a hint and its actions appear; a failure says what happened and offers Retry. Done, it fades. |
| 11 | * |
| 12 | * const l = OpenVibeLoading.mount(el, { poster, label: 'Connecting', sub: 'Getting your viewer ready', steps: 5 }) |
| 13 | * l.update({ label, sub, step, progress }) step: 0-based of steps (or progress 0-1); omit both to keep it |
| 14 | * l.slow({ hint, actions: [{ label, onClick }] }) a hint and actions under the status (it keeps loading) |
| 15 | * l.fail({ title, detail, actions: [{ label, onClick }] }) stops the mark, says what happened |
| 16 | * l.done() fades out and removes itself (the picture underneath shows through as it goes) |
| 17 | * l.destroy() removes it at once |
| 18 | * OpenVibeLoading.skeleton(el, { rows: 3, mark: true }) shimmering rows while a list loads → { done() } |
| 19 | * |
| 20 | * The element it mounts into should be positioned (relative, absolute or fixed); it fills it. Text is set with |
| 21 | * textContent; the poster must be an https URL, a same-site path or a blob/data image. Motion stops under |
| 22 | * the Motion setting (Calm slows its loops, Off stops them). Works without the rest of the OpenVibe Frame; colours follow the site's theme. |
| 23 | */ |
| 24 | (function (root) { |
| 25 | ; |
| 26 | // The V's two strokes, and the "!" it becomes when loading fails (openvibe-shared/mark shapes('pair').v and .err). |
| 27 | 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']; |
| 28 | // The tick it becomes when the picture arrives (mark.js shapes('pair').ok). |
| 29 | const TICK = ['M70.02 70L59.68 50.8', 'M70.02 70L90.69 30']; |
| 30 | // Actions carry an icon (by name, or guessed from the word): Try again turns, Copy copies (11-09, the owner: "icons |
| 31 | // for the buttons"). |
| 32 | const ACT_ICONS = { |
| 33 | 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', |
| 34 | copy: 'M9 9h10v10H9zM5 15V5h10', |
| 35 | report: 'M5 4h10l4 4v12H5zM9 12h6M9 16h4', |
| 36 | }; |
| 37 | let marks = 0; |
| 38 | const CSS = ` |
| 39 | .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; } |
| 40 | .ovld.is-done { opacity: 0; pointer-events: none; } |
| 41 | .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; } |
| 42 | .ovld-poster.is-in { opacity: 1; } |
| 43 | .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; } |
| 44 | .ovld { container-type: size; } |
| 45 | .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; } |
| 46 | @keyframes ovld-in { from { opacity: 0; transform: translateY(6px) scale(.97); } } |
| 47 | .ovld-hint { max-width: 34ch; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } |
| 48 | .ovld-acts button { display: inline-flex; align-items: center; gap: 7px; } |
| 49 | .ovld-acts button svg { width: 16px; height: 16px; flex: none; } |
| 50 | .ovld-acts button.is-icon { width: 38px; padding: 0; justify-content: center; } |
| 51 | .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)); } |
| 52 | .ovld.is-ok .ovld-ring .og, .ovld.is-ok .ovld-ring .vg { animation: none; } |
| 53 | .ovld.is-ok .ovld-line i { background: var(--success, #22c55e); box-shadow: 0 0 10px rgba(34, 197, 94, .6); } |
| 54 | @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; } } |
| 55 | @container (max-height: 210px) { .ovld-ring { display: none; } .ovld-sub { display: none; } } |
| 56 | .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)); } |
| 57 | .ovld-ring svg { width: 100%; height: 100%; overflow: visible; } |
| 58 | .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; } |
| 59 | .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; } |
| 60 | .ovld-label { font-size: 15px; font-weight: 650; letter-spacing: .01em; transition: opacity .2s ease, transform .2s ease; } |
| 61 | .ovld-sub { font-size: 12.5px; line-height: 1.45; color: rgba(244,246,251,.72); min-height: 1.45em; transition: opacity .2s ease; } |
| 62 | .ovld-swap .ovld-label, .ovld-swap .ovld-sub { opacity: 0; transform: translateY(3px); } |
| 63 | .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); } |
| 64 | .ovld.is-slow .ovld-help, .ovld.is-failed .ovld-help { display: grid; } |
| 65 | .ovld-acts { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } |
| 66 | .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); } |
| 67 | .ovld-acts button:hover, .ovld-acts button:focus-visible { background: rgba(255,255,255,.24); outline: none; } |
| 68 | .ovld-acts button:first-child { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); } |
| 69 | .ovld-line { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; height: 2px; background: rgba(255,255,255,.08); overflow: hidden; } |
| 70 | .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); } |
| 71 | .ovld.is-unknown .ovld-line i { width: 30%; animation: ovld-sweep 1.4s ease-in-out infinite; } |
| 72 | .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; } |
| 73 | .ovld.is-failed .ovld-ring .og, .ovld.is-failed .ovld-ring .vg { animation: none; } |
| 74 | .ovld.is-failed .ovld-line i { background: var(--danger, #ef4444); box-shadow: none; animation: none; width: 100%; } |
| 75 | .ovld-skel { display: grid; gap: 10px; padding: 4px 0; } |
| 76 | .ovld-skel-row { display: flex; align-items: center; gap: 12px; } |
| 77 | .ovld-skel-mark { flex: none; width: 40px; height: 40px; border-radius: 50%; } |
| 78 | .ovld-skel-lines { flex: 1; display: grid; gap: 7px; } |
| 79 | .ovld-skel-line { height: 11px; border-radius: 6px; } |
| 80 | .ovld-skel-line:last-child { width: 62%; } |
| 81 | .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; } |
| 82 | @keyframes ovld-pop { from { transform: scale(0); } to { transform: scale(1); } } |
| 83 | @keyframes ovld-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } } @keyframes ovld-breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(.88); } } |
| 84 | @keyframes ovld-shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-4px); } 40% { transform: translateX(4px); } 60% { transform: translateX(-3px); } 80% { transform: translateX(2px); } } |
| 85 | @keyframes ovld-breathe { 0%, 100% { transform: scale(.72); opacity: .75; } 50% { transform: scale(1); opacity: 1; } } |
| 86 | @keyframes ovld-drift { from { transform: scale(1.06) translate3d(-1.5%, -1%, 0); } to { transform: scale(1.14) translate3d(1.5%, 1%, 0); } } |
| 87 | @keyframes ovld-sweep { from { left: -30%; } to { left: 100%; } } |
| 88 | @keyframes ovld-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } } |
| 89 | `; |
| 90 | |
| 91 | function styles() { |
| 92 | if (typeof document === 'undefined' || document.getElementById('openvibe-loading-styles')) return; |
| 93 | const st = document.createElement('style'); |
| 94 | st.id = 'openvibe-loading-styles'; |
| 95 | st.textContent = CSS; |
| 96 | (document.head || document.documentElement).appendChild(st); |
| 97 | } |
| 98 | 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; } }; |
| 99 | const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; |
| 100 | // Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it. |
| 101 | const reduced = () => document.documentElement.getAttribute('data-ov-motion') === 'off'; |
| 102 | |
| 103 | function mount(host, opts = {}) { |
| 104 | styles(); |
| 105 | const box = el('div', 'ovld is-unknown'); |
| 106 | box.setAttribute('role', 'status'); |
| 107 | box.setAttribute('aria-live', 'polite'); |
| 108 | const poster = el('div', 'ovld-poster'); |
| 109 | const core = el('div', 'ovld-core'); |
| 110 | const ring = el('div', 'ovld-ring'); |
| 111 | ring.setAttribute('aria-hidden', 'true'); |
| 112 | // The OpenVibe mark (openvibe-shared/mark, the pair form): the O pops in, the V grows out of its point, then they |
| 113 | // work in turn. The gap around the V is a mask, so it sits on the blurred picture. |
| 114 | const mid = `ovld${++marks}`; |
| 115 | ring.innerHTML = `<svg viewBox="2.88 2.88 94.24 94.24"><defs><mask id="${mid}" maskUnits="userSpaceOnUse" x="-20" y="-20" width="140" height="140"><rect x="-20" y="-20" width="140" height="140" fill="#fff"/><g class="vg"><path class="ha" d="${V[0]}"/><path class="hb" d="${V[1]}"/></g></mask></defs>` |
| 116 | + `<g mask="url(#${mid})"><g class="og"><circle class="o" cx="31.38" cy="50" r="20"/></g></g><g class="vg"><path class="va" d="${V[0]}"/><path class="vb" d="${V[1]}"/></g></svg>`; |
| 117 | const label = el('div', 'ovld-label', opts.label || 'Loading'); |
| 118 | const sub = el('div', 'ovld-sub', opts.sub || ''); |
| 119 | const help = el('div', 'ovld-help'); |
| 120 | const hint = el('div', 'ovld-hint'); |
| 121 | const acts = el('div', 'ovld-acts'); |
| 122 | help.append(hint, acts); |
| 123 | core.append(ring, label, sub, help); |
| 124 | const line = el('div', 'ovld-line'); |
| 125 | const fill = el('i'); |
| 126 | line.appendChild(fill); |
| 127 | box.append(poster, core, line); |
| 128 | host.appendChild(box); |
| 129 | let steps = Number(opts.steps) || 0; |
| 130 | let gone = false; |
| 131 | |
| 132 | function setPoster(u) { |
| 133 | const url = posterUrl(u); |
| 134 | if (!url) return; |
| 135 | const img = new Image(); |
| 136 | img.decoding = 'async'; |
| 137 | img.onload = () => { if (gone) return; poster.style.backgroundImage = `url("${url.replace(/"/g, '%22')}")`; poster.classList.add('is-in'); }; |
| 138 | img.src = url; |
| 139 | } |
| 140 | function setText(l, s) { |
| 141 | const change = (l != null && l !== label.textContent) || (s != null && s !== sub.textContent); |
| 142 | if (!change) return; |
| 143 | if (reduced()) { if (l != null) label.textContent = l; if (s != null) sub.textContent = s; return; } |
| 144 | core.classList.add('ovld-swap'); |
| 145 | setTimeout(() => { if (l != null) label.textContent = l; if (s != null) sub.textContent = s; core.classList.remove('ovld-swap'); }, 160); |
| 146 | } |
| 147 | function setActions(list) { |
| 148 | acts.textContent = ''; |
| 149 | (list || []).slice(0, 3).forEach((a, i) => { |
| 150 | if (!a || !a.label) return; |
| 151 | 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' : ''); |
| 152 | const b = el('button', ''); |
| 153 | b.type = 'button'; |
| 154 | if (ACT_ICONS[name]) b.insertAdjacentHTML('beforeend', `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${ACT_ICONS[name]}"/></svg>`); |
| 155 | // The first action is the button with its word; the others are icon buttons whose word is the tooltip. |
| 156 | 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; } |
| 157 | if (typeof a.onClick === 'function') b.addEventListener('click', (e) => a.onClick(e)); |
| 158 | acts.appendChild(b); |
| 159 | }); |
| 160 | } |
| 161 | if (opts.poster) setPoster(opts.poster); |
| 162 | |
| 163 | const api = { |
| 164 | update({ label: l, sub: s, step, progress, steps: n, poster: p } = {}) { |
| 165 | if (gone) return api; |
| 166 | if (n != null) steps = Number(n) || 0; |
| 167 | let pct = null; |
| 168 | if (progress != null && Number.isFinite(Number(progress))) pct = Math.max(0, Math.min(1, Number(progress))); |
| 169 | else if (step != null && steps > 0) pct = Math.max(0, Math.min(1, (Number(step) + 1) / (steps + 1))); |
| 170 | if (pct != null) { box.classList.remove('is-unknown'); box.style.setProperty('--ovld-p', `${Math.round(pct * 100)}%`); } |
| 171 | if (l != null || s != null) setText(l, s); |
| 172 | if (p) setPoster(p); |
| 173 | box.classList.remove('is-slow'); |
| 174 | return api; |
| 175 | }, |
| 176 | slow({ hint: h = '', actions = [] } = {}) { |
| 177 | if (gone) return api; |
| 178 | hint.textContent = h; |
| 179 | setActions(actions); |
| 180 | box.classList.add('is-slow'); |
| 181 | return api; |
| 182 | }, |
| 183 | fail({ title = 'Something went wrong', detail = '', actions = [] } = {}) { |
| 184 | if (gone) return api; |
| 185 | box.classList.remove('is-slow', 'is-unknown'); |
| 186 | box.classList.add('is-failed'); |
| 187 | box.setAttribute('role', 'alert'); |
| 188 | ring.querySelectorAll('.va, .ha').forEach((p) => p.setAttribute('d', BANG[0])); |
| 189 | ring.querySelectorAll('.vb, .hb').forEach((p) => p.setAttribute('d', BANG[1])); |
| 190 | setText(title, detail); |
| 191 | hint.textContent = ''; |
| 192 | setActions(actions); |
| 193 | return api; |
| 194 | }, |
| 195 | done() { |
| 196 | if (gone) return; |
| 197 | gone = true; |
| 198 | box.style.setProperty('--ovld-p', '100%'); |
| 199 | // The V becomes a tick and turns green, then the screen fades (Calm keeps it short). |
| 200 | if (!box.classList.contains('is-failed')) { |
| 201 | box.classList.add('is-ok'); |
| 202 | ring.querySelectorAll('.va, .ha').forEach((p) => p.setAttribute('d', TICK[0])); |
| 203 | ring.querySelectorAll('.vb, .hb').forEach((p) => p.setAttribute('d', TICK[1])); |
| 204 | } |
| 205 | setTimeout(() => { box.classList.add('is-done'); setTimeout(() => box.remove(), 400); }, reduced() ? 160 : 420); |
| 206 | }, |
| 207 | destroy() { gone = true; box.remove(); }, |
| 208 | get element() { return box; }, |
| 209 | }; |
| 210 | return api; |
| 211 | } |
| 212 | |
| 213 | function skeleton(host, { rows = 3, mark = true } = {}) { |
| 214 | styles(); |
| 215 | const wrap = el('div', 'ovld-skel'); |
| 216 | wrap.setAttribute('aria-busy', 'true'); |
| 217 | wrap.setAttribute('aria-label', 'Loading'); |
| 218 | for (let i = 0; i < Math.max(1, Math.min(20, rows)); i++) { |
| 219 | const r = el('div', 'ovld-skel-row'); |
| 220 | if (mark) r.appendChild(el('div', 'ovld-skel-mark')); |
| 221 | const lines = el('div', 'ovld-skel-lines'); |
| 222 | lines.append(el('div', 'ovld-skel-line'), el('div', 'ovld-skel-line')); |
| 223 | r.appendChild(lines); |
| 224 | wrap.appendChild(r); |
| 225 | } |
| 226 | host.appendChild(wrap); |
| 227 | return { done() { wrap.remove(); }, element: wrap }; |
| 228 | } |
| 229 | |
| 230 | const api = { mount, skeleton, CSS }; |
| 231 | root.OpenVibeLoading = api; |
| 232 | if (typeof module === 'object' && module.exports) module.exports = api; |
| 233 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 234 |