openvibers/openvibe.shared loading.js

loading.js

main 17.3 KB · 234 lines · javascript

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 'use strict';
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