openvibers/openvibe.live Commits
The dashboard's Scenes card: pick a theme and a countdown, copy each scene's browser-source URL (#487)
Changes
@@@ -3818,6 +3818,13 @@ textarea.form-input { resize: vertical; min-height: 60px; }.form-row .form-group { flex: 1; }.key-display { display: flex; gap: 6px; flex-wrap: wrap; }++/* The dashboard's Scenes card (L45): a theme and a countdown, then a URL for each scene. */++.dash-scene-opts { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 12px; }++.dash-scene-opts label { display: inline-flex; align-items: center; gap: 8px; font-size: .88rem; font-weight: 600; color: var(--text-secondary); }++.dash-scene-opts select { min-height: 36px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input, var(--bg-secondary)); color: var(--text-primary); font: inherit; }++.dash-scenes { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }++.dash-scene-name { display: block; margin-bottom: 6px; font-size: .9rem; font-weight: 700; }++.dash-scene-name i { width: 18px; color: var(--accent); }.key-display .form-input { flex: 1; }.endpoint-info {@@@ -75,6 +75,17 @@<p class="muted" style="font-size:0.82rem;margin-bottom:0">Per-slot overlay URLs are available in each stream slot's Settings tab on the <a href="/broadcast">Broadcast page</a>.<br>Browser Source size: 800×600 is a good starting point. Background stays transparent.</p></div>++ <!-- Scenes in your look (plan L45): Starting soon, Be right back, Thanks for watching, as browser sources. -->++ <div class="dash-card" id="dash-card-scenes">++ <h3><i class="fa-solid fa-clapperboard"></i> Scenes</h3>++ <p class="muted" style="margin-bottom:12px">Full-screen scenes for OBS, in the theme you pick, with the OpenVibe mark arriving. Add each as a Browser Source at 1920 × 1080.</p>++ <div class="dash-scene-opts">++ <label>Theme <select id="dash-scene-theme" onchange="updateDashScenes()"></select></label>++ <label>Countdown <select id="dash-scene-in" onchange="updateDashScenes()"><option value="">None</option><option value="2">2 min</option><option value="5" selected>5 min</option><option value="10">10 min</option><option value="15">15 min</option></select></label>++ </div>++ <ul class="dash-scenes" id="dash-scenes"></ul>++ </div>++<!-- API Tokens (Bot / Integration) --><div class="dash-card" id="dash-card-tokens"><h3><i class="fa-solid fa-key"></i> API Tokens</h3>@@@ -12,7 +12,7 @@ let activeStreams = [];modules (e.g. moderation) register their loader on window._dashTabLoaders. */const _DASH_TAB_CARDS = {home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],-- chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],++ chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes', 'dash-card-chatlogs'],moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderationcontrols: ['dash-card-bot-embed', 'dash-card-tokens'],money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],@@@ -101,7 +101,7 @@ window.switchDashTab = switchDashTab;// Per-tab loaders (guarded so a missing fn never breaks the tab).function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };--window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); loadDashVibe(); };++window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('updateDashScenes'); _call('loadAiViewers'); loadDashVibe(); };/* ── Vibe on stream (server/ai/vibe-voice.js, /api/vibe/settings) ─────────────────── */async function loadDashVibe() {@@@ -215,6 +215,45 @@ function getDashObsOverlayUrl() {return `${window.location.origin}/overlay/chat/${encodeURIComponent(currentUser.username)}`;}++/* ── Scenes in your look (plan L45): /obs/scene/<kind>/<username>?theme=…&in=… ── */++const DASH_SCENES = [['starting', 'Starting soon', 'fa-hourglass-start'], ['brb', 'Be right back', 'fa-mug-hot'], ['ending', 'Thanks for watching', 'fa-hand']];++function dashSceneUrl(kind) {++ if (!currentUser?.username) return '';++ const q = new URLSearchParams();++ const theme = document.getElementById('dash-scene-theme')?.value;++ const mins = document.getElementById('dash-scene-in')?.value;++ if (theme && theme !== 'vibe') q.set('theme', theme);++ if (mins && kind !== 'ending') q.set('in', mins);++ const s = q.toString();++ const path = ['', 'obs', 'scene', kind, encodeURIComponent(currentUser.username)].join('/'); // a scene's address, not a channel link++ return `${window.location.origin}${path}${s ? `?${s}` : ''}`;++}++function updateDashScenes() {++ const list = document.getElementById('dash-scenes');++ const pick = document.getElementById('dash-scene-theme');++ if (!list || !pick) return;++ if (!pick.options.length) {++ const themes = (window.OpenVibeThemeLoader && window.OpenVibeThemeLoader.THEMES) || { vibe: {} };++ const mine = (window.OpenVibeThemeLoader && window.OpenVibeThemeLoader.getCurrent && window.OpenVibeThemeLoader.getCurrent()) || 'vibe';++ Object.keys(themes).sort().forEach((id) => { const o = document.createElement('option'); o.value = id; o.textContent = id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '); pick.appendChild(o); });++ pick.value = themes[mine] ? mine : 'vibe';++ }++ list.replaceChildren(...DASH_SCENES.map(([kind, label, icon]) => {++ const li = document.createElement('li');++ const name = document.createElement('span'); name.className = 'dash-scene-name';++ const i = document.createElement('i'); i.className = `fa-solid ${icon}`; i.setAttribute('aria-hidden', 'true');++ name.append(i, ` ${label}`);++ const row = document.createElement('div'); row.className = 'key-display';++ const input = document.createElement('input'); input.type = 'text'; input.readOnly = true; input.className = 'form-input'; input.value = dashSceneUrl(kind); input.setAttribute('aria-label', `${label} scene URL`);++ const copy = document.createElement('button'); copy.className = 'btn btn-small'; copy.title = `Copy the ${label} URL`; copy.innerHTML = '<i class="fa-solid fa-copy"></i>';++ copy.addEventListener('click', () => navigator.clipboard.writeText(input.value).then(() => toast(`${label} URL copied`, 'success')));++ const open = document.createElement('a'); open.className = 'btn btn-small'; open.href = input.value; open.target = '_blank'; open.rel = 'noopener'; open.title = `Preview ${label}`; open.innerHTML = '<i class="fa-solid fa-arrow-up-right-from-square"></i>';++ row.append(input, copy, open);++ li.append(name, row);++ return li;++ }));++}++function updateDashObsOverlayUrl() {const input = document.getElementById('dash-obs-chat-overlay-url');if (!input) return;@@@ -28,4 +28,12 @@ assert.match(SRC, /\(q\.get\('msg'\) \|\| ''\)\.slice\(0, 140\)/, 'a message ofassert.match(SRC, /setAttribute\('data-state', 'busy'\)/, 'on Be right back the mark breathes');assert.match(SRC, /@media \(prefers-reduced-motion: reduce\) \{ \.sc-light \{ animation: none; \}/, 'still under reduced motion');assert.ok(!/spin|rotate\(/.test(SRC.replace(/<!--[\s\S]*?-->/g, '')), 'nothing spins');++// ── The dashboard: a Scenes card in the Chat tab, a URL for each scene in the theme picked ──++const frag = fs.readFileSync(path.join(__dirname, '..', 'public/fragments/dashboard.html'), 'utf8');++const dash = fs.readFileSync(path.join(__dirname, '..', 'public/js/dashboard.js'), 'utf8');++assert.ok(frag.includes('id="dash-card-scenes"') && frag.includes('id="dash-scene-theme"') && frag.includes('id="dash-scenes"'), 'the card');++assert.match(dash, /chatai: +\['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes',/, 'beside the chat overlay');++assert.match(dash, /\['', 'obs', 'scene', kind, encodeURIComponent\(currentUser\.username\)\]\.join\('\/'\)/, 'each scene\'s address');++assert.match(dash, /if \(theme && theme !== 'vibe'\) q\.set\('theme', theme\)/, 'the theme picked rides along');++assert.match(dash, /_call\('updateDashScenes'\)/, 'drawn when the tab opens');console.log('obs scenes: three scenes by route, the loader and the mark, any theme by name, the mark arriving, text only, still under reduced motion');
@@ -3818,6 +3818,13 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -3818,6 +3818,13 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.form-row .form-group { flex: 1; }
.form-row .form-group { flex: 1; }
.key-display { display: flex; gap: 6px; flex-wrap: wrap; }
.key-display { display: flex; gap: 6px; flex-wrap: wrap; }
+/* The dashboard's Scenes card (L45): a theme and a countdown, then a URL for each scene. */
+.dash-scene-opts { display: flex; flex-wrap: wrap; gap: 10px 16px; margin-bottom: 12px; }
+.dash-scene-opts label { display: inline-flex; align-items: center; gap: 8px; font-size: .88rem; font-weight: 600; color: var(--text-secondary); }
+.dash-scene-opts select { min-height: 36px; padding: 0 10px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-input, var(--bg-secondary)); color: var(--text-primary); font: inherit; }
+.dash-scenes { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
+.dash-scene-name { display: block; margin-bottom: 6px; font-size: .9rem; font-weight: 700; }
+.dash-scene-name i { width: 18px; color: var(--accent); }
.key-display .form-input { flex: 1; }
.key-display .form-input { flex: 1; }
.endpoint-info {
.endpoint-info {
@@ -75,6 +75,17 @@
@@ -75,6 +75,17 @@
<p class="muted" style="font-size:0.82rem;margin-bottom:0">Per-slot overlay URLs are available in each stream slot's Settings tab on the <a href="/broadcast">Broadcast page</a>.<br>Browser Source size: 800×600 is a good starting point. Background stays transparent.</p>
<p class="muted" style="font-size:0.82rem;margin-bottom:0">Per-slot overlay URLs are available in each stream slot's Settings tab on the <a href="/broadcast">Broadcast page</a>.<br>Browser Source size: 800×600 is a good starting point. Background stays transparent.</p>
</div>
</div>
+ <!-- Scenes in your look (plan L45): Starting soon, Be right back, Thanks for watching, as browser sources. -->
+ <div class="dash-card" id="dash-card-scenes">
+ <h3><i class="fa-solid fa-clapperboard"></i> Scenes</h3>
+ <p class="muted" style="margin-bottom:12px">Full-screen scenes for OBS, in the theme you pick, with the OpenVibe mark arriving. Add each as a Browser Source at 1920 × 1080.</p>
+ <div class="dash-scene-opts">
+ <label>Theme <select id="dash-scene-theme" onchange="updateDashScenes()"></select></label>
+ <label>Countdown <select id="dash-scene-in" onchange="updateDashScenes()"><option value="">None</option><option value="2">2 min</option><option value="5" selected>5 min</option><option value="10">10 min</option><option value="15">15 min</option></select></label>
+ </div>
+ <ul class="dash-scenes" id="dash-scenes"></ul>
+ </div>
+
<!-- API Tokens (Bot / Integration) -->
<!-- API Tokens (Bot / Integration) -->
<div class="dash-card" id="dash-card-tokens">
<div class="dash-card" id="dash-card-tokens">
<h3><i class="fa-solid fa-key"></i> API Tokens</h3>
<h3><i class="fa-solid fa-key"></i> API Tokens</h3>
@@ -12,7 +12,7 @@ let activeStreams = [];
@@ -12,7 +12,7 @@ let activeStreams = [];
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
const _DASH_TAB_CARDS = {
const _DASH_TAB_CARDS = {
home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],
home: ['dash-card-profile', 'dash-card-schedule', 'dash-card-ai-derivation', 'dash-card-offline'],
- chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
+ chatai: ['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],
money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],
@@ -101,7 +101,7 @@ window.switchDashTab = switchDashTab;
@@ -101,7 +101,7 @@ window.switchDashTab = switchDashTab;
// Per-tab loaders (guarded so a missing fn never breaks the tab).
// Per-tab loaders (guarded so a missing fn never breaks the tab).
function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }
function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }
window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
-window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); loadDashVibe(); };
+window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('updateDashScenes'); _call('loadAiViewers'); loadDashVibe(); };
/* ── Vibe on stream (server/ai/vibe-voice.js, /api/vibe/settings) ─────────────────── */
/* ── Vibe on stream (server/ai/vibe-voice.js, /api/vibe/settings) ─────────────────── */
async function loadDashVibe() {
async function loadDashVibe() {
@@ -215,6 +215,45 @@ function getDashObsOverlayUrl() {
@@ -215,6 +215,45 @@ function getDashObsOverlayUrl() {
return `${window.location.origin}/overlay/chat/${encodeURIComponent(currentUser.username)}`;
return `${window.location.origin}/overlay/chat/${encodeURIComponent(currentUser.username)}`;
}
}
+/* ── Scenes in your look (plan L45): /obs/scene/<kind>/<username>?theme=…&in=… ── */
+const DASH_SCENES = [['starting', 'Starting soon', 'fa-hourglass-start'], ['brb', 'Be right back', 'fa-mug-hot'], ['ending', 'Thanks for watching', 'fa-hand']];
+function dashSceneUrl(kind) {
+ if (!currentUser?.username) return '';
+ const q = new URLSearchParams();
+ const theme = document.getElementById('dash-scene-theme')?.value;
+ const mins = document.getElementById('dash-scene-in')?.value;
+ if (theme && theme !== 'vibe') q.set('theme', theme);
+ if (mins && kind !== 'ending') q.set('in', mins);
+ const s = q.toString();
+ const path = ['', 'obs', 'scene', kind, encodeURIComponent(currentUser.username)].join('/'); // a scene's address, not a channel link
+ return `${window.location.origin}${path}${s ? `?${s}` : ''}`;
+}
+function updateDashScenes() {
+ const list = document.getElementById('dash-scenes');
+ const pick = document.getElementById('dash-scene-theme');
+ if (!list || !pick) return;
+ if (!pick.options.length) {
+ const themes = (window.OpenVibeThemeLoader && window.OpenVibeThemeLoader.THEMES) || { vibe: {} };
+ const mine = (window.OpenVibeThemeLoader && window.OpenVibeThemeLoader.getCurrent && window.OpenVibeThemeLoader.getCurrent()) || 'vibe';
+ Object.keys(themes).sort().forEach((id) => { const o = document.createElement('option'); o.value = id; o.textContent = id.split('-').map((w) => w[0].toUpperCase() + w.slice(1)).join(' '); pick.appendChild(o); });
+ pick.value = themes[mine] ? mine : 'vibe';
+ }
+ list.replaceChildren(...DASH_SCENES.map(([kind, label, icon]) => {
+ const li = document.createElement('li');
+ const name = document.createElement('span'); name.className = 'dash-scene-name';
+ const i = document.createElement('i'); i.className = `fa-solid ${icon}`; i.setAttribute('aria-hidden', 'true');
+ name.append(i, ` ${label}`);
+ const row = document.createElement('div'); row.className = 'key-display';
+ const input = document.createElement('input'); input.type = 'text'; input.readOnly = true; input.className = 'form-input'; input.value = dashSceneUrl(kind); input.setAttribute('aria-label', `${label} scene URL`);
+ const copy = document.createElement('button'); copy.className = 'btn btn-small'; copy.title = `Copy the ${label} URL`; copy.innerHTML = '<i class="fa-solid fa-copy"></i>';
+ copy.addEventListener('click', () => navigator.clipboard.writeText(input.value).then(() => toast(`${label} URL copied`, 'success')));
+ const open = document.createElement('a'); open.className = 'btn btn-small'; open.href = input.value; open.target = '_blank'; open.rel = 'noopener'; open.title = `Preview ${label}`; open.innerHTML = '<i class="fa-solid fa-arrow-up-right-from-square"></i>';
+ row.append(input, copy, open);
+ li.append(name, row);
+ return li;
+ }));
+}
+
function updateDashObsOverlayUrl() {
function updateDashObsOverlayUrl() {
const input = document.getElementById('dash-obs-chat-overlay-url');
const input = document.getElementById('dash-obs-chat-overlay-url');
if (!input) return;
if (!input) return;
@@ -28,4 +28,12 @@ assert.match(SRC, /\(q\.get\('msg'\) \|\| ''\)\.slice\(0, 140\)/, 'a message of
@@ -28,4 +28,12 @@ assert.match(SRC, /\(q\.get\('msg'\) \|\| ''\)\.slice\(0, 140\)/, 'a message of
assert.match(SRC, /setAttribute\('data-state', 'busy'\)/, 'on Be right back the mark breathes');
assert.match(SRC, /setAttribute\('data-state', 'busy'\)/, 'on Be right back the mark breathes');
assert.match(SRC, /@media \(prefers-reduced-motion: reduce\) \{ \.sc-light \{ animation: none; \}/, 'still under reduced motion');
assert.match(SRC, /@media \(prefers-reduced-motion: reduce\) \{ \.sc-light \{ animation: none; \}/, 'still under reduced motion');
assert.ok(!/spin|rotate\(/.test(SRC.replace(/<!--[\s\S]*?-->/g, '')), 'nothing spins');
assert.ok(!/spin|rotate\(/.test(SRC.replace(/<!--[\s\S]*?-->/g, '')), 'nothing spins');
+// ── The dashboard: a Scenes card in the Chat tab, a URL for each scene in the theme picked ──
+const frag = fs.readFileSync(path.join(__dirname, '..', 'public/fragments/dashboard.html'), 'utf8');
+const dash = fs.readFileSync(path.join(__dirname, '..', 'public/js/dashboard.js'), 'utf8');
+assert.ok(frag.includes('id="dash-card-scenes"') && frag.includes('id="dash-scene-theme"') && frag.includes('id="dash-scenes"'), 'the card');
+assert.match(dash, /chatai: +\['dash-card-vibe', 'dash-aibot-card', 'dash-card-overlay', 'dash-card-scenes',/, 'beside the chat overlay');
+assert.match(dash, /\['', 'obs', 'scene', kind, encodeURIComponent\(currentUser\.username\)\]\.join\('\/'\)/, 'each scene\'s address');
+assert.match(dash, /if \(theme && theme !== 'vibe'\) q\.set\('theme', theme\)/, 'the theme picked rides along');
+assert.match(dash, /_call\('updateDashScenes'\)/, 'drawn when the tab opens');
console.log('obs scenes: three scenes by route, the loader and the mark, any theme by name, the mark arriving, text only, still under reduced motion');
console.log('obs scenes: three scenes by route, the loader and the mark, any theme by name, the mark arriving, text only, still under reduced motion');