openvibers/openvibe.live Commits
The Pair on the home hero, and the mark's wait instead of spinners (#790)
Changes
@@@ -26,23 +26,6 @@"*::after","*::before",".badge",-- ".brand-mark",-- ".brand-mark .ov-comet",-- ".brand-mark .ov-dot",-- ".brand-mark .ov-glow",-- ".brand-mark .ov-spark",-- ".brand-mark .ov-v",-- ".brand-mark svg",-- ".brand-mark--hero",-- ".brand-mark--hero svg",-- ".brand-mark.is-awake .ov-comet",-- ".brand-mark.is-awake .ov-dot",-- ".brand-mark.is-awake .ov-glow",-- ".brand-mark.is-awake .ov-spark",-- ".brand-mark.is-awake .ov-v",-- ".brand-mark.is-awake svg",-- ".brand-mark.is-settling svg",-- ".brand-mark.is-spin svg",".btn",".btn-danger",".btn-follow",@@@ -412,6 +395,7 @@"input","select","select.form-input",-- "textarea"++ "textarea",++ ".hero h1 .ov-mark"]}@@@ -779,26 +779,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:flex-shrink: 0; align-self: center;}--.stream-switch-spinner::before,--.stream-switch-spinner::after {-- content: '';-- position: absolute;-- inset: 12%;-- border-radius: 50%;--}----.stream-switch-spinner::before {-- inset: 18%;-- border: 2px solid rgba(255,255,255,0.9);-- animation: spin 1.2s var(--ov-ease-in-out, ease-in-out) infinite; /* "spin" breathes now (style.css): nothing spins */--}----.stream-switch-spinner::after {-- inset: 34%;-- background: radial-gradient(circle at 32% 30%, rgba(255,255,255,0.9), color-mix(in srgb, var(--accent) 86%, white 10%));-- box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 44%, transparent);-- animation: playerLoaderPulse 1.8s var(--ov-ease-in-out, ease-in-out) infinite;--}.ch-cumulative-viewers {display: flex; align-items: center; gap: 10px;flex-wrap: wrap; margin-top: 4px;@@@ -15,7 +15,6 @@.pc-fade-in { animation: pcFadeIn var(--ov-dur-2, .22s) var(--ov-ease-out, ease-out); }.pc-card-in { animation: pcCardIn var(--ov-dur-3, .4s) var(--ov-ease-spring, cubic-bezier(.34,1.4,.64,1)); }#dash-powerchat-card.pc-busy { border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent), 0 6px 24px color-mix(in srgb, var(--accent) 12%, transparent); }--.pc-spinner { width: 15px; height: 15px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--accent); display: inline-block; animation: pcSpin 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }.pc-ellipsis span { animation: pcBlink 1.2s infinite both; }.pc-ellipsis span:nth-child(2) { animation-delay: .2s; }.pc-ellipsis span:nth-child(3) { animation-delay: .4s; }@@@ -547,7 +547,6 @@ html[data-ov-motion=off] .cta-hero-grad{-webkit-text-fill-color: var(--accent);c.hero-tip { max-width: calc(100vw - 24px); }.hero-stat--clickable:hover { transform: none; box-shadow: none; }}--.brand-mark--hero .ov-ring { stroke-width: 3.6; } .brand-mark--hero .ov-comet { stroke-width: 3.6; } .brand-mark--hero .ov-v { stroke-width: 4.2; }.hero-stats { min-height: 246px; }.hero-stat-strip[hidden] { display: none !important; }.hero-stat-strip {@@@ -75,15 +75,6 @@ code { background: rgba(255,255,255,.08); padding: 2px 6px; border-radius: 6px;.mp-error-overlay i { font-size: 48px; color: var(--mp-danger); }--.mp-spinner {-- width: 40px; height: 40px;-- border: 3px solid var(--mp-accent);-- border-radius: 50%;-- animation: mp-pulse 1.4s var(--ov-ease-out, ease-out) infinite; /* a ring that pulses out: nothing spins */--}--@keyframes mp-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mp-accent) 55%, transparent); } to { box-shadow: 0 0 0 12px transparent; } }--html[data-ov-motion=off] .mp-spinner{opacity: .7} /* a still ring while it loads */--/* ── Custom player controls ──────────────────────────────── */.mp-controls {position: absolute;@@@ -4420,18 +4420,6 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b.stream-switch-overlay.visible {display: flex; animation: fadeIn var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);}--.stream-switch-spinner {-- position: relative;-- width: 58px;-- height: 58px;-- border-radius: 50%;-- background: linear-gradient(140deg, color-mix(in srgb, var(--accent) 82%, white 8%), color-mix(in srgb, var(--text-primary, #fff) 8%, transparent));-- box-shadow:-- 0 0 0 1px rgba(255,255,255,0.1),-- 0 18px 32px rgba(0,0,0,0.3),-- inset 0 0 26px rgba(255,255,255,0.08);-- animation: playerLoaderMorph 5.8s var(--ov-ease-in-out, ease-in-out) infinite alternate;--}/* "spin" is kept as a name for older rules, but it breathes: nothing spins (design lint, plan L45). */@keyframes spin { 50% { opacity: 0.45; transform: scale(0.85); } }@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }@@@ -9489,76 +9477,7 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }reason to stop motion (the owner, 2026-11-10). */}--/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V is-- drawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */--.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }--/* Idle is the default state. Every loop below is declared as before but held at its 0% frame —-- each one's 0% is a sensible resting pose — and only runs while .is-awake is on the mark, which-- app.js adds for five seconds after a route change. Six looping animations on an element that is-- on screen the entire session is a composited repaint per frame for no reason while someone is-- reading. */--.brand-mark svg { overflow: visible; transform-origin: 50% 50%; animation: ovFloat 5s var(--ov-ease-in-out, ease-in-out) infinite; }--.brand-mark svg,--.brand-mark .ov-glow,--.brand-mark .ov-comet,--.brand-mark .ov-v,--.brand-mark .ov-dot,--.brand-mark .ov-spark { animation-play-state: paused; }--.brand-mark.is-awake svg,--.brand-mark.is-awake .ov-glow,--.brand-mark.is-awake .ov-comet,--.brand-mark.is-awake .ov-v,--.brand-mark.is-awake .ov-dot,--.brand-mark.is-awake .ov-spark { animation-play-state: running; }--/* Hovering is a reason to come alive too, without waiting for a navigation. */--.nav-brand:hover .brand-mark svg,--.nav-brand:hover .brand-mark .ov-glow,--.nav-brand:hover .brand-mark .ov-comet,--.nav-brand:hover .brand-mark .ov-dot { animation-play-state: running; }--.ov-glow { fill: currentColor; opacity: 0.1; transform-box: fill-box; transform-origin: center; animation: ovGlow 3.4s var(--ov-ease-in-out, ease-in-out) infinite; }--.ov-ring { fill: none; stroke: currentColor; stroke-width: 4; opacity: 0.28; }--.ov-comet { fill: none; stroke-width: 4; stroke-linecap: round; stroke-dasharray: 34 79; transform-box: fill-box; transform-origin: center; animation: ovComet 3.6s linear infinite; }--.ov-v { fill: none; stroke-width: 4.6; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 46; stroke-dashoffset: 0; animation: ovDraw 1.1s var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both; }--.ov-dot { fill: #fff; transform-box: fill-box; transform-origin: center; animation: ovDot 2.4s var(--ov-ease-in-out, ease-in-out) infinite; }--.ov-orbit { fill: #fff; filter: drop-shadow(0 0 3px currentColor); }--.ov-orbit--tail { opacity: 0.5; }--.nav-brand:hover .brand-mark svg, .hero h1:hover .brand-mark--hero svg { animation: ovSpin 0.9s var(--ov-ease-spring, cubic-bezier(.2,1.5,.3,1)) 1; }--.nav-brand:hover .ov-comet, .hero h1:hover .ov-comet { animation-duration: 0; }--.nav-brand:hover .ov-glow, .hero h1:hover .ov-glow { opacity: 0.28; }--.nav-brand:hover .ov-v, .hero h1:hover .ov-v { animation: ovDraw var(--ov-dur-4, 0.7s) var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both; }--@keyframes ovFloat { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-1px) rotate(2deg); } }--@keyframes ovGlow { 0%, 100% { opacity: 0.08; transform: scale(0.92); } 50% { opacity: 0.2; transform: scale(1.06); } }--@keyframes ovComet { 50% { opacity: 0.35; } }--@keyframes ovDraw { from { stroke-dashoffset: 46; } to { stroke-dashoffset: 0; } }--@keyframes ovDot { 0%, 100% { transform: scale(1); opacity: 0.85; } 50% { transform: scale(1.5); opacity: 1; } }--@keyframes ovSpin { 40% { transform: scale(1.18); } }--/* One spring as the route lands: it grows past its size and settles (it no longer turns). */--.brand-mark.is-spin svg { animation: ovSpinIn 1.05s var(--ov-ease-spring, cubic-bezier(.16, 1.16, .3, 1)) 1 both; animation-play-state: running; }--@keyframes ovSpinIn {-- 0% { transform: scale(0.86); }-- 45% { transform: scale(1.2); }-- 72% { transform: scale(0.97); }-- 100% { transform: scale(1); }--}--/* And a short overshoot as it comes to rest, so going still reads as deliberate. */--.brand-mark.is-settling svg { animation: ovSettle var(--ov-dur-4, 0.68s) var(--ov-ease-spring, cubic-bezier(.26, 1.32, .42, 1)) 1 both; animation-play-state: running; }--@keyframes ovSettle {-- 0% { transform: rotate(5deg) scale(1.07); }-- 55% { transform: rotate(-2.5deg) scale(0.982); }-- 100% { transform: none; }--}--/* Hero: bigger, plus sparks off the V */--.brand-mark--hero { width: 1.05em; height: 1.05em; vertical-align: -0.16em; color: var(--accent); margin-right: 0.08em; }--.brand-mark--hero svg { width: 100%; height: 100%; }--.ov-spark { fill: #fff; opacity: 0; transform-box: fill-box; transform-origin: center; animation: ovSpark 3.4s var(--ov-ease-out, ease-out) infinite; }--.ov-spark--2 { animation-delay: 1.1s; animation-name: ovSpark2; } .ov-spark--3 { animation-delay: 2.3s; animation-name: ovSpark3; }--@keyframes ovSpark { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(12px,-12px) scale(1.4); } 100% { opacity: 0; } }--@keyframes ovSpark2 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(-13px,-8px) scale(1.3); } 100% { opacity: 0; } }--@keyframes ovSpark3 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(3px,16px) scale(1.2); } 100% { opacity: 0; } }--@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } .brand-mark--hero svg { width: 100%; height: 100%; } }--.rs-lite .ov-orbit, .rs-lite .ov-spark { filter: none; }--html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: none}html[data-ov-motion=off] .ov-comet{stroke-dasharray: none;opacity: 0.9}--@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } }++.hero h1 .ov-mark { width: 1.05em; height: 1.05em; vertical-align: -0.14em; margin-right: 0.08em; }.ov-mark--ph { opacity: 0.45; color: var(--text-muted); }/* ── Hero stat board: headline strip + expandable full board ──────────────────@@@ -9753,7 +9672,7 @@ html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: n.nav-brand { min-width: 0; overflow: hidden; }@media (max-width: 420px) {.navbar { padding: 0 9px; gap: 8px; }-- .nav-brand .brand-text, .nav-brand > span:not(.brand-mark) { display: none; }++ .nav-brand .brand-text, .nav-brand > span:not(.ov-mark) { display: none; }.nav-right { gap: 6px; min-width: 0; }.nav-coins, .nav-balance { max-width: 88px; overflow: hidden; }}@@@ -126,7 +126,7 @@<!-- RTMP Feed Status Indicator --><div id="bc-rtmp-status" class="bc-rtmp-status waiting"><div class="bc-rtmp-status-icon">-- <i class="fa-solid fa-circle-notch fa-spin" id="bc-rtmp-status-spinner"></i>++ <span class="ov-mark" data-state="busy" data-size="18" id="bc-rtmp-status-spinner" aria-hidden="true"></span><i class="fa-solid fa-check-circle" id="bc-rtmp-status-ok" style="display:none"></i></div><div class="bc-rtmp-status-text">@@@ -787,7 +787,7 @@ WantedBy=multi-user.target</code><span>No media playing</span></div><div class="bc-media-pip-loading" id="bc-media-pip-loading" style="display:none">-- <div class="spinner-small"></div>++ <span class="ov-mark" data-state="busy" data-size="18" data-tone="ink" aria-hidden="true"></span><span>Loading…</span></div><div id="bc-media-pip-host"></div>@@@ -1206,7 +1206,7 @@ WantedBy=multi-user.target</code></div><div class="bc-camera-sheet-list" id="bc-camera-sheet-list"><div class="bc-camera-sheet-empty">-- <i class="fa-solid fa-spinner fa-spin"></i>++ <span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span><span>Loading cameras…</span></div></div>@@@ -18,7 +18,7 @@</div><!-- Loading overlay shown during stream switching --><div class="stream-switch-overlay" id="stream-switch-overlay">-- <div class="stream-switch-spinner"></div>++ <span class="ov-mark" data-state="busy" data-size="18" data-tone="ink" aria-hidden="true"></span><p>Switching stream…</p></div><canvas id="video-canvas"></canvas>@@@ -4,21 +4,7 @@<div class="hero-collage" id="hero-collage" aria-hidden="true"></div><div class="hero-scrim" aria-hidden="true"></div><div class="hero-content">-- <h1><span class="brand-mark brand-mark--hero" aria-hidden="true"><svg viewBox="0 0 48 48">-- <defs>-- <linearGradient id="ovh-g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>-- <linearGradient id="ovh-v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>-- <path id="ovh-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>-- </defs>-- <circle class="ov-glow" cx="24" cy="24" r="21"/>-- <circle class="ov-ring" cx="24" cy="24" r="18"/>-- <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovh-g)"/>-- <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovh-v)"/>-- <circle class="ov-dot" cx="24" cy="34" r="2.6"/>-- <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovh-ring"/></animateMotion></circle>-- <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovh-ring"/></animateMotion></circle>-- <circle class="ov-spark" cx="24" cy="24" r="1.3"/><circle class="ov-spark ov-spark--2" cx="24" cy="24" r="1"/><circle class="ov-spark ov-spark--3" cx="24" cy="24" r="1.1"/>--</svg></span> OpenVibe.Live</h1>++ <h1><span class="ov-mark hero-mark" aria-hidden="true"></span> OpenVibe.Live</h1><!-- The first word and quip ship in the HTML so the hero is complete at first paint; therotators take over once the page's data arrives. No aria-live: a slogan that changesevery few seconds would be read out to screen-reader users over and over. -->@@@ -385,7 +371,7 @@</div><div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed"><div id="home-changelog" class="home-changelog">-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><div class="home-changelog-fade" id="home-changelog-fade"></div></div>@@@ -1,5 +1,5 @@<div class="content-container rundown-container"><div id="rundown-root">-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div></div>@@@ -4,6 +4,6 @@<p class="page-subtitle">Every change deployed to OpenVibe.Live, newest first: each line is a commit from the <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener">OpenVibers repositories</a>, and batches become <a href="https://openvibe.blog/@openvibe" rel="noopener">Patch notes on openvibe.blog</a>. See <a href="https://openvibe.network/updates">the whole network</a>.</p></div><div id="updates-list" class="updates-list">-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div></div>@@@ -165,7 +165,7 @@<section id="page-stream" class="page"><div class="container"><div class="empty-state">-- <i class="fa-solid fa-spinner fa-spin fa-3x"></i>++ <span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span><p>Redirecting to channel...</p></div></div>@@@ -226,7 +226,7 @@<button class="btn btn-outline btn-sm" onclick="navigate('/content?type=pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button></div><div id="paste-viewer-content">-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>++ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div></div></section>@@@ -234,7 +234,7 @@<!-- Documentation Page --><section id="page-recap" class="page"><div class="content-container recap-container">-- <div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>++ <div id="recap-root"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div></div></section>@@@ -401,6 +401,7 @@adds the requested route's scripts at the end of this document, and js/ov-loader.js loads therest when someone navigates. The asset manifest below carries content hashes and the registry. --><!--ov:asset-manifest-->++<script src="/shared/ov-mark.js" defer></script><script src="/shared/web-runtime.js" defer></script><script src="/js/ov-loader.js" defer></script><script src="/shared/navbar.js" defer></script>@@@ -264,7 +264,7 @@window.previewAiViewers = async function previewAiViewers(btn) {const box = $('aiv-preview'); if (btn) btn.disabled = true;-- if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Asking the director… (this is a dry run — nothing is posted)</span>'; }++ if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Asking the director… (this is a dry run — nothing is posted)</span>'; }try {const d = await api('/ai-viewers/preview', { method: 'POST', body: {} });if (!box) return;@@@ -2647,7 +2647,7 @@ async function _aiTlToggleTranscript(btn) {box.hidden = false; btn.classList.add('open');if (!box.dataset.built) {box.dataset.built = '1';-- box.innerHTML = '<div class="loading" style="padding:8px"><i class="fa-solid fa-spinner fa-spin"></i> Loading transcript…</div>';++ box.innerHTML = '<div class="loading" style="padding:8px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading transcript…</div>';try {const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);const segs = data.segments || [];@@@ -2834,7 +2834,7 @@ async function loadChannelAiTimeline(username) {const summary = `<div class="ai-tl-summary">${data.sessionCount || sessions.length} session${(data.sessionCount || sessions.length) === 1 ? '' : 's'} · ${data.momentCount || 0} AI moment${(data.momentCount || 0) === 1 ? '' : 's'} tracked</div>`;streamerInner = streamerOvCard + summary + _aiTlBuildMonthBar()+ `<div class="ai-tl-track" id="ai-tl-track">${sessions.map(_aiTimelineSessionHTML).join('')}</div>`-- + `<div id="ai-tl-sentinel" class="ai-tl-sentinel">${_aiTl.hasMore ? '<i class="fa-solid fa-spinner fa-spin"></i> Loading more…' : ''}</div>`;++ + `<div id="ai-tl-sentinel" class="ai-tl-sentinel">${_aiTl.hasMore ? '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading more…' : ''}</div>`;}// ── As a chatter (inner content) ──@@@ -3592,7 +3592,7 @@ async function submitMediaRequest(e) {const val = input && input.value.trim();if (!val) return false;if (status) { status.className = 'ch-media-status'; status.textContent = ''; }-- if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><i class="fa-solid fa-spinner fa-spin"></i> Checking that link…</div>';++ if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Checking that link…</div>';try {const q = await api('/media/quote', { method: 'POST', body: { username: currentChannelUsername, input: val } });_mediaQuote = { ...q, input: val };@@@ -81,7 +81,7 @@ function _gaiTlOnScroll(el) {async function _gaiTlInit() {_gaiTl.oldestTs = 0; _gaiTl.done = false; _gaiTl.loading = false; _gaiTl.lastDay = null;const el = document.getElementById('global-ai-timeline');-- if (el) { el.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>'; el.scrollTop = 0; }++ if (el) { el.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>'; el.scrollTop = 0; }await _gaiTlLoad(true);}async function _gaiTlLoad(reset) {@@@ -1360,57 +1360,6 @@ function showNotFound({ title = 'Page not found', message = 'Nothing lives at thRoute-change motion: the brand mark, and the page itself.═══════════════════════════════════════════════════════════════════════════ */--/** How long the mark stays animated after a route change before settling. */--const BRAND_AWAKE_MS = 5000;--let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;----/**-- * Wake the OV mark for a moment, then let it go still: a route change spins it once and keeps it-- * lively for five seconds (looping forever repainted every frame while people read). CSS loops stop-- * via animation-play-state on their 0% rest pose; SMIL needs the SVG's pauseAnimations().-- */--function ovWakeBrandMark() {-- let marks;-- try { marks = document.querySelectorAll('.brand-mark'); } catch { return; }-- if (!marks || !marks.length) return;-- let reduce = false;-- try { reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { /* */ }-- if (reduce) {-- // CSS stops the loops under reduced motion but cannot reach SMIL — the gradient sweep on the-- // V would otherwise run for the whole session. Hold everything still, once.-- if (!_brandEverWoken) marks.forEach(m => { try { const svg = m.querySelector('svg'); svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ } });-- _brandEverWoken = true;-- return;-- }---- _brandEverWoken = true;-- clearTimeout(_brandIdleTimer); clearTimeout(_brandSettleTimer); clearTimeout(_brandSpinTimer);-- marks.forEach((m) => {-- m.classList.remove('is-settling', 'is-spin');-- m.classList.add('is-awake');-- void m.offsetWidth; // restart the spin on a repeat navigation-- m.classList.add('is-spin');-- const svg = m.querySelector('svg');-- try { svg && svg.unpauseAnimations && svg.unpauseAnimations(); } catch { /* no SMIL here */ }-- });---- // The spin owns the svg's animation slot while it runs, so the ambient float cannot start-- // until it is off. Hand over as soon as the spin finishes rather than at the end of the window.-- _brandSpinTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-spin')), 1080);---- _brandIdleTimer = setTimeout(() => {-- marks.forEach((m) => {-- m.classList.remove('is-awake', 'is-spin');-- m.classList.add('is-settling');-- const svg = m.querySelector('svg');-- // Pause in place rather than rewinding: a jump back to t=0 would throw the orbit dot-- // across the ring at the exact moment the mark is supposed to be coming to rest.-- try { svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ }-- });-- _brandSettleTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-settling')), 720);-- }, BRAND_AWAKE_MS);--}--const _PAGE_LABELS = { broadcast: 'Go Live', recap: 'recap' };function _pageLabel(page) { return _PAGE_LABELS[page] || String(page).replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()); }@@@ -1423,11 +1372,7 @@ function showPage(page) {// change only — re-rendering the page you are already on should not restart either.// A page move: the page stays hidden until its route's styles and code are in (never unstyled), with a progress// bar, a "Loading …" line if it takes a moment, and a fade-in (openvibe-shared web-runtime enter()).-- if (changed) { ovWakeBrandMark(); if (window.ov && ov.enter && el) ov.enter(el, ov.route(location.pathname), { label: el.dataset.title || _pageLabel(page) }); }-- // A fresh page load is not a "change" — currentPage is already the page being shown — so the-- // mark would never take its first sleep and the SMIL orbits would loop for the whole session.-- // Wake it once on arrival; the same timer puts it to sleep five seconds later.-- else if (!_brandEverWoken) ovWakeBrandMark();++ if (changed) { if (window.ov && ov.enter && el) ov.enter(el, ov.route(location.pathname), { label: el.dataset.title || _pageLabel(page) }); }// One event for modules that react to the visible page (voice mini bar, floating chat…), instead// of each attaching MutationObservers to every section's class attribute.try { document.dispatchEvent(new CustomEvent('ov:page', { detail: { page, changed } })); } catch { /* */ }@@@ -2434,7 +2379,7 @@ async function uploadGoalMedia(input) {const file = input.files && input.files[0];if (!file) return;const preview = document.getElementById('goal-media-preview');-- if (preview) preview.innerHTML = '<div class="muted" style="font-size:0.8rem"><i class="fa-solid fa-spinner fa-spin"></i> Uploading…</div>';++ if (preview) preview.innerHTML = '<div class="muted" style="font-size:0.8rem"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Uploading…</div>';try {const fd = new FormData(); fd.append('file', file);const token = localStorage.getItem('token');@@@ -2530,7 +2475,7 @@ async function loadUpdatesPage() {const container = document.getElementById('updates-list');if (!container) return;if (container.dataset.ovShippedMounted) return; // already showing; "Load more" keeps its place-- container.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';++ container.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';const shipped = await loadShipped();if (!shipped) {container.innerHTML = '<p style="opacity:0.6;text-align:center;padding:32px 0;">The update log could not be loaded. <a href="https://openvibe.network/updates?site=live">See it on openvibe.network</a>.</p>';@@@ -483,7 +483,7 @@ async function requestMediaPermissions() {// Immediate visual feedback — if user doesn't see spinner, the function isn't reachedif (btn) {btn.disabled = true;-- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Requesting access...';++ btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Requesting access...';}if (dbg) { dbg.style.display = ''; dbg.textContent = 'Requesting permissions...'; }@@@ -3575,7 +3575,7 @@ function _wsRenderRestreamConnect(platform) {// Status not loaded yet — show a light placeholderif (!st) {box.innerHTML = `<div style="background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin:0 0 12px">-- <p class="muted" style="font-size:0.78rem;margin:0"><i class="fa-solid fa-spinner fa-spin"></i> Checking ${esc(plat.name)} connection…</p></div>`;++ <p class="muted" style="font-size:0.78rem;margin:0"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Checking ${esc(plat.name)} connection…</p></div>`;return;}@@@ -3636,7 +3636,7 @@ function _wsConnectPlatform(platform) {const box = document.getElementById('ws-rs-connect');if (box && !document.getElementById('ws-rs-connecting')) {box.insertAdjacentHTML('afterbegin',-- `<div id="ws-rs-connecting" style="text-align:center;padding:6px 0;color:var(--text-secondary);font-size:0.8rem"><i class="fa-solid fa-spinner fa-spin"></i> Waiting for authorization…</div>`);++ `<div id="ws-rs-connecting" style="text-align:center;padding:6px 0;color:var(--text-secondary);font-size:0.8rem"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for authorization…</div>`);}let finished = false;@@@ -272,7 +272,7 @@ function updateRsRestreamSlotUI() {html += `<span class="bc-rs-status bc-rs-live"><i class="fa-solid fa-circle"></i> Live on RobotStreamer</span>`;html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Stop</button>`;} else if (isStarting) {-- html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Connecting…</span>`;++ html += `<span class="bc-rs-status bc-rs-connecting"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Connecting…</span>`;html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;} else if (hasFailed) {html += `<span class="bc-rs-status bc-rs-error"><i class="fa-solid fa-circle-exclamation"></i> RobotStreamer failed</span>`;@@@ -281,7 +281,7 @@ function updateRsRestreamSlotUI() {// Slot assigned but not started yet (could be reconnecting)const isReconnecting = !!(slotSs._rsReconnectTimer);if (isReconnecting) {-- html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Reconnecting…</span>`;++ html += `<span class="bc-rs-status bc-rs-connecting"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Reconnecting…</span>`;html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;} else {html += `<span class="bc-rs-status bc-rs-ready"><i class="fa-solid fa-robot"></i> RobotStreamer ready</span>`;@@@ -3196,6 +3196,8 @@ function applyManualGain(streamId) {/* ── Multi-Platform Restream Destinations ──────────────────── */++const MARK_BUSY_16 = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>';++const statusIcon = (icon) => icon === 'busy' ? MARK_BUSY_16 : `<i class="${icon}"></i>`;const RESTREAM_PLATFORM_META = {youtube: { name: 'YouTube', icon: 'fa-brands fa-youtube', color: '#ff0000' },twitch: { name: 'Twitch', icon: 'fa-brands fa-twitch', color: '#9146ff' },@@@ -3356,8 +3358,8 @@ function rsDetailLine(rs) {function rsBadgeFor(rs, clientFallback) {if (rs) {if (rs.state === 'live') return { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };-- if (rs.state === 'starting') return { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: 'Starting…', active: true };-- if (rs.state === 'restarting') return { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: 'Reconnecting…', active: true };++ if (rs.state === 'starting') return { cls: 'bc-restream-status-starting', icon: 'busy', text: 'Starting…', active: true };++ if (rs.state === 'restarting') return { cls: 'bc-restream-status-starting', icon: 'busy', text: 'Reconnecting…', active: true };if (rs.state === 'failed') return { cls: 'bc-restream-status-failed', icon: 'fa-solid fa-circle-xmark', text: 'Failed', active: false };}return clientFallback;@@@ -3419,7 +3421,7 @@ function updateRestreamControlPanel() {badgeText = 'Live';} else if (statusLabel === 'starting') {badgeClass = 'bc-restream-status-starting';-- badgeIcon = 'fa-solid fa-spinner fa-spin';++ badgeIcon = 'busy';badgeText = 'Starting…';} else if (statusLabel === 'error') {badgeClass = 'bc-restream-status-error';@@@ -3445,7 +3447,7 @@ function updateRestreamControlPanel() {html += `<div class="bc-restream-row" data-dest="${dest.id}"><span class="bc-restream-platform-icon" style="color:${meta.color}"><i class="${meta.icon}"></i></span><span class="bc-restream-name">${esc(dest.name || meta.name)}${transport}</span>-- <span class="bc-restream-status-badge ${badgeClass}"><i class="${badgeIcon}"></i> ${badgeText}</span>++ <span class="bc-restream-status-badge ${badgeClass}">${statusIcon(badgeIcon)} ${badgeText}</span><div class="bc-restream-actions">${isActive? `<button class="bc-ctrl-btn-sm" onclick="stopRestreamDest(${dest.id})" title="Stop pushing to this destination"><i class="fa-solid fa-stop"></i> Stop</button>`@@@ -3467,14 +3469,14 @@ function updateRestreamControlPanel() {let fallback = { cls: 'bc-restream-status-idle', icon: 'fa-solid fa-circle', text: 'Idle', active: false };if (isRsLive) fallback = { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };-- else if (isRsStarting || slotSs?._rsReconnectTimer) fallback = { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: isRsStarting ? 'Starting…' : 'Reconnecting…', active: true };++ else if (isRsStarting || slotSs?._rsReconnectTimer) fallback = { cls: 'bc-restream-status-starting', icon: 'busy', text: isRsStarting ? 'Starting…' : 'Reconnecting…', active: true };const b = rsBadgeFor(rsSrv, fallback);const robot = broadcastState.robotStreamer?.robotId || rsSrv?.robot_id;html += `<div class="bc-restream-row" data-dest="rs"><span class="bc-restream-platform-icon" style="color:#4a9eff"><i class="fa-solid fa-robot"></i></span><span class="bc-restream-name">RobotStreamer${robot ? ` <span class="bc-restream-transport" title="Robot id">#${esc(String(robot))}</span>` : ''}</span>-- <span class="bc-restream-status-badge ${b.cls}"><i class="${b.icon}"></i> ${b.text}</span>++ <span class="bc-restream-status-badge ${b.cls}">${statusIcon(b.icon)} ${b.text}</span><div class="bc-restream-actions">${b.active? `<button class="bc-ctrl-btn-sm" onclick="stopRsRestream()" title="Take the robot offline"><i class="fa-solid fa-stop"></i> Stop</button>`@@@ -4908,7 +4910,7 @@ async function openCameraSwitcher() {sheet.style.display = 'flex';_bcLockSheet('camera', document.getElementById('bc-btn-switch-cam'));_bcDragSheet(sheet.querySelector('.bc-camera-sheet-header'), sheet.querySelector('.bc-camera-sheet-panel'), closeCameraSwitcher);-- list.innerHTML = '<div class="bc-camera-sheet-empty"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading cameras…</span></div>';++ list.innerHTML = '<div class="bc-camera-sheet-empty"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span><span>Loading cameras…</span></div>';try {const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: true });@@@ -5920,7 +5922,7 @@ async function createBroadcastClip() {// does, from what was said and shown: server/ai/clip-title.js).const typed = document.getElementById('bc-clip-title')?.value?.trim() || '';const btn = document.getElementById('bc-clip-create-btn');-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating…'; }++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Creating…'; }try {const res = await fetch('/api/vods/clips', {@@@ -235,7 +235,7 @@</div><div class="link-preview-body"><div class="link-preview-loading">-- <i class="fa-solid fa-spinner fa-spin"></i> Loading preview...++ <span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading preview...</div><iframe class="link-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms" src="${esc(url)}" onload="this.previousElementSibling.style.display='none'" onerror="this.previousElementSibling.innerHTML='<i class=\\'fa-solid fa-triangle-exclamation\\'></i> Could not load preview'"></iframe></div>@@@ -1125,7 +1125,7 @@ async function hydrateActiveChatHistory(streamId, { clear = false } = {}) {_swapChatHistory(messages, _renderHistoryOffscreen(chatEl, () => _renderHistoryPayload(_historyPieces(cached, 50)[0])));messages.dataset.room = room;} else if (clear || roomChanged) {-- messages.innerHTML = '<div class="chat-loading-history"><i class="fa-solid fa-spinner fa-spin"></i> Loading history…</div>';++ messages.innerHTML = '<div class="chat-loading-history"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading history…</div>';messages.dataset.room = room;}}@@@ -1798,7 +1798,7 @@ async function _translateForMe(el, text, btn) {if (existing) { existing.hidden = !existing.hidden; return; }const hit = _trCache.get(to + '\u0001' + text);if (hit) { _trShow(el, hit, to); return; }-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i>'; }++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>'; }try {const d = await api('/i18n/translate', { method: 'POST', body: { text, to } });if (d && d.same) { toast(`Already in ${_LANG_LABEL[to] || to}`, 'info'); return; }@@@ -4417,7 +4417,7 @@ async function ctxAdminTools(username, userId, anonId) {<button class="modal-close-btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i></button></div><div class="admin-tools-body">-- <div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading IP intelligence...</div>++ <div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading IP intelligence...</div></div></div>`;@@@ -4536,7 +4536,7 @@ function renderAdminToolsUser(container, data, username) {<div class="at-section" id="at-tts-voice" data-kind="user" data-id="${esc(user.username)}"><h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>-- <div class="at-tts-body"><div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading voice…</div></div>++ <div class="at-tts-body"><div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading voice…</div></div></div>`;@@@ -4602,7 +4602,7 @@ function renderAdminToolsAnon(container, data, username) {html += `<div class="at-section" id="at-tts-voice" data-kind="anon" data-id="${esc(anon_id || username)}"><h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>-- <div class="at-tts-body"><div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading voice…</div></div>++ <div class="at-tts-body"><div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading voice…</div></div></div>`;container.innerHTML = html;@@@ -4660,7 +4660,7 @@ function renderAdminTtsVoice(bodyEl, kind, id, d) {if (pr.speed != null) { $('#atv-speed').value = pr.speed; $('#atv-speed-n').textContent = pr.speed; }}));$('#atv-preview').addEventListener('click', async () => {-- const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Playing…';++ const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Playing…';try {const r = await api('/mod/tts-voice/preview', { method: 'POST', body: getParams() });if (r.url || r.audio) { const a = new Audio(r.url || `data:${r.mimeType};base64,${r.audio}`); await a.play().catch(() => {}); }@@@ -1186,7 +1186,7 @@ async function createDashToken() {const btn = document.getElementById('token-create-btn');btn.disabled = true;-- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating...';++ btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Creating...';try {const data = await api('/auth/tokens', {@@@ -479,7 +479,7 @@ async function searchFFZEmotes(query) {if (!query || query.length < 2) return;const { grid } = _getPickerEls(_emotePickerTarget);if (!grid) return;-- grid.innerHTML = '<div class="emote-picker-empty"><i class="fa-solid fa-spinner fa-spin"></i> Searching FFZ...</div>';++ grid.innerHTML = '<div class="emote-picker-empty"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Searching FFZ...</div>';try {const data = await api(`/emotes/search?q=${encodeURIComponent(query)}`);@@@ -250,7 +250,7 @@const oauthBtn = f.querySelector('#ovg-oauth-go');if (oauthBtn) oauthBtn.onclick = () => {oauthBtn.disabled = true;-- oauthBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for ' + esc(name) + '…';++ oauthBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for ' + esc(name) + '…';connectPlatform(plat, (ok, needsKey) => {if (ok && !needsKey) return rerender(); // done — back to the listif (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting@@@ -757,7 +757,7 @@<div class="msg-settings-section"><div class="msg-settings-section-title"><i class="fa-solid fa-shield-halved"></i> Blocked Users</div>-- <div id="msg-blocked-list" class="msg-blocked-list"><div class="msg-settings-loading"><i class="fa-solid fa-spinner fa-spin"></i></div></div>++ <div id="msg-blocked-list" class="msg-blocked-list"><div class="msg-settings-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span></div></div></div><div class="msg-settings-footer">@@@ -88,7 +88,7 @@$('#ovg-kicker').textContent = step.kicker || S.journey.kicker || '';$('#ovg-title').textContent = typeof step.title === 'function' ? step.title(S.ctx) : (step.heading || step.title);renderSteps();-- panel.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';++ panel.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';let html = '';try { html = await step.render(S.ctx); } catch (e) { html = `<div class="ovg-error"><i class="fa-solid fa-triangle-exclamation"></i> ${cfg.esc((e && e.message) || 'Something went wrong')}</div>`; }if (S.step !== step) return; // user moved on while we loaded@@@ -226,7 +226,7 @@ function togglePasteMarkdown() {async function loadPasteViewer(slug) {const container = document.getElementById('paste-viewer-content');if (!container) return;-- container.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';++ container.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';try {const data = await api(`/pastes/${slug}`);@@@ -1149,7 +1149,7 @@ function _setScreenshotCapturing(on) {el = document.createElement('div');el.id = 'screenshot-capturing';el.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:10px;padding:32px;color:var(--text-secondary);font-size:0.95rem';-- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Capturing screenshot…';++ el.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Capturing screenshot…';area.prepend(el);}} else {@@@ -1421,7 +1421,7 @@ async function submitScreenshot() {formData.append('user_agent', navigator.userAgent);const submitBtn = document.getElementById('screenshot-submit-btn');-- if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Uploading…'; }++ if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Uploading…'; }try {const token = localStorage.getItem('token');@@@ -111,7 +111,7 @@if (s) s.classList.add('pc-status-block');_swap(s, `<div class="pc-connecting-box">-- <div class="pc-connecting-head"><span class="pc-spinner"></span> Waiting for you to authorize in PowerChat<span class="pc-ellipsis"><span>.</span><span>.</span><span>.</span></span></div>++ <div class="pc-connecting-head"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for you to authorize in PowerChat<span class="pc-ellipsis"><span>.</span><span>.</span><span>.</span></span></div><div class="pc-progress"><span class="pc-progress-bar"></span></div><div class="pc-manual"><span>Popup didn't open?</span>@@@ -233,7 +233,7 @@window.powerchatDisconnect = async function powerchatDisconnect(btn) {if (!confirm('Disconnect PowerChat? Tips will stop flowing into OpenVibe.Live until you reconnect.')) return;-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Disconnecting…'; }++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Disconnecting…'; }const card = $('dash-powerchat-card'); if (card) card.classList.add('pc-busy');try { await api('/powerchat/oauth/connection', { method: 'DELETE' }); toast('PowerChat disconnected', 'success'); }catch (e) { toast(e.message || 'Failed to disconnect', 'error'); }@@@ -242,7 +242,7 @@// Local pipeline test — verifies the streamer's alert sound + chat celebration.window.powerchatTestTip = async function powerchatTestTip(btn) {-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…'; }try {const r = await api('/powerchat/test-tip', { method: 'POST', body: { amount: 5 } });toast(r && r.powerchat@@@ -278,7 +278,7 @@const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);buttons.forEach(b => { b.disabled = true; });const orig = btn ? btn.innerHTML : '';-- if (btn) btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…';++ if (btn) btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…';try {const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');@@@ -89,14 +89,14 @@${more}</article>`;const regen = root.querySelector('#recap-regen');-- if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };++ if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }}window.loadRecapPage = async function (id) {const root = document.getElementById('recap-root');if (!root) return;-- root.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';++ root.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';let data = null;try { data = await api(`/recap/${encodeURIComponent(id)}`); } catch (e) {const live = e && /live/i.test(e.message || '');@@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {const el = document.createElement('div');el.id = 'reconnecting-indicator';el.style.cssText = 'position:absolute;top:12px;right:12px;z-index:25;background:rgba(0,0,0,0.7);color:#fbbf24;padding:6px 14px;border-radius:8px;font-size:0.85rem;display:flex;align-items:center;gap:8px;pointer-events:none;';-- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:0.9rem"></i> The streamer is reconnecting…';++ el.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> The streamer is reconnecting…';container.appendChild(el);}@@@ -3142,7 +3142,7 @@ function setClipButtonBusy(isBusy) {btn.disabled = !!isBusy;btn.classList.toggle('is-busy', !!isBusy);btn.innerHTML = isBusy-- ? '<i class="fa-solid fa-spinner fa-spin"></i>'++ ? '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>': '<i class="fa-solid fa-scissors"></i>';}@@@ -3528,7 +3528,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {if (saveBtn.disabled) return;const title = titleInput.value.trim();saveBtn.disabled = true;-- saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';++ saveBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Saving...';try {const token = localStorage.getItem('token');const resp = await fetch(`/api/clips/${clipId}/trim`, {@@@ -9,7 +9,7 @@ function openvibeSubscribeModal(username) {<h3><i class="fa-solid fa-star"></i> Subscribe to ${username ? username : 'this channel'}</h3><p class="muted" id="sub-price-line" style="margin-bottom:16px">Monthly channel subscription.</p><div id="subscribe-options" data-streamer="${username || ''}">-- <p class="muted" style="font-size:0.85rem;text-align:center"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</p>++ <p class="muted" style="font-size:0.85rem;text-align:center"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading…</p></div>`;}@@@ -942,7 +942,7 @@ button{font:inherit}const kind=el.dataset.kind, username=el.dataset.username, label=el.dataset.label;const menu=document.createElement("div");menu.className="chat-context-menu";-- menu.innerHTML=`<div class="ctx-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>`;++ menu.innerHTML=`<div class="ctx-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading…</div>`;document.body.appendChild(menu); _kioskMenu=menu;_posMenu(menu,x,y);menu.addEventListener("click",e=>e.stopPropagation());@@@ -1204,7 +1204,7 @@ button{font:inherit}if(looksLikeDomain(q)){const key=q.toLowerCase(); let res=resolved.get(key);if(!res){-- goBtn.innerHTML='<i class="fa-solid fa-spinner fa-spin"></i>';++ goBtn.innerHTML='<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>';try{ const r=await fetch(`/api/kiosk/site?url=${encodeURIComponent(q)}`); const d=await r.json(); res=d.reachable?{reachable:true,url:d.url,host:d.host,title:d.title}:{reachable:false}; resolved.set(key,res); }catch{ res={reachable:true,url:fullUrl(q),host:hostOf(q)}; }goBtn.innerHTML='<i class="fa-solid fa-arrow-right"></i>';@@@ -48,7 +48,7 @@</div><div id="mp-player-host" class="mp-player-host" hidden><div id="mp-loading-overlay" class="mp-loading-overlay" hidden>-- <div class="mp-spinner"></div>++ <span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span><p id="mp-loading-text">Loading media…</p></div><div id="mp-error-overlay" class="mp-error-overlay" hidden>BBinary files a/public/og-image.png and b/public/og-image.png differ
@@ -26,23 +26,6 @@
@@ -26,23 +26,6 @@
"*::after",
"*::after",
"*::before",
"*::before",
".badge",
".badge",
- ".brand-mark",
- ".brand-mark .ov-comet",
- ".brand-mark .ov-dot",
- ".brand-mark .ov-glow",
- ".brand-mark .ov-spark",
- ".brand-mark .ov-v",
- ".brand-mark svg",
- ".brand-mark--hero",
- ".brand-mark--hero svg",
- ".brand-mark.is-awake .ov-comet",
- ".brand-mark.is-awake .ov-dot",
- ".brand-mark.is-awake .ov-glow",
- ".brand-mark.is-awake .ov-spark",
- ".brand-mark.is-awake .ov-v",
- ".brand-mark.is-awake svg",
- ".brand-mark.is-settling svg",
- ".brand-mark.is-spin svg",
".btn",
".btn",
".btn-danger",
".btn-danger",
".btn-follow",
".btn-follow",
@@ -412,6 +395,7 @@
@@ -412,6 +395,7 @@
"input",
"input",
"select",
"select",
"select.form-input",
"select.form-input",
- "textarea"
+ "textarea",
+ ".hero h1 .ov-mark"
]
]
}
}
@@ -779,26 +779,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
@@ -779,26 +779,6 @@ button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset:
flex-shrink: 0; align-self: center;
flex-shrink: 0; align-self: center;
}
}
-.stream-switch-spinner::before,
-.stream-switch-spinner::after {
- content: '';
- position: absolute;
- inset: 12%;
- border-radius: 50%;
-}
-
-.stream-switch-spinner::before {
- inset: 18%;
- border: 2px solid rgba(255,255,255,0.9);
- animation: spin 1.2s var(--ov-ease-in-out, ease-in-out) infinite; /* "spin" breathes now (style.css): nothing spins */
-}
-
-.stream-switch-spinner::after {
- inset: 34%;
- background: radial-gradient(circle at 32% 30%, rgba(255,255,255,0.9), color-mix(in srgb, var(--accent) 86%, white 10%));
- box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 44%, transparent);
- animation: playerLoaderPulse 1.8s var(--ov-ease-in-out, ease-in-out) infinite;
-}
.ch-cumulative-viewers {
.ch-cumulative-viewers {
display: flex; align-items: center; gap: 10px;
display: flex; align-items: center; gap: 10px;
flex-wrap: wrap; margin-top: 4px;
flex-wrap: wrap; margin-top: 4px;
@@ -15,7 +15,6 @@
@@ -15,7 +15,6 @@
.pc-fade-in { animation: pcFadeIn var(--ov-dur-2, .22s) var(--ov-ease-out, ease-out); }
.pc-fade-in { animation: pcFadeIn var(--ov-dur-2, .22s) var(--ov-ease-out, ease-out); }
.pc-card-in { animation: pcCardIn var(--ov-dur-3, .4s) var(--ov-ease-spring, cubic-bezier(.34,1.4,.64,1)); }
.pc-card-in { animation: pcCardIn var(--ov-dur-3, .4s) var(--ov-ease-spring, cubic-bezier(.34,1.4,.64,1)); }
#dash-powerchat-card.pc-busy { border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent), 0 6px 24px color-mix(in srgb, var(--accent) 12%, transparent); }
#dash-powerchat-card.pc-busy { border-color: color-mix(in srgb, var(--accent) 50%, transparent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 25%, transparent), 0 6px 24px color-mix(in srgb, var(--accent) 12%, transparent); }
-.pc-spinner { width: 15px; height: 15px; border-radius: 50%; flex-shrink: 0; border: 2px solid var(--accent); display: inline-block; animation: pcSpin 1.2s var(--ov-ease-in-out, ease-in-out) infinite; }
.pc-ellipsis span { animation: pcBlink 1.2s infinite both; }
.pc-ellipsis span { animation: pcBlink 1.2s infinite both; }
.pc-ellipsis span:nth-child(2) { animation-delay: .2s; }
.pc-ellipsis span:nth-child(2) { animation-delay: .2s; }
.pc-ellipsis span:nth-child(3) { animation-delay: .4s; }
.pc-ellipsis span:nth-child(3) { animation-delay: .4s; }
@@ -547,7 +547,6 @@ html[data-ov-motion=off] .cta-hero-grad{-webkit-text-fill-color: var(--accent);c
@@ -547,7 +547,6 @@ html[data-ov-motion=off] .cta-hero-grad{-webkit-text-fill-color: var(--accent);c
.hero-tip { max-width: calc(100vw - 24px); }
.hero-tip { max-width: calc(100vw - 24px); }
.hero-stat--clickable:hover { transform: none; box-shadow: none; }
.hero-stat--clickable:hover { transform: none; box-shadow: none; }
}
}
-.brand-mark--hero .ov-ring { stroke-width: 3.6; } .brand-mark--hero .ov-comet { stroke-width: 3.6; } .brand-mark--hero .ov-v { stroke-width: 4.2; }
.hero-stats { min-height: 246px; }
.hero-stats { min-height: 246px; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip[hidden] { display: none !important; }
.hero-stat-strip {
.hero-stat-strip {
@@ -75,15 +75,6 @@ code { background: rgba(255,255,255,.08); padding: 2px 6px; border-radius: 6px;
@@ -75,15 +75,6 @@ code { background: rgba(255,255,255,.08); padding: 2px 6px; border-radius: 6px;
.mp-error-overlay i { font-size: 48px; color: var(--mp-danger); }
.mp-error-overlay i { font-size: 48px; color: var(--mp-danger); }
-.mp-spinner {
- width: 40px; height: 40px;
- border: 3px solid var(--mp-accent);
- border-radius: 50%;
- animation: mp-pulse 1.4s var(--ov-ease-out, ease-out) infinite; /* a ring that pulses out: nothing spins */
-}
-@keyframes mp-pulse { from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--mp-accent) 55%, transparent); } to { box-shadow: 0 0 0 12px transparent; } }
-html[data-ov-motion=off] .mp-spinner{opacity: .7} /* a still ring while it loads */
-
/* ── Custom player controls ──────────────────────────────── */
/* ── Custom player controls ──────────────────────────────── */
.mp-controls {
.mp-controls {
position: absolute;
position: absolute;
@@ -4420,18 +4420,6 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
@@ -4420,18 +4420,6 @@ body :is(.modal-overlay, .bc-ws-confirm-overlay, .bc-modal, .bc-perm-overlay, .b
.stream-switch-overlay.visible {
.stream-switch-overlay.visible {
display: flex; animation: fadeIn var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);
display: flex; animation: fadeIn var(--ov-dur-2, 0.15s) var(--ov-ease-out, ease);
}
}
-.stream-switch-spinner {
- position: relative;
- width: 58px;
- height: 58px;
- border-radius: 50%;
- background: linear-gradient(140deg, color-mix(in srgb, var(--accent) 82%, white 8%), color-mix(in srgb, var(--text-primary, #fff) 8%, transparent));
- box-shadow:
- 0 0 0 1px rgba(255,255,255,0.1),
- 0 18px 32px rgba(0,0,0,0.3),
- inset 0 0 26px rgba(255,255,255,0.08);
- animation: playerLoaderMorph 5.8s var(--ov-ease-in-out, ease-in-out) infinite alternate;
-}
/* "spin" is kept as a name for older rules, but it breathes: nothing spins (design lint, plan L45). */
/* "spin" is kept as a name for older rules, but it breathes: nothing spins (design lint, plan L45). */
@keyframes spin { 50% { opacity: 0.45; transform: scale(0.85); } }
@keyframes spin { 50% { opacity: 0.45; transform: scale(0.85); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@@ -9489,76 +9477,7 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -9489,76 +9477,7 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
reason to stop motion (the owner, 2026-11-10). */
reason to stop motion (the owner, 2026-11-10). */
}
}
-/* ── OV brand mark (navbar + hero): the O is a ring with a comet running around it, the V is
- drawn inside with a light sweep, a dot orbits the ring. Transform/opacity/stroke motion only. ── */
-.brand-mark { display: inline-grid; place-items: center; width: 32px; height: 32px; color: var(--accent); flex: none; }
-/* Idle is the default state. Every loop below is declared as before but held at its 0% frame —
- each one's 0% is a sensible resting pose — and only runs while .is-awake is on the mark, which
- app.js adds for five seconds after a route change. Six looping animations on an element that is
- on screen the entire session is a composited repaint per frame for no reason while someone is
- reading. */
-.brand-mark svg { overflow: visible; transform-origin: 50% 50%; animation: ovFloat 5s var(--ov-ease-in-out, ease-in-out) infinite; }
-.brand-mark svg,
-.brand-mark .ov-glow,
-.brand-mark .ov-comet,
-.brand-mark .ov-v,
-.brand-mark .ov-dot,
-.brand-mark .ov-spark { animation-play-state: paused; }
-.brand-mark.is-awake svg,
-.brand-mark.is-awake .ov-glow,
-.brand-mark.is-awake .ov-comet,
-.brand-mark.is-awake .ov-v,
-.brand-mark.is-awake .ov-dot,
-.brand-mark.is-awake .ov-spark { animation-play-state: running; }
-/* Hovering is a reason to come alive too, without waiting for a navigation. */
-.nav-brand:hover .brand-mark svg,
-.nav-brand:hover .brand-mark .ov-glow,
-.nav-brand:hover .brand-mark .ov-comet,
-.nav-brand:hover .brand-mark .ov-dot { animation-play-state: running; }
-.ov-glow { fill: currentColor; opacity: 0.1; transform-box: fill-box; transform-origin: center; animation: ovGlow 3.4s var(--ov-ease-in-out, ease-in-out) infinite; }
-.ov-ring { fill: none; stroke: currentColor; stroke-width: 4; opacity: 0.28; }
-.ov-comet { fill: none; stroke-width: 4; stroke-linecap: round; stroke-dasharray: 34 79; transform-box: fill-box; transform-origin: center; animation: ovComet 3.6s linear infinite; }
-.ov-v { fill: none; stroke-width: 4.6; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 46; stroke-dashoffset: 0; animation: ovDraw 1.1s var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both; }
-.ov-dot { fill: #fff; transform-box: fill-box; transform-origin: center; animation: ovDot 2.4s var(--ov-ease-in-out, ease-in-out) infinite; }
-.ov-orbit { fill: #fff; filter: drop-shadow(0 0 3px currentColor); }
-.ov-orbit--tail { opacity: 0.5; }
-.nav-brand:hover .brand-mark svg, .hero h1:hover .brand-mark--hero svg { animation: ovSpin 0.9s var(--ov-ease-spring, cubic-bezier(.2,1.5,.3,1)) 1; }
-.nav-brand:hover .ov-comet, .hero h1:hover .ov-comet { animation-duration: 0; }
-.nav-brand:hover .ov-glow, .hero h1:hover .ov-glow { opacity: 0.28; }
-.nav-brand:hover .ov-v, .hero h1:hover .ov-v { animation: ovDraw var(--ov-dur-4, 0.7s) var(--ov-ease-out, cubic-bezier(.2,.8,.2,1)) both; }
-@keyframes ovFloat { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-1px) rotate(2deg); } }
-@keyframes ovGlow { 0%, 100% { opacity: 0.08; transform: scale(0.92); } 50% { opacity: 0.2; transform: scale(1.06); } }
-@keyframes ovComet { 50% { opacity: 0.35; } }
-@keyframes ovDraw { from { stroke-dashoffset: 46; } to { stroke-dashoffset: 0; } }
-@keyframes ovDot { 0%, 100% { transform: scale(1); opacity: 0.85; } 50% { transform: scale(1.5); opacity: 1; } }
-@keyframes ovSpin { 40% { transform: scale(1.18); } }
-/* One spring as the route lands: it grows past its size and settles (it no longer turns). */
-.brand-mark.is-spin svg { animation: ovSpinIn 1.05s var(--ov-ease-spring, cubic-bezier(.16, 1.16, .3, 1)) 1 both; animation-play-state: running; }
-@keyframes ovSpinIn {
- 0% { transform: scale(0.86); }
- 45% { transform: scale(1.2); }
- 72% { transform: scale(0.97); }
- 100% { transform: scale(1); }
-}
-/* And a short overshoot as it comes to rest, so going still reads as deliberate. */
-.brand-mark.is-settling svg { animation: ovSettle var(--ov-dur-4, 0.68s) var(--ov-ease-spring, cubic-bezier(.26, 1.32, .42, 1)) 1 both; animation-play-state: running; }
-@keyframes ovSettle {
- 0% { transform: rotate(5deg) scale(1.07); }
- 55% { transform: rotate(-2.5deg) scale(0.982); }
- 100% { transform: none; }
-}
-/* Hero: bigger, plus sparks off the V */
-.brand-mark--hero { width: 1.05em; height: 1.05em; vertical-align: -0.16em; color: var(--accent); margin-right: 0.08em; }
-.brand-mark--hero svg { width: 100%; height: 100%; }
-.ov-spark { fill: #fff; opacity: 0; transform-box: fill-box; transform-origin: center; animation: ovSpark 3.4s var(--ov-ease-out, ease-out) infinite; }
-.ov-spark--2 { animation-delay: 1.1s; animation-name: ovSpark2; } .ov-spark--3 { animation-delay: 2.3s; animation-name: ovSpark3; }
-@keyframes ovSpark { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(12px,-12px) scale(1.4); } 100% { opacity: 0; } }
-@keyframes ovSpark2 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(-13px,-8px) scale(1.3); } 100% { opacity: 0; } }
-@keyframes ovSpark3 { 0% { opacity: 0; transform: translate(0,10px) scale(0.6); } 10% { opacity: 1; } 60% { opacity: 0; transform: translate(3px,16px) scale(1.2); } 100% { opacity: 0; } }
-@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } .brand-mark--hero svg { width: 100%; height: 100%; } }
-.rs-lite .ov-orbit, .rs-lite .ov-spark { filter: none; }
-html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: none}html[data-ov-motion=off] .ov-comet{stroke-dasharray: none;opacity: 0.9}
-@media (max-width: 600px) { .brand-mark { width: 26px; height: 26px; } .brand-mark svg { width: 25px; height: 25px; } }
+.hero h1 .ov-mark { width: 1.05em; height: 1.05em; vertical-align: -0.14em; margin-right: 0.08em; }
.ov-mark--ph { opacity: 0.45; color: var(--text-muted); }
.ov-mark--ph { opacity: 0.45; color: var(--text-muted); }
/* ── Hero stat board: headline strip + expandable full board ──────────────────
/* ── Hero stat board: headline strip + expandable full board ──────────────────
@@ -9753,7 +9672,7 @@ html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: n
@@ -9753,7 +9672,7 @@ html[data-ov-motion=off] .ov-orbit,html[data-ov-motion=off] .ov-spark{display: n
.nav-brand { min-width: 0; overflow: hidden; }
.nav-brand { min-width: 0; overflow: hidden; }
@media (max-width: 420px) {
@media (max-width: 420px) {
.navbar { padding: 0 9px; gap: 8px; }
.navbar { padding: 0 9px; gap: 8px; }
- .nav-brand .brand-text, .nav-brand > span:not(.brand-mark) { display: none; }
+ .nav-brand .brand-text, .nav-brand > span:not(.ov-mark) { display: none; }
.nav-right { gap: 6px; min-width: 0; }
.nav-right { gap: 6px; min-width: 0; }
.nav-coins, .nav-balance { max-width: 88px; overflow: hidden; }
.nav-coins, .nav-balance { max-width: 88px; overflow: hidden; }
}
}
@@ -126,7 +126,7 @@
@@ -126,7 +126,7 @@
<!-- RTMP Feed Status Indicator -->
<!-- RTMP Feed Status Indicator -->
<div id="bc-rtmp-status" class="bc-rtmp-status waiting">
<div id="bc-rtmp-status" class="bc-rtmp-status waiting">
<div class="bc-rtmp-status-icon">
<div class="bc-rtmp-status-icon">
- <i class="fa-solid fa-circle-notch fa-spin" id="bc-rtmp-status-spinner"></i>
+ <span class="ov-mark" data-state="busy" data-size="18" id="bc-rtmp-status-spinner" aria-hidden="true"></span>
<i class="fa-solid fa-check-circle" id="bc-rtmp-status-ok" style="display:none"></i>
<i class="fa-solid fa-check-circle" id="bc-rtmp-status-ok" style="display:none"></i>
</div>
</div>
<div class="bc-rtmp-status-text">
<div class="bc-rtmp-status-text">
@@ -787,7 +787,7 @@ WantedBy=multi-user.target</code>
@@ -787,7 +787,7 @@ WantedBy=multi-user.target</code>
<span>No media playing</span>
<span>No media playing</span>
</div>
</div>
<div class="bc-media-pip-loading" id="bc-media-pip-loading" style="display:none">
<div class="bc-media-pip-loading" id="bc-media-pip-loading" style="display:none">
- <div class="spinner-small"></div>
+ <span class="ov-mark" data-state="busy" data-size="18" data-tone="ink" aria-hidden="true"></span>
<span>Loading…</span>
<span>Loading…</span>
</div>
</div>
<div id="bc-media-pip-host"></div>
<div id="bc-media-pip-host"></div>
@@ -1206,7 +1206,7 @@ WantedBy=multi-user.target</code>
@@ -1206,7 +1206,7 @@ WantedBy=multi-user.target</code>
</div>
</div>
<div class="bc-camera-sheet-list" id="bc-camera-sheet-list">
<div class="bc-camera-sheet-list" id="bc-camera-sheet-list">
<div class="bc-camera-sheet-empty">
<div class="bc-camera-sheet-empty">
- <i class="fa-solid fa-spinner fa-spin"></i>
+ <span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>
<span>Loading cameras…</span>
<span>Loading cameras…</span>
</div>
</div>
</div>
</div>
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
</div>
</div>
<!-- Loading overlay shown during stream switching -->
<!-- Loading overlay shown during stream switching -->
<div class="stream-switch-overlay" id="stream-switch-overlay">
<div class="stream-switch-overlay" id="stream-switch-overlay">
- <div class="stream-switch-spinner"></div>
+ <span class="ov-mark" data-state="busy" data-size="18" data-tone="ink" aria-hidden="true"></span>
<p>Switching stream…</p>
<p>Switching stream…</p>
</div>
</div>
<canvas id="video-canvas"></canvas>
<canvas id="video-canvas"></canvas>
@@ -4,21 +4,7 @@
@@ -4,21 +4,7 @@
<div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
<div class="hero-collage" id="hero-collage" aria-hidden="true"></div>
<div class="hero-scrim" aria-hidden="true"></div>
<div class="hero-scrim" aria-hidden="true"></div>
<div class="hero-content">
<div class="hero-content">
- <h1><span class="brand-mark brand-mark--hero" aria-hidden="true"><svg viewBox="0 0 48 48">
- <defs>
- <linearGradient id="ovh-g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
- <linearGradient id="ovh-v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
- <path id="ovh-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>
- </defs>
- <circle class="ov-glow" cx="24" cy="24" r="21"/>
- <circle class="ov-ring" cx="24" cy="24" r="18"/>
- <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovh-g)"/>
- <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovh-v)"/>
- <circle class="ov-dot" cx="24" cy="34" r="2.6"/>
- <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovh-ring"/></animateMotion></circle>
- <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovh-ring"/></animateMotion></circle>
- <circle class="ov-spark" cx="24" cy="24" r="1.3"/><circle class="ov-spark ov-spark--2" cx="24" cy="24" r="1"/><circle class="ov-spark ov-spark--3" cx="24" cy="24" r="1.1"/>
-</svg></span> OpenVibe.Live</h1>
+ <h1><span class="ov-mark hero-mark" aria-hidden="true"></span> OpenVibe.Live</h1>
<!-- The first word and quip ship in the HTML so the hero is complete at first paint; the
<!-- The first word and quip ship in the HTML so the hero is complete at first paint; the
rotators take over once the page's data arrives. No aria-live: a slogan that changes
rotators take over once the page's data arrives. No aria-live: a slogan that changes
every few seconds would be read out to screen-reader users over and over. -->
every few seconds would be read out to screen-reader users over and over. -->
@@ -385,7 +371,7 @@
@@ -385,7 +371,7 @@
</div>
</div>
<div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
<div id="home-changelog-wrapper" class="home-changelog-wrapper collapsed">
<div id="home-changelog" class="home-changelog">
<div id="home-changelog" class="home-changelog">
- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
+ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
<div class="home-changelog-fade" id="home-changelog-fade"></div>
</div>
</div>
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
<div class="content-container rundown-container">
<div class="content-container rundown-container">
<div id="rundown-root">
<div id="rundown-root">
- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
+ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
</div>
</div>
@@ -4,6 +4,6 @@
@@ -4,6 +4,6 @@
<p class="page-subtitle">Every change deployed to OpenVibe.Live, newest first: each line is a commit from the <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener">OpenVibers repositories</a>, and batches become <a href="https://openvibe.blog/@openvibe" rel="noopener">Patch notes on openvibe.blog</a>. See <a href="https://openvibe.network/updates">the whole network</a>.</p>
<p class="page-subtitle">Every change deployed to OpenVibe.Live, newest first: each line is a commit from the <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" rel="noopener">OpenVibers repositories</a>, and batches become <a href="https://openvibe.blog/@openvibe" rel="noopener">Patch notes on openvibe.blog</a>. See <a href="https://openvibe.network/updates">the whole network</a>.</p>
</div>
</div>
<div id="updates-list" class="updates-list">
<div id="updates-list" class="updates-list">
- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
+ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
</div>
</div>
@@ -165,7 +165,7 @@
@@ -165,7 +165,7 @@
<section id="page-stream" class="page">
<section id="page-stream" class="page">
<div class="container">
<div class="container">
<div class="empty-state">
<div class="empty-state">
- <i class="fa-solid fa-spinner fa-spin fa-3x"></i>
+ <span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span>
<p>Redirecting to channel...</p>
<p>Redirecting to channel...</p>
</div>
</div>
</div>
</div>
@@ -226,7 +226,7 @@
@@ -226,7 +226,7 @@
<button class="btn btn-outline btn-sm" onclick="navigate('/content?type=pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button>
<button class="btn btn-outline btn-sm" onclick="navigate('/content?type=pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button>
</div>
</div>
<div id="paste-viewer-content">
<div id="paste-viewer-content">
- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
+ <div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>
</div>
</div>
</div>
</div>
</section>
</section>
@@ -234,7 +234,7 @@
@@ -234,7 +234,7 @@
<!-- Documentation Page -->
<!-- Documentation Page -->
<section id="page-recap" class="page">
<section id="page-recap" class="page">
<div class="content-container recap-container">
<div class="content-container recap-container">
- <div id="recap-root"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
+ <div id="recap-root"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
</div>
</div>
</section>
</section>
@@ -401,6 +401,7 @@
@@ -401,6 +401,7 @@
adds the requested route's scripts at the end of this document, and js/ov-loader.js loads the
adds the requested route's scripts at the end of this document, and js/ov-loader.js loads the
rest when someone navigates. The asset manifest below carries content hashes and the registry. -->
rest when someone navigates. The asset manifest below carries content hashes and the registry. -->
<!--ov:asset-manifest-->
<!--ov:asset-manifest-->
+<script src="/shared/ov-mark.js" defer></script>
<script src="/shared/web-runtime.js" defer></script>
<script src="/shared/web-runtime.js" defer></script>
<script src="/js/ov-loader.js" defer></script>
<script src="/js/ov-loader.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
@@ -264,7 +264,7 @@
@@ -264,7 +264,7 @@
window.previewAiViewers = async function previewAiViewers(btn) {
window.previewAiViewers = async function previewAiViewers(btn) {
const box = $('aiv-preview'); if (btn) btn.disabled = true;
const box = $('aiv-preview'); if (btn) btn.disabled = true;
- if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Asking the director… (this is a dry run — nothing is posted)</span>'; }
+ if (box) { box.style.display = ''; box.innerHTML = '<span class="muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Asking the director… (this is a dry run — nothing is posted)</span>'; }
try {
try {
const d = await api('/ai-viewers/preview', { method: 'POST', body: {} });
const d = await api('/ai-viewers/preview', { method: 'POST', body: {} });
if (!box) return;
if (!box) return;
@@ -2647,7 +2647,7 @@ async function _aiTlToggleTranscript(btn) {
@@ -2647,7 +2647,7 @@ async function _aiTlToggleTranscript(btn) {
box.hidden = false; btn.classList.add('open');
box.hidden = false; btn.classList.add('open');
if (!box.dataset.built) {
if (!box.dataset.built) {
box.dataset.built = '1';
box.dataset.built = '1';
- box.innerHTML = '<div class="loading" style="padding:8px"><i class="fa-solid fa-spinner fa-spin"></i> Loading transcript…</div>';
+ box.innerHTML = '<div class="loading" style="padding:8px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading transcript…</div>';
try {
try {
const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);
const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);
const segs = data.segments || [];
const segs = data.segments || [];
@@ -2834,7 +2834,7 @@ async function loadChannelAiTimeline(username) {
@@ -2834,7 +2834,7 @@ async function loadChannelAiTimeline(username) {
const summary = `<div class="ai-tl-summary">${data.sessionCount || sessions.length} session${(data.sessionCount || sessions.length) === 1 ? '' : 's'} · ${data.momentCount || 0} AI moment${(data.momentCount || 0) === 1 ? '' : 's'} tracked</div>`;
const summary = `<div class="ai-tl-summary">${data.sessionCount || sessions.length} session${(data.sessionCount || sessions.length) === 1 ? '' : 's'} · ${data.momentCount || 0} AI moment${(data.momentCount || 0) === 1 ? '' : 's'} tracked</div>`;
streamerInner = streamerOvCard + summary + _aiTlBuildMonthBar()
streamerInner = streamerOvCard + summary + _aiTlBuildMonthBar()
+ `<div class="ai-tl-track" id="ai-tl-track">${sessions.map(_aiTimelineSessionHTML).join('')}</div>`
+ `<div class="ai-tl-track" id="ai-tl-track">${sessions.map(_aiTimelineSessionHTML).join('')}</div>`
- + `<div id="ai-tl-sentinel" class="ai-tl-sentinel">${_aiTl.hasMore ? '<i class="fa-solid fa-spinner fa-spin"></i> Loading more…' : ''}</div>`;
+ + `<div id="ai-tl-sentinel" class="ai-tl-sentinel">${_aiTl.hasMore ? '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading more…' : ''}</div>`;
}
}
// ── As a chatter (inner content) ──
// ── As a chatter (inner content) ──
@@ -3592,7 +3592,7 @@ async function submitMediaRequest(e) {
@@ -3592,7 +3592,7 @@ async function submitMediaRequest(e) {
const val = input && input.value.trim();
const val = input && input.value.trim();
if (!val) return false;
if (!val) return false;
if (status) { status.className = 'ch-media-status'; status.textContent = ''; }
if (status) { status.className = 'ch-media-status'; status.textContent = ''; }
- if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><i class="fa-solid fa-spinner fa-spin"></i> Checking that link…</div>';
+ if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Checking that link…</div>';
try {
try {
const q = await api('/media/quote', { method: 'POST', body: { username: currentChannelUsername, input: val } });
const q = await api('/media/quote', { method: 'POST', body: { username: currentChannelUsername, input: val } });
_mediaQuote = { ...q, input: val };
_mediaQuote = { ...q, input: val };
@@ -81,7 +81,7 @@ function _gaiTlOnScroll(el) {
@@ -81,7 +81,7 @@ function _gaiTlOnScroll(el) {
async function _gaiTlInit() {
async function _gaiTlInit() {
_gaiTl.oldestTs = 0; _gaiTl.done = false; _gaiTl.loading = false; _gaiTl.lastDay = null;
_gaiTl.oldestTs = 0; _gaiTl.done = false; _gaiTl.loading = false; _gaiTl.lastDay = null;
const el = document.getElementById('global-ai-timeline');
const el = document.getElementById('global-ai-timeline');
- if (el) { el.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>'; el.scrollTop = 0; }
+ if (el) { el.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>'; el.scrollTop = 0; }
await _gaiTlLoad(true);
await _gaiTlLoad(true);
}
}
async function _gaiTlLoad(reset) {
async function _gaiTlLoad(reset) {
@@ -1360,57 +1360,6 @@ function showNotFound({ title = 'Page not found', message = 'Nothing lives at th
@@ -1360,57 +1360,6 @@ function showNotFound({ title = 'Page not found', message = 'Nothing lives at th
Route-change motion: the brand mark, and the page itself.
Route-change motion: the brand mark, and the page itself.
═══════════════════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════════════════ */
-/** How long the mark stays animated after a route change before settling. */
-const BRAND_AWAKE_MS = 5000;
-let _brandIdleTimer = 0, _brandSettleTimer = 0, _brandSpinTimer = 0, _brandEverWoken = false;
-
-/**
- * Wake the OV mark for a moment, then let it go still: a route change spins it once and keeps it
- * lively for five seconds (looping forever repainted every frame while people read). CSS loops stop
- * via animation-play-state on their 0% rest pose; SMIL needs the SVG's pauseAnimations().
- */
-function ovWakeBrandMark() {
- let marks;
- try { marks = document.querySelectorAll('.brand-mark'); } catch { return; }
- if (!marks || !marks.length) return;
- let reduce = false;
- try { reduce = (document.documentElement.getAttribute('data-ov-motion') === 'off'); } catch { /* */ }
- if (reduce) {
- // CSS stops the loops under reduced motion but cannot reach SMIL — the gradient sweep on the
- // V would otherwise run for the whole session. Hold everything still, once.
- if (!_brandEverWoken) marks.forEach(m => { try { const svg = m.querySelector('svg'); svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ } });
- _brandEverWoken = true;
- return;
- }
-
- _brandEverWoken = true;
- clearTimeout(_brandIdleTimer); clearTimeout(_brandSettleTimer); clearTimeout(_brandSpinTimer);
- marks.forEach((m) => {
- m.classList.remove('is-settling', 'is-spin');
- m.classList.add('is-awake');
- void m.offsetWidth; // restart the spin on a repeat navigation
- m.classList.add('is-spin');
- const svg = m.querySelector('svg');
- try { svg && svg.unpauseAnimations && svg.unpauseAnimations(); } catch { /* no SMIL here */ }
- });
-
- // The spin owns the svg's animation slot while it runs, so the ambient float cannot start
- // until it is off. Hand over as soon as the spin finishes rather than at the end of the window.
- _brandSpinTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-spin')), 1080);
-
- _brandIdleTimer = setTimeout(() => {
- marks.forEach((m) => {
- m.classList.remove('is-awake', 'is-spin');
- m.classList.add('is-settling');
- const svg = m.querySelector('svg');
- // Pause in place rather than rewinding: a jump back to t=0 would throw the orbit dot
- // across the ring at the exact moment the mark is supposed to be coming to rest.
- try { svg && svg.pauseAnimations && svg.pauseAnimations(); } catch { /* */ }
- });
- _brandSettleTimer = setTimeout(() => marks.forEach(m => m.classList.remove('is-settling')), 720);
- }, BRAND_AWAKE_MS);
-}
-
const _PAGE_LABELS = { broadcast: 'Go Live', recap: 'recap' };
const _PAGE_LABELS = { broadcast: 'Go Live', recap: 'recap' };
function _pageLabel(page) { return _PAGE_LABELS[page] || String(page).replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
function _pageLabel(page) { return _PAGE_LABELS[page] || String(page).replace(/-/g, ' ').replace(/^./, (c) => c.toUpperCase()); }
@@ -1423,11 +1372,7 @@ function showPage(page) {
@@ -1423,11 +1372,7 @@ function showPage(page) {
// change only — re-rendering the page you are already on should not restart either.
// change only — re-rendering the page you are already on should not restart either.
// A page move: the page stays hidden until its route's styles and code are in (never unstyled), with a progress
// A page move: the page stays hidden until its route's styles and code are in (never unstyled), with a progress
// bar, a "Loading …" line if it takes a moment, and a fade-in (openvibe-shared web-runtime enter()).
// bar, a "Loading …" line if it takes a moment, and a fade-in (openvibe-shared web-runtime enter()).
- if (changed) { ovWakeBrandMark(); if (window.ov && ov.enter && el) ov.enter(el, ov.route(location.pathname), { label: el.dataset.title || _pageLabel(page) }); }
- // A fresh page load is not a "change" — currentPage is already the page being shown — so the
- // mark would never take its first sleep and the SMIL orbits would loop for the whole session.
- // Wake it once on arrival; the same timer puts it to sleep five seconds later.
- else if (!_brandEverWoken) ovWakeBrandMark();
+ if (changed) { if (window.ov && ov.enter && el) ov.enter(el, ov.route(location.pathname), { label: el.dataset.title || _pageLabel(page) }); }
// One event for modules that react to the visible page (voice mini bar, floating chat…), instead
// One event for modules that react to the visible page (voice mini bar, floating chat…), instead
// of each attaching MutationObservers to every section's class attribute.
// of each attaching MutationObservers to every section's class attribute.
try { document.dispatchEvent(new CustomEvent('ov:page', { detail: { page, changed } })); } catch { /* */ }
try { document.dispatchEvent(new CustomEvent('ov:page', { detail: { page, changed } })); } catch { /* */ }
@@ -2434,7 +2379,7 @@ async function uploadGoalMedia(input) {
@@ -2434,7 +2379,7 @@ async function uploadGoalMedia(input) {
const file = input.files && input.files[0];
const file = input.files && input.files[0];
if (!file) return;
if (!file) return;
const preview = document.getElementById('goal-media-preview');
const preview = document.getElementById('goal-media-preview');
- if (preview) preview.innerHTML = '<div class="muted" style="font-size:0.8rem"><i class="fa-solid fa-spinner fa-spin"></i> Uploading…</div>';
+ if (preview) preview.innerHTML = '<div class="muted" style="font-size:0.8rem"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Uploading…</div>';
try {
try {
const fd = new FormData(); fd.append('file', file);
const fd = new FormData(); fd.append('file', file);
const token = localStorage.getItem('token');
const token = localStorage.getItem('token');
@@ -2530,7 +2475,7 @@ async function loadUpdatesPage() {
@@ -2530,7 +2475,7 @@ async function loadUpdatesPage() {
const container = document.getElementById('updates-list');
const container = document.getElementById('updates-list');
if (!container) return;
if (!container) return;
if (container.dataset.ovShippedMounted) return; // already showing; "Load more" keeps its place
if (container.dataset.ovShippedMounted) return; // already showing; "Load more" keeps its place
- container.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
+ container.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
const shipped = await loadShipped();
const shipped = await loadShipped();
if (!shipped) {
if (!shipped) {
container.innerHTML = '<p style="opacity:0.6;text-align:center;padding:32px 0;">The update log could not be loaded. <a href="https://openvibe.network/updates?site=live">See it on openvibe.network</a>.</p>';
container.innerHTML = '<p style="opacity:0.6;text-align:center;padding:32px 0;">The update log could not be loaded. <a href="https://openvibe.network/updates?site=live">See it on openvibe.network</a>.</p>';
@@ -483,7 +483,7 @@ async function requestMediaPermissions() {
@@ -483,7 +483,7 @@ async function requestMediaPermissions() {
// Immediate visual feedback — if user doesn't see spinner, the function isn't reached
// Immediate visual feedback — if user doesn't see spinner, the function isn't reached
if (btn) {
if (btn) {
btn.disabled = true;
btn.disabled = true;
- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Requesting access...';
+ btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Requesting access...';
}
}
if (dbg) { dbg.style.display = ''; dbg.textContent = 'Requesting permissions...'; }
if (dbg) { dbg.style.display = ''; dbg.textContent = 'Requesting permissions...'; }
@@ -3575,7 +3575,7 @@ function _wsRenderRestreamConnect(platform) {
@@ -3575,7 +3575,7 @@ function _wsRenderRestreamConnect(platform) {
// Status not loaded yet — show a light placeholder
// Status not loaded yet — show a light placeholder
if (!st) {
if (!st) {
box.innerHTML = `<div style="background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin:0 0 12px">
box.innerHTML = `<div style="background:rgba(255,255,255,0.04);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin:0 0 12px">
- <p class="muted" style="font-size:0.78rem;margin:0"><i class="fa-solid fa-spinner fa-spin"></i> Checking ${esc(plat.name)} connection…</p></div>`;
+ <p class="muted" style="font-size:0.78rem;margin:0"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Checking ${esc(plat.name)} connection…</p></div>`;
return;
return;
}
}
@@ -3636,7 +3636,7 @@ function _wsConnectPlatform(platform) {
@@ -3636,7 +3636,7 @@ function _wsConnectPlatform(platform) {
const box = document.getElementById('ws-rs-connect');
const box = document.getElementById('ws-rs-connect');
if (box && !document.getElementById('ws-rs-connecting')) {
if (box && !document.getElementById('ws-rs-connecting')) {
box.insertAdjacentHTML('afterbegin',
box.insertAdjacentHTML('afterbegin',
- `<div id="ws-rs-connecting" style="text-align:center;padding:6px 0;color:var(--text-secondary);font-size:0.8rem"><i class="fa-solid fa-spinner fa-spin"></i> Waiting for authorization…</div>`);
+ `<div id="ws-rs-connecting" style="text-align:center;padding:6px 0;color:var(--text-secondary);font-size:0.8rem"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for authorization…</div>`);
}
}
let finished = false;
let finished = false;
@@ -272,7 +272,7 @@ function updateRsRestreamSlotUI() {
@@ -272,7 +272,7 @@ function updateRsRestreamSlotUI() {
html += `<span class="bc-rs-status bc-rs-live"><i class="fa-solid fa-circle"></i> Live on RobotStreamer</span>`;
html += `<span class="bc-rs-status bc-rs-live"><i class="fa-solid fa-circle"></i> Live on RobotStreamer</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Stop</button>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Stop</button>`;
} else if (isStarting) {
} else if (isStarting) {
- html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Connecting…</span>`;
+ html += `<span class="bc-rs-status bc-rs-connecting"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Connecting…</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else if (hasFailed) {
} else if (hasFailed) {
html += `<span class="bc-rs-status bc-rs-error"><i class="fa-solid fa-circle-exclamation"></i> RobotStreamer failed</span>`;
html += `<span class="bc-rs-status bc-rs-error"><i class="fa-solid fa-circle-exclamation"></i> RobotStreamer failed</span>`;
@@ -281,7 +281,7 @@ function updateRsRestreamSlotUI() {
@@ -281,7 +281,7 @@ function updateRsRestreamSlotUI() {
// Slot assigned but not started yet (could be reconnecting)
// Slot assigned but not started yet (could be reconnecting)
const isReconnecting = !!(slotSs._rsReconnectTimer);
const isReconnecting = !!(slotSs._rsReconnectTimer);
if (isReconnecting) {
if (isReconnecting) {
- html += `<span class="bc-rs-status bc-rs-connecting"><i class="fa-solid fa-spinner fa-spin"></i> Reconnecting…</span>`;
+ html += `<span class="bc-rs-status bc-rs-connecting"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Reconnecting…</span>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
html += `<button class="bc-ctrl-btn bc-ctrl-btn-sm" onclick="stopRsRestream()"><i class="fa-solid fa-stop"></i> Cancel</button>`;
} else {
} else {
html += `<span class="bc-rs-status bc-rs-ready"><i class="fa-solid fa-robot"></i> RobotStreamer ready</span>`;
html += `<span class="bc-rs-status bc-rs-ready"><i class="fa-solid fa-robot"></i> RobotStreamer ready</span>`;
@@ -3196,6 +3196,8 @@ function applyManualGain(streamId) {
@@ -3196,6 +3196,8 @@ function applyManualGain(streamId) {
/* ── Multi-Platform Restream Destinations ──────────────────── */
/* ── Multi-Platform Restream Destinations ──────────────────── */
+const MARK_BUSY_16 = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>';
+const statusIcon = (icon) => icon === 'busy' ? MARK_BUSY_16 : `<i class="${icon}"></i>`;
const RESTREAM_PLATFORM_META = {
const RESTREAM_PLATFORM_META = {
youtube: { name: 'YouTube', icon: 'fa-brands fa-youtube', color: '#ff0000' },
youtube: { name: 'YouTube', icon: 'fa-brands fa-youtube', color: '#ff0000' },
twitch: { name: 'Twitch', icon: 'fa-brands fa-twitch', color: '#9146ff' },
twitch: { name: 'Twitch', icon: 'fa-brands fa-twitch', color: '#9146ff' },
@@ -3356,8 +3358,8 @@ function rsDetailLine(rs) {
@@ -3356,8 +3358,8 @@ function rsDetailLine(rs) {
function rsBadgeFor(rs, clientFallback) {
function rsBadgeFor(rs, clientFallback) {
if (rs) {
if (rs) {
if (rs.state === 'live') return { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };
if (rs.state === 'live') return { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };
- if (rs.state === 'starting') return { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: 'Starting…', active: true };
- if (rs.state === 'restarting') return { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: 'Reconnecting…', active: true };
+ if (rs.state === 'starting') return { cls: 'bc-restream-status-starting', icon: 'busy', text: 'Starting…', active: true };
+ if (rs.state === 'restarting') return { cls: 'bc-restream-status-starting', icon: 'busy', text: 'Reconnecting…', active: true };
if (rs.state === 'failed') return { cls: 'bc-restream-status-failed', icon: 'fa-solid fa-circle-xmark', text: 'Failed', active: false };
if (rs.state === 'failed') return { cls: 'bc-restream-status-failed', icon: 'fa-solid fa-circle-xmark', text: 'Failed', active: false };
}
}
return clientFallback;
return clientFallback;
@@ -3419,7 +3421,7 @@ function updateRestreamControlPanel() {
@@ -3419,7 +3421,7 @@ function updateRestreamControlPanel() {
badgeText = 'Live';
badgeText = 'Live';
} else if (statusLabel === 'starting') {
} else if (statusLabel === 'starting') {
badgeClass = 'bc-restream-status-starting';
badgeClass = 'bc-restream-status-starting';
- badgeIcon = 'fa-solid fa-spinner fa-spin';
+ badgeIcon = 'busy';
badgeText = 'Starting…';
badgeText = 'Starting…';
} else if (statusLabel === 'error') {
} else if (statusLabel === 'error') {
badgeClass = 'bc-restream-status-error';
badgeClass = 'bc-restream-status-error';
@@ -3445,7 +3447,7 @@ function updateRestreamControlPanel() {
@@ -3445,7 +3447,7 @@ function updateRestreamControlPanel() {
html += `<div class="bc-restream-row" data-dest="${dest.id}">
html += `<div class="bc-restream-row" data-dest="${dest.id}">
<span class="bc-restream-platform-icon" style="color:${meta.color}"><i class="${meta.icon}"></i></span>
<span class="bc-restream-platform-icon" style="color:${meta.color}"><i class="${meta.icon}"></i></span>
<span class="bc-restream-name">${esc(dest.name || meta.name)}${transport}</span>
<span class="bc-restream-name">${esc(dest.name || meta.name)}${transport}</span>
- <span class="bc-restream-status-badge ${badgeClass}"><i class="${badgeIcon}"></i> ${badgeText}</span>
+ <span class="bc-restream-status-badge ${badgeClass}">${statusIcon(badgeIcon)} ${badgeText}</span>
<div class="bc-restream-actions">
<div class="bc-restream-actions">
${isActive
${isActive
? `<button class="bc-ctrl-btn-sm" onclick="stopRestreamDest(${dest.id})" title="Stop pushing to this destination"><i class="fa-solid fa-stop"></i> Stop</button>`
? `<button class="bc-ctrl-btn-sm" onclick="stopRestreamDest(${dest.id})" title="Stop pushing to this destination"><i class="fa-solid fa-stop"></i> Stop</button>`
@@ -3467,14 +3469,14 @@ function updateRestreamControlPanel() {
@@ -3467,14 +3469,14 @@ function updateRestreamControlPanel() {
let fallback = { cls: 'bc-restream-status-idle', icon: 'fa-solid fa-circle', text: 'Idle', active: false };
let fallback = { cls: 'bc-restream-status-idle', icon: 'fa-solid fa-circle', text: 'Idle', active: false };
if (isRsLive) fallback = { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };
if (isRsLive) fallback = { cls: 'bc-restream-status-live', icon: 'fa-solid fa-circle', text: 'Live', active: true };
- else if (isRsStarting || slotSs?._rsReconnectTimer) fallback = { cls: 'bc-restream-status-starting', icon: 'fa-solid fa-spinner fa-spin', text: isRsStarting ? 'Starting…' : 'Reconnecting…', active: true };
+ else if (isRsStarting || slotSs?._rsReconnectTimer) fallback = { cls: 'bc-restream-status-starting', icon: 'busy', text: isRsStarting ? 'Starting…' : 'Reconnecting…', active: true };
const b = rsBadgeFor(rsSrv, fallback);
const b = rsBadgeFor(rsSrv, fallback);
const robot = broadcastState.robotStreamer?.robotId || rsSrv?.robot_id;
const robot = broadcastState.robotStreamer?.robotId || rsSrv?.robot_id;
html += `<div class="bc-restream-row" data-dest="rs">
html += `<div class="bc-restream-row" data-dest="rs">
<span class="bc-restream-platform-icon" style="color:#4a9eff"><i class="fa-solid fa-robot"></i></span>
<span class="bc-restream-platform-icon" style="color:#4a9eff"><i class="fa-solid fa-robot"></i></span>
<span class="bc-restream-name">RobotStreamer${robot ? ` <span class="bc-restream-transport" title="Robot id">#${esc(String(robot))}</span>` : ''}</span>
<span class="bc-restream-name">RobotStreamer${robot ? ` <span class="bc-restream-transport" title="Robot id">#${esc(String(robot))}</span>` : ''}</span>
- <span class="bc-restream-status-badge ${b.cls}"><i class="${b.icon}"></i> ${b.text}</span>
+ <span class="bc-restream-status-badge ${b.cls}">${statusIcon(b.icon)} ${b.text}</span>
<div class="bc-restream-actions">
<div class="bc-restream-actions">
${b.active
${b.active
? `<button class="bc-ctrl-btn-sm" onclick="stopRsRestream()" title="Take the robot offline"><i class="fa-solid fa-stop"></i> Stop</button>`
? `<button class="bc-ctrl-btn-sm" onclick="stopRsRestream()" title="Take the robot offline"><i class="fa-solid fa-stop"></i> Stop</button>`
@@ -4908,7 +4910,7 @@ async function openCameraSwitcher() {
@@ -4908,7 +4910,7 @@ async function openCameraSwitcher() {
sheet.style.display = 'flex';
sheet.style.display = 'flex';
_bcLockSheet('camera', document.getElementById('bc-btn-switch-cam'));
_bcLockSheet('camera', document.getElementById('bc-btn-switch-cam'));
_bcDragSheet(sheet.querySelector('.bc-camera-sheet-header'), sheet.querySelector('.bc-camera-sheet-panel'), closeCameraSwitcher);
_bcDragSheet(sheet.querySelector('.bc-camera-sheet-header'), sheet.querySelector('.bc-camera-sheet-panel'), closeCameraSwitcher);
- list.innerHTML = '<div class="bc-camera-sheet-empty"><i class="fa-solid fa-spinner fa-spin"></i><span>Loading cameras…</span></div>';
+ list.innerHTML = '<div class="bc-camera-sheet-empty"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span><span>Loading cameras…</span></div>';
try {
try {
const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: true });
const devices = await _enumerateBroadcastVideoInputs({ ensureLabels: true });
@@ -5920,7 +5922,7 @@ async function createBroadcastClip() {
@@ -5920,7 +5922,7 @@ async function createBroadcastClip() {
// does, from what was said and shown: server/ai/clip-title.js).
// does, from what was said and shown: server/ai/clip-title.js).
const typed = document.getElementById('bc-clip-title')?.value?.trim() || '';
const typed = document.getElementById('bc-clip-title')?.value?.trim() || '';
const btn = document.getElementById('bc-clip-create-btn');
const btn = document.getElementById('bc-clip-create-btn');
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating…'; }
+ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Creating…'; }
try {
try {
const res = await fetch('/api/vods/clips', {
const res = await fetch('/api/vods/clips', {
@@ -235,7 +235,7 @@
@@ -235,7 +235,7 @@
</div>
</div>
<div class="link-preview-body">
<div class="link-preview-body">
<div class="link-preview-loading">
<div class="link-preview-loading">
- <i class="fa-solid fa-spinner fa-spin"></i> Loading preview...
+ <span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading preview...
</div>
</div>
<iframe class="link-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms" src="${esc(url)}" onload="this.previousElementSibling.style.display='none'" onerror="this.previousElementSibling.innerHTML='<i class=\\'fa-solid fa-triangle-exclamation\\'></i> Could not load preview'"></iframe>
<iframe class="link-preview-frame" sandbox="allow-scripts allow-same-origin allow-forms" src="${esc(url)}" onload="this.previousElementSibling.style.display='none'" onerror="this.previousElementSibling.innerHTML='<i class=\\'fa-solid fa-triangle-exclamation\\'></i> Could not load preview'"></iframe>
</div>
</div>
@@ -1125,7 +1125,7 @@ async function hydrateActiveChatHistory(streamId, { clear = false } = {}) {
@@ -1125,7 +1125,7 @@ async function hydrateActiveChatHistory(streamId, { clear = false } = {}) {
_swapChatHistory(messages, _renderHistoryOffscreen(chatEl, () => _renderHistoryPayload(_historyPieces(cached, 50)[0])));
_swapChatHistory(messages, _renderHistoryOffscreen(chatEl, () => _renderHistoryPayload(_historyPieces(cached, 50)[0])));
messages.dataset.room = room;
messages.dataset.room = room;
} else if (clear || roomChanged) {
} else if (clear || roomChanged) {
- messages.innerHTML = '<div class="chat-loading-history"><i class="fa-solid fa-spinner fa-spin"></i> Loading history…</div>';
+ messages.innerHTML = '<div class="chat-loading-history"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading history…</div>';
messages.dataset.room = room;
messages.dataset.room = room;
}
}
}
}
@@ -1798,7 +1798,7 @@ async function _translateForMe(el, text, btn) {
@@ -1798,7 +1798,7 @@ async function _translateForMe(el, text, btn) {
if (existing) { existing.hidden = !existing.hidden; return; }
if (existing) { existing.hidden = !existing.hidden; return; }
const hit = _trCache.get(to + '\u0001' + text);
const hit = _trCache.get(to + '\u0001' + text);
if (hit) { _trShow(el, hit, to); return; }
if (hit) { _trShow(el, hit, to); return; }
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i>'; }
+ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>'; }
try {
try {
const d = await api('/i18n/translate', { method: 'POST', body: { text, to } });
const d = await api('/i18n/translate', { method: 'POST', body: { text, to } });
if (d && d.same) { toast(`Already in ${_LANG_LABEL[to] || to}`, 'info'); return; }
if (d && d.same) { toast(`Already in ${_LANG_LABEL[to] || to}`, 'info'); return; }
@@ -4417,7 +4417,7 @@ async function ctxAdminTools(username, userId, anonId) {
@@ -4417,7 +4417,7 @@ async function ctxAdminTools(username, userId, anonId) {
<button class="modal-close-btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i></button>
<button class="modal-close-btn" onclick="closeModal()"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
<div class="admin-tools-body">
<div class="admin-tools-body">
- <div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading IP intelligence...</div>
+ <div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading IP intelligence...</div>
</div>
</div>
</div>
</div>
`;
`;
@@ -4536,7 +4536,7 @@ function renderAdminToolsUser(container, data, username) {
@@ -4536,7 +4536,7 @@ function renderAdminToolsUser(container, data, username) {
<div class="at-section" id="at-tts-voice" data-kind="user" data-id="${esc(user.username)}">
<div class="at-section" id="at-tts-voice" data-kind="user" data-id="${esc(user.username)}">
<h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>
<h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>
- <div class="at-tts-body"><div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading voice…</div></div>
+ <div class="at-tts-body"><div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading voice…</div></div>
</div>
</div>
`;
`;
@@ -4602,7 +4602,7 @@ function renderAdminToolsAnon(container, data, username) {
@@ -4602,7 +4602,7 @@ function renderAdminToolsAnon(container, data, username) {
html += `
html += `
<div class="at-section" id="at-tts-voice" data-kind="anon" data-id="${esc(anon_id || username)}">
<div class="at-section" id="at-tts-voice" data-kind="anon" data-id="${esc(anon_id || username)}">
<h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>
<h4><i class="fa-solid fa-microphone-lines"></i> TTS Voice</h4>
- <div class="at-tts-body"><div class="admin-tools-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading voice…</div></div>
+ <div class="at-tts-body"><div class="admin-tools-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading voice…</div></div>
</div>`;
</div>`;
container.innerHTML = html;
container.innerHTML = html;
@@ -4660,7 +4660,7 @@ function renderAdminTtsVoice(bodyEl, kind, id, d) {
@@ -4660,7 +4660,7 @@ function renderAdminTtsVoice(bodyEl, kind, id, d) {
if (pr.speed != null) { $('#atv-speed').value = pr.speed; $('#atv-speed-n').textContent = pr.speed; }
if (pr.speed != null) { $('#atv-speed').value = pr.speed; $('#atv-speed-n').textContent = pr.speed; }
}));
}));
$('#atv-preview').addEventListener('click', async () => {
$('#atv-preview').addEventListener('click', async () => {
- const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Playing…';
+ const btn = $('#atv-preview'); btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Playing…';
try {
try {
const r = await api('/mod/tts-voice/preview', { method: 'POST', body: getParams() });
const r = await api('/mod/tts-voice/preview', { method: 'POST', body: getParams() });
if (r.url || r.audio) { const a = new Audio(r.url || `data:${r.mimeType};base64,${r.audio}`); await a.play().catch(() => {}); }
if (r.url || r.audio) { const a = new Audio(r.url || `data:${r.mimeType};base64,${r.audio}`); await a.play().catch(() => {}); }
@@ -1186,7 +1186,7 @@ async function createDashToken() {
@@ -1186,7 +1186,7 @@ async function createDashToken() {
const btn = document.getElementById('token-create-btn');
const btn = document.getElementById('token-create-btn');
btn.disabled = true;
btn.disabled = true;
- btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Creating...';
+ btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Creating...';
try {
try {
const data = await api('/auth/tokens', {
const data = await api('/auth/tokens', {
@@ -479,7 +479,7 @@ async function searchFFZEmotes(query) {
@@ -479,7 +479,7 @@ async function searchFFZEmotes(query) {
if (!query || query.length < 2) return;
if (!query || query.length < 2) return;
const { grid } = _getPickerEls(_emotePickerTarget);
const { grid } = _getPickerEls(_emotePickerTarget);
if (!grid) return;
if (!grid) return;
- grid.innerHTML = '<div class="emote-picker-empty"><i class="fa-solid fa-spinner fa-spin"></i> Searching FFZ...</div>';
+ grid.innerHTML = '<div class="emote-picker-empty"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Searching FFZ...</div>';
try {
try {
const data = await api(`/emotes/search?q=${encodeURIComponent(query)}`);
const data = await api(`/emotes/search?q=${encodeURIComponent(query)}`);
@@ -250,7 +250,7 @@
@@ -250,7 +250,7 @@
const oauthBtn = f.querySelector('#ovg-oauth-go');
const oauthBtn = f.querySelector('#ovg-oauth-go');
if (oauthBtn) oauthBtn.onclick = () => {
if (oauthBtn) oauthBtn.onclick = () => {
oauthBtn.disabled = true;
oauthBtn.disabled = true;
- oauthBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Waiting for ' + esc(name) + '…';
+ oauthBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for ' + esc(name) + '…';
connectPlatform(plat, (ok, needsKey) => {
connectPlatform(plat, (ok, needsKey) => {
if (ok && !needsKey) return rerender(); // done — back to the list
if (ok && !needsKey) return rerender(); // done — back to the list
if (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting
if (ok && needsKey) { rerender(); return; } // linked, but the key still needs pasting
@@ -757,7 +757,7 @@
@@ -757,7 +757,7 @@
<div class="msg-settings-section">
<div class="msg-settings-section">
<div class="msg-settings-section-title"><i class="fa-solid fa-shield-halved"></i> Blocked Users</div>
<div class="msg-settings-section-title"><i class="fa-solid fa-shield-halved"></i> Blocked Users</div>
- <div id="msg-blocked-list" class="msg-blocked-list"><div class="msg-settings-loading"><i class="fa-solid fa-spinner fa-spin"></i></div></div>
+ <div id="msg-blocked-list" class="msg-blocked-list"><div class="msg-settings-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span></div></div>
</div>
</div>
<div class="msg-settings-footer">
<div class="msg-settings-footer">
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
$('#ovg-kicker').textContent = step.kicker || S.journey.kicker || '';
$('#ovg-kicker').textContent = step.kicker || S.journey.kicker || '';
$('#ovg-title').textContent = typeof step.title === 'function' ? step.title(S.ctx) : (step.heading || step.title);
$('#ovg-title').textContent = typeof step.title === 'function' ? step.title(S.ctx) : (step.heading || step.title);
renderSteps();
renderSteps();
- panel.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
+ panel.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
let html = '';
let html = '';
try { html = await step.render(S.ctx); } catch (e) { html = `<div class="ovg-error"><i class="fa-solid fa-triangle-exclamation"></i> ${cfg.esc((e && e.message) || 'Something went wrong')}</div>`; }
try { html = await step.render(S.ctx); } catch (e) { html = `<div class="ovg-error"><i class="fa-solid fa-triangle-exclamation"></i> ${cfg.esc((e && e.message) || 'Something went wrong')}</div>`; }
if (S.step !== step) return; // user moved on while we loaded
if (S.step !== step) return; // user moved on while we loaded
@@ -226,7 +226,7 @@ function togglePasteMarkdown() {
@@ -226,7 +226,7 @@ function togglePasteMarkdown() {
async function loadPasteViewer(slug) {
async function loadPasteViewer(slug) {
const container = document.getElementById('paste-viewer-content');
const container = document.getElementById('paste-viewer-content');
if (!container) return;
if (!container) return;
- container.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
+ container.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
try {
try {
const data = await api(`/pastes/${slug}`);
const data = await api(`/pastes/${slug}`);
@@ -1149,7 +1149,7 @@ function _setScreenshotCapturing(on) {
@@ -1149,7 +1149,7 @@ function _setScreenshotCapturing(on) {
el = document.createElement('div');
el = document.createElement('div');
el.id = 'screenshot-capturing';
el.id = 'screenshot-capturing';
el.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:10px;padding:32px;color:var(--text-secondary);font-size:0.95rem';
el.style.cssText = 'display:flex;align-items:center;justify-content:center;gap:10px;padding:32px;color:var(--text-secondary);font-size:0.95rem';
- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Capturing screenshot…';
+ el.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Capturing screenshot…';
area.prepend(el);
area.prepend(el);
}
}
} else {
} else {
@@ -1421,7 +1421,7 @@ async function submitScreenshot() {
@@ -1421,7 +1421,7 @@ async function submitScreenshot() {
formData.append('user_agent', navigator.userAgent);
formData.append('user_agent', navigator.userAgent);
const submitBtn = document.getElementById('screenshot-submit-btn');
const submitBtn = document.getElementById('screenshot-submit-btn');
- if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Uploading…'; }
+ if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Uploading…'; }
try {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token');
@@ -111,7 +111,7 @@
@@ -111,7 +111,7 @@
if (s) s.classList.add('pc-status-block');
if (s) s.classList.add('pc-status-block');
_swap(s, `
_swap(s, `
<div class="pc-connecting-box">
<div class="pc-connecting-box">
- <div class="pc-connecting-head"><span class="pc-spinner"></span> Waiting for you to authorize in PowerChat<span class="pc-ellipsis"><span>.</span><span>.</span><span>.</span></span></div>
+ <div class="pc-connecting-head"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Waiting for you to authorize in PowerChat<span class="pc-ellipsis"><span>.</span><span>.</span><span>.</span></span></div>
<div class="pc-progress"><span class="pc-progress-bar"></span></div>
<div class="pc-progress"><span class="pc-progress-bar"></span></div>
<div class="pc-manual">
<div class="pc-manual">
<span>Popup didn't open?</span>
<span>Popup didn't open?</span>
@@ -233,7 +233,7 @@
@@ -233,7 +233,7 @@
window.powerchatDisconnect = async function powerchatDisconnect(btn) {
window.powerchatDisconnect = async function powerchatDisconnect(btn) {
if (!confirm('Disconnect PowerChat? Tips will stop flowing into OpenVibe.Live until you reconnect.')) return;
if (!confirm('Disconnect PowerChat? Tips will stop flowing into OpenVibe.Live until you reconnect.')) return;
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Disconnecting…'; }
+ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Disconnecting…'; }
const card = $('dash-powerchat-card'); if (card) card.classList.add('pc-busy');
const card = $('dash-powerchat-card'); if (card) card.classList.add('pc-busy');
try { await api('/powerchat/oauth/connection', { method: 'DELETE' }); toast('PowerChat disconnected', 'success'); }
try { await api('/powerchat/oauth/connection', { method: 'DELETE' }); toast('PowerChat disconnected', 'success'); }
catch (e) { toast(e.message || 'Failed to disconnect', 'error'); }
catch (e) { toast(e.message || 'Failed to disconnect', 'error'); }
@@ -242,7 +242,7 @@
@@ -242,7 +242,7 @@
// Local pipeline test — verifies the streamer's alert sound + chat celebration.
// Local pipeline test — verifies the streamer's alert sound + chat celebration.
window.powerchatTestTip = async function powerchatTestTip(btn) {
window.powerchatTestTip = async function powerchatTestTip(btn) {
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
+ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…'; }
try {
try {
const r = await api('/powerchat/test-tip', { method: 'POST', body: { amount: 5 } });
const r = await api('/powerchat/test-tip', { method: 'POST', body: { amount: 5 } });
toast(r && r.powerchat
toast(r && r.powerchat
@@ -278,7 +278,7 @@
@@ -278,7 +278,7 @@
const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);
const buttons = menu ? Array.from(menu.querySelectorAll('button')) : (btn ? [btn] : []);
buttons.forEach(b => { b.disabled = true; });
buttons.forEach(b => { b.disabled = true; });
const orig = btn ? btn.innerHTML : '';
const orig = btn ? btn.innerHTML : '';
- if (btn) btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…';
+ if (btn) btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…';
try {
try {
const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });
const r = await api('/powerchat/test-alert', { method: 'POST', body: { kind } });
toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');
toast((r && r.note) || 'Test sent to PowerChat ✓', 'success');
@@ -89,14 +89,14 @@
@@ -89,14 +89,14 @@
${more}
${more}
</article>`;
</article>`;
const regen = root.querySelector('#recap-regen');
const regen = root.querySelector('#recap-regen');
- if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
+ if (regen) regen.onclick = async () => { regen.disabled = true; regen.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Rewriting…'; try { await api(`/recap/${id}/regenerate`, { method: 'POST' }); await loadRecapPage(id); } catch (e) { regen.disabled = false; regen.innerHTML = '<i class="fa-solid fa-rotate"></i> Rewrite report'; } };
try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }
try { document.title = `${w.headline || s.title} — ${u.display_name}'s stream report | OpenVibe.Live`; } catch { /* */ }
}
}
window.loadRecapPage = async function (id) {
window.loadRecapPage = async function (id) {
const root = document.getElementById('recap-root');
const root = document.getElementById('recap-root');
if (!root) return;
if (!root) return;
- root.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
+ root.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
let data = null;
let data = null;
try { data = await api(`/recap/${encodeURIComponent(id)}`); } catch (e) {
try { data = await api(`/recap/${encodeURIComponent(id)}`); } catch (e) {
const live = e && /live/i.test(e.message || '');
const live = e && /live/i.test(e.message || '');
@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {
@@ -721,7 +721,7 @@ function _showReconnectingIndicator() {
const el = document.createElement('div');
const el = document.createElement('div');
el.id = 'reconnecting-indicator';
el.id = 'reconnecting-indicator';
el.style.cssText = 'position:absolute;top:12px;right:12px;z-index:25;background:rgba(0,0,0,0.7);color:#fbbf24;padding:6px 14px;border-radius:8px;font-size:0.85rem;display:flex;align-items:center;gap:8px;pointer-events:none;';
el.style.cssText = 'position:absolute;top:12px;right:12px;z-index:25;background:rgba(0,0,0,0.7);color:#fbbf24;padding:6px 14px;border-radius:8px;font-size:0.85rem;display:flex;align-items:center;gap:8px;pointer-events:none;';
- el.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="font-size:0.9rem"></i> The streamer is reconnecting…';
+ el.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> The streamer is reconnecting…';
container.appendChild(el);
container.appendChild(el);
}
}
@@ -3142,7 +3142,7 @@ function setClipButtonBusy(isBusy) {
@@ -3142,7 +3142,7 @@ function setClipButtonBusy(isBusy) {
btn.disabled = !!isBusy;
btn.disabled = !!isBusy;
btn.classList.toggle('is-busy', !!isBusy);
btn.classList.toggle('is-busy', !!isBusy);
btn.innerHTML = isBusy
btn.innerHTML = isBusy
- ? '<i class="fa-solid fa-spinner fa-spin"></i>'
+ ? '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>'
: '<i class="fa-solid fa-scissors"></i>';
: '<i class="fa-solid fa-scissors"></i>';
}
}
@@ -3528,7 +3528,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
@@ -3528,7 +3528,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
if (saveBtn.disabled) return;
if (saveBtn.disabled) return;
const title = titleInput.value.trim();
const title = titleInput.value.trim();
saveBtn.disabled = true;
saveBtn.disabled = true;
- saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
+ saveBtn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Saving...';
try {
try {
const token = localStorage.getItem('token');
const token = localStorage.getItem('token');
const resp = await fetch(`/api/clips/${clipId}/trim`, {
const resp = await fetch(`/api/clips/${clipId}/trim`, {
@@ -9,7 +9,7 @@ function openvibeSubscribeModal(username) {
@@ -9,7 +9,7 @@ function openvibeSubscribeModal(username) {
<h3><i class="fa-solid fa-star"></i> Subscribe to ${username ? username : 'this channel'}</h3>
<h3><i class="fa-solid fa-star"></i> Subscribe to ${username ? username : 'this channel'}</h3>
<p class="muted" id="sub-price-line" style="margin-bottom:16px">Monthly channel subscription.</p>
<p class="muted" id="sub-price-line" style="margin-bottom:16px">Monthly channel subscription.</p>
<div id="subscribe-options" data-streamer="${username || ''}">
<div id="subscribe-options" data-streamer="${username || ''}">
- <p class="muted" style="font-size:0.85rem;text-align:center"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</p>
+ <p class="muted" style="font-size:0.85rem;text-align:center"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading…</p>
</div>`;
</div>`;
}
}
@@ -942,7 +942,7 @@ button{font:inherit}
@@ -942,7 +942,7 @@ button{font:inherit}
const kind=el.dataset.kind, username=el.dataset.username, label=el.dataset.label;
const kind=el.dataset.kind, username=el.dataset.username, label=el.dataset.label;
const menu=document.createElement("div");
const menu=document.createElement("div");
menu.className="chat-context-menu";
menu.className="chat-context-menu";
- menu.innerHTML=`<div class="ctx-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>`;
+ menu.innerHTML=`<div class="ctx-loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Loading…</div>`;
document.body.appendChild(menu); _kioskMenu=menu;
document.body.appendChild(menu); _kioskMenu=menu;
_posMenu(menu,x,y);
_posMenu(menu,x,y);
menu.addEventListener("click",e=>e.stopPropagation());
menu.addEventListener("click",e=>e.stopPropagation());
@@ -1204,7 +1204,7 @@ button{font:inherit}
@@ -1204,7 +1204,7 @@ button{font:inherit}
if(looksLikeDomain(q)){
if(looksLikeDomain(q)){
const key=q.toLowerCase(); let res=resolved.get(key);
const key=q.toLowerCase(); let res=resolved.get(key);
if(!res){
if(!res){
- goBtn.innerHTML='<i class="fa-solid fa-spinner fa-spin"></i>';
+ goBtn.innerHTML='<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span>';
try{ const r=await fetch(`/api/kiosk/site?url=${encodeURIComponent(q)}`); const d=await r.json(); res=d.reachable?{reachable:true,url:d.url,host:d.host,title:d.title}:{reachable:false}; resolved.set(key,res); }
try{ const r=await fetch(`/api/kiosk/site?url=${encodeURIComponent(q)}`); const d=await r.json(); res=d.reachable?{reachable:true,url:d.url,host:d.host,title:d.title}:{reachable:false}; resolved.set(key,res); }
catch{ res={reachable:true,url:fullUrl(q),host:hostOf(q)}; }
catch{ res={reachable:true,url:fullUrl(q),host:hostOf(q)}; }
goBtn.innerHTML='<i class="fa-solid fa-arrow-right"></i>';
goBtn.innerHTML='<i class="fa-solid fa-arrow-right"></i>';
@@ -48,7 +48,7 @@
@@ -48,7 +48,7 @@
</div>
</div>
<div id="mp-player-host" class="mp-player-host" hidden>
<div id="mp-player-host" class="mp-player-host" hidden>
<div id="mp-loading-overlay" class="mp-loading-overlay" hidden>
<div id="mp-loading-overlay" class="mp-loading-overlay" hidden>
- <div class="mp-spinner"></div>
+ <span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span>
<p id="mp-loading-text">Loading media…</p>
<p id="mp-loading-text">Loading media…</p>
</div>
</div>
<div id="mp-error-overlay" class="mp-error-overlay" hidden>
<div id="mp-error-overlay" class="mp-error-overlay" hidden>
Binary files a/public/og-image.png and b/public/og-image.png differ
Binary files a/public/og-image.png and b/public/og-image.png differ