openvibers/openvibe.live Commits

Docs: Live Frame API section; scrollable tab bar with arrows + wheel support

OP OpenVibers committed 18 Aug 2026 · 934d878af4 · 3 files, +71 −5

- New docs section under VODs covering openvibe.media/live/:sel/frame.jpg
  (slot id / slug / @username selectors, 5s cache, offline card).
- Docs tab bar: left/right arrow buttons + edge fades appear when tabs
  overflow, plain mouse wheel scrolls the bar horizontally, and the active
  tab scrolls into view — previously overflow was invisible and only
  shift+scroll worked.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 4d60f0ed..a5ce1dbc 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -9190,8 +9190,19 @@ body.theme-light .user-dropdown {
.doc-vibe-hero h2 { margin-top: 0 !important; border: none !important; padding: 0 !important; }
/* Doc tab navigation */
--.docs-tab-bar { -webkit-overflow-scrolling: touch; scrollbar-width: none; }
++.docs-tab-wrap { display: flex; align-items: stretch; position: relative; border-bottom: 2px solid var(--border); margin-bottom: 1.5rem; }
++.docs-tab-bar { flex: 1 1 auto; min-width: 0; -webkit-overflow-scrolling: touch; scrollbar-width: none; scroll-behavior: smooth; }
.docs-tab-bar::-webkit-scrollbar { display: none; }
++.docs-tab-arrow { flex: 0 0 auto; display: none; align-items: center; justify-content: center; width: 30px; background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 0.8rem; transition: color 0.15s; }
++.docs-tab-arrow:hover { color: var(--accent); }
++.docs-tab-wrap.can-scroll-left #docs-tab-arrow-left { display: flex; }
++.docs-tab-wrap.can-scroll-right #docs-tab-arrow-right { display: flex; }
++/* Edge fades hint at more tabs beyond the fold */
++.docs-tab-wrap::before, .docs-tab-wrap::after { content: ''; position: absolute; top: 0; bottom: 0; width: 28px; pointer-events: none; opacity: 0; transition: opacity 0.15s; z-index: 1; }
++.docs-tab-wrap::before { left: 30px; background: linear-gradient(to right, var(--bg-primary, #0f1420), transparent); }
++.docs-tab-wrap::after { right: 30px; background: linear-gradient(to left, var(--bg-primary, #0f1420), transparent); }
++.docs-tab-wrap.can-scroll-left::before { opacity: 1; }
++.docs-tab-wrap.can-scroll-right::after { opacity: 1; }
.docs-tab { background: none; border: none; color: var(--text-muted); padding: 0.6rem 1rem; font-size: 0.85rem; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; white-space: nowrap; transition: color 0.15s, border-color 0.15s; }
.docs-tab:hover { color: var(--text); }
.docs-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
ddiff --git a/public/index.html b/public/index.html
iindex 5edfc2f4..3e06dace 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -38,7 +38,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=133">
++ <link rel="stylesheet" href="/css/style.css?v=134">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=9">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@@ -3455,7 +3455,9 @@ WantedBy=multi-user.target</code>
</div>
<!-- Docs tab navigation -->
-- <div class="docs-tab-bar" style="display:flex;gap:0;overflow-x:auto;border-bottom:2px solid var(--border);margin-bottom:1.5rem;padding-bottom:0">
++ <div class="docs-tab-wrap" id="docs-tab-wrap">
++ <button class="docs-tab-arrow" id="docs-tab-arrow-left" title="Scroll tabs left" aria-label="Scroll tabs left"><i class="fa-solid fa-chevron-left"></i></button>
++ <div class="docs-tab-bar" id="docs-tab-bar" style="display:flex;gap:0;overflow-x:auto;padding-bottom:0">
<button class="docs-tab active" onclick="showDocTab('start',this)">Getting Started</button>
<button class="docs-tab" onclick="showDocTab('arch',this)">Architecture</button>
<button class="docs-tab" onclick="showDocTab('auth',this)">Auth</button>
@@@ -3471,6 +3473,8 @@ WantedBy=multi-user.target</code>
<button class="docs-tab" onclick="showDocTab('vods',this)">VODs</button>
<button class="docs-tab" onclick="showDocTab('misc',this)">API Ref</button>
</div>
++ <button class="docs-tab-arrow" id="docs-tab-arrow-right" title="Scroll tabs right" aria-label="Scroll tabs right"><i class="fa-solid fa-chevron-right"></i></button>
++ </div>
<div id="docs-ai-content">
@@@ -4610,6 +4614,23 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<tr><td>PUT / DELETE</td><td>/api/pastes/:slug</td><td>Owner/staff</td><td>Edit / delete a paste</td></tr>
</tbody>
</table>
++
++<h3>Live Frame API — <code>openvibe.media/live/:selector/frame.jpg</code></h3>
++<p>Grab a <strong>near-realtime JPEG frame</strong> of any actively-live stream slot — built for external APIs, bots, dashboards, and dev tooling. No auth, CORS-open (<code>Access-Control-Allow-Origin: *</code>), so you can hotlink it in an <code>&lt;img&gt;</code>, poll it from a script, or draw it onto a canvas.</p>
++<table class="doc-table">
++<thead><tr><th>Selector</th><th>Example</th><th>Resolves to</th></tr></thead>
++<tbody>
++<tr><td>Slot id</td><td><code>/live/1/frame.jpg</code></td><td>That managed-stream slot (id from your Stream Manager)</td></tr>
++<tr><td>Slot slug</td><td><code>/live/whip/frame.jpg</code></td><td>The currently-live slot with that slug</td></tr>
++<tr><td>@username</td><td><code>/live/@Goosely/frame.jpg</code></td><td>That streamer's <strong>top-viewed</strong> currently-live slot</td></tr>
++</tbody>
++</table>
++<p><code>?w=64..1920</code> scales the width (aspect kept) &middot; responses are cached <strong>5s per slot</strong> &mdash; that cache is the rate limit, so poll as fast as you like &middot; frames are cut ~2s behind the live edge. <strong>Offline?</strong> You get a styled "OFFLINE" card image with a <code>404</code> status &mdash; embeds keep looking good, scripts key off the status code (or add <code>?format=json</code> for JSON errors). <code>503</code> = live but no frame could be cut (retry next window).</p>
++<pre><code># Is Goosely live? Grab a frame if so
++curl -f -o now.jpg https://openvibe.media/live/@Goosely/frame.jpg &amp;&amp; echo LIVE || echo OFFLINE
++
++# 320px-wide preview for a status page — shows the OFFLINE card automatically when not live
++&lt;img src="https://openvibe.media/live/whip/frame.jpg?w=320"&gt;</code></pre>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
@@@ -4904,7 +4925,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=2" defer></script>
--<script src="/js/app.js?v=120" defer></script>
++<script src="/js/app.js?v=121" defer></script>
<script src="/js/stream-player.js?v=17" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex ff3d536c..cec4ed57 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -987,6 +987,7 @@ function routeFromURL() {
loadPasteViewer(segments[1]);
} else if (segments[0] === 'documentation') {
showPage('documentation');
++ initDocsTabScroller();
} else if (segments[0] === 'stream' && segments[1]) {
// Legacy stream URL: /stream/:id
showPage('stream');
@@@ -8169,7 +8170,40 @@ function showDocTab(tabName, btn) {
document.querySelectorAll('.docs-tab').forEach(el => el.classList.remove('active'));
const target = document.querySelector(`.doc-tab-content[data-doc-tab="${tabName}"]`);
if (target) target.style.display = '';
-- if (btn) btn.classList.add('active');
++ if (btn) {
++ btn.classList.add('active');
++ // Keep the selected tab fully visible in the scrollable bar.
++ try { btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } catch { /* */ }
++ }
++}
++
++// Tab-bar overflow affordance: arrow buttons + edge fades when tabs overflow,
++// plain mouse wheel scrolls the bar horizontally (no shift needed).
++function initDocsTabScroller() {
++ const wrap = document.getElementById('docs-tab-wrap');
++ const bar = document.getElementById('docs-tab-bar');
++ if (!wrap || !bar) return;
++ const update = () => {
++ wrap.classList.toggle('can-scroll-left', bar.scrollLeft > 4);
++ wrap.classList.toggle('can-scroll-right', bar.scrollLeft + bar.clientWidth < bar.scrollWidth - 4);
++ };
++ if (!wrap._scrollerInit) {
++ wrap._scrollerInit = true;
++ bar.addEventListener('scroll', update, { passive: true });
++ window.addEventListener('resize', update);
++ const L = document.getElementById('docs-tab-arrow-left');
++ const R = document.getElementById('docs-tab-arrow-right');
++ if (L) L.onclick = () => bar.scrollBy({ left: -Math.max(200, bar.clientWidth * 0.6), behavior: 'smooth' });
++ if (R) R.onclick = () => bar.scrollBy({ left: Math.max(200, bar.clientWidth * 0.6), behavior: 'smooth' });
++ bar.addEventListener('wheel', (e) => {
++ if (Math.abs(e.deltaY) > Math.abs(e.deltaX) && bar.scrollWidth > bar.clientWidth) {
++ bar.scrollLeft += e.deltaY;
++ e.preventDefault();
++ }
++ }, { passive: false });
++ }
++ // Widths are 0 until the page is actually displayed — measure on next frame.
++ requestAnimationFrame(update);
}
function copyDocSection() {