openvibers/openvibe.live Commits
Hero buttons read as pressable; digest spells out hours; guide points at real dashboard controls
Changes
@@@ -10,33 +10,84 @@background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);}#page-home .hero-content { position: relative; z-index: 2; }--/* ── Hero buttons: icon tile + label, brand colour per destination, one clean hover ── */--#page-home .hero-cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }++/* ── Hero buttons: icon tile + label, brand colour per destination ──++ These need to read as *pressable* at a glance on a phone, so they carry a raised surface,++ a top highlight, a real shadow and a press state — plus a slow staggered sheen that sweeps++ the row so the eye catches that they are interactive without anything shouting. */++#page-home .hero-cta {++ display: grid; gap: clamp(8px, 1.6vw, 12px); justify-content: center; margin-inline: auto;++ grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 22vw, 168px), max-content));++ max-width: min(920px, 96vw);++}.hb {-- --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px; padding: 6px 18px 6px 6px; border-radius: 999px; min-height: 46px; letter-spacing: 0.01em;-- font: inherit; font-weight: 700; font-size: 0.95rem; color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate;-- background: color-mix(in srgb, var(--bg-card) 78%, transparent); border: 1px solid color-mix(in srgb, var(--c) 28%, var(--border));-- box-shadow: 0 6px 18px rgba(0,0,0,0.22); transition: transform 0.22s cubic-bezier(.2,1.3,.3,1), border-color 0.22s, box-shadow 0.22s, color 0.22s;++ --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;++ padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;++ border-radius: 999px; min-height: clamp(44px, 7.2vw, 52px); width: 100%;++ font: inherit; font-weight: 700; font-size: clamp(0.88rem, 1.6vw, 0.98rem); letter-spacing: 0.01em;++ color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate; overflow: hidden;++ background:++ linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0) 46%),++ color-mix(in srgb, var(--c) 10%, var(--bg-card));++ border: 1px solid color-mix(in srgb, var(--c) 42%, var(--border));++ box-shadow: 0 2px 0 0 color-mix(in srgb, var(--c) 22%, transparent), 0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09);++ transition: transform 0.18s cubic-bezier(.2,1.3,.3,1), border-color 0.2s, box-shadow 0.2s, background 0.2s;++}++/* Slow sheen that crosses each button in turn — the idle "these are buttons" tell. */++.hb::before {++ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;++ background: linear-gradient(105deg, transparent 38%, color-mix(in srgb, var(--c) 26%, transparent) 50%, transparent 62%);++ background-size: 280% 100%; background-position: 220% 0;++ animation: hbSheen 9s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.7s);++}++.hb::after {++ content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;++ background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%);++ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);++ -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s;++}++.hb-ico {++ width: clamp(30px, 5vw, 34px); height: clamp(30px, 5vw, 34px); border-radius: 50%; flex: none;++ display: grid; place-items: center; font-size: clamp(0.82rem, 1.5vw, 0.92rem); color: var(--c);++ background: color-mix(in srgb, var(--c) 16%, var(--bg-tertiary));++ border: 1px solid color-mix(in srgb, var(--c) 40%, transparent);++ box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;++ transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;++ animation: hbBreathe 4.5s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.35s);}--.hb::before { content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: linear-gradient(120deg, color-mix(in srgb, var(--c) 22%, transparent), transparent 60%); opacity: 0; transition: opacity 0.25s; }--.hb::after { content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px; background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s; }--.hb-ico { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 0.92rem; color: var(--c); background: color-mix(in srgb, var(--c) 12%, var(--bg-tertiary)); border: 1px solid color-mix(in srgb, var(--c) 32%, transparent); box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset; transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s; }--.hb:hover, .hb:focus-visible { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 70%, transparent); box-shadow: 0 12px 30px rgba(0,0,0,0.3), 0 0 24px color-mix(in srgb, var(--c) 30%, transparent); outline: none; }--.hb:hover::before, .hb:focus-visible::before { opacity: 1; }--.hb:hover::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }--.hb:hover .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); }--.hb:active { transform: translateY(0) scale(0.98); }++.hb-label { flex: 1; text-align: left; }++/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */++.hb-go { width: 0; opacity: 0; font-size: 0.72rem; color: var(--c); transition: width 0.22s, opacity 0.22s, transform 0.22s; transform: translateX(-4px); }++.hb:hover, .hb:focus-visible {++ transform: translateY(-3px);++ border-color: color-mix(in srgb, var(--c) 78%, transparent);++ box-shadow: 0 3px 0 0 color-mix(in srgb, var(--c) 40%, transparent), 0 16px 32px rgba(0,0,0,0.38), 0 0 26px color-mix(in srgb, var(--c) 32%, transparent), inset 0 1px 0 rgba(255,255,255,0.12);++ outline: none;++}++.hb:hover::after, .hb:focus-visible::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }++.hb:hover .hb-ico, .hb:focus-visible .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); animation: none; }++.hb:hover .hb-go, .hb:focus-visible .hb-go { width: 10px; opacity: 1; transform: none; }++.hb:active { transform: translateY(0) scale(0.975); box-shadow: 0 1px 0 0 color-mix(in srgb, var(--c) 30%, transparent), 0 4px 12px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.3); }@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }@keyframes hbOrbit { to { --a: 360deg; } }--.hb-stream { --c: var(--accent); color: #fff; border-color: color-mix(in srgb, var(--accent) 55%, transparent);-- background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 78%, #000) 0%, color-mix(in srgb, var(--accent) 55%, #000) 100%);-- box-shadow: 0 8px 22px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.05) inset, 0 0 22px color-mix(in srgb, var(--accent) 22%, transparent); }--.hb-stream::before { background: linear-gradient(180deg, rgba(255,255,255,0.14), transparent 55%); opacity: 1; }--.hb-stream .hb-ico { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.28); color: #fff; }--.hb-stream:hover { box-shadow: 0 14px 32px rgba(0,0,0,0.4), 0 0 30px color-mix(in srgb, var(--accent) 40%, transparent); }--.hb-stream:hover .hb-ico { background: #fff; color: color-mix(in srgb, var(--accent) 70%, #000); box-shadow: 0 0 16px rgba(255,255,255,0.45); }++@keyframes hbSheen { 0%, 62% { background-position: 220% 0; } 88%, 100% { background-position: -120% 0; } }++@keyframes hbBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }++.hb-stream { --c: var(--accent); color: #fff;++ background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0) 50%), linear-gradient(160deg, color-mix(in srgb, var(--accent) 80%, #000) 0%, color-mix(in srgb, var(--accent) 58%, #000) 100%);++ border-color: color-mix(in srgb, var(--accent) 65%, transparent);++ box-shadow: 0 2px 0 0 color-mix(in srgb, var(--accent) 45%, transparent), 0 10px 26px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.18); }++.hb-stream .hb-ico { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.32); color: #fff; }++.hb-stream:hover .hb-ico, .hb-stream:focus-visible .hb-ico { background: #fff; color: color-mix(in srgb, var(--accent) 70%, #000); }.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }--@media (max-width: 560px) { .hb { padding: 6px 13px 6px 6px; font-size: 0.88rem; } .hb-ico { width: 30px; height: 30px; font-size: 0.85rem; } }++/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */++@media (max-width: 560px) {++ #page-home .hero-cta { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 420px; }++ .hb { font-size: 0.86rem; padding-right: 12px; }++}++@media (max-width: 560px) and (orientation: landscape) {++ #page-home .hero-cta { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); max-width: 96vw; }++ .hb { min-height: 40px; }++}++@media (max-width: 360px) { #page-home .hero-cta { grid-template-columns: 1fr; max-width: 300px; } }@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */@@@ -59,12 +110,13 @@@media (prefers-reduced-motion: reduce) {.hfx-sparks, .hfx-spot { display: none; }-- .hfx-shimmer, .hfx-glow, .hb::after, #page-home .section-header h2 .live-dot { animation: none !important; }++ .hfx-shimmer, .hfx-glow, .hb::before, .hb::after, .hb-ico, #page-home .section-header h2 .live-dot { animation: none !important; }.hfx-reveal { opacity: 1; transform: none; transition: none; }}.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }++.rs-lite .hb::before { animation-duration: 14s; }.rs-lite #page-home .stream-card { transition: none; }/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────@@@ -67,7 +67,7 @@<link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2"><link rel="stylesheet" href="/css/messenger.css?v=1">-- <link rel="stylesheet" href="/css/home-fx.css?v=6">++ <link rel="stylesheet" href="/css/home-fx.css?v=7"><link rel="stylesheet" href="/css/home-tour.css?v=2"><link rel="stylesheet" href="/css/ov-guide.css?v=1"><link rel="stylesheet" href="/css/i18n-star.css?v=5">@@@ -280,12 +280,12 @@</div></div><div class="hero-cta">-- <button class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span></button>-- <a href="https://openvibe.network" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span></a>-- <a href="https://openvibe.games/game" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span></a>-- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span></a>-- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span></a>-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span></a>++ <button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>++ <a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ <a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a></div><!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) --><div class="home-tour-mount" id="home-tour-mount"></div>@@@ -5089,7 +5089,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<!-- Scripts --><script src="/js/theme-engine.js?v=4"></script><script src="/live-notify.js?v=3" defer></script>--<script src="/js/app.js?v=141" defer></script>++<script src="/js/app.js?v=142" defer></script><script src="/js/stream-player.js?v=21" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -1994,7 +1994,7 @@ async function loadHomeDigest() {const st = d.stats || {};const stats = [st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',-- st.hours >= 0.5 ? `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(Math.round(st.hours))}</b>h live</span>` : '',++ st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',@@@ -31,7 +31,46 @@async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }const onBroadcast = () => location.pathname.startsWith('/broadcast');-- const dashboard = (tab, heading) => { G.close(false); G.cfg.navigate('/dashboard'); setTimeout(() => { try { if (typeof switchDashTab === 'function' && tab) { const b = document.querySelector(`[data-dtab="${tab}"]`); switchDashTab(tab, b); } if (heading) { const h = [...document.querySelectorAll('h3, h4')].find(x => x.textContent.trim().toLowerCase().startsWith(heading.toLowerCase())); if (h) { h.scrollIntoView({ block: 'center', behavior: 'smooth' }); G.spotlight({ target: h.closest('.card, .dash-card, section, div') || h, title: heading, text: 'Here it is — set it up right here.' }); } } } catch { /* */ } }, 900); };++ /**++ * Send someone to a real control in the dashboard and point at it.++ * Targets a stable element id rather than matching heading text, opens whichever tab and++ * sub-panel actually contains it, and waits for it to be laid out before spotlighting —++ * a hidden element has a zero-size rect, which is how the spotlight used to land in the++ * top-left corner highlighting nothing.++ */++ async function dashboard(targetSel, label, hint) {++ G.close(false);++ if (!location.pathname.startsWith('/dashboard')) G.cfg.navigate('/dashboard');++ const deadline = Date.now() + 12000;++ let el = null;++ while (Date.now() < deadline) {++ el = document.querySelector(targetSel);++ if (el) {++ // Open the dashboard tab and sub-panel that own this element.++ try {++ const panel = el.closest('.dash-tab-panel[id^="dash-panel-"]');++ if (panel && !panel.classList.contains('active') && typeof switchDashTab === 'function') {++ const tab = panel.id.replace('dash-panel-', '');++ switchDashTab(tab, document.querySelector(`[data-dtab="${tab}"]`));++ await new Promise(r => setTimeout(r, 400));++ }++ const sub = el.closest('.dash-subpanel');++ if (sub && !sub.classList.contains('active')) {++ const btn = document.querySelector(`[data-ctab="${sub.id.replace(/^dash-[a-z]+-/, '')}"]`);++ if (btn) { btn.click(); await new Promise(r => setTimeout(r, 400)); }++ }++ } catch { /* */ }++ const r = el.getBoundingClientRect();++ if (r.height > 8 && el.offsetParent !== null) break; // laid out and visible++ }++ await new Promise(r => setTimeout(r, 250));++ el = null;++ }++ if (!el) { say(`${label} is in your dashboard — scroll down to find it.`, 'info'); return; }++ try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ }++ await new Promise(r => setTimeout(r, 450));++ G.spotlight({ target: el, title: label, text: hint || 'Set it up right here — the guide is done, this is the real control.', actions: [{ label: 'Got it', primary: true }] });++ }// ── Journey: go live (stream → method → restream → go live) ─const streamStep = {@@@ -193,7 +232,7 @@async () => { const cmd = ((document.querySelector('#ovg-sound-cmd') || {}).value || '').trim().replace(/^!+/, ''); const f = document.querySelector('#ovg-sound-file'); const file = f && f.files && f.files[0]; if (!cmd) return 'Name the command'; if (!file) return 'Choose an audio file'; try { await ui.upload('/sounds', { command: cmd }, file, 'sound'); say(`!${cmd} is ready`, 'success'); ui.confetti(); return true; } catch (e) { return (e && e.message) || 'Upload failed'; } }, 'Add sound');one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },-- async () => { dashboard('content', 'Offline Screen'); return true; }, 'Open the offline screen settings');++ async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
@@ -10,33 +10,84 @@
@@ -10,33 +10,84 @@
background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
background: radial-gradient(360px circle at var(--x, 50%) var(--y, 30%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
}
}
#page-home .hero-content { position: relative; z-index: 2; }
#page-home .hero-content { position: relative; z-index: 2; }
-/* ── Hero buttons: icon tile + label, brand colour per destination, one clean hover ── */
-#page-home .hero-cta { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
+/* ── Hero buttons: icon tile + label, brand colour per destination ──
+ These need to read as *pressable* at a glance on a phone, so they carry a raised surface,
+ a top highlight, a real shadow and a press state — plus a slow staggered sheen that sweeps
+ the row so the eye catches that they are interactive without anything shouting. */
+#page-home .hero-cta {
+ display: grid; gap: clamp(8px, 1.6vw, 12px); justify-content: center; margin-inline: auto;
+ grid-template-columns: repeat(auto-fit, minmax(clamp(140px, 22vw, 168px), max-content));
+ max-width: min(920px, 96vw);
+}
.hb {
.hb {
- --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px; padding: 6px 18px 6px 6px; border-radius: 999px; min-height: 46px; letter-spacing: 0.01em;
- font: inherit; font-weight: 700; font-size: 0.95rem; color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate;
- background: color-mix(in srgb, var(--bg-card) 78%, transparent); border: 1px solid color-mix(in srgb, var(--c) 28%, var(--border));
- box-shadow: 0 6px 18px rgba(0,0,0,0.22); transition: transform 0.22s cubic-bezier(.2,1.3,.3,1), border-color 0.22s, box-shadow 0.22s, color 0.22s;
+ --c: var(--accent); position: relative; display: inline-flex; align-items: center; gap: 10px;
+ padding: clamp(6px, 1.2vw, 8px) clamp(14px, 2.4vw, 20px) clamp(6px, 1.2vw, 8px) 6px;
+ border-radius: 999px; min-height: clamp(44px, 7.2vw, 52px); width: 100%;
+ font: inherit; font-weight: 700; font-size: clamp(0.88rem, 1.6vw, 0.98rem); letter-spacing: 0.01em;
+ color: var(--text-primary); text-decoration: none; cursor: pointer; isolation: isolate; overflow: hidden;
+ background:
+ linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0) 46%),
+ color-mix(in srgb, var(--c) 10%, var(--bg-card));
+ border: 1px solid color-mix(in srgb, var(--c) 42%, var(--border));
+ box-shadow: 0 2px 0 0 color-mix(in srgb, var(--c) 22%, transparent), 0 8px 20px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.09);
+ transition: transform 0.18s cubic-bezier(.2,1.3,.3,1), border-color 0.2s, box-shadow 0.2s, background 0.2s;
+}
+/* Slow sheen that crosses each button in turn — the idle "these are buttons" tell. */
+.hb::before {
+ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit; pointer-events: none;
+ background: linear-gradient(105deg, transparent 38%, color-mix(in srgb, var(--c) 26%, transparent) 50%, transparent 62%);
+ background-size: 280% 100%; background-position: 220% 0;
+ animation: hbSheen 9s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.7s);
+}
+.hb::after {
+ content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px;
+ background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%);
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
+ -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s;
+}
+.hb-ico {
+ width: clamp(30px, 5vw, 34px); height: clamp(30px, 5vw, 34px); border-radius: 50%; flex: none;
+ display: grid; place-items: center; font-size: clamp(0.82rem, 1.5vw, 0.92rem); color: var(--c);
+ background: color-mix(in srgb, var(--c) 16%, var(--bg-tertiary));
+ border: 1px solid color-mix(in srgb, var(--c) 40%, transparent);
+ box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset;
+ transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s;
+ animation: hbBreathe 4.5s ease-in-out infinite; animation-delay: calc(var(--i, 0) * 0.35s);
}
}
-.hb::before { content: ''; position: absolute; inset: 0; border-radius: inherit; z-index: -1; background: linear-gradient(120deg, color-mix(in srgb, var(--c) 22%, transparent), transparent 60%); opacity: 0; transition: opacity 0.25s; }
-.hb::after { content: ''; position: absolute; inset: -1px; border-radius: inherit; z-index: -2; padding: 1px; background: conic-gradient(from var(--a, 0deg), transparent 0 70%, var(--c) 85%, transparent 100%); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; opacity: 0; transition: opacity 0.25s; }
-.hb-ico { width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center; font-size: 0.92rem; color: var(--c); background: color-mix(in srgb, var(--c) 12%, var(--bg-tertiary)); border: 1px solid color-mix(in srgb, var(--c) 32%, transparent); box-shadow: 0 2px 6px rgba(0,0,0,0.35) inset; transition: transform 0.35s cubic-bezier(.2,1.6,.3,1), background 0.25s, color 0.25s, box-shadow 0.25s; }
-.hb:hover, .hb:focus-visible { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 70%, transparent); box-shadow: 0 12px 30px rgba(0,0,0,0.3), 0 0 24px color-mix(in srgb, var(--c) 30%, transparent); outline: none; }
-.hb:hover::before, .hb:focus-visible::before { opacity: 1; }
-.hb:hover::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }
-.hb:hover .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); }
-.hb:active { transform: translateY(0) scale(0.98); }
+.hb-label { flex: 1; text-align: left; }
+/* Chevron appears on hover/focus: an explicit "this goes somewhere" cue. */
+.hb-go { width: 0; opacity: 0; font-size: 0.72rem; color: var(--c); transition: width 0.22s, opacity 0.22s, transform 0.22s; transform: translateX(-4px); }
+.hb:hover, .hb:focus-visible {
+ transform: translateY(-3px);
+ border-color: color-mix(in srgb, var(--c) 78%, transparent);
+ box-shadow: 0 3px 0 0 color-mix(in srgb, var(--c) 40%, transparent), 0 16px 32px rgba(0,0,0,0.38), 0 0 26px color-mix(in srgb, var(--c) 32%, transparent), inset 0 1px 0 rgba(255,255,255,0.12);
+ outline: none;
+}
+.hb:hover::after, .hb:focus-visible::after { opacity: 1; animation: hbOrbit 1.6s linear infinite; }
+.hb:hover .hb-ico, .hb:focus-visible .hb-ico { transform: rotate(-12deg) scale(1.12); background: var(--c); color: #fff; box-shadow: 0 0 18px color-mix(in srgb, var(--c) 60%, transparent); animation: none; }
+.hb:hover .hb-go, .hb:focus-visible .hb-go { width: 10px; opacity: 1; transform: none; }
+.hb:active { transform: translateY(0) scale(0.975); box-shadow: 0 1px 0 0 color-mix(in srgb, var(--c) 30%, transparent), 0 4px 12px rgba(0,0,0,0.35), inset 0 2px 6px rgba(0,0,0,0.3); }
@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@property --a { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
@keyframes hbOrbit { to { --a: 360deg; } }
@keyframes hbOrbit { to { --a: 360deg; } }
-.hb-stream { --c: var(--accent); color: #fff; border-color: color-mix(in srgb, var(--accent) 55%, transparent);
- background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 78%, #000) 0%, color-mix(in srgb, var(--accent) 55%, #000) 100%);
- box-shadow: 0 8px 22px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.05) inset, 0 0 22px color-mix(in srgb, var(--accent) 22%, transparent); }
-.hb-stream::before { background: linear-gradient(180deg, rgba(255,255,255,0.14), transparent 55%); opacity: 1; }
-.hb-stream .hb-ico { background: rgba(255,255,255,0.14); border-color: rgba(255,255,255,0.28); color: #fff; }
-.hb-stream:hover { box-shadow: 0 14px 32px rgba(0,0,0,0.4), 0 0 30px color-mix(in srgb, var(--accent) 40%, transparent); }
-.hb-stream:hover .hb-ico { background: #fff; color: color-mix(in srgb, var(--accent) 70%, #000); box-shadow: 0 0 16px rgba(255,255,255,0.45); }
+@keyframes hbSheen { 0%, 62% { background-position: 220% 0; } 88%, 100% { background-position: -120% 0; } }
+@keyframes hbBreathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.06); } }
+.hb-stream { --c: var(--accent); color: #fff;
+ background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0) 50%), linear-gradient(160deg, color-mix(in srgb, var(--accent) 80%, #000) 0%, color-mix(in srgb, var(--accent) 58%, #000) 100%);
+ border-color: color-mix(in srgb, var(--accent) 65%, transparent);
+ box-shadow: 0 2px 0 0 color-mix(in srgb, var(--accent) 45%, transparent), 0 10px 26px rgba(0,0,0,0.38), inset 0 1px 0 rgba(255,255,255,0.18); }
+.hb-stream .hb-ico { background: rgba(255,255,255,0.16); border-color: rgba(255,255,255,0.32); color: #fff; }
+.hb-stream:hover .hb-ico, .hb-stream:focus-visible .hb-ico { background: #fff; color: color-mix(in srgb, var(--accent) 70%, #000); }
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
.hb-tools { --c: #2dd4bf; } .hb-game { --c: #4ade80; } .hb-github { --c: #e5e7eb; } .hb-discord { --c: #5865f2; } .hb-vibe { --c: #f472b6; }
-@media (max-width: 560px) { .hb { padding: 6px 13px 6px 6px; font-size: 0.88rem; } .hb-ico { width: 30px; height: 30px; font-size: 0.85rem; } }
+/* Narrow phones: two tidy columns instead of a ragged wrap. Landscape keeps a single row. */
+@media (max-width: 560px) {
+ #page-home .hero-cta { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 420px; }
+ .hb { font-size: 0.86rem; padding-right: 12px; }
+}
+@media (max-width: 560px) and (orientation: landscape) {
+ #page-home .hero-cta { grid-template-columns: repeat(auto-fit, minmax(128px, 1fr)); max-width: 96vw; }
+ .hb { min-height: 40px; }
+}
+@media (max-width: 360px) { #page-home .hero-cta { grid-template-columns: 1fr; max-width: 300px; } }
@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
@keyframes hfxPulse { 0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); } 70% { box-shadow: 0 0 0 14px color-mix(in srgb, var(--accent) 0%, transparent); } 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 0%, transparent); } }
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
/* Headline: subtle breathing glow (the sweeping sheen rendered as a block over gradient text, so it is gone) */
@@ -59,12 +110,13 @@
@@ -59,12 +110,13 @@
@media (prefers-reduced-motion: reduce) {
@media (prefers-reduced-motion: reduce) {
.hfx-sparks, .hfx-spot { display: none; }
.hfx-sparks, .hfx-spot { display: none; }
- .hfx-shimmer, .hfx-glow, .hb::after, #page-home .section-header h2 .live-dot { animation: none !important; }
+ .hfx-shimmer, .hfx-glow, .hb::before, .hb::after, .hb-ico, #page-home .section-header h2 .live-dot { animation: none !important; }
.hfx-reveal { opacity: 1; transform: none; transition: none; }
.hfx-reveal { opacity: 1; transform: none; transition: none; }
}
}
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-sparks, .rs-lite .hfx-spot { display: none; }
.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
.rs-lite .hfx-shimmer, .rs-lite .hfx-glow, .rs-lite .hb::after, .rs-lite #page-home .section-header h2 .live-dot { animation: none !important; text-shadow: none; }
+.rs-lite .hb::before { animation-duration: 14s; }
.rs-lite #page-home .stream-card { transition: none; }
.rs-lite #page-home .stream-card { transition: none; }
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
/* ── Mobile / lite: the home page's own heavy effects (pre-existing) ──────────────
@@ -67,7 +67,7 @@
@@ -67,7 +67,7 @@
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
- <link rel="stylesheet" href="/css/home-fx.css?v=6">
+ <link rel="stylesheet" href="/css/home-fx.css?v=7">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/ov-guide.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@ -280,12 +280,12 @@
@@ -280,12 +280,12 @@
</div>
</div>
</div>
</div>
<div class="hero-cta">
<div class="hero-cta">
- <button class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span></button>
- <a href="https://openvibe.network" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span></a>
- <a href="https://openvibe.games/game" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span></a>
- <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span></a>
- <a href="https://discord.gg/M6MuRUaeJj" target="_blank" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span></a>
- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span></a>
+ <button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
+ <a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ <a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ <a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ <a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')" style="--i:5" class="hb hb-vibe"><span class="hb-ico"><i class="fa-solid fa-wand-magic-sparkles"></i></span><span class="hb-label">Vibe Coders</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
</div>
</div>
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<!-- One stream, everywhere — restream pipeline + feature tour (public/js/home-tour.js) -->
<div class="home-tour-mount" id="home-tour-mount"></div>
<div class="home-tour-mount" id="home-tour-mount"></div>
@@ -5089,7 +5089,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5089,7 +5089,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- 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=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
-<script src="/js/app.js?v=141" defer></script>
+<script src="/js/app.js?v=142" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1994,7 +1994,7 @@ async function loadHomeDigest() {
@@ -1994,7 +1994,7 @@ async function loadHomeDigest() {
const st = d.stats || {};
const st = d.stats || {};
const stats = [
const stats = [
st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
st.streams ? `<span class="digest-stat"><i class="fa-solid fa-tower-broadcast"></i> <b>${n(st.streams)}</b> stream${st.streams === 1 ? '' : 's'}</span>` : '',
- st.hours >= 0.5 ? `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(Math.round(st.hours))}</b>h live</span>` : '',
+ st.hours >= 0.5 ? (() => { const hrs = Math.round(st.hours); return `<span class="digest-stat"><i class="fa-regular fa-clock"></i> <b>${n(hrs)}</b> ${hrs === 1 ? 'hour' : 'hours'} live</span>`; })() : '',
st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',
st.chat_lines ? `<span class="digest-stat"><i class="fa-solid fa-comments"></i> <b>${n(st.chat_lines)}</b> chat lines</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_follows ? `<span class="digest-stat"><i class="fa-solid fa-heart"></i> <b>${n(st.new_follows)}</b> new follow${st.new_follows === 1 ? '' : 's'}</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
st.new_members ? `<span class="digest-stat"><i class="fa-solid fa-user-plus"></i> <b>${n(st.new_members)}</b> joined</span>` : '',
@@ -31,7 +31,46 @@
@@ -31,7 +31,46 @@
async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }
async function pickDefaultSlot() { await loadSlots(); const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? D.slots.find(s => s.id === _wsState.selectedId) : null; D.slot = cur || D.slots[0] || null; if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); } }
async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }
async function syncWorkspace() { try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (D.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(D.slot.id); } catch { /* */ } }
const onBroadcast = () => location.pathname.startsWith('/broadcast');
const onBroadcast = () => location.pathname.startsWith('/broadcast');
- const dashboard = (tab, heading) => { G.close(false); G.cfg.navigate('/dashboard'); setTimeout(() => { try { if (typeof switchDashTab === 'function' && tab) { const b = document.querySelector(`[data-dtab="${tab}"]`); switchDashTab(tab, b); } if (heading) { const h = [...document.querySelectorAll('h3, h4')].find(x => x.textContent.trim().toLowerCase().startsWith(heading.toLowerCase())); if (h) { h.scrollIntoView({ block: 'center', behavior: 'smooth' }); G.spotlight({ target: h.closest('.card, .dash-card, section, div') || h, title: heading, text: 'Here it is — set it up right here.' }); } } } catch { /* */ } }, 900); };
+ /**
+ * Send someone to a real control in the dashboard and point at it.
+ * Targets a stable element id rather than matching heading text, opens whichever tab and
+ * sub-panel actually contains it, and waits for it to be laid out before spotlighting —
+ * a hidden element has a zero-size rect, which is how the spotlight used to land in the
+ * top-left corner highlighting nothing.
+ */
+ async function dashboard(targetSel, label, hint) {
+ G.close(false);
+ if (!location.pathname.startsWith('/dashboard')) G.cfg.navigate('/dashboard');
+ const deadline = Date.now() + 12000;
+ let el = null;
+ while (Date.now() < deadline) {
+ el = document.querySelector(targetSel);
+ if (el) {
+ // Open the dashboard tab and sub-panel that own this element.
+ try {
+ const panel = el.closest('.dash-tab-panel[id^="dash-panel-"]');
+ if (panel && !panel.classList.contains('active') && typeof switchDashTab === 'function') {
+ const tab = panel.id.replace('dash-panel-', '');
+ switchDashTab(tab, document.querySelector(`[data-dtab="${tab}"]`));
+ await new Promise(r => setTimeout(r, 400));
+ }
+ const sub = el.closest('.dash-subpanel');
+ if (sub && !sub.classList.contains('active')) {
+ const btn = document.querySelector(`[data-ctab="${sub.id.replace(/^dash-[a-z]+-/, '')}"]`);
+ if (btn) { btn.click(); await new Promise(r => setTimeout(r, 400)); }
+ }
+ } catch { /* */ }
+ const r = el.getBoundingClientRect();
+ if (r.height > 8 && el.offsetParent !== null) break; // laid out and visible
+ }
+ await new Promise(r => setTimeout(r, 250));
+ el = null;
+ }
+ if (!el) { say(`${label} is in your dashboard — scroll down to find it.`, 'info'); return; }
+ try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ }
+ await new Promise(r => setTimeout(r, 450));
+ G.spotlight({ target: el, title: label, text: hint || 'Set it up right here — the guide is done, this is the real control.', actions: [{ label: 'Got it', primary: true }] });
+ }
// ── Journey: go live (stream → method → restream → go live) ─
// ── Journey: go live (stream → method → restream → go live) ─
const streamStep = {
const streamStep = {
@@ -193,7 +232,7 @@
@@ -193,7 +232,7 @@
async () => { const cmd = ((document.querySelector('#ovg-sound-cmd') || {}).value || '').trim().replace(/^!+/, ''); const f = document.querySelector('#ovg-sound-file'); const file = f && f.files && f.files[0]; if (!cmd) return 'Name the command'; if (!file) return 'Choose an audio file'; try { await ui.upload('/sounds', { command: cmd }, file, 'sound'); say(`!${cmd} is ready`, 'success'); ui.confetti(); return true; } catch (e) { return (e && e.message) || 'Upload failed'; } }, 'Add sound');
async () => { const cmd = ((document.querySelector('#ovg-sound-cmd') || {}).value || '').trim().replace(/^!+/, ''); const f = document.querySelector('#ovg-sound-file'); const file = f && f.files && f.files[0]; if (!cmd) return 'Name the command'; if (!file) return 'Choose an audio file'; try { await ui.upload('/sounds', { command: cmd }, file, 'sound'); say(`!${cmd} is ready`, 'success'); ui.confetti(); return true; } catch (e) { return (e && e.message) || 'Upload failed'; } }, 'Add sound');
one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,
one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,
(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },
(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },
- async () => { dashboard('content', 'Offline Screen'); return true; }, 'Open the offline screen settings');
+ async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');
one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,