openvibers/openvibe.live Commits

Go Live setup wizard: stream → method (Browser / OBS RTMP / OBS WHIP with server, key and OBS steps) → restream (RobotStreamer login + pick, Twitch/YouTube/Kick/RTMP keys) → go live (browser goes live from the wizard; OBS waits for the encoder and celebrates). Auto-opens for first-timers, ?setup=new|restream, Setup wizard button; replaces the spotlight guide (which always bounced to sign-in because it read window.currentUser)

OP OpenVibers committed 16 Sept 2026 · 3dcedc86d9 · 4 files, +462 −128

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/golive-wizard.css b/public/css/golive-wizard.css
nnew file mode 100644
iindex 00000000..1d26d53c
---- /dev/null
++++ b/public/css/golive-wizard.css
@@@ -0,0 +1,102 @@
++/* ── Go Live setup wizard (public/js/golive-wizard.js) ── */
++.glw { position: fixed; inset: 0; z-index: 21000; display: none; }
++.glw.is-open { display: block; }
++body.glw-lock { overflow: hidden; }
++.glw-scrim { position: absolute; inset: 0; background: rgba(4,5,10,0.72); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: glwFade .25s ease; }
++.glw-card { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(720px, calc(100vw - 24px)); max-height: calc(100vh - 32px); display: flex; flex-direction: column;
++ border-radius: 22px; background: var(--bg-card); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border)); box-shadow: 0 40px 100px rgba(0,0,0,0.65), 0 0 0 1px rgba(255,255,255,0.04), 0 0 80px color-mix(in srgb, var(--accent) 25%, transparent);
++ animation: glwPop .4s cubic-bezier(.2,1.3,.3,1); overflow: hidden; }
++@keyframes glwFade { from { opacity: 0; } to { opacity: 1; } }
++@keyframes glwPop { from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
++.glw-close { position: absolute; top: 10px; right: 10px; width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-secondary); cursor: pointer; z-index: 2; }
++.glw-close:hover { color: var(--text-primary); border-color: var(--accent); }
++.glw-head { display: flex; align-items: center; gap: 12px; padding: 18px 56px 8px 20px; }
++.glw-kicker { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }
++.glw-title { font-size: 1.25rem; font-weight: 900; line-height: 1.15; }
++.glw-steps { --p: 0%; list-style: none; margin: 8px 20px 0; padding: 0 0 12px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; position: relative; }
++.glw-steps::before { content: ''; position: absolute; left: 12%; right: 12%; top: 15px; height: 3px; background: var(--bg-tertiary); border-radius: 2px; }
++.glw-steps::after { content: ''; position: absolute; left: 12%; top: 15px; height: 3px; width: calc(76% * var(--p) / 100%); background: linear-gradient(90deg, var(--accent), #22d3ee); border-radius: 2px; transition: width .5s cubic-bezier(.2,.9,.3,1); }
++.glw-steps li { position: relative; z-index: 1; display: grid; justify-items: center; gap: 5px; font-size: 0.68rem; font-weight: 700; color: var(--text-muted); text-align: center; }
++.glw-step-dot { width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; background: var(--bg-tertiary); border: 2px solid var(--border); font-size: 0.8rem; color: var(--text-muted); transition: all .3s; }
++.glw-steps li.now .glw-step-dot { background: var(--accent); border-color: var(--accent); color: #fff; box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent) 22%, transparent); transform: scale(1.08); }
++.glw-steps li.now { color: var(--text-primary); }
++.glw-steps li.done .glw-step-dot { background: color-mix(in srgb, #06d6a0 25%, var(--bg-tertiary)); border-color: #06d6a0; color: #06d6a0; }
++.glw-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 20px 12px; }
++.glw-panel { animation-duration: .35s; animation-timing-function: cubic-bezier(.2,.9,.3,1); animation-fill-mode: both; }
++.glw-panel.in-r { animation-name: glwInR; } .glw-panel.in-l { animation-name: glwInL; }
++@keyframes glwInR { from { opacity: 0; transform: translateX(28px); } to { opacity: 1; transform: none; } }
++@keyframes glwInL { from { opacity: 0; transform: translateX(-28px); } to { opacity: 1; transform: none; } }
++.glw-lead { font-size: 0.92rem; color: var(--text-secondary); line-height: 1.55; margin: 6px 0 14px; }
++.glw-lead b { color: var(--text-primary); }
++.glw-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
++.glw-grid--3 { grid-template-columns: repeat(3, 1fr); }
++.glw-pick { --c: var(--accent); display: flex; align-items: center; gap: 12px; text-align: left; padding: 12px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; font: inherit; transition: transform .15s, border-color .15s, box-shadow .15s; animation: glwRise .4s both; }
++.glw-pick:nth-child(2) { animation-delay: .05s; } .glw-pick:nth-child(3) { animation-delay: .1s; } .glw-pick:nth-child(4) { animation-delay: .15s; } .glw-pick:nth-child(5) { animation-delay: .2s; }
++@keyframes glwRise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
++.glw-pick:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 60%, var(--border)); }
++.glw-pick.on { border-color: var(--c); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 22%, transparent), 0 10px 30px rgba(0,0,0,0.25); background: color-mix(in srgb, var(--c) 8%, var(--bg-secondary)); }
++.glw-pick--new { border-style: dashed; }
++.glw-pick-ico { width: 40px; height: 40px; border-radius: 12px; display: grid; place-items: center; flex: none; font-size: 1.05rem; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); }
++.glw-pick.on .glw-pick-ico { background: var(--c); color: #fff; }
++.glw-pick-body { display: grid; gap: 2px; min-width: 0; }
++.glw-pick-body b { font-size: 0.92rem; } .glw-pick-body small { font-size: 0.74rem; color: var(--text-muted); line-height: 1.35; }
++.glw-tag { font-style: normal; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c); margin-left: 6px; }
++.glw-form { display: grid; gap: 10px; } .glw-form.is-hidden { display: none; }
++.glw-form label { display: grid; gap: 6px; font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); }
++.glw-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.glw-chip { padding: 6px 11px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-size: 0.8rem; font-weight: 700; cursor: pointer; transition: all .15s; }
++.glw-chip:hover { border-color: var(--accent); } .glw-chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
++.glw-detail { margin-top: 4px; }
++.glw-box { padding: 14px; border-radius: 14px; background: var(--bg-secondary); border: 1px solid var(--border); animation: glwRise .35s both; }
++.glw-box p { margin: 0 0 10px; font-size: 0.86rem; color: var(--text-secondary); line-height: 1.5; }
++.glw-box-head { font-weight: 800; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } .glw-box-head i { color: var(--accent); }
++.glw-kv { display: grid; grid-template-columns: 90px 1fr auto auto; align-items: center; gap: 8px; padding: 8px 0; border-top: 1px dashed var(--border); font-size: 0.84rem; }
++.glw-kv span { color: var(--text-muted); font-weight: 700; }
++.glw-kv code { padding: 6px 9px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-primary); font-size: 0.8rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.glw-copy { padding: 5px 9px; border-radius: 8px; border: 1px solid var(--border); background: var(--bg-tertiary); color: var(--text-primary); font: inherit; font-size: 0.74rem; font-weight: 700; cursor: pointer; white-space: nowrap; }
++.glw-copy:hover { border-color: var(--accent); }
++.glw-obs { margin: 10px 0 6px; padding-left: 0; list-style: none; display: grid; gap: 6px; counter-reset: obs; }
++.glw-obs li { counter-increment: obs; display: flex; gap: 10px; align-items: center; font-size: 0.86rem; }
++.glw-obs li::before { content: counter(obs); width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 0.7rem; font-weight: 800; color: #fff; background: var(--accent); flex: none; }
++.glw-tips { margin: 8px 0 0; padding-left: 18px; font-size: 0.8rem; color: var(--text-muted); }
++.glw-fine { font-size: 0.76rem; }
++.glw-seg { display: inline-flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--bg-tertiary); border: 1px solid var(--border); margin: 4px 0 8px; }
++.glw-seg button { border: 0; background: transparent; color: var(--text-secondary); padding: 7px 12px; border-radius: 999px; font: inherit; font-weight: 700; font-size: 0.82rem; cursor: pointer; }
++.glw-seg button.on { background: var(--accent); color: #fff; }
++.glw-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
++.glw-row .form-input { flex: 1 1 180px; }
++.glw-check { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; margin: 8px 0; }
++.glw-added { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
++.glw-added li { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 999px; background: color-mix(in srgb, #06d6a0 12%, var(--bg-tertiary)); border: 1px solid color-mix(in srgb, #06d6a0 45%, var(--border)); font-size: 0.8rem; font-weight: 700; }
++.glw-ok { color: #06d6a0; }
++.glw-summary { display: grid; gap: 8px; margin-bottom: 14px; }
++.glw-sum-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 12px; background: var(--bg-secondary); border: 1px solid var(--border); animation: glwRise .35s both; }
++.glw-sum-row:nth-child(2) { animation-delay: .06s; } .glw-sum-row:nth-child(3) { animation-delay: .12s; }
++.glw-sum-row > i { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; color: var(--accent); background: color-mix(in srgb, var(--accent) 14%, transparent); flex: none; }
++.glw-sum-row span { display: grid; min-width: 0; } .glw-sum-row b { font-size: 0.9rem; } .glw-sum-row small { color: var(--text-muted); font-size: 0.74rem; }
++.glw-launch { text-align: center; padding: 8px 0 4px; }
++.glw-big { font-size: 1.1rem; padding: 14px 28px; box-shadow: 0 12px 34px color-mix(in srgb, var(--accent) 45%, transparent); animation: glwPulse 2.2s ease-out infinite; }
++@keyframes glwPulse { 0% { box-shadow: 0 12px 34px color-mix(in srgb, var(--accent) 45%, transparent), 0 0 0 0 color-mix(in srgb, var(--accent) 50%, transparent); } 100% { box-shadow: 0 12px 34px color-mix(in srgb, var(--accent) 45%, transparent), 0 0 0 20px transparent; } }
++.glw-wait { display: grid; justify-items: center; gap: 6px; padding: 10px; }
++.glw-wait b { font-size: 1rem; } .glw-wait em { color: var(--accent-light, var(--accent)); font-style: normal; } .glw-wait small { color: var(--text-muted); font-size: 0.8rem; }
++.glw-radar { position: relative; width: 64px; height: 64px; margin-bottom: 6px; }
++.glw-radar i { position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent); opacity: 0; animation: glwRadar 2.4s ease-out infinite; }
++.glw-radar i:nth-child(2) { animation-delay: .8s; } .glw-radar i:nth-child(3) { animation-delay: 1.6s; }
++@keyframes glwRadar { 0% { transform: scale(0.3); opacity: 0.9; } 100% { transform: scale(1.2); opacity: 0; } }
++.glw-live { text-align: center; padding: 16px 6px; display: grid; justify-items: center; gap: 8px; }
++.glw-live-badge { font-size: 0.8rem; font-weight: 900; letter-spacing: 0.14em; padding: 4px 12px; border-radius: 6px; background: #e0245e; color: #fff; animation: glwPulse 1.6s ease-out infinite; }
++.glw-live h3 { margin: 0; font-size: 1.6rem; font-weight: 900; } .glw-live p { margin: 0 0 8px; color: var(--text-secondary); }
++.glw-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 20px 16px; border-top: 1px solid var(--border); background: var(--bg-card); }
++.glw-foot-note { font-size: 0.74rem; color: var(--text-muted); font-weight: 700; }
++.glw-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 22000; overflow: hidden; }
++.glw-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: glwFall linear forwards; }
++@keyframes glwFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
++@media (max-width: 640px) {
++ .glw-card { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; animation: glwSheet .35s cubic-bezier(.2,1,.3,1); }
++ @keyframes glwSheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
++ .glw-grid, .glw-grid--3 { grid-template-columns: 1fr; }
++ .glw-steps { grid-template-columns: repeat(4, 1fr); } .glw-step-label { font-size: 0.6rem; }
++ .glw-kv { grid-template-columns: 1fr; } .glw-kv span { font-size: 0.7rem; }
++ .glw-foot { padding: 10px 14px 14px; }
++}
++@media (prefers-reduced-motion: reduce) { .glw-card, .glw-panel, .glw-pick, .glw-box, .glw-sum-row, .glw-big, .glw-radar i, .glw-live-badge { animation: none !important; } }
ddiff --git a/public/index.html b/public/index.html
iindex 6b93ec8e..b1f7a864 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -69,7 +69,7 @@
<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-tour.css?v=2">
-- <link rel="stylesheet" href="/css/broadcast-guide.css?v=1">
++ <link rel="stylesheet" href="/css/golive-wizard.css?v=1">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/context-cards.css?v=3">
@@@ -2031,7 +2031,7 @@
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live">
-- <h2><i class="fa-solid fa-tower-broadcast"></i> Go Live</h2>
++ <h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openGoLiveWizard({step:'stream'})" title="Step-by-step setup"><i class="fa-solid fa-wand-magic-sparkles"></i> Setup wizard</button></h2>
<p class="bc-page-subtitle">Select a stream slot to configure your profile and go live.</p>
<!-- Top-level tabs — the "Active Streams" tab only appears when a stream is live -->
@@@ -5170,7 +5170,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/ov-mark.js?v=1" defer></script>
<script src="/js/home-fx.js?v=3" defer></script>
<script src="/js/home-tour.js?v=4" defer></script>
--<script src="/js/broadcast-guide.js?v=1" defer></script>
++<script src="/js/golive-wizard.js?v=1" defer></script>
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
ddiff --git a/public/js/broadcast-guide.js b/public/js/broadcast-guide.js
iindex 6e37c549..eafa2a7d 100644
---- a/public/js/broadcast-guide.js
++++ b/public/js/broadcast-guide.js
@@@ -1,125 +1 @@
--/**
-- * broadcast-guide.js — guided setup on the Go Live page.
-- *
-- * /broadcast?setup=restream → walks a streamer through restreaming: create a slot if they
-- * have none, open Restream Destinations, add RobotStreamer with
-- * the one-click "log in & fetch robots" path, add more platforms,
-- * then Go Live. A spotlight + card overlay that follows the real
-- * controls on the page (nothing is faked or duplicated).
-- *
-- * Logged out: the intent is remembered in localStorage, the user is sent through SSO, and the
-- * guide resumes on the Go Live page after login.
-- */
--(function () {
-- 'use strict';
-- const PENDING_KEY = 'ov-guide-pending';
-- const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
-- const sleep = (ms) => new Promise(r => setTimeout(r, ms));
-- async function waitFor(fn, { timeout = 20000, every = 200 } = {}) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { try { const v = fn(); if (v) return v; } catch { /* */ } await sleep(every); } return null; }
--
-- // ── Overlay ────────────────────────────────────────────────
-- let ui = null, raf = 0;
-- function ensureUi() {
-- if (ui) return ui;
-- const root = document.createElement('div');
-- root.className = 'bg-guide';
-- root.innerHTML = `<div class="bg-guide-hole"></div><div class="bg-guide-card" role="dialog" aria-live="polite"><div class="bg-guide-step"></div><h4 class="bg-guide-title"></h4><p class="bg-guide-text"></p><div class="bg-guide-actions"></div></div>`;
-- document.body.appendChild(root);
-- ui = { root, hole: root.querySelector('.bg-guide-hole'), card: root.querySelector('.bg-guide-card'), step: root.querySelector('.bg-guide-step'), title: root.querySelector('.bg-guide-title'), text: root.querySelector('.bg-guide-text'), actions: root.querySelector('.bg-guide-actions'), target: null };
-- const track = () => { if (!ui) return; place(); raf = requestAnimationFrame(track); };
-- raf = requestAnimationFrame(track);
-- return ui;
-- }
-- function place() {
-- if (!ui) return;
-- const t = ui.target;
-- if (!t || !t.isConnected) { ui.hole.style.opacity = '0'; ui.card.style.top = '50%'; ui.card.style.left = '50%'; ui.card.style.transform = 'translate(-50%, -50%)'; return; }
-- const r = t.getBoundingClientRect();
-- ui.hole.style.opacity = '1';
-- ui.hole.style.left = `${r.left - 8}px`; ui.hole.style.top = `${r.top - 8}px`; ui.hole.style.width = `${r.width + 16}px`; ui.hole.style.height = `${r.height + 16}px`;
-- const cw = Math.min(380, window.innerWidth - 24), ch = ui.card.offsetHeight || 180;
-- let top = r.bottom + 16, left = Math.max(12, Math.min(r.left, window.innerWidth - cw - 12));
-- if (top + ch > window.innerHeight - 12) top = Math.max(12, r.top - ch - 16);
-- ui.card.style.transform = 'none'; ui.card.style.top = `${top}px`; ui.card.style.left = `${left}px`; ui.card.style.width = `${cw}px`;
-- }
-- function show({ target, step, total, title, text, actions }) {
-- const u = ensureUi();
-- u.target = target || null;
-- if (target) { try { target.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ } }
-- u.step.textContent = `Step ${step} of ${total}`;
-- u.title.textContent = title;
-- u.text.innerHTML = text;
-- u.actions.innerHTML = '';
-- for (const a of actions) { const b = document.createElement('button'); b.type = 'button'; b.className = `btn ${a.primary ? 'btn-primary' : 'btn-outline'} btn-sm`; b.innerHTML = a.label; b.onclick = a.onClick; u.actions.appendChild(b); }
-- u.root.classList.add('is-on');
-- place();
-- }
-- function close() { if (!ui) return; cancelAnimationFrame(raf); ui.root.remove(); ui = null; }
--
-- // ── The restream guide ─────────────────────────────────────
-- async function runRestreamGuide() {
-- if (!window.currentUser) { try { localStorage.setItem(PENDING_KEY, 'restream'); } catch { /* */ } window.location.href = '/api/auth/sso/login'; return; }
-- const total = 5;
-- const skip = { label: 'Skip', onClick: close };
-- // Wait for the workspace to initialise.
-- await waitFor(() => typeof _wsState !== 'undefined' && document.getElementById('bc-ws-list'));
-- await sleep(400);
--
-- // 1. A stream slot
-- if (typeof _wsState !== 'undefined' && !(_wsState.managedStreams || []).length) {
-- const btn = document.querySelector('#bc-ws-empty button') || document.getElementById('bc-ws-empty');
-- show({ target: btn, step: 1, total, title: 'First, a stream slot', text: 'A slot is one stream you run — its own key, settings, VODs and restream destinations. Make one (name it anything), then we\'ll wire up the restreams.', actions: [{ label: '<i class="fa-solid fa-plus"></i> Create a slot', primary: true, onClick: () => { try { showCreateManagedStreamModal(); } catch { /* */ } } }, skip] });
-- const ok = await waitFor(() => (_wsState.managedStreams || []).length > 0 && document.getElementById('bc-ws-restream-details'), { timeout: 10 * 60 * 1000, every: 400 });
-- if (!ok || !ui) return;
-- } else {
-- await waitFor(() => document.getElementById('bc-ws-restream-details'));
-- }
-- if (!ui && !ensureUi()) return;
--
-- // 2. Restream destinations
-- const details = document.getElementById('bc-ws-restream-details');
-- if (details) details.open = true;
-- await new Promise(r => { show({ target: details, step: 2, total, title: 'Restream destinations', text: 'This is where one stream fans out. Everything you add here gets your OpenVibe stream mirrored to it — Twitch, YouTube, Kick, any RTMP server, and RobotStreamer over its own connection. Your chat, emotes, sound commands and controls stay here.', actions: [{ label: 'Next <i class="fa-solid fa-arrow-right"></i>', primary: true, onClick: r }, skip] }); });
-- if (!ui) return;
--
-- // 3. RobotStreamer, the easy way
-- const addBtn = details ? details.querySelector('button') : null;
-- await new Promise(r => { show({ target: addBtn, step: 3, total, title: 'Add RobotStreamer', text: 'Pick <b>RobotStreamer</b> as the platform, then use <b>Log in &amp; fetch robots</b>: your RobotStreamer username and password are used once to grab your token and your robot list — nothing is stored. Pick the robot, save. Done.', actions: [{ label: '<i class="fa-solid fa-robot"></i> Add RobotStreamer now', primary: true, onClick: async () => { try { _wsAddRestreamDest(); await sleep(150); const sel = document.getElementById('ws-rs-platform'); if (sel) { sel.value = 'robotstreamer'; _wsRestreamPlatformChanged(); } const u = await waitFor(() => document.getElementById('ws-rs-rs-username'), { timeout: 3000 }); if (u) { u.focus(); if (ui) { ui.target = u.closest('.form-group') || u; ui.title.textContent = 'Log in & fetch robots'; ui.text.innerHTML = 'Enter your RobotStreamer username and password, hit <b>Log in &amp; fetch robots</b>, pick your robot from the list, then <b>Save</b>. The guide continues when the dialog closes.'; ui.actions.innerHTML = ''; } } } catch (e) { console.warn('[Guide]', e.message); } r(); } }, { label: 'I\'ll do it later', onClick: r }, skip] }); });
-- if (!ui) return;
-- // Wait for the dialog (if opened) to close.
-- await waitFor(() => !document.querySelector('.bc-ws-confirm-overlay'), { timeout: 15 * 60 * 1000, every: 300 });
-- if (!ui) return;
--
-- // 4. More platforms
-- await new Promise(r => { show({ target: addBtn, step: 4, total, title: 'Twitch, YouTube, Kick, RTMP', text: 'Same button, different platform: paste the stream key from each service. Every destination has its own <b>Auto-start</b> switch and its own <b>→ PowerChat</b> switch, so you decide what starts with the stream and whose chat lands on your overlay.', actions: [{ label: 'Next <i class="fa-solid fa-arrow-right"></i>', primary: true, onClick: r }, skip] }); });
-- if (!ui) return;
--
-- // 5. Go live
-- const golive = document.getElementById('bc-ws-golive-btn') || document.getElementById('bc-ws-golive-section');
-- await new Promise(r => { show({ target: golive, step: 5, total, title: 'Go live', text: 'Hit Go Live from this slot. Destinations with Auto-start fire the moment you\'re up; the rest you toggle from the live controls. Your viewers on every platform see one stream, and every chat comes back here.', actions: [{ label: '<i class="fa-solid fa-check"></i> Got it', primary: true, onClick: r }] }); });
-- close();
-- try { localStorage.removeItem(PENDING_KEY); } catch { /* */ }
-- }
--
-- // ── Entry points ───────────────────────────────────────────
-- function wanted() { try { return new URLSearchParams(location.search).get('setup'); } catch { return null; } }
-- async function boot() {
-- // Resume after SSO: the intent was saved before the redirect.
-- let pending = null; try { pending = localStorage.getItem(PENDING_KEY); } catch { /* */ }
-- if (pending === 'restream' && !wanted()) {
-- const user = await waitFor(() => window.currentUser, { timeout: 8000, every: 250 });
-- if (user) { try { localStorage.removeItem(PENDING_KEY); } catch { /* */ } if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream'; }
-- return;
-- }
-- if (wanted() === 'restream') {
-- await waitFor(() => window.currentUser !== undefined && document.getElementById('page-broadcast')?.classList.contains('active'), { timeout: 8000 });
-- await waitFor(() => typeof window.currentUser !== 'undefined' || window._userLoaded, { timeout: 6000 });
-- runRestreamGuide().catch(e => console.warn('[Guide]', e.message));
-- }
-- }
-- // Also react to in-app navigation to /broadcast?setup=restream (SPA pushState).
-- const _push = history.pushState;
-- history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(() => { if (wanted() === 'restream' && !ui) boot(); }, 50); return r; };
-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot); else boot();
-- window.startRestreamGuide = () => { if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream'; };
--})();
++/* Superseded by public/js/golive-wizard.js — kept so old cached pages don't 404. */
ddiff --git a/public/js/golive-wizard.js b/public/js/golive-wizard.js
nnew file mode 100644
iindex 00000000..c3f02fc0
---- /dev/null
++++ b/public/js/golive-wizard.js
@@@ -0,0 +1,356 @@
++/**
++ * golive-wizard.js — the Go Live setup wizard: first stream to first frame, step by step.
++ *
++ * openGoLiveWizard({ step }) step: 'stream' | 'method' | 'restream' | 'golive'
++ *
++ * Four steps, every one driven by the real APIs (no fragile clicking around the workspace):
++ * 1. Your stream — pick an existing slot or create one (title, category)
++ * 2. How you stream — Browser (cam / screen), OBS via RTMP, or OBS via WHIP; shows the
++ * server + key with copy buttons and the exact OBS clicks
++ * 3. Restream — RobotStreamer (log in & pick the robot), Twitch / YouTube / Kick /
++ * any RTMP with a stream key; optional
++ * 4. Go live — Browser: goes live from here. OBS: waits for your encoder and
++ * celebrates when the stream shows up.
++ *
++ * Opens on its own the first time someone lands on Go Live with no slots (until dismissed),
++ * from /broadcast?setup=new|restream, and from the home page's "Set up restreams" button.
++ */
++(function () {
++ 'use strict';
++ const CATS = [['irl', 'IRL', 'fa-person-walking'], ['outdoors', 'Outdoors', 'fa-mountain-sun'], ['travel', 'Travel', 'fa-plane'], ['building', 'Building / Craft', 'fa-hammer'], ['music', 'Music', 'fa-music'], ['gaming', 'Gaming', 'fa-gamepad'], ['robot', 'Robot', 'fa-robot'], ['desktop', 'Desktop', 'fa-desktop'], ['other', 'Other', 'fa-sparkles']];
++ const STEPS = [['stream', 'Your stream', 'fa-tower-broadcast'], ['method', 'How you stream', 'fa-sliders'], ['restream', 'Restream', 'fa-satellite-dish'], ['golive', 'Go live', 'fa-play']];
++ const PLAT = { twitch: ['Twitch', 'fa-brands fa-twitch', '#a970ff'], youtube: ['YouTube', 'fa-brands fa-youtube', '#ff4b4b'], kick: ['Kick', 'fa-solid fa-bolt', '#53fc18'], custom: ['Any RTMP', 'fa-solid fa-tower-broadcast', '#e5e7eb'] };
++ const S = { open: false, step: 'stream', slots: [], slot: null, method: null, mode: 'camera', key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, poll: null, dir: 1 };
++ const $ = (sel, root) => (root || document).querySelector(sel);
++ const h = (s) => (typeof esc === 'function' ? esc(s) : String(s == null ? '' : s));
++ const say = (m, t) => { try { toast(m, t || 'info'); } catch { /* */ } };
++ // app.js keeps the signed-in user in a top-level `let`, not on window — read it the right way.
++ const me = () => { try { return (typeof currentUser !== 'undefined' && currentUser) || null; } catch { return null; } };
++
++ // ── Shell ──────────────────────────────────────────────────
++ function ensure() {
++ let root = $('#glw');
++ if (root) return root;
++ root = document.createElement('div');
++ root.id = 'glw'; root.className = 'glw';
++ root.innerHTML = `<div class="glw-scrim"></div>
++ <div class="glw-card" role="dialog" aria-modal="true" aria-label="Go Live setup">
++ <button type="button" class="glw-close" aria-label="Close" onclick="closeGoLiveWizard()"><i class="fa-solid fa-xmark"></i></button>
++ <div class="glw-head"><span class="ov-mark" data-size="30"></span><div><div class="glw-kicker">Go Live setup</div><div class="glw-title" id="glw-title">Let's get you live</div></div></div>
++ <ol class="glw-steps" id="glw-steps"></ol>
++ <div class="glw-body"><div class="glw-panel" id="glw-panel"></div></div>
++ <div class="glw-foot" id="glw-foot"></div>
++ </div>`;
++ document.body.appendChild(root);
++ $('.glw-scrim', root).addEventListener('click', () => closeGoLiveWizard());
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && S.open) closeGoLiveWizard(); });
++ if (typeof ovMarkMount === 'function') ovMarkMount(root);
++ return root;
++ }
++ function renderSteps() {
++ const idx = STEPS.findIndex(s => s[0] === S.step);
++ $('#glw-steps').innerHTML = STEPS.map(([id, label, icon], i) => `<li class="${i < idx ? 'done' : i === idx ? 'now' : ''}"><span class="glw-step-dot"><i class="fa-solid ${i < idx ? 'fa-check' : icon}"></i></span><span class="glw-step-label">${label}</span></li>`).join('');
++ $('#glw-steps').style.setProperty('--p', `${(idx / (STEPS.length - 1)) * 100}%`);
++ }
++ function go(step, dir) {
++ S.dir = dir || (STEPS.findIndex(s => s[0] === step) >= STEPS.findIndex(s => s[0] === S.step) ? 1 : -1);
++ S.step = step;
++ const panel = $('#glw-panel');
++ panel.classList.remove('in-l', 'in-r'); void panel.offsetWidth;
++ renderSteps();
++ ({ stream: renderStream, method: renderMethod, restream: renderRestream, golive: renderGoLive })[step]();
++ panel.classList.add(S.dir > 0 ? 'in-r' : 'in-l');
++ try { if (typeof ovMarkMount === 'function') ovMarkMount(panel); } catch { /* */ }
++ panel.scrollTop = 0;
++ }
++ function foot(html) { $('#glw-foot').innerHTML = html; }
++ function title(t) { $('#glw-title').textContent = t; }
++
++ // ── Data ───────────────────────────────────────────────────
++ async function loadSlots() { try { const d = await api('/streams/managed'); S.slots = d.managed_streams || []; } catch { S.slots = []; } return S.slots; }
++ async function loadEndpoint() {
++ if (!S.slot) return;
++ try { const d = await api(`/streams/managed/${S.slot.id}/profile`); S.key = d.stream_key || S.slot.stream_key || null; S.rtmpUrl = d.rtmp_url || 'rtmp://openvibe.live/live'; S.whipBase = (d.whip_url_base || location.origin).replace(/\/$/, ''); }
++ catch { S.key = S.slot.stream_key || null; S.rtmpUrl = 'rtmp://openvibe.live/live'; S.whipBase = location.origin; }
++ }
++ async function loadDests() {
++ if (!S.slot) return;
++ try { const d = await api(`/restream/destinations?managed_stream_id=${S.slot.id}`); S.dests = (d.destinations || d || []).filter(x => !x.managed_stream_id || x.managed_stream_id === S.slot.id); } catch { S.dests = []; }
++ try { const r = await api(`/robotstreamer/integration?managed_stream_id=${S.slot.id}`); S.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { S.rs = null; }
++ }
++ async function syncWorkspace() {
++ try { if (typeof _wsLoadManagedStreams === 'function') await _wsLoadManagedStreams(); if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); if (S.slot && typeof _wsSelectStream === 'function') await _wsSelectStream(S.slot.id); } catch { /* */ }
++ }
++
++ // ── Step 1: your stream ───────────────────────────────────
++ function renderStream() {
++ title(S.slots.length ? 'Which stream are we setting up?' : 'First, your stream');
++ const cats = CATS.map(([v, l, i]) => `<button type="button" class="glw-chip" data-cat="${v}"><i class="fa-solid ${i}"></i> ${l}</button>`).join('');
++ const existing = S.slots.length ? `<div class="glw-grid">${S.slots.map(s => `
++ <button type="button" class="glw-pick ${S.slot && S.slot.id === s.id ? 'on' : ''}" data-slot="${s.id}">
++ <span class="glw-pick-ico"><i class="fa-solid ${s.is_currently_live ? 'fa-circle live-dot' : 'fa-tower-broadcast'}"></i></span>
++ <span class="glw-pick-body"><b>${h(s.title || 'Untitled')}</b><small>${h(s.streaming_method || s.protocol || 'browser')}${s.category ? ' · ' + h(s.category) : ''}${s.is_currently_live ? ' · <em>live now</em>' : ''}</small></span>
++ </button>`).join('')}
++ <button type="button" class="glw-pick glw-pick--new" data-slot="new"><span class="glw-pick-ico"><i class="fa-solid fa-plus"></i></span><span class="glw-pick-body"><b>New stream</b><small>another slot with its own key, settings and restreams</small></span></button>
++ </div>` : '';
++ $('#glw-panel').innerHTML = `
++ <p class="glw-lead">${S.slots.length ? 'Each <b>stream slot</b> is one show you run — its own title, key, VODs and restream destinations. Pick one to set up, or make a new one.' : 'A <b>stream slot</b> is one show you run — it gets its own key, settings, VODs and restream destinations. Give it a name and a category; everything else can wait.'}</p>
++ ${existing}
++ <form class="glw-form ${S.slots.length ? 'is-hidden' : ''}" id="glw-new">
++ <label>Stream title<input class="form-input" id="glw-title-in" maxlength="80" placeholder="e.g. Late night tinkering" autocomplete="off"></label>
++ <label>Category</label><div class="glw-chips" id="glw-cats">${cats}</div>
++ <label>One line about it <span class="muted">(optional)</span><input class="form-input" id="glw-desc-in" maxlength="200" placeholder="What people will see under the title"></label>
++ </form>`;
++ const form = $('#glw-new');
++ $('#glw-panel').querySelectorAll('[data-slot]').forEach(b => b.addEventListener('click', () => {
++ $('#glw-panel').querySelectorAll('[data-slot]').forEach(x => x.classList.remove('on')); b.classList.add('on');
++ if (b.dataset.slot === 'new') { S.slot = null; form.classList.remove('is-hidden'); $('#glw-title-in').focus(); }
++ else { S.slot = S.slots.find(s => String(s.id) === b.dataset.slot) || null; form.classList.add('is-hidden'); }
++ renderStreamFoot();
++ }));
++ $('#glw-cats').addEventListener('click', (e) => { const c = e.target.closest('[data-cat]'); if (!c) return; $('#glw-cats').querySelectorAll('.glw-chip').forEach(x => x.classList.remove('on')); c.classList.add('on'); });
++ renderStreamFoot();
++ }
++ function renderStreamFoot() {
++ const formOpen = !$('#glw-new').classList.contains('is-hidden');
++ const creating = !S.slot && formOpen;
++ const undecided = !S.slot && !formOpen; // slots exist, nothing picked yet
++ foot(`<span class="glw-foot-note">Step 1 of 4</span><button type="button" class="btn btn-primary btn-lg" id="glw-next" ${undecided ? 'disabled' : ''}>${undecided ? 'Pick a stream above' : creating ? '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream' : 'Next <i class="fa-solid fa-arrow-right"></i>'}</button>`);
++ $('#glw-next').onclick = async () => {
++ if (undecided) return;
++ if (S.slot) { await loadEndpoint(); await loadDests(); return go('method'); }
++ const t = ($('#glw-title-in') || {}).value || '';
++ const cat = ($('#glw-cats .glw-chip.on') || {}).dataset ? $('#glw-cats .glw-chip.on').dataset.cat : '';
++ if (!t.trim()) { say('Give your stream a title first', 'error'); $('#glw-title-in').focus(); return; }
++ const btn = $('#glw-next'); btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin"></i> Creating…';
++ try {
++ const d = await api('/streams/managed', { method: 'POST', body: { title: t.trim(), category: cat || null, description: (($('#glw-desc-in') || {}).value || '').trim(), streaming_method: 'browser', protocol: 'webrtc' } });
++ S.slot = d.managed_stream; await loadSlots(); S.slot = S.slots.find(s => s.id === d.managed_stream.id) || d.managed_stream;
++ await syncWorkspace(); await loadEndpoint(); await loadDests();
++ say(`"${t.trim()}" is ready`, 'success'); go('method');
++ } catch (e) { say((e && e.message) || 'Could not create the stream', 'error'); btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream'; }
++ };
++ }
++
++ // ── Step 2: how you stream ────────────────────────────────
++ function renderMethod() {
++ title('How will you stream?');
++ const m = S.method || S.slot.streaming_method || 'browser';
++ S.method = m; S.mode = S.slot.browser_mode || S.mode || 'camera';
++ const cards = [
++ ['browser', 'fa-globe', 'Browser', 'Camera, mic or your screen — straight from this page. No software.', 'Easiest'],
++ ['rtmp', 'fa-desktop', 'OBS / Streamlabs', 'Any RTMP encoder. Scenes, overlays, the works.', 'Most flexible'],
++ ['whip', 'fa-bolt', 'OBS via WHIP', 'OBS 30+ over WebRTC. Sub-second latency.', 'Lowest latency'],
++ ].map(([id, i, name, sub, tag]) => `<button type="button" class="glw-pick glw-pick--method ${m === id ? 'on' : ''}" data-method="${id}"><span class="glw-pick-ico"><i class="fa-solid ${i}"></i></span><span class="glw-pick-body"><b>${name} <em class="glw-tag">${tag}</em></b><small>${sub}</small></span></button>`).join('');
++ $('#glw-panel').innerHTML = `<p class="glw-lead">Pick how the video gets here. You can change this any time from the Go Live page.</p><div class="glw-grid glw-grid--3">${cards}</div><div class="glw-detail" id="glw-detail"></div>`;
++ $('#glw-panel').querySelectorAll('[data-method]').forEach(b => b.addEventListener('click', async () => {
++ S.method = b.dataset.method; $('#glw-panel').querySelectorAll('[data-method]').forEach(x => x.classList.remove('on')); b.classList.add('on'); renderMethodDetail();
++ try { await api(`/streams/managed/${S.slot.id}`, { method: 'PUT', body: { streaming_method: S.method, protocol: S.method === 'rtmp' ? 'rtmp' : 'webrtc' } }); S.slot.streaming_method = S.method; } catch (e) { say((e && e.message) || 'Could not save the method', 'error'); }
++ }));
++ renderMethodDetail();
++ foot(`<button type="button" class="btn btn-outline" onclick="window.__glwBack()"><i class="fa-solid fa-arrow-left"></i> Back</button><span class="glw-foot-note">Step 2 of 4</span><button type="button" class="btn btn-primary btn-lg" onclick="window.__glwNext()">Next <i class="fa-solid fa-arrow-right"></i></button>`);
++ window.__glwBack = () => go('stream', -1);
++ window.__glwNext = () => go('restream', 1);
++ }
++ const copyBtn = (v, label) => `<button type="button" class="glw-copy" data-copy="${h(v)}" title="Copy"><i class="fa-regular fa-copy"></i> ${label || 'Copy'}</button>`;
++ function renderMethodDetail() {
++ const d = $('#glw-detail'); if (!d) return;
++ const key = S.key || '(loading…)';
++ if (S.method === 'browser') {
++ d.innerHTML = `<div class="glw-box"><div class="glw-box-head"><i class="fa-solid fa-globe"></i> Browser streaming</div>
++ <p>When you press <b>Go live</b> we'll ask for your camera and mic (or a screen to share). Nothing to install.</p>
++ <div class="glw-seg"><button type="button" class="${S.mode === 'camera' ? 'on' : ''}" data-mode="camera"><i class="fa-solid fa-video"></i> Camera + mic</button><button type="button" class="${S.mode === 'screen' ? 'on' : ''}" data-mode="screen"><i class="fa-solid fa-display"></i> Share my screen</button></div>
++ <ul class="glw-tips"><li>Phones work great — landscape looks best.</li><li>Good light beats a good camera.</li></ul></div>`;
++ d.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', async () => { S.mode = b.dataset.mode; d.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('on')); b.classList.add('on'); try { await api(`/streams/managed/${S.slot.id}`, { method: 'PUT', body: { browser_mode: S.mode } }); S.slot.browser_mode = S.mode; } catch { /* */ } }));
++ } else if (S.method === 'rtmp') {
++ d.innerHTML = `<div class="glw-box"><div class="glw-box-head"><i class="fa-solid fa-desktop"></i> Paste these into OBS</div>
++ <div class="glw-kv"><span>Server</span><code>${h(S.rtmpUrl || 'rtmp://openvibe.live/live')}</code>${copyBtn(S.rtmpUrl || 'rtmp://openvibe.live/live')}</div>
++ <div class="glw-kv"><span>Stream key</span><code class="glw-secret" data-secret="${h(key)}">••••••••••••••••</code>${copyBtn(key)}<button type="button" class="glw-copy" data-reveal="1"><i class="fa-regular fa-eye"></i> Show</button></div>
++ <ol class="glw-obs"><li><b>Settings</b> → <b>Stream</b></li><li>Service: <b>Custom…</b></li><li>Paste the server and the key</li><li><b>OK</b>, then <b>Start Streaming</b></li></ol>
++ <p class="muted glw-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p></div>`;
++ } else {
++ const whip = `${S.whipBase || location.origin}/whip/${S.slot.id}`;
++ d.innerHTML = `<div class="glw-box"><div class="glw-box-head"><i class="fa-solid fa-bolt"></i> OBS 30+ over WHIP</div>
++ <div class="glw-kv"><span>Server</span><code style="word-break:break-all">${h(whip)}</code>${copyBtn(whip)}</div>
++ <div class="glw-kv"><span>Bearer token</span><code class="glw-secret" data-secret="${h(key)}">••••••••••••••••</code>${copyBtn(key)}<button type="button" class="glw-copy" data-reveal="1"><i class="fa-regular fa-eye"></i> Show</button></div>
++ <ol class="glw-obs"><li><b>Settings</b> → <b>Stream</b></li><li>Service: <b>WHIP</b></li><li>Paste the server and the bearer token</li><li><b>OK</b>, then <b>Start Streaming</b></li></ol>
++ <p class="muted glw-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p></div>`;
++ }
++ wireCopy(d);
++ }
++ function wireCopy(root) {
++ root.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', async () => { try { await navigator.clipboard.writeText(b.dataset.copy); b.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { b.innerHTML = '<i class="fa-regular fa-copy"></i> Copy'; }, 1500); } catch { say('Copy failed — long-press to copy', 'error'); } }));
++ root.querySelectorAll('[data-reveal]').forEach(b => b.addEventListener('click', () => { const c = b.parentElement.querySelector('.glw-secret'); if (!c) return; const on = c.dataset.on === '1'; c.textContent = on ? '••••••••••••••••' : c.dataset.secret; c.dataset.on = on ? '' : '1'; b.innerHTML = on ? '<i class="fa-regular fa-eye"></i> Show' : '<i class="fa-regular fa-eye-slash"></i> Hide'; }));
++ }
++
++ // ── Step 3: restream ───────────────────────────────────────
++ function renderRestream() {
++ title('Mirror it everywhere (optional)');
++ const added = [
++ ...(S.rs ? [`<li class="on"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> RobotStreamer · ${h(S.rs.stream_name || ('robot ' + S.rs.robot_id))}<i class="fa-solid fa-check glw-ok"></i></li>`] : []),
++ ...S.dests.map(d => `<li class="on"><i class="${(PLAT[d.platform] || PLAT.custom)[1]}" style="color:${(PLAT[d.platform] || PLAT.custom)[2]}"></i> ${h(d.name || (PLAT[d.platform] || PLAT.custom)[0])}<i class="fa-solid fa-check glw-ok"></i></li>`),
++ ];
++ $('#glw-panel').innerHTML = `
++ <p class="glw-lead">Go live here once and the same stream goes out to every platform you add — while your chat, emotes, sound commands and controls stay on OpenVibe. Add what you want, or skip.</p>
++ ${added.length ? `<ul class="glw-added">${added.join('')}</ul>` : ''}
++ <div class="glw-grid glw-grid--3" id="glw-plats">
++ <button type="button" class="glw-pick glw-pick--plat" data-plat="robotstreamer" style="--c:#4a9eff"><span class="glw-pick-ico"><i class="fa-solid fa-robot"></i></span><span class="glw-pick-body"><b>RobotStreamer</b><small>log in once, pick your robot</small></span></button>
++ ${Object.entries(PLAT).map(([id, [name, icon, color]]) => `<button type="button" class="glw-pick glw-pick--plat" data-plat="${id}" style="--c:${color}"><span class="glw-pick-ico"><i class="${icon}"></i></span><span class="glw-pick-body"><b>${name}</b><small>${id === 'custom' ? 'server URL + key' : 'paste your stream key'}</small></span></button>`).join('')}
++ </div>
++ <div class="glw-detail" id="glw-plat-form"></div>`;
++ $('#glw-plats').querySelectorAll('[data-plat]').forEach(b => b.addEventListener('click', () => { $('#glw-plats').querySelectorAll('.glw-pick').forEach(x => x.classList.remove('on')); b.classList.add('on'); renderPlatForm(b.dataset.plat); }));
++ foot(`<button type="button" class="btn btn-outline" onclick="window.__glwBack()"><i class="fa-solid fa-arrow-left"></i> Back</button><span class="glw-foot-note">Step 3 of 4</span><button type="button" class="btn btn-primary btn-lg" onclick="window.__glwNext()">${added.length ? 'Next' : 'Skip for now'} <i class="fa-solid fa-arrow-right"></i></button>`);
++ window.__glwBack = () => go('method', -1);
++ window.__glwNext = () => go('golive', 1);
++ }
++ function renderPlatForm(plat) {
++ const f = $('#glw-plat-form'); if (!f) return;
++ if (plat === 'robotstreamer') {
++ f.innerHTML = `<div class="glw-box"><div class="glw-box-head"><i class="fa-solid fa-robot"></i> RobotStreamer</div>
++ <p>Your RobotStreamer login is used once to fetch your token and robot list — the password is never stored.</p>
++ <div class="glw-row"><input class="form-input" id="glw-rs-user" placeholder="RobotStreamer username" autocomplete="username"><input class="form-input" id="glw-rs-pass" type="password" placeholder="Password" autocomplete="current-password"></div>
++ <div id="glw-rs-robots"></div>
++ <div class="glw-row"><button type="button" class="btn btn-primary" id="glw-rs-go"><i class="fa-solid fa-right-to-bracket"></i> Log in &amp; fetch robots</button><span class="muted" id="glw-rs-status"></span></div></div>`;
++ $('#glw-rs-go').onclick = async () => {
++ const u = $('#glw-rs-user').value.trim(), p = $('#glw-rs-pass').value;
++ if (!u || !p) return say('Enter your RobotStreamer username and password', 'error');
++ const st = $('#glw-rs-status'); st.textContent = 'Logging in…';
++ try {
++ const d = await api('/robotstreamer/integration/login', { method: 'POST', body: { user_name: u, password: p, managed_stream_id: S.slot.id } });
++ const robots = d.available_robots || [];
++ if (!robots.length) { st.textContent = 'Logged in, but no robots on that account yet.'; return; }
++ if (robots.length === 1) { S.rs = d.integration || { robot_id: robots[0].robot_id, stream_name: robots[0].stream_name || robots[0].name }; say(`RobotStreamer connected — ${S.rs.stream_name || 'robot ' + S.rs.robot_id}`, 'success'); return renderRestream(); }
++ $('#glw-rs-robots').innerHTML = `<div class="glw-chips">${robots.map(r => `<button type="button" class="glw-chip" data-robot="${h(r.robot_id)}"><i class="fa-solid fa-robot"></i> ${h(r.stream_name || r.name || r.robot_id)}</button>`).join('')}</div><p class="muted glw-fine">Pick the robot this stream drives.</p>`;
++ st.textContent = `Logged in as ${u}. Pick a robot.`;
++ $('#glw-rs-robots').querySelectorAll('[data-robot]').forEach(b => b.addEventListener('click', async () => {
++ try { const v = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: b.dataset.robot, managed_stream_id: S.slot.id } }); S.rs = v.integration || { robot_id: b.dataset.robot }; say('RobotStreamer connected', 'success'); renderRestream(); }
++ catch (e) { say((e && e.message) || 'Could not select that robot', 'error'); }
++ }));
++ } catch (e) { st.textContent = ''; say((e && e.message) || 'RobotStreamer login failed', 'error'); }
++ };
++ return;
++ }
++ const [name] = PLAT[plat] || PLAT.custom;
++ const needsUrl = plat === 'custom' || plat === 'kick';
++ f.innerHTML = `<div class="glw-box"><div class="glw-box-head"><i class="${(PLAT[plat] || PLAT.custom)[1]}"></i> ${name}</div>
++ <p>${plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.' : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.' : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.' : 'Paste the RTMP server URL and stream key from the service you want to mirror to.'}</p>
++ ${needsUrl ? `<input class="form-input" id="glw-p-url" placeholder="rtmps://… server URL">` : ''}
++ <input class="form-input" id="glw-p-key" type="password" placeholder="${name} stream key" autocomplete="off">
++ <label class="glw-check"><input type="checkbox" id="glw-p-auto" checked> Start automatically whenever I go live</label>
++ <div class="glw-row"><button type="button" class="btn btn-primary" id="glw-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div></div>`;
++ $('#glw-p-add').onclick = async () => {
++ const key = $('#glw-p-key').value.trim(); const url = needsUrl ? $('#glw-p-url').value.trim() : '';
++ if (!key) return say('Paste the stream key first', 'error');
++ if (needsUrl && !url) return say('The server URL is needed too', 'error');
++ try {
++ await api('/restream/destinations', { method: 'POST', body: { platform: plat, stream_key: key, server_url: url || undefined, managed_stream_id: S.slot.id, auto_start: $('#glw-p-auto').checked ? 1 : 0, name: name } });
++ await loadDests(); say(`${name} added`, 'success'); renderRestream();
++ } catch (e) { say((e && e.message) || `Could not add ${name}`, 'error'); }
++ };
++ }
++
++ // ── Step 4: go live ────────────────────────────────────────
++ function renderGoLive() {
++ title("You're set. Let's go live.");
++ const m = S.method || S.slot.streaming_method || 'browser';
++ const mname = { browser: 'Browser', rtmp: 'OBS / RTMP', whip: 'OBS / WHIP' }[m] || m;
++ const dests = [...(S.rs ? ['RobotStreamer'] : []), ...S.dests.map(d => d.name || (PLAT[d.platform] || PLAT.custom)[0])];
++ $('#glw-panel').innerHTML = `
++ <div class="glw-summary">
++ <div class="glw-sum-row"><i class="fa-solid fa-tower-broadcast"></i><span><b>${h(S.slot.title)}</b><small>${h(S.slot.category || 'no category yet')} · openvibe.live/@${h((me() || {}).username || '')}${S.slot.slug ? '/' + h(S.slot.slug) : ''}</small></span></div>
++ <div class="glw-sum-row"><i class="fa-solid fa-sliders"></i><span><b>${mname}</b><small>${m === 'browser' ? (S.mode === 'screen' ? 'sharing your screen' : 'camera + mic') : 'waiting for your encoder'}</small></span></div>
++ <div class="glw-sum-row"><i class="fa-solid fa-satellite-dish"></i><span><b>${dests.length ? dests.join(', ') : 'No restreams'}</b><small>${dests.length ? 'they start with the stream' : 'add them any time from Settings → Restream'}</small></span></div>
++ </div>
++ <div class="glw-launch" id="glw-launch">${m === 'browser'
++ ? `<button type="button" class="btn btn-primary btn-lg glw-big" id="glw-golive"><i class="fa-solid fa-play"></i> Go live now</button><p class="muted glw-fine">We'll ask for camera/mic permission, then you're on.</p>`
++ : `<div class="glw-wait"><span class="glw-radar"><i></i><i></i><i></i></span><b>Press <em>Start Streaming</em> in OBS</b><small>Watching for your stream… this screen lights up the moment it arrives.</small></div>`}
++ </div>`;
++ foot(`<button type="button" class="btn btn-outline" onclick="window.__glwBack()"><i class="fa-solid fa-arrow-left"></i> Back</button><span class="glw-foot-note">Step 4 of 4</span><button type="button" class="btn btn-outline" onclick="closeGoLiveWizard(true)">Finish</button>`);
++ window.__glwBack = () => go('restream', -1);
++ if (m === 'browser') {
++ $('#glw-golive').onclick = async () => {
++ try { localStorage.setItem('ov_wizard_done', '1'); } catch { /* */ }
++ closeGoLiveWizard(true);
++ try { await syncWorkspace(); if (typeof goLiveFromWorkspace === 'function') await goLiveFromWorkspace(); else say('Press Go Live on this page to start', 'info'); } catch (e) { say((e && e.message) || 'Could not start — use the Go Live button on this page', 'error'); }
++ };
++ } else {
++ clearInterval(S.poll);
++ S.poll = setInterval(async () => {
++ if (!S.open) return clearInterval(S.poll);
++ await loadSlots(); const cur = S.slots.find(s => s.id === S.slot.id);
++ if (cur && cur.is_currently_live) { clearInterval(S.poll); celebrate(cur); }
++ }, 4000);
++ }
++ }
++ function celebrate(slot) {
++ try { localStorage.setItem('ov_wizard_done', '1'); } catch { /* */ }
++ const path = `/@${(me() || {}).username || ''}${slot && slot.slug ? '/' + slot.slug : ''}`;
++ $('#glw-panel').innerHTML = `<div class="glw-live"><div class="glw-live-badge">LIVE</div><h3>You're live!</h3><p>Your stream is on OpenVibe${S.dests.length || S.rs ? ' and heading out to your restreams' : ''}. Go say hi to chat.</p>
++ <a class="btn btn-primary btn-lg" href="${path}" onclick="closeGoLiveWizard(true); return handleLinkClick(event, '${path}')"><i class="fa-solid fa-eye"></i> Open my channel</a></div>`;
++ foot(`<span></span><button type="button" class="btn btn-outline" onclick="closeGoLiveWizard(true)">Done</button>`);
++ try { $('#glw-steps').style.setProperty('--p', '100%'); $('#glw-steps').querySelectorAll('li').forEach(li => li.classList.add('done')); } catch { /* */ }
++ confetti();
++ }
++ function confetti() {
++ try {
++ const c = document.createElement('div'); c.className = 'glw-confetti';
++ const colors = ['#ff5a5f', '#ffd166', '#06d6a0', '#4d96ff', '#c77dff', '#fff'];
++ for (let i = 0; i < 140; i++) { const p = document.createElement('span'); p.style.left = (Math.random() * 100) + 'vw'; p.style.background = colors[i % colors.length]; p.style.animationDelay = (Math.random() * 0.6) + 's'; p.style.animationDuration = (1.6 + Math.random() * 1.6) + 's'; c.appendChild(p); }
++ document.body.appendChild(c); setTimeout(() => c.remove(), 4000);
++ } catch { /* */ }
++ }
++
++ // ── Open / close ───────────────────────────────────────────
++ window.openGoLiveWizard = async function (opts = {}) {
++ if (!me()) {
++ // Give the page a moment to finish loading the session before deciding we're logged out.
++ for (let i = 0; i < 24 && !me(); i++) await new Promise(r => setTimeout(r, 250));
++ if (!me()) { try { localStorage.setItem('ov-guide-pending', opts.step || 'new'); } catch { /* */ } location.href = '/api/auth/sso/login'; return; }
++ }
++ const root = ensure();
++ S.open = true; root.classList.add('is-open'); document.body.classList.add('glw-lock');
++ $('#glw-panel').innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ await loadSlots();
++ const want = opts.step || 'stream';
++ // A specific slot (the workspace's current one) when we're asked to skip ahead.
++ if (want !== 'stream' && S.slots.length) {
++ const cur = (typeof _wsState !== 'undefined' && _wsState.selectedId) ? S.slots.find(s => s.id === _wsState.selectedId) : null;
++ S.slot = cur || S.slots[0]; await loadEndpoint(); await loadDests(); go(want, 1);
++ } else { S.slot = null; go('stream', 1); }
++ };
++ window.closeGoLiveWizard = function (finished) {
++ const root = $('#glw'); if (!root) return;
++ S.open = false; clearInterval(S.poll); root.classList.remove('is-open'); document.body.classList.remove('glw-lock');
++ try { localStorage.setItem('ov_wizard_dismissed', '1'); if (finished) localStorage.setItem('ov_wizard_done', '1'); } catch { /* */ }
++ try { if (typeof _wsLoadManagedStreams === 'function') _wsLoadManagedStreams().then(() => { if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); }); } catch { /* */ }
++ };
++ // Home page / anywhere: "Set up restreams (guided)".
++ window.startRestreamGuide = function () {
++ if (location.pathname.startsWith('/broadcast')) return openGoLiveWizard({ step: 'restream' });
++ if (typeof navigate === 'function') navigate('/broadcast?setup=restream'); else location.href = '/broadcast?setup=restream';
++ };
++ window.startGoLiveWizard = function () { if (location.pathname.startsWith('/broadcast')) return openGoLiveWizard({ step: 'stream' }); if (typeof navigate === 'function') navigate('/broadcast?setup=new'); else location.href = '/broadcast?setup=new'; };
++
++ // Auto-open: ?setup=…, a pending intent after SSO, or a first visit with no slots.
++ async function maybeAuto() {
++ if (!location.pathname.startsWith('/broadcast')) return;
++ const q = new URLSearchParams(location.search).get('setup');
++ let pending = null; try { pending = localStorage.getItem('ov-guide-pending'); } catch { /* */ }
++ const waitUser = async () => { for (let i = 0; i < 40; i++) { if (me()) return true; await new Promise(r => setTimeout(r, 250)); } return !!me(); };
++ if (q || pending) {
++ if (!(await waitUser())) return;
++ const step = (q === 'restream' || pending === 'restream') ? 'restream' : 'stream';
++ try { localStorage.removeItem('ov-guide-pending'); } catch { /* */ }
++ try { history.replaceState(null, '', location.pathname); } catch { /* */ }
++ return openGoLiveWizard({ step });
++ }
++ if (!(await waitUser())) return;
++ let dismissed = null; try { dismissed = localStorage.getItem('ov_wizard_dismissed'); } catch { /* */ }
++ if (dismissed) return;
++ await loadSlots();
++ if (!S.slots.length) openGoLiveWizard({ step: 'stream' });
++ }
++ const _push = history.pushState;
++ history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(() => { if (!S.open) maybeAuto(); }, 60); return r; };
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(maybeAuto, 300)); else setTimeout(maybeAuto, 300);
++})();