openvibers/openvibe.live Commits
Docs: Live Frame API section; scrollable tab bar with arrows + wheel support
Changes
@@@ -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; }@@@ -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><img></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) · responses are cached <strong>5s per slot</strong> — that cache is the rate limit, so poll as fast as you like · frames are cut ~2s behind the live edge. <strong>Offline?</strong> You get a styled "OFFLINE" card image with a <code>404</code> status — 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 && echo LIVE || echo OFFLINE++++# 320px-wide preview for a status page — shows the OFFLINE card automatically when not live++<img src="https://openvibe.media/live/whip/frame.jpg?w=320"></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>@@@ -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/:idshowPage('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() {
@@ -9190,8 +9190,19 @@ body.theme-light .user-dropdown {
@@ -9190,8 +9190,19 @@ body.theme-light .user-dropdown {
.doc-vibe-hero h2 { margin-top: 0 !important; border: none !important; padding: 0 !important; }
.doc-vibe-hero h2 { margin-top: 0 !important; border: none !important; padding: 0 !important; }
/* Doc tab navigation */
/* 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-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 { 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:hover { color: var(--text); }
.docs-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.docs-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
@@ -38,7 +38,7 @@
@@ -38,7 +38,7 @@
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<!-- Stylesheets -->
<!-- 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/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/broadcast.css?v=9">
<link rel="stylesheet" href="/css/broadcast.css?v=9">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -3455,7 +3455,9 @@ WantedBy=multi-user.target</code>
@@ -3455,7 +3455,9 @@ WantedBy=multi-user.target</code>
</div>
</div>
<!-- Docs tab navigation -->
<!-- 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 active" onclick="showDocTab('start',this)">Getting Started</button>
<button class="docs-tab" onclick="showDocTab('arch',this)">Architecture</button>
<button class="docs-tab" onclick="showDocTab('arch',this)">Architecture</button>
<button class="docs-tab" onclick="showDocTab('auth',this)">Auth</button>
<button class="docs-tab" onclick="showDocTab('auth',this)">Auth</button>
@@ -3471,6 +3473,8 @@ WantedBy=multi-user.target</code>
@@ -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('vods',this)">VODs</button>
<button class="docs-tab" onclick="showDocTab('misc',this)">API Ref</button>
<button class="docs-tab" onclick="showDocTab('misc',this)">API Ref</button>
</div>
</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">
<div id="docs-ai-content">
@@ -4610,6 +4614,23 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -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>
<tr><td>PUT / DELETE</td><td>/api/pastes/:slug</td><td>Owner/staff</td><td>Edit / delete a paste</td></tr>
</tbody>
</tbody>
</table>
</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><img></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) · responses are cached <strong>5s per slot</strong> — that cache is the rate limit, so poll as fast as you like · frames are cut ~2s behind the live edge. <strong>Offline?</strong> You get a styled "OFFLINE" card image with a <code>404</code> status — 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 && echo LIVE || echo OFFLINE
+
+# 320px-wide preview for a status page — shows the OFFLINE card automatically when not live
+<img src="https://openvibe.media/live/whip/frame.jpg?w=320"></code></pre>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
@@ -4904,7 +4925,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -4904,7 +4925,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=2" defer></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/stream-player.js?v=17" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
<script src="/js/emotes.js?v=8" defer></script>
@@ -987,6 +987,7 @@ function routeFromURL() {
@@ -987,6 +987,7 @@ function routeFromURL() {
loadPasteViewer(segments[1]);
loadPasteViewer(segments[1]);
} else if (segments[0] === 'documentation') {
} else if (segments[0] === 'documentation') {
showPage('documentation');
showPage('documentation');
+ initDocsTabScroller();
} else if (segments[0] === 'stream' && segments[1]) {
} else if (segments[0] === 'stream' && segments[1]) {
// Legacy stream URL: /stream/:id
// Legacy stream URL: /stream/:id
showPage('stream');
showPage('stream');
@@ -8169,7 +8170,40 @@ function showDocTab(tabName, btn) {
@@ -8169,7 +8170,40 @@ function showDocTab(tabName, btn) {
document.querySelectorAll('.docs-tab').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.docs-tab').forEach(el => el.classList.remove('active'));
const target = document.querySelector(`.doc-tab-content[data-doc-tab="${tabName}"]`);
const target = document.querySelector(`.doc-tab-content[data-doc-tab="${tabName}"]`);
if (target) target.style.display = '';
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() {
function copyDocSection() {