openvibers/openvibe.live Commits

Shared guidance engine (ov-guide.js): journeys with a common shell, clickable step dots, progress memory, ?guide= deep links, in-page spotlight, SSO resume. Live journeys (guides-live.js): streamer setup hub backed by /api/streams/setup-progress (12 tasks across stream, look & feel, community & money, grow), go-live, restream, profile, emote, sound, goal, offline screen, panels, PowerChat, share, and a site tour. Home button names the next unfinished task; Streamer setup in the user menu and on the Go Live page

OP OpenVibers committed 16 Sept 2026 · d6458435a1 · 8 files, +701 −463

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
ddeleted file mode 100644
iindex 1d26d53c..00000000
---- a/public/css/golive-wizard.css
++++ /dev/null
@@@ -1,102 +0,0 @@
--/* ── 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/css/ov-guide.css b/public/css/ov-guide.css
nnew file mode 100644
iindex 00000000..61fa165d
---- /dev/null
++++ b/public/css/ov-guide.css
@@@ -0,0 +1,142 @@
++/* ── Go Live setup wizard (public/js/golive-wizard.js) ── */
++.ovg { position: fixed; inset: 0; z-index: 21000; display: none; }
++.ovg.is-open { display: block; }
++body.ovg-lock { overflow: hidden; }
++.ovg-scrim { position: absolute; inset: 0; background: rgba(4,5,10,0.72); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ovgFade .25s ease; }
++.ovg-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: ovgPop .4s cubic-bezier(.2,1.3,.3,1); overflow: hidden; }
++@keyframes ovgFade { from { opacity: 0; } to { opacity: 1; } }
++@keyframes ovgPop { from { opacity: 0; transform: translate(-50%, -46%) scale(0.96); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
++.ovg-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; }
++.ovg-close:hover { color: var(--text-primary); border-color: var(--accent); }
++.ovg-head { display: flex; align-items: center; gap: 12px; padding: 18px 56px 8px 20px; }
++.ovg-kicker { font-size: 0.66rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }
++.ovg-title { font-size: 1.25rem; font-weight: 900; line-height: 1.15; }
++.ovg-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; }
++.ovg-steps::before { content: ''; position: absolute; left: 12%; right: 12%; top: 15px; height: 3px; background: var(--bg-tertiary); border-radius: 2px; }
++.ovg-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); }
++.ovg-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; }
++.ovg-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; }
++.ovg-steps li.now .ovg-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); }
++.ovg-steps li.now { color: var(--text-primary); }
++.ovg-steps li.done .ovg-step-dot { background: color-mix(in srgb, #06d6a0 25%, var(--bg-tertiary)); border-color: #06d6a0; color: #06d6a0; }
++.ovg-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 20px 12px; }
++.ovg-panel { animation-duration: .35s; animation-timing-function: cubic-bezier(.2,.9,.3,1); animation-fill-mode: both; }
++.ovg-panel.in-r { animation-name: ovgInR; } .ovg-panel.in-l { animation-name: ovgInL; }
++@keyframes ovgInR { from { opacity: 0; transform: translateX(28px); } to { opacity: 1; transform: none; } }
++@keyframes ovgInL { from { opacity: 0; transform: translateX(-28px); } to { opacity: 1; transform: none; } }
++.ovg-lead { font-size: 0.92rem; color: var(--text-secondary); line-height: 1.55; margin: 6px 0 14px; }
++.ovg-lead b { color: var(--text-primary); }
++.ovg-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; }
++.ovg-grid--3 { grid-template-columns: repeat(3, 1fr); }
++.ovg-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: ovgRise .4s both; }
++.ovg-pick:nth-child(2) { animation-delay: .05s; } .ovg-pick:nth-child(3) { animation-delay: .1s; } .ovg-pick:nth-child(4) { animation-delay: .15s; } .ovg-pick:nth-child(5) { animation-delay: .2s; }
++@keyframes ovgRise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
++.ovg-pick:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--c) 60%, var(--border)); }
++.ovg-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)); }
++.ovg-pick--new { border-style: dashed; }
++.ovg-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); }
++.ovg-pick.on .ovg-pick-ico { background: var(--c); color: #fff; }
++.ovg-pick-body { display: grid; gap: 2px; min-width: 0; }
++.ovg-pick-body b { font-size: 0.92rem; } .ovg-pick-body small { font-size: 0.74rem; color: var(--text-muted); line-height: 1.35; }
++.ovg-tag { font-style: normal; font-size: 0.62rem; font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; color: var(--c); margin-left: 6px; }
++.ovg-form { display: grid; gap: 10px; } .ovg-form.is-hidden { display: none; }
++.ovg-form label { display: grid; gap: 6px; font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); }
++.ovg-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.ovg-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; }
++.ovg-chip:hover { border-color: var(--accent); } .ovg-chip.on { background: var(--accent); border-color: var(--accent); color: #fff; }
++.ovg-detail { margin-top: 4px; }
++.ovg-box { padding: 14px; border-radius: 14px; background: var(--bg-secondary); border: 1px solid var(--border); animation: ovgRise .35s both; }
++.ovg-box p { margin: 0 0 10px; font-size: 0.86rem; color: var(--text-secondary); line-height: 1.5; }
++.ovg-box-head { font-weight: 800; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; } .ovg-box-head i { color: var(--accent); }
++.ovg-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; }
++.ovg-kv span { color: var(--text-muted); font-weight: 700; }
++.ovg-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; }
++.ovg-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; }
++.ovg-copy:hover { border-color: var(--accent); }
++.ovg-obs { margin: 10px 0 6px; padding-left: 0; list-style: none; display: grid; gap: 6px; counter-reset: obs; }
++.ovg-obs li { counter-increment: obs; display: flex; gap: 10px; align-items: center; font-size: 0.86rem; }
++.ovg-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; }
++.ovg-tips { margin: 8px 0 0; padding-left: 18px; font-size: 0.8rem; color: var(--text-muted); }
++.ovg-fine { font-size: 0.76rem; }
++.ovg-seg { display: inline-flex; gap: 4px; padding: 4px; border-radius: 999px; background: var(--bg-tertiary); border: 1px solid var(--border); margin: 4px 0 8px; }
++.ovg-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; }
++.ovg-seg button.on { background: var(--accent); color: #fff; }
++.ovg-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 8px; }
++.ovg-row .form-input { flex: 1 1 180px; }
++.ovg-check { display: flex; align-items: center; gap: 8px; font-size: 0.82rem; margin: 8px 0; }
++.ovg-added { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
++.ovg-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; }
++.ovg-ok { color: #06d6a0; }
++.ovg-summary { display: grid; gap: 8px; margin-bottom: 14px; }
++.ovg-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: ovgRise .35s both; }
++.ovg-sum-row:nth-child(2) { animation-delay: .06s; } .ovg-sum-row:nth-child(3) { animation-delay: .12s; }
++.ovg-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; }
++.ovg-sum-row span { display: grid; min-width: 0; } .ovg-sum-row b { font-size: 0.9rem; } .ovg-sum-row small { color: var(--text-muted); font-size: 0.74rem; }
++.ovg-launch { text-align: center; padding: 8px 0 4px; }
++.ovg-big { font-size: 1.1rem; padding: 14px 28px; box-shadow: 0 12px 34px color-mix(in srgb, var(--accent) 45%, transparent); animation: ovgPulse 2.2s ease-out infinite; }
++@keyframes ovgPulse { 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; } }
++.ovg-wait { display: grid; justify-items: center; gap: 6px; padding: 10px; }
++.ovg-wait b { font-size: 1rem; } .ovg-wait em { color: var(--accent-light, var(--accent)); font-style: normal; } .ovg-wait small { color: var(--text-muted); font-size: 0.8rem; }
++.ovg-radar { position: relative; width: 64px; height: 64px; margin-bottom: 6px; }
++.ovg-radar i { position: absolute; inset: 0; border-radius: 50%; border: 2px solid var(--accent); opacity: 0; animation: ovgRadar 2.4s ease-out infinite; }
++.ovg-radar i:nth-child(2) { animation-delay: .8s; } .ovg-radar i:nth-child(3) { animation-delay: 1.6s; }
++@keyframes ovgRadar { 0% { transform: scale(0.3); opacity: 0.9; } 100% { transform: scale(1.2); opacity: 0; } }
++.ovg-live { text-align: center; padding: 16px 6px; display: grid; justify-items: center; gap: 8px; }
++.ovg-live-badge { font-size: 0.8rem; font-weight: 900; letter-spacing: 0.14em; padding: 4px 12px; border-radius: 6px; background: #e0245e; color: #fff; animation: ovgPulse 1.6s ease-out infinite; }
++.ovg-live h3 { margin: 0; font-size: 1.6rem; font-weight: 900; } .ovg-live p { margin: 0 0 8px; color: var(--text-secondary); }
++.ovg-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); }
++.ovg-foot-note { font-size: 0.74rem; color: var(--text-muted); font-weight: 700; }
++.ovg-confetti { position: fixed; inset: 0; pointer-events: none; z-index: 22000; overflow: hidden; }
++.ovg-confetti span { position: absolute; top: -14px; width: 9px; height: 14px; border-radius: 2px; animation: ovgFall linear forwards; }
++@keyframes ovgFall { to { transform: translateY(105vh) rotate(720deg); opacity: 0.9; } }
++@media (max-width: 640px) {
++ .ovg-card { left: 0; right: 0; top: auto; bottom: 0; transform: none; width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; animation: ovgSheet .35s cubic-bezier(.2,1,.3,1); }
++ @keyframes ovgSheet { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
++ .ovg-grid, .ovg-grid--3 { grid-template-columns: 1fr; }
++ .ovg-steps { grid-template-columns: repeat(4, 1fr); } .ovg-step-label { font-size: 0.6rem; }
++ .ovg-kv { grid-template-columns: 1fr; } .ovg-kv span { font-size: 0.7rem; }
++ .ovg-foot { padding: 10px 14px 14px; }
++}
++@media (prefers-reduced-motion: reduce) { .ovg-card, .ovg-panel, .ovg-pick, .ovg-box, .ovg-sum-row, .ovg-big, .ovg-radar i, .ovg-live-badge { animation: none !important; } }
++
++/* ── Engine extras: clickable steps, hub, rings, spotlight, mini-journey forms ── */
++.ovg-steps { grid-template-columns: repeat(var(--n, 4), 1fr); }
++.ovg-steps li.can { cursor: pointer; } .ovg-steps li.can:hover .ovg-step-dot { transform: scale(1.1); border-color: var(--accent); }
++.ovg-foot-right { display: flex; gap: 8px; align-items: center; }
++.ovg-error { padding: 12px; border-radius: 12px; background: color-mix(in srgb, #ff5a5f 12%, var(--bg-secondary)); border: 1px solid #ff5a5f; }
++.ovg-field { display: grid; gap: 6px; font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); margin-bottom: 10px; }
++.ovg-field-label { font-size: 0.8rem; font-weight: 700; color: var(--text-secondary); margin: 2px 0 6px; }
++.ovg-prefix { display: flex; align-items: center; gap: 0; } .ovg-prefix span { padding: 9px 12px; border: 1px solid var(--border); border-right: 0; border-radius: 10px 0 0 10px; background: var(--bg-tertiary); font-weight: 900; } .ovg-prefix .form-input { border-radius: 0 10px 10px 0; }
++.ovg-drop { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 96px; border: 2px dashed color-mix(in srgb, var(--accent) 45%, var(--border)); border-radius: 14px; background: var(--bg-secondary); cursor: pointer; color: var(--text-secondary); font-weight: 700; padding: 12px; transition: border-color .15s, background .15s; }
++.ovg-drop:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 6%, var(--bg-secondary)); }
++.ovg-drop img { width: 64px; height: 64px; object-fit: contain; border-radius: 10px; background: var(--bg-tertiary); }
++.ovg-drop i { font-size: 1.4rem; color: var(--accent); }
++.ovg-profile { display: grid; grid-template-columns: 96px 1fr; gap: 16px; align-items: start; }
++.ovg-avatar-pick { position: relative; width: 96px; height: 96px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--accent); color: #fff; font-size: 2.2rem; font-weight: 900; cursor: pointer; }
++.ovg-avatar-pick img { width: 100%; height: 100%; object-fit: cover; } .ovg-avatar-pick i { position: absolute; right: 4px; bottom: 4px; width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-size: 0.75rem; background: var(--bg-card); color: var(--text-primary); border: 1px solid var(--border); }
++.ovg-hub-top { display: grid; grid-template-columns: 96px 1fr; gap: 16px; align-items: center; padding: 14px; border-radius: 16px; background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 14%, var(--bg-secondary)), var(--bg-secondary)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); margin-bottom: 14px; }
++.ovg-hub-title { font-size: 1.05rem; font-weight: 900; margin-bottom: 2px; } .ovg-hub-sub { font-size: 0.82rem; color: var(--text-secondary); margin-bottom: 8px; }
++.ovg-ring { --pct: 0; width: 96px; height: 96px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--accent) calc(var(--pct) * 1%), var(--bg-tertiary) 0); position: relative; }
++.ovg-ring::before { content: ''; position: absolute; inset: 9px; border-radius: 50%; background: var(--bg-card); }
++.ovg-ring span { position: relative; font-size: 1.5rem; font-weight: 900; } .ovg-ring small { font-size: 0.75rem; color: var(--text-muted); font-weight: 700; }
++.ovg-hub-group h4 { margin: 12px 0 6px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
++.ovg-tasks { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
++.ovg-tasks li { display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 12px; background: var(--bg-secondary); border: 1px solid var(--border); }
++.ovg-tasks li.focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); }
++.ovg-tasks li.done { opacity: 0.8; } .ovg-tasks li.done .ovg-task-ico { background: color-mix(in srgb, #06d6a0 20%, transparent); color: #06d6a0; }
++.ovg-task-ico { width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
++.ovg-task-body { display: grid; min-width: 0; } .ovg-task-body b { font-size: 0.88rem; } .ovg-task-body b small { color: var(--text-muted); font-weight: 700; } .ovg-task-body small { font-size: 0.74rem; color: var(--text-muted); line-height: 1.35; }
++.ovg-mini-ring { --pct: 0; display: inline-block; width: 18px; height: 18px; border-radius: 50%; background: conic-gradient(#fff calc(var(--pct) * 1%), rgba(255,255,255,0.25) 0); vertical-align: -3px; margin-right: 4px; position: relative; }
++.ovg-mini-ring::before { content: ''; position: absolute; inset: 4px; border-radius: 50%; background: var(--accent); }
++.btn.is-ready small { opacity: 0.75; font-weight: 600; margin-left: 4px; }
++/* Spotlight: point at a real element */
++.ovg-spot { position: fixed; inset: 0; z-index: 20500; pointer-events: none; opacity: 0; transition: opacity .3s; }
++.ovg-spot.is-on { opacity: 1; }
++.ovg-spot-hole { position: fixed; border-radius: 14px; pointer-events: none; box-shadow: 0 0 0 9999px rgba(3,3,8,0.68), 0 0 0 3px var(--accent), 0 0 30px color-mix(in srgb, var(--accent) 60%, transparent); transition: left .25s, top .25s, width .25s, height .25s, opacity .25s; }
++.ovg-spot-card { position: fixed; pointer-events: auto; width: 380px; max-width: calc(100vw - 24px); padding: 16px 18px; border-radius: 16px; color: #fff; background: linear-gradient(160deg, #17132b, #0c0c12 60%); border: 1px solid rgba(255,255,255,0.14); box-shadow: 0 30px 70px rgba(0,0,0,0.6); transition: top .25s, left .25s; }
++.ovg-spot-step { font-size: 0.68rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-light, var(--accent)); margin-bottom: 4px; }
++.ovg-spot-card h4 { margin: 0 0 6px; font-size: 1.05rem; font-weight: 900; } .ovg-spot-card p { margin: 0 0 12px; font-size: 0.86rem; line-height: 1.5; color: #e2e8f0; }
++.ovg-spot-actions { display: flex; flex-wrap: wrap; gap: 8px; }
++@media (max-width: 640px) { .ovg-hub-top { grid-template-columns: 76px 1fr; } .ovg-ring { width: 76px; height: 76px; } .ovg-ring span { font-size: 1.2rem; } .ovg-profile { grid-template-columns: 1fr; justify-items: center; } .ovg-tasks li { grid-template-columns: 30px 1fr; } .ovg-tasks li .btn { grid-column: 2; justify-self: start; } }
ddiff --git a/public/index.html b/public/index.html
iindex b1f7a864..3030dd15 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/golive-wizard.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/recap.css?v=1">
<link rel="stylesheet" href="/css/context-cards.css?v=3">
@@@ -205,6 +205,7 @@
<a href="#" onclick="event.preventDefault();captureScreenshot()"><i class="fa-solid fa-camera"></i> Screenshot</a>
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')"><i class="fa-solid fa-book"></i> Documentation</a>
<hr>
++ <a href="#" onclick="event.preventDefault();openSetupHub()"><i class="fa-solid fa-list-check"></i> Streamer setup</a>
<a href="#" onclick="event.preventDefault();switchAccount()"><i class="fa-solid fa-people-arrows"></i> Switch account</a>
<a href="#" onclick="event.preventDefault();browseAsGuest()"><i class="fa-solid fa-user-secret"></i> Browse as guest</a>
<a href="#" onclick="event.preventDefault();logout()"><i class="fa-solid fa-right-from-bracket"></i> Logout</a>
@@@ -2031,7 +2032,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 <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>
++ <h2><i class="fa-solid fa-tower-broadcast"></i> Go Live <button type="button" class="btn btn-outline btn-sm glw-open-btn" onclick="openSetupHub()" title="Everything you can set up, step by step"><i class="fa-solid fa-list-check"></i> Streamer setup</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 -->
@@@ -5169,8 +5170,9 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/js/messenger.js?v=2" defer></script>
<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/golive-wizard.js?v=1" defer></script>
++<script src="/js/home-tour.js?v=5" defer></script>
++<script src="/js/ov-guide.js?v=1" defer></script>
++<script src="/js/guides-live.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/golive-wizard.js b/public/js/golive-wizard.js
ddeleted file mode 100644
iindex c3f02fc0..00000000
---- a/public/js/golive-wizard.js
++++ /dev/null
@@@ -1,356 +0,0 @@
--/**
-- * 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);
--})();
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
nnew file mode 100644
iindex 00000000..7813b7f9
---- /dev/null
++++ b/public/js/guides-live.js
@@@ -0,0 +1,271 @@
++/**
++ * guides-live.js — OpenVibe.Live's journeys for the shared guidance engine (ov-guide.js).
++ *
++ * setup-hub the streamer checklist: everything the site offers, what's done, what's next
++ * golive stream slot → streaming method → restream → go live
++ * restream just the restream step (home page button)
++ * profile avatar + bio | offline offline screen | emote first custom emote
++ * sound first sound command | goal donation goal | powerchat real tips
++ * panels about panels | share first follower | tour what the site can do (viewers too)
++ *
++ * Entry points: openGoLiveWizard(), startGoLiveWizard(), startRestreamGuide(), openSetupHub(),
++ * ?guide=<journey>[:step] on any page, and the home page's "next up" button (setupNextUp()).
++ */
++(function () {
++ 'use strict';
++ if (!window.OVGuide) return;
++ const G = window.OVGuide, ui = G.ui, esc = (s) => ui.esc(s);
++ const api = (p, o) => G.cfg.api(p, o);
++ const say = (m, t) => G.cfg.toast(m, t);
++ const me = () => G.cfg.me();
++ 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 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 TASK_JOURNEY = { slot: ['golive', 'stream'], method: ['golive', 'method'], restream: ['golive', 'restream'], golive: ['golive', 'golive'], profile: ['profile'], offline: ['offline'], emote: ['emote'], sound: ['sound'], goal: ['goal'], powerchat: ['powerchat'], panels: ['panels'], share: ['share'] };
++ const TASK_ICON = { slot: 'fa-tower-broadcast', method: 'fa-sliders', restream: 'fa-satellite-dish', golive: 'fa-play', profile: 'fa-user', offline: 'fa-image', emote: 'fa-face-grin-squint-tears', sound: 'fa-volume-high', goal: 'fa-bullseye', powerchat: 'fa-hand-holding-dollar', panels: 'fa-table-columns', share: 'fa-share-nodes' };
++
++ // ── Shared stream state (slot / endpoint / restreams) ─────
++ const D = { slots: [], slot: null, key: null, rtmpUrl: null, whipBase: null, dests: [], rs: null, method: null, mode: 'camera', poll: null };
++ async function loadSlots() { try { const d = await api('/streams/managed'); D.slots = d.managed_streams || []; } catch { D.slots = []; } return D.slots; }
++ async function loadEndpoint() { if (!D.slot) return; try { const d = await api(`/streams/managed/${D.slot.id}/profile`); D.key = d.stream_key || D.slot.stream_key || null; D.rtmpUrl = d.rtmp_url || 'rtmp://openvibe.live/live'; D.whipBase = (d.whip_url_base || location.origin).replace(/\/$/, ''); } catch { D.key = D.slot.stream_key || null; D.rtmpUrl = 'rtmp://openvibe.live/live'; D.whipBase = location.origin; } }
++ async function loadDests() { if (!D.slot) return; try { const d = await api(`/restream/destinations?managed_stream_id=${D.slot.id}`); D.dests = (d.destinations || []).filter(x => !x.managed_stream_id || x.managed_stream_id === D.slot.id); } catch { D.dests = []; } try { const r = await api(`/robotstreamer/integration?managed_stream_id=${D.slot.id}`); D.rs = r && r.integration && (r.integration.robot_id || r.integration.stream_name) ? r.integration : null; } catch { D.rs = null; } }
++ 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); };
++
++ // ── Journey: go live (stream → method → restream → go live) ─
++ const streamStep = {
++ id: 'stream', title: 'Your stream', icon: 'fa-tower-broadcast',
++ heading: 'Your stream',
++ render: async () => {
++ await loadSlots();
++ const cats = ui.chips(CATS, 'cat');
++ const existing = D.slots.length ? ui.grid([
++ ...D.slots.map(s => ui.pick({ id: String(s.id), icon: s.is_currently_live ? 'fa-solid fa-circle live-dot' : 'fa-solid fa-tower-broadcast', title: esc(s.title || 'Untitled'), sub: `${esc(s.streaming_method || s.protocol || 'browser')}${s.category ? ' · ' + esc(s.category) : ''}${s.is_currently_live ? ' · <em>live now</em>' : ''}`, on: D.slot && D.slot.id === s.id })),
++ ui.pick({ id: 'new', icon: 'fa-solid fa-plus', title: 'New stream', sub: 'another slot with its own key, settings and restreams', attrs: 'data-new="1"' }),
++ ]) : '';
++ return `${ui.lead(D.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.')}
++ ${existing}
++ <form class="ovg-form ${D.slots.length ? 'is-hidden' : ''}" id="ovg-new" onsubmit="return false">
++ ${ui.field('Stream title', '<input class="form-input" id="ovg-title-in" maxlength="80" placeholder="e.g. Late night tinkering" autocomplete="off">')}
++ <div class="ovg-field-label">Category</div>${cats}
++ ${ui.field('One line about it <span class="muted">(optional)</span>', '<input class="form-input" id="ovg-desc-in" maxlength="200" placeholder="What people will see under the title">')}
++ </form>`;
++ },
++ mount: (el) => {
++ ui.wire(el);
++ const form = el.querySelector('#ovg-new');
++ el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => {
++ el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on');
++ if (b.dataset.pick === 'new') { D.slot = null; form.classList.remove('is-hidden'); el.querySelector('#ovg-title-in').focus(); }
++ else { D.slot = D.slots.find(s => String(s.id) === b.dataset.pick) || null; form.classList.add('is-hidden'); }
++ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n) n.innerHTML = D.slot ? 'Next <i class="fa-solid fa-arrow-right"></i>' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';
++ }));
++ const n = document.querySelector('#ovg-foot [data-act="next"]'); if (n && !D.slot) n.innerHTML = D.slots.length ? 'Pick a stream above' : '<i class="fa-solid fa-wand-magic-sparkles"></i> Create my stream';
++ },
++ validate: async () => {
++ if (D.slot) { D.method = D.slot.streaming_method || 'browser'; D.mode = D.slot.browser_mode || 'camera'; await loadEndpoint(); await loadDests(); return true; }
++ const form = document.querySelector('#ovg-new'); if (!form || form.classList.contains('is-hidden')) return 'Pick a stream above, or choose "New stream"';
++ const t = (document.querySelector('#ovg-title-in') || {}).value || ''; if (!t.trim()) return 'Give your stream a title first';
++ const cat = ui.picked(form.parentElement, 'cat');
++ try {
++ const d = await api('/streams/managed', { method: 'POST', body: { title: t.trim(), category: cat || null, description: ((document.querySelector('#ovg-desc-in') || {}).value || '').trim(), streaming_method: 'browser', protocol: 'webrtc' } });
++ await loadSlots(); D.slot = D.slots.find(s => s.id === d.managed_stream.id) || d.managed_stream; D.method = 'browser'; D.mode = 'camera';
++ await syncWorkspace(); await loadEndpoint(); await loadDests(); say(`"${t.trim()}" is ready`, 'success'); return true;
++ } catch (e) { return (e && e.message) || 'Could not create the stream'; }
++ },
++ };
++ const methodStep = {
++ id: 'method', title: 'How you stream', icon: 'fa-sliders', heading: 'How will you stream?',
++ render: async () => {
++ if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');
++ const m = D.method || D.slot.streaming_method || 'browser'; D.method = m;
++ return `${ui.lead('Pick how the video gets here. You can change this any time from the Go Live page.')}
++ ${ui.grid([
++ ui.pick({ id: 'browser', icon: 'fa-solid fa-globe', title: 'Browser', tag: 'Easiest', sub: 'Camera, mic or your screen — straight from this page. No software.', on: m === 'browser' }),
++ ui.pick({ id: 'rtmp', icon: 'fa-solid fa-desktop', title: 'OBS / Streamlabs', tag: 'Most flexible', sub: 'Any RTMP encoder. Scenes, overlays, the works.', on: m === 'rtmp' }),
++ ui.pick({ id: 'whip', icon: 'fa-solid fa-bolt', title: 'OBS via WHIP', tag: 'Lowest latency', sub: 'OBS 30+ over WebRTC. Sub-second latency.', on: m === 'whip' }),
++ ], 3)}<div class="ovg-detail" id="ovg-method-detail"></div>`;
++ },
++ mount: (el) => {
++ const detail = () => {
++ const d = el.querySelector('#ovg-method-detail'); if (!d) return;
++ const key = D.key || '(loading…)';
++ if (D.method === 'browser') d.innerHTML = ui.box('Browser streaming', `<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="ovg-seg"><button type="button" class="${D.mode === 'camera' ? 'on' : ''}" data-mode="camera"><i class="fa-solid fa-video"></i> Camera + mic</button><button type="button" class="${D.mode === 'screen' ? 'on' : ''}" data-mode="screen"><i class="fa-solid fa-display"></i> Share my screen</button></div><ul class="ovg-tips"><li>Phones work great — landscape looks best.</li><li>Good light beats a good camera.</li></ul>`, 'fa-globe');
++ else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');
++ else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');
++ ui.wire(d);
++ d.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', async () => { D.mode = b.dataset.mode; d.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('on')); b.classList.add('on'); try { await api(`/streams/managed/${D.slot.id}`, { method: 'PUT', body: { browser_mode: D.mode } }); D.slot.browser_mode = D.mode; } catch { /* */ } }));
++ };
++ el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', async () => { D.method = b.dataset.pick; el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); detail(); try { await api(`/streams/managed/${D.slot.id}`, { method: 'PUT', body: { streaming_method: D.method, protocol: D.method === 'rtmp' ? 'rtmp' : 'webrtc' } }); D.slot.streaming_method = D.method; } catch (e) { say((e && e.message) || 'Could not save the method', 'error'); } }));
++ detail();
++ },
++ };
++ const restreamStep = {
++ id: 'restream', title: 'Restream', icon: 'fa-satellite-dish', heading: 'Mirror it everywhere (optional)', optional: true, skipLabel: 'Skip for now',
++ render: async () => {
++ if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');
++ const added = [...(D.rs ? [`<li class="on"><i class="fa-solid fa-robot" style="color:#4a9eff"></i> RobotStreamer · ${esc(D.rs.stream_name || ('robot ' + D.rs.robot_id))}<i class="fa-solid fa-check ovg-ok"></i></li>`] : []), ...D.dests.map(d => `<li class="on"><i class="${(PLAT[d.platform] || PLAT.custom)[1]}" style="color:${(PLAT[d.platform] || PLAT.custom)[2]}"></i> ${esc(d.name || (PLAT[d.platform] || PLAT.custom)[0])}<i class="fa-solid fa-check ovg-ok"></i></li>`)];
++ return `${ui.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.${added.length ? ` <b>${added.length} already set up for "${esc(D.slot.title)}"</b> — add more, or move on.` : ' Add what you want, or skip.'}`)}
++ ${added.length ? `<ul class="ovg-added">${added.join('')}</ul>` : ''}
++ ${ui.grid([
++ ui.pick({ id: 'robotstreamer', icon: 'fa-solid fa-robot', title: 'RobotStreamer', sub: 'log in once, pick your robot', color: '#4a9eff' }),
++ ...Object.entries(PLAT).map(([id, [name, icon, color]]) => ui.pick({ id, icon, title: name, sub: id === 'custom' ? 'server URL + key' : 'paste your stream key', color })),
++ ], 3)}<div class="ovg-detail" id="ovg-plat-form"></div>`;
++ },
++ mount: (el) => {
++ el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); platForm(el, b.dataset.pick); }));
++ },
++ };
++ function platForm(el, plat) {
++ const f = el.querySelector('#ovg-plat-form'); if (!f) return;
++ const rerender = () => G.goTo('restream', 1);
++ if (plat === 'robotstreamer') {
++ f.innerHTML = ui.box('RobotStreamer', `<p>Your RobotStreamer login is used once to fetch your token and robot list — the password is never stored.</p><div class="ovg-row"><input class="form-input" id="ovg-rs-user" placeholder="RobotStreamer username" autocomplete="username"><input class="form-input" id="ovg-rs-pass" type="password" placeholder="Password" autocomplete="current-password"></div><div id="ovg-rs-robots"></div><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-rs-go"><i class="fa-solid fa-right-to-bracket"></i> Log in &amp; fetch robots</button><span class="muted" id="ovg-rs-status"></span></div>`, 'fa-robot');
++ f.querySelector('#ovg-rs-go').onclick = async () => {
++ const u = f.querySelector('#ovg-rs-user').value.trim(), p = f.querySelector('#ovg-rs-pass').value; if (!u || !p) return say('Enter your RobotStreamer username and password', 'error');
++ const st = f.querySelector('#ovg-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: D.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) { D.rs = d.integration || { robot_id: robots[0].robot_id, stream_name: robots[0].stream_name || robots[0].name }; say(`RobotStreamer connected — ${D.rs.stream_name || 'robot ' + D.rs.robot_id}`, 'success'); return rerender(); }
++ f.querySelector('#ovg-rs-robots').innerHTML = `${ui.chips(robots.map(r => [String(r.robot_id), r.stream_name || r.name || r.robot_id, 'fa-robot']), 'robot')}<p class="muted ovg-fine">Pick the robot this stream drives.</p>`; ui.wire(f); st.textContent = `Logged in as ${u}. Pick a robot.`;
++ f.querySelectorAll('[data-chip]').forEach(b => b.addEventListener('click', async () => { try { const v = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: b.dataset.chip, managed_stream_id: D.slot.id } }); D.rs = v.integration || { robot_id: b.dataset.chip }; say('RobotStreamer connected', 'success'); rerender(); } 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, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
++ f.innerHTML = ui.box(name, `<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="ovg-p-url" placeholder="rtmps://… server URL">' : ''}<input class="form-input" id="ovg-p-key" type="password" placeholder="${name} stream key" autocomplete="off"><label class="ovg-check"><input type="checkbox" id="ovg-p-auto" checked> Start automatically whenever I go live</label><div class="ovg-row"><button type="button" class="btn btn-primary" id="ovg-p-add"><i class="fa-solid fa-plus"></i> Add ${name}</button></div>`, icon.replace('fa-brands ', '').replace('fa-solid ', ''));
++ f.querySelector('.ovg-box-head i').className = icon;
++ f.querySelector('#ovg-p-add').onclick = async () => {
++ const key = f.querySelector('#ovg-p-key').value.trim(); const url = needsUrl ? f.querySelector('#ovg-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: D.slot.id, auto_start: f.querySelector('#ovg-p-auto').checked ? 1 : 0, name } }); await loadDests(); say(`${name} added`, 'success'); rerender(); }
++ catch (e) { say((e && e.message) || `Could not add ${name}`, 'error'); }
++ };
++ }
++ const goLiveStep = {
++ id: 'golive', title: 'Go live', icon: 'fa-play', heading: "You're set. Let's go live.",
++ render: async () => {
++ if (!D.slot) await pickDefaultSlot(); if (!D.slot) return ui.lead('Create a stream first.');
++ const m = D.method || D.slot.streaming_method || 'browser'; const mname = { browser: 'Browser', rtmp: 'OBS / RTMP', whip: 'OBS / WHIP' }[m] || m;
++ const dests = [...(D.rs ? ['RobotStreamer'] : []), ...D.dests.map(d => d.name || (PLAT[d.platform] || PLAT.custom)[0])];
++ const u = me() || {};
++ return `<div class="ovg-summary">
++ <div class="ovg-sum-row"><i class="fa-solid fa-tower-broadcast"></i><span><b>${esc(D.slot.title)}</b><small>${esc(D.slot.category || 'no category yet')} · openvibe.live/@${esc(u.username || '')}${D.slot.slug ? '/' + esc(D.slot.slug) : ''}</small></span></div>
++ <div class="ovg-sum-row"><i class="fa-solid fa-sliders"></i><span><b>${mname}</b><small>${m === 'browser' ? (D.mode === 'screen' ? 'sharing your screen' : 'camera + mic') : 'waiting for your encoder'}</small></span></div>
++ <div class="ovg-sum-row"><i class="fa-solid fa-satellite-dish"></i><span><b>${dests.length ? esc(dests.join(', ')) : 'No restreams'}</b><small>${dests.length ? 'they start with the stream' : 'add them any time'}</small></span></div>
++ </div>
++ <div class="ovg-launch">${m === 'browser'
++ ? `<button type="button" class="btn btn-primary btn-lg ovg-big" id="ovg-golive"><i class="fa-solid fa-play"></i> Go live now</button><p class="muted ovg-fine">We'll ask for camera/mic permission, then you're on.</p>`
++ : `<div class="ovg-wait"><span class="ovg-radar"><i></i><i></i><i></i></span><b>Press <em>Start Streaming</em> in OBS</b><small>Watching for your stream — this lights up the moment it arrives.</small></div>`}</div>`;
++ },
++ mount: (el) => {
++ const m = D.method || D.slot.streaming_method || 'browser';
++ if (m === 'browser') { const b = el.querySelector('#ovg-golive'); if (b) b.onclick = async () => { G.close(true); if (!onBroadcast()) { G.cfg.navigate('/broadcast'); await new Promise(r => setTimeout(r, 1200)); } 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 the page', 'error'); } }; }
++ else { clearInterval(D.poll); D.poll = setInterval(async () => { if (!G.state.open) return clearInterval(D.poll); await loadSlots(); const cur = D.slots.find(s => s.id === D.slot.id); if (cur && cur.is_currently_live) { clearInterval(D.poll); celebrate(el, cur); } }, 4000); }
++ },
++ footer: () => `<button type="button" class="btn btn-outline" data-act="back"><i class="fa-solid fa-arrow-left"></i> Back</button><span class="ovg-foot-note">Step 4 of 4</span><button type="button" class="btn btn-outline" data-act="next">Finish</button>`,
++ next: 'close',
++ };
++ function celebrate(el, slot) {
++ const u = me() || {}; const path = `/@${u.username || ''}${slot && slot.slug ? '/' + slot.slug : ''}`;
++ el.innerHTML = `<div class="ovg-live"><div class="ovg-live-badge">LIVE</div><h3>You're live!</h3><p>Your stream is on OpenVibe${D.dests.length || D.rs ? ' and heading out to your restreams' : ''}. Go say hi to chat.</p><a class="btn btn-primary btn-lg" href="${path}" onclick="OVGuide.close(true); return handleLinkClick(event, '${path}')"><i class="fa-solid fa-eye"></i> Open my channel</a></div>`;
++ ui.confetti();
++ }
++ G.register('golive', { title: 'Go Live setup', kicker: 'Go Live setup', steps: [streamStep, methodStep, restreamStep, goLiveStep], onOpen: async (ctx, opts) => { D.slot = null; if (opts.step && opts.step !== 'stream') await pickDefaultSlot(); }, onClose: () => { clearInterval(D.poll); try { if (typeof _wsLoadManagedStreams === 'function') _wsLoadManagedStreams().then(() => { if (typeof _wsRenderSidebar === 'function') _wsRenderSidebar(); }); } catch { /* */ } } });
++ G.register('restream', { title: 'Restream setup', kicker: 'Restream setup', steps: [{ ...restreamStep, optional: false, nextLabel: 'Done', next: 'close' }], onOpen: pickDefaultSlot });
++
++ // ── Mini journeys (one screen each, all real forms) ───────
++ const one = (id, title, icon, render, mount, validate, nextLabel) => G.register(id, { title, kicker: 'Streamer setup', noStepBar: true, steps: [{ id: 'main', title, icon, heading: title, render, mount, validate, nextLabel: nextLabel || 'Done', next: 'close' }] });
++ one('profile', 'Avatar and bio', 'fa-user', async () => { const u = me() || {}; return `${ui.lead('The first thing people see on your channel and next to every chat message.')}<div class="ovg-profile"><label class="ovg-avatar-pick" for="ovg-avatar-file">${u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="">` : `<span>${esc(String(u.display_name || u.username || '?').charAt(0).toUpperCase())}</span>`}<i class="fa-solid fa-camera"></i></label><input type="file" id="ovg-avatar-file" accept="image/*" hidden><div class="ovg-profile-body">${ui.field('Bio', `<textarea class="form-input" id="ovg-bio" rows="3" maxlength="500" placeholder="What you stream, when, and why people should stick around">${esc(u.bio || '')}</textarea>`)}<p class="muted ovg-fine">Tap the picture to change your avatar. Square images look best.</p></div></div>`; },
++ (el) => { const f = el.querySelector('#ovg-avatar-file'); f.addEventListener('change', async () => { const file = f.files && f.files[0]; if (!file) return; try { const d = await ui.upload('/auth/avatar', {}, file, 'avatar'); const url = d.avatar_url || (d.user && d.user.avatar_url); if (url) { el.querySelector('.ovg-avatar-pick').innerHTML = `<img src="${esc(url)}" alt=""><i class="fa-solid fa-camera"></i>`; const u = me(); if (u) u.avatar_url = url; } say('Avatar updated', 'success'); } catch (e) { say((e && e.message) || 'Upload failed', 'error'); } }); },
++ async () => { const bio = (document.querySelector('#ovg-bio') || {}).value || ''; try { await api('/auth/profile', { method: 'PUT', body: { bio: bio.trim() } }); const u = me(); if (u) u.bio = bio.trim(); say('Profile saved', 'success'); return true; } catch (e) { return (e && e.message) || 'Could not save'; } }, 'Save');
++ one('goal', 'Set a donation goal', 'fa-bullseye', async () => `${ui.lead('A visible target on your channel turns tips into a team effort. Keep the first one small and specific.')}${ui.field('What are you saving for?', '<input class="form-input" id="ovg-goal-title" maxlength="80" placeholder="e.g. New capture card">')}${ui.field('Target (Vibes)', '<input class="form-input" id="ovg-goal-amount" type="number" min="1" step="1" placeholder="500">')}<p class="muted ovg-fine">Goals show on your channel page and in the stream overlay; progress updates live as tips come in.</p>`,
++ null, async () => { const t = (document.querySelector('#ovg-goal-title') || {}).value || '', a = parseInt((document.querySelector('#ovg-goal-amount') || {}).value, 10); if (!t.trim()) return 'Name the goal'; if (!(a > 0)) return 'Set a target amount'; try { await api('/funds/goals', { method: 'POST', body: { title: t.trim(), target_amount: a } }); say('Goal created', 'success'); ui.confetti(); return true; } catch (e) { return (e && e.message) || 'Could not create the goal'; } }, 'Create goal');
++ one('emote', 'Upload a custom emote', 'fa-face-grin-squint-tears', async () => `${ui.lead('Your chat, your inside jokes. Viewers type the code and the image shows in chat. PNG, GIF (animated) or WebP, square, 112px or larger looks crisp.')}${ui.field('Emote code', '<input class="form-input" id="ovg-emote-code" maxlength="32" placeholder="e.g. gooseHype" autocomplete="off">')}<label class="ovg-drop" for="ovg-emote-file" id="ovg-emote-drop"><i class="fa-solid fa-image"></i><span>Tap to choose the image</span></label><input type="file" id="ovg-emote-file" accept="image/png,image/gif,image/webp,image/jpeg" hidden>`,
++ (el) => { const f = el.querySelector('#ovg-emote-file'), d = el.querySelector('#ovg-emote-drop'); f.addEventListener('change', () => { const file = f.files && f.files[0]; if (!file) return; const r = new FileReader(); r.onload = () => { d.innerHTML = `<img src="${r.result}" alt=""><span>${esc(file.name)}</span>`; }; r.readAsDataURL(file); }); },
++ async () => { const code = ((document.querySelector('#ovg-emote-code') || {}).value || '').trim(); const f = document.querySelector('#ovg-emote-file'); const file = f && f.files && f.files[0]; if (!code) return 'Give the emote a code'; if (!file) return 'Choose an image'; try { await ui.upload('/emotes', { code }, file, 'image'); say(`Emote "${code}" is live in your chat`, 'success'); ui.confetti(); return true; } catch (e) { return (e && e.message) || 'Upload failed'; } }, 'Upload emote');
++ one('sound', 'Add a sound command', 'fa-volume-high', async () => `${ui.lead('Viewers type <b>!boom</b> in chat and your stream plays it. Short clips hit hardest — under 5 seconds.')}${ui.field('Command', '<div class="ovg-prefix"><span>!</span><input class="form-input" id="ovg-sound-cmd" maxlength="24" placeholder="boom" autocomplete="off"></div>')}<label class="ovg-drop" for="ovg-sound-file" id="ovg-sound-drop"><i class="fa-solid fa-music"></i><span>Tap to choose the audio (mp3, ogg, wav)</span></label><input type="file" id="ovg-sound-file" accept="audio/*" hidden>`,
++ (el) => { const f = el.querySelector('#ovg-sound-file'), d = el.querySelector('#ovg-sound-drop'); f.addEventListener('change', () => { const file = f.files && f.files[0]; if (file) d.innerHTML = `<i class="fa-solid fa-check"></i><span>${esc(file.name)}</span>`; }); },
++ 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');
++ 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>`,
++ null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');
++ one('share', 'Get your first follower', 'fa-share-nodes', async () => { const u = me() || {}; const link = `${location.origin}/@${u.username || ''}`; return `${ui.lead('Followers get pinged the moment you go live, and they show up on your channel. Share the link where your people already are.')}${ui.kv('Your channel', link, true)}<div class="ovg-row"><a class="btn btn-outline" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=${encodeURIComponent(`I'm streaming on OpenVibe.Live — follow me at ${link}`)}"><i class="fa-brands fa-x-twitter"></i> Post</a><a class="btn btn-outline" target="_blank" rel="noopener" href="https://discord.gg/M6MuRUaeJj"><i class="fa-brands fa-discord"></i> Say hi in the Discord</a></div><ul class="ovg-tips"><li>Restream to where you already have viewers and tell them the chat lives here.</li><li>Go live at a regular time — the after-show reports and the Star of the day reward showing up.</li></ul>`; },
++ (el) => ui.wire(el), null, 'Done');
++
++ // ── Journey: the setup hub (checklist) ────────────────────
++ let PROG = null;
++ async function loadProgress() { try { PROG = await api('/streams/setup-progress'); } catch { PROG = null; } return PROG; }
++ G.register('setup-hub', {
++ title: 'Streamer setup', kicker: 'Streamer setup', noStepBar: true, onOpen: loadProgress,
++ steps: [{
++ id: 'hub', title: 'Streamer setup', icon: 'fa-list-check',
++ heading: 'Your streaming setup',
++ render: async (ctx) => {
++ const p = PROG || await loadProgress();
++ if (!p) return ui.lead('Could not load your setup right now.');
++ const pct = Math.round((p.done / p.total) * 100);
++ const groups = {}; p.tasks.forEach(t => { (groups[t.group] = groups[t.group] || []).push(t); });
++ const next = p.next;
++ return `<div class="ovg-hub-top"><div class="ovg-ring" style="--pct:${pct}"><span>${p.done}<small>/${p.total}</small></span></div><div><div class="ovg-hub-title">${pct === 100 ? 'Everything is set up. Go be great.' : next ? `Next up: ${esc(next.title)}` : 'Nice work'}</div><div class="ovg-hub-sub">${pct === 100 ? 'Come back any time something new ships.' : next ? esc(next.why) : ''}</div>${next ? `<button type="button" class="btn btn-primary" data-task="${esc(next.id)}"><i class="fa-solid ${TASK_ICON[next.id] || 'fa-arrow-right'}"></i> Do it now</button>` : ''}</div></div>
++ ${Object.entries(groups).map(([g, tasks]) => `<div class="ovg-hub-group"><h4>${esc(g)}</h4><ul class="ovg-tasks">${tasks.map(t => `<li class="${t.done ? 'done' : ''} ${ctx.focus === t.id ? 'focus' : ''}"><span class="ovg-task-ico"><i class="fa-solid ${t.done ? 'fa-check' : (TASK_ICON[t.id] || 'fa-circle')}"></i></span><span class="ovg-task-body"><b>${esc(t.title)}${t.done && t.count > 1 ? ` <small>×${t.count}</small>` : ''}</b><small>${esc(t.why)}</small></span><button type="button" class="btn btn-sm ${t.done ? 'btn-outline' : 'btn-primary'}" data-task="${esc(t.id)}">${t.done ? 'Revisit' : 'Set up'}</button></li>`).join('')}</ul></div>`).join('')}`;
++ },
++ mount: (el) => { el.querySelectorAll('[data-task]').forEach(b => b.addEventListener('click', () => { const [j, step] = TASK_JOURNEY[b.dataset.task] || []; if (!j) return; const needsBroadcast = j === 'golive' && !onBroadcast(); if (needsBroadcast) { G.close(false); G.cfg.navigate(`/broadcast?guide=golive${step ? ':' + step : ''}`); } else G.open(j, { step }); })); },
++ footer: () => `<span class="ovg-foot-note">Everything here is optional — do it in any order.</span><button type="button" class="btn btn-outline" data-act="next">Close</button>`,
++ next: 'close',
++ }],
++ });
++
++ // ── Journey: site tour (for anyone, logged in or not) ─────
++ const tourStep = (id, icon, title, html) => ({ id, icon, title, heading: title, render: async () => html });
++ G.register('tour', {
++ title: 'What OpenVibe can do', kicker: 'Quick tour', requiresUser: false,
++ steps: [
++ tourStep('stream', 'fa-tower-broadcast', 'One stream, everywhere', `${ui.lead('Go live from your browser, OBS or a robot, and mirror the same stream to Twitch, YouTube, Kick, RobotStreamer or any RTMP server at once. Chat from every platform lands in one place.')}<ul class="ovg-tips"><li>Sub-second WebRTC latency for viewers here.</li><li>Every stream is recorded; clips and AI moments are cut for you.</li></ul>`),
++ tourStep('chat', 'fa-comments', 'Chat is the show', `${ui.lead('7TV, BTTV, FFZ and custom emotes; sound commands like <b>!boom</b> that play on stream; server-rendered TTS with collectible voices; auto-translation both ways for non-English streamers.')}<ul class="ovg-tips"><li>Type <b>.</b> before a message to keep it out of TTS.</li><li>Viewers earn Vibes for watching and chatting.</li></ul>`),
++ tourStep('arena', 'fa-microphone-lines', 'The Arena', `${ui.lead('Everything streamers say on mic is judged by AI: the best trash talk lands in the Arena, callouts open beefs, and levels rise with the mouth. Chat can <b>!hype</b> a fight but never write one.')}`),
++ tourStep('after', 'fa-clipboard-list', 'After every stream', `${ui.lead('An after-show report with a grade, the viewer curve, the loudest chatters, mic moments and clips — plus a daily AI-picked Star of OpenVibe and a fresh daily secret to crack.')}`),
++ tourStep('you', 'fa-user-astronaut', 'Your turn', `${ui.lead(me() ? 'You have an account — the streamer setup walks you through everything in a few minutes.' : 'One free account works across the whole OpenVibe network. Sign in and the setup walks you through your first stream.')}`),
++ ],
++ onClose: (ctx, finished) => { if (finished && me()) G.open('setup-hub'); },
++ });
++
++ // ── Entry points ───────────────────────────────────────────
++ window.openGoLiveWizard = (o = {}) => { const step = o.step || 'stream'; if (onBroadcast()) return G.open('golive', { step }); G.cfg.navigate(`/broadcast?guide=golive:${step}`); };
++ window.startGoLiveWizard = () => openGoLiveWizard({ step: 'stream' });
++ window.startRestreamGuide = () => { if (onBroadcast()) return G.open('golive', { step: 'restream' }); G.cfg.navigate('/broadcast?guide=golive:restream'); };
++ window.openSetupHub = (focus) => G.open('setup-hub', { ctx: { focus } });
++ window.openSiteTour = () => G.open('tour');
++ window.setupNextUp = async function (el) {
++ // The home page button: names the next task for signed-in streamers, offers the tour otherwise.
++ if (!el) return;
++ if (!me()) { el.innerHTML = '<i class="fa-solid fa-wand-magic-sparkles"></i> See what you can do here'; el.onclick = (e) => { e.preventDefault(); openSiteTour(); }; return; }
++ const p = await loadProgress();
++ if (!p) return;
++ const pct = Math.round((p.done / p.total) * 100);
++ const ring = `<span class="ovg-mini-ring" style="--pct:${pct}"></span>`;
++ el.innerHTML = p.next ? `${ring} Next up: ${esc(p.next.title)} <small>${p.done}/${p.total}</small>` : `${ring} Streamer setup complete <small>${p.done}/${p.total}</small>`;
++ el.onclick = (e) => { e.preventDefault(); openSetupHub(p.next ? p.next.id : null); };
++ el.classList.add('is-ready');
++ };
++ // First visit to Go Live with no slots → the go-live journey opens itself (until dismissed once).
++ (function autoFirstTime() {
++ const tryIt = async () => {
++ if (!onBroadcast() || G.state.open) return;
++ if (new URLSearchParams(location.search).get('guide')) return;
++ if (G.progress('golive').dismissed || G.progress('golive').finished) return;
++ for (let i = 0; i < 40 && !me(); i++) await new Promise(r => setTimeout(r, 250)); if (!me()) return;
++ await loadSlots(); if (!D.slots.length) G.open('golive', { step: 'stream' });
++ };
++ const _push = history.pushState; history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(tryIt, 400); return r; };
++ setTimeout(tryIt, 800);
++ })();
++})();
ddiff --git a/public/js/home-tour.js b/public/js/home-tour.js
iindex aa9aaabe..51837275 100644
---- a/public/js/home-tour.js
++++ b/public/js/home-tour.js
@@@ -63,10 +63,13 @@
<div class="tour-features">${FEATURES.map(([i, t], k) => `<span class="tour-feat" style="--i:${k}"><i class="${i.startsWith('fa-brands') ? i : 'fa-solid ' + i}"></i> ${t}</span>`).join('')}</div>
<div class="tour-actions">
<a class="btn btn-primary btn-lg" ${go('/broadcast')}><i class="fa-solid fa-tower-broadcast"></i> Go live</a>
-- <a class="btn btn-outline btn-lg" href="/broadcast?setup=restream" onclick="event.preventDefault(); startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
++ <a class="btn btn-outline btn-lg tour-next-up" id="tour-next-up" href="/broadcast?guide=golive:restream" onclick="event.preventDefault(); if (typeof startRestreamGuide === 'function') startRestreamGuide();"><i class="fa-solid fa-satellite-dish"></i> Set up restreams (guided)</a>
</div>
</section>`;
wire();
++ // Signed-in streamers get a context-aware button: the next thing they haven't set up.
++ const nextUp = () => { if (typeof setupNextUp === 'function') setupNextUp(document.getElementById('tour-next-up')); };
++ setTimeout(nextUp, 900); setTimeout(nextUp, 3500);
let raf = 0;
const redraw = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(wire); };
if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));
ddiff --git a/public/js/ov-guide.js b/public/js/ov-guide.js
nnew file mode 100644
iindex 00000000..ac63222a
---- /dev/null
++++ b/public/js/ov-guide.js
@@@ -0,0 +1,238 @@
++/**
++ * ov-guide.js — OpenVibe's shared guidance engine. One shell, many journeys.
++ *
++ * OVGuide.register(id, journey) journey = { title, kicker, icon, steps: [step…], onFinish }
++ * OVGuide.open(id, { step, ctx }) open a journey (optionally at a step), OVGuide.close(finished)
++ * OVGuide.next() / back() / goTo(stepId)
++ * OVGuide.spotlight({ target, title, text, actions, step, total }) point at a real element on the page
++ * OVGuide.progress(id) { seen, finished, at } remembered per browser
++ * OVGuide.configure({ api, toast, me, navigate }) host bindings (Live sets these)
++ *
++ * A step: { id, title, icon, kicker?, optional?, hidden?(ctx),
++ * render(ctx) → html | Promise<html> the panel body (ctx.ui helpers available)
++ * mount?(el, ctx) wire events after render
++ * validate?(ctx) → true | string gate "Next" (string = error toast)
++ * next?: stepId | (ctx)=>stepId where Next goes (default: following step)
++ * footer?(ctx) → html | null custom footer; null = default Back/Next
++ * nextLabel?, backLabel?, skipLabel? button copy }
++ *
++ * The shell: a centered card on desktop, a bottom sheet on phones; progress dots you can click to go
++ * back to any step you have visited; animated step transitions; Escape/scrim to close. Journeys
++ * can chain (a step's action can OVGuide.open another journey). Everything is host-agnostic: the
++ * engine only needs api()/toast()/me()/navigate() which the host page provides.
++ */
++(function () {
++ 'use strict';
++ if (window.OVGuide) return;
++ const J = new Map();
++ const cfg = {
++ api: (p, o) => (typeof api === 'function' ? api(p, o) : Promise.reject(new Error('no api'))),
++ toast: (m, t) => { try { toast(m, t || 'info'); } catch { /* */ } },
++ me: () => { try { return (typeof currentUser !== 'undefined' && currentUser) || null; } catch { return null; } },
++ navigate: (p) => { if (typeof navigate === 'function') navigate(p); else location.href = p; },
++ esc: (s) => (typeof esc === 'function' ? esc(s) : String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]))),
++ loginHref: '/api/auth/sso/login',
++ };
++ const S = { open: false, journey: null, step: null, visited: [], ctx: null, dir: 1, spot: null };
++ const $ = (sel, root) => (root || document).querySelector(sel);
++ const store = (k, v) => { try { if (v === undefined) return JSON.parse(localStorage.getItem('ovg:' + k) || 'null'); localStorage.setItem('ovg:' + k, JSON.stringify(v)); } catch { return null; } };
++
++ // ── Shell ──────────────────────────────────────────────────
++ function shell() {
++ let root = $('#ovg');
++ if (root) return root;
++ root = document.createElement('div'); root.id = 'ovg'; root.className = 'ovg';
++ root.innerHTML = `<div class="ovg-scrim"></div>
++ <div class="ovg-card" role="dialog" aria-modal="true">
++ <button type="button" class="ovg-close" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
++ <div class="ovg-head"><span class="ov-mark" data-size="30"></span><div><div class="ovg-kicker" id="ovg-kicker"></div><div class="ovg-title" id="ovg-title"></div></div></div>
++ <ol class="ovg-steps" id="ovg-steps"></ol>
++ <div class="ovg-body"><div class="ovg-panel" id="ovg-panel"></div></div>
++ <div class="ovg-foot" id="ovg-foot"></div>
++ </div>`;
++ document.body.appendChild(root);
++ $('.ovg-scrim', root).addEventListener('click', () => close(false));
++ $('.ovg-close', root).addEventListener('click', () => close(false));
++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && S.open) close(false); });
++ try { if (typeof ovMarkMount === 'function') ovMarkMount(root); } catch { /* */ }
++ return root;
++ }
++ const visibleSteps = () => (S.journey.steps || []).filter(st => !(typeof st.hidden === 'function' && st.hidden(S.ctx)));
++ function renderSteps() {
++ const steps = visibleSteps(); const idx = Math.max(0, steps.findIndex(st => st.id === S.step.id));
++ const ol = $('#ovg-steps');
++ if (steps.length < 2 || S.journey.noStepBar) { ol.innerHTML = ''; ol.style.display = 'none'; return; }
++ ol.style.display = '';
++ ol.style.setProperty('--n', steps.length);
++ ol.innerHTML = steps.map((st, i) => {
++ const state = i < idx ? 'done' : i === idx ? 'now' : '';
++ const canJump = i < idx || S.visited.includes(st.id);
++ return `<li class="${state} ${canJump ? 'can' : ''}" data-step="${cfg.esc(st.id)}" title="${canJump ? 'Go back to this step' : ''}"><span class="ovg-step-dot"><i class="fa-solid ${i < idx ? 'fa-check' : (st.icon || 'fa-circle')}"></i></span><span class="ovg-step-label">${cfg.esc(st.title)}</span></li>`;
++ }).join('');
++ ol.style.setProperty('--p', `${steps.length > 1 ? (idx / (steps.length - 1)) * 100 : 0}%`);
++ ol.querySelectorAll('li.can').forEach(li => li.addEventListener('click', () => goTo(li.dataset.step, -1)));
++ }
++ function defaultFooter(step) {
++ const steps = visibleSteps(); const idx = steps.findIndex(st => st.id === step.id);
++ const first = idx <= 0, last = idx === steps.length - 1;
++ return `${!first ? `<button type="button" class="btn btn-outline" data-act="back"><i class="fa-solid fa-arrow-left"></i> ${cfg.esc(step.backLabel || 'Back')}</button>` : '<span></span>'}
++ <span class="ovg-foot-note">${steps.length > 1 ? `Step ${idx + 1} of ${steps.length}` : ''}</span>
++ <span class="ovg-foot-right">${step.optional && !last ? `<button type="button" class="btn btn-outline" data-act="skip">${cfg.esc(step.skipLabel || 'Skip')}</button>` : ''}
++ <button type="button" class="btn btn-primary btn-lg" data-act="next">${cfg.esc(step.nextLabel || (last ? 'Finish' : 'Next'))} <i class="fa-solid ${last ? 'fa-check' : 'fa-arrow-right'}"></i></button></span>`;
++ }
++ async function show(step, dir) {
++ S.step = step; S.dir = dir || 1;
++ if (!S.visited.includes(step.id)) S.visited.push(step.id);
++ const panel = $('#ovg-panel');
++ panel.classList.remove('in-l', 'in-r'); void panel.offsetWidth;
++ $('#ovg-kicker').textContent = step.kicker || S.journey.kicker || '';
++ $('#ovg-title').textContent = typeof step.title === 'function' ? step.title(S.ctx) : (step.heading || step.title);
++ renderSteps();
++ panel.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
++ let html = '';
++ try { html = await step.render(S.ctx); } catch (e) { html = `<div class="ovg-error"><i class="fa-solid fa-triangle-exclamation"></i> ${cfg.esc((e && e.message) || 'Something went wrong')}</div>`; }
++ if (S.step !== step) return; // user moved on while we loaded
++ panel.innerHTML = html;
++ const f = typeof step.footer === 'function' ? step.footer(S.ctx) : undefined;
++ $('#ovg-foot').innerHTML = f == null ? defaultFooter(step) : f;
++ $('#ovg-foot').querySelectorAll('[data-act]').forEach(b => b.addEventListener('click', () => ({ back, next, skip })[b.dataset.act]()));
++ try { if (typeof step.mount === 'function') step.mount(panel, S.ctx); } catch (e) { console.warn('[OVGuide] mount:', e.message); }
++ try { if (typeof ovMarkMount === 'function') ovMarkMount(panel); } catch { /* */ }
++ panel.classList.add(S.dir > 0 ? 'in-r' : 'in-l');
++ $('.ovg-body').scrollTop = 0;
++ store(S.journey.id, { ...(store(S.journey.id) || {}), seen: true, at: step.id, when: Date.now() });
++ }
++ function goTo(stepId, dir) { const st = (S.journey.steps || []).find(x => x.id === stepId); if (st) show(st, dir); }
++ async function next() {
++ const step = S.step; if (!step) return;
++ if (typeof step.validate === 'function') { const v = await step.validate(S.ctx); if (v !== true && v !== undefined) { if (typeof v === 'string') cfg.toast(v, 'error'); return; } }
++ const steps = visibleSteps(); const idx = steps.findIndex(st => st.id === step.id);
++ let target = typeof step.next === 'function' ? step.next(S.ctx) : step.next;
++ if (target === 'close') return close(true);
++ if (!target) target = steps[idx + 1] ? steps[idx + 1].id : null;
++ if (!target) return close(true);
++ goTo(target, 1);
++ }
++ function skip() { const steps = visibleSteps(); const idx = steps.findIndex(st => st.id === S.step.id); if (steps[idx + 1]) goTo(steps[idx + 1].id, 1); else close(true); }
++ function back() { const steps = visibleSteps(); const idx = steps.findIndex(st => st.id === S.step.id); if (idx > 0) goTo(steps[idx - 1].id, -1); }
++
++ async function open(id, opts = {}) {
++ const journey = J.get(id);
++ if (!journey) { console.warn('[OVGuide] unknown journey', id); return; }
++ if (journey.requiresUser !== false && !cfg.me()) {
++ for (let i = 0; i < 24 && !cfg.me(); i++) await new Promise(r => setTimeout(r, 250));
++ if (!cfg.me()) { store('pending', { id, opts, when: Date.now() }); location.href = cfg.loginHref; return; }
++ }
++ closeSpot();
++ const root = shell();
++ S.open = true; S.journey = journey; S.visited = []; S.ctx = { ...(opts.ctx || {}), journey: id, ui, guide: window.OVGuide };
++ root.classList.add('is-open'); document.body.classList.add('ovg-lock');
++ $('.ovg-card', root).setAttribute('aria-label', journey.title || 'Guide');
++ try { if (typeof journey.onOpen === 'function') await journey.onOpen(S.ctx, opts); } catch (e) { console.warn('[OVGuide] onOpen:', e.message); }
++ const steps = visibleSteps();
++ const start = (opts.step && steps.find(st => st.id === opts.step)) || steps[0];
++ if (!start) return close(false);
++ // Steps before the requested one count as visited so the dots let you go back to them.
++ S.visited = steps.slice(0, steps.indexOf(start)).map(st => st.id);
++ show(start, 1);
++ }
++ function close(finished) {
++ const root = $('#ovg'); if (!root || !S.open) return;
++ S.open = false; root.classList.remove('is-open'); document.body.classList.remove('ovg-lock');
++ const j = S.journey;
++ if (j) { store(j.id, { ...(store(j.id) || {}), seen: true, finished: !!finished || !!(store(j.id) || {}).finished, dismissed: !finished, when: Date.now() }); try { if (typeof j.onClose === 'function') j.onClose(S.ctx, !!finished); } catch { /* */ } }
++ S.journey = null; S.step = null;
++ }
++
++ // ── Spotlight: point at something real on the page ─────────
++ let raf = 0;
++ function spotlight({ target, title, text, actions = [], step, total, placement }) {
++ closeSpot();
++ const el = typeof target === 'string' ? $(target) : target;
++ const root = document.createElement('div'); root.className = 'ovg-spot';
++ root.innerHTML = `<div class="ovg-spot-hole"></div><div class="ovg-spot-card" role="dialog"><div class="ovg-spot-step">${step && total ? `Step ${step} of ${total}` : ''}</div><h4>${cfg.esc(title || '')}</h4><p>${text || ''}</p><div class="ovg-spot-actions"></div></div>`;
++ document.body.appendChild(root);
++ const acts = $('.ovg-spot-actions', root);
++ (actions.length ? actions : [{ label: 'Got it', primary: true }]).forEach(a => { const b = document.createElement('button'); b.type = 'button'; b.className = `btn btn-sm ${a.primary ? 'btn-primary' : 'btn-outline'}`; b.innerHTML = a.label; b.onclick = () => { const r = a.onClick ? a.onClick() : null; if (r !== false) closeSpot(); }; acts.appendChild(b); });
++ S.spot = { root, el };
++ const place = () => {
++ if (!S.spot) return;
++ const hole = $('.ovg-spot-hole', root), card = $('.ovg-spot-card', root);
++ if (!el || !el.isConnected) { hole.style.opacity = '0'; card.style.top = '50%'; card.style.left = '50%'; card.style.transform = 'translate(-50%,-50%)'; }
++ else {
++ const r = el.getBoundingClientRect();
++ hole.style.opacity = '1'; hole.style.left = `${r.left - 8}px`; hole.style.top = `${r.top - 8}px`; hole.style.width = `${r.width + 16}px`; hole.style.height = `${r.height + 16}px`;
++ const cw = Math.min(380, window.innerWidth - 24), ch = card.offsetHeight || 160;
++ let top = r.bottom + 16, left = Math.max(12, Math.min(r.left, window.innerWidth - cw - 12));
++ if (placement === 'top' || top + ch > window.innerHeight - 12) top = Math.max(12, r.top - ch - 16);
++ card.style.transform = 'none'; card.style.top = `${top}px`; card.style.left = `${left}px`; card.style.width = `${cw}px`;
++ }
++ raf = requestAnimationFrame(place);
++ };
++ if (el && el.scrollIntoView) { try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch { /* */ } }
++ raf = requestAnimationFrame(place);
++ requestAnimationFrame(() => root.classList.add('is-on'));
++ return { close: closeSpot };
++ }
++ function closeSpot() { if (!S.spot) return; cancelAnimationFrame(raf); try { S.spot.root.remove(); } catch { /* */ } S.spot = null; }
++
++ // ── Small UI helpers journeys can use inside render() ──────
++ const ui = {
++ esc: (s) => cfg.esc(s),
++ lead: (html) => `<p class="ovg-lead">${html}</p>`,
++ grid: (items, cols) => `<div class="ovg-grid ${cols === 3 ? 'ovg-grid--3' : ''}">${items.join('')}</div>`,
++ pick: ({ id, icon, title, sub, tag, on, color, attrs }) => `<button type="button" class="ovg-pick ${on ? 'on' : ''}" data-pick="${cfg.esc(id)}" ${color ? `style="--c:${cfg.esc(color)}"` : ''} ${attrs || ''}><span class="ovg-pick-ico"><i class="${cfg.esc(icon)}"></i></span><span class="ovg-pick-body"><b>${title}${tag ? ` <em class="ovg-tag">${cfg.esc(tag)}</em>` : ''}</b>${sub ? `<small>${sub}</small>` : ''}</span></button>`,
++ box: (head, body, icon) => `<div class="ovg-box"><div class="ovg-box-head"><i class="fa-solid ${icon || 'fa-circle-info'}"></i> ${head}</div>${body}</div>`,
++ kv: (label, value, copy) => `<div class="ovg-kv"><span>${cfg.esc(label)}</span><code>${cfg.esc(value)}</code>${copy ? `<button type="button" class="ovg-copy" data-copy="${cfg.esc(value)}"><i class="fa-regular fa-copy"></i> Copy</button>` : ''}</div>`,
++ secret: (label, value) => `<div class="ovg-kv"><span>${cfg.esc(label)}</span><code class="ovg-secret" data-secret="${cfg.esc(value)}">••••••••••••••••</code><button type="button" class="ovg-copy" data-copy="${cfg.esc(value)}"><i class="fa-regular fa-copy"></i> Copy</button><button type="button" class="ovg-copy" data-reveal="1"><i class="fa-regular fa-eye"></i> Show</button></div>`,
++ steps: (items) => `<ol class="ovg-obs">${items.map(i => `<li>${i}</li>`).join('')}</ol>`,
++ chips: (items, name) => `<div class="ovg-chips" data-chips="${cfg.esc(name || '')}">${items.map(([v, l, i]) => `<button type="button" class="ovg-chip" data-chip="${cfg.esc(v)}">${i ? `<i class="fa-solid ${i}"></i> ` : ''}${cfg.esc(l)}</button>`).join('')}</div>`,
++ field: (label, inputHtml) => `<label class="ovg-field">${label}${inputHtml}</label>`,
++ wire(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 { cfg.toast('Copy failed — long-press to copy', 'error'); } }));
++ root.querySelectorAll('[data-reveal]').forEach(b => b.addEventListener('click', () => { const c = b.parentElement.querySelector('.ovg-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'; }));
++ root.querySelectorAll('[data-chips]').forEach(g => g.addEventListener('click', (e) => { const c = e.target.closest('[data-chip]'); if (!c) return; g.querySelectorAll('.ovg-chip').forEach(x => x.classList.remove('on')); c.classList.add('on'); }));
++ },
++ picked: (root, name) => { const c = root.querySelector(`[data-chips="${name}"] .ovg-chip.on`); return c ? c.dataset.chip : null; },
++ confetti() {
++ try { const c = document.createElement('div'); c.className = 'ovg-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 { /* */ }
++ },
++ upload: async (path, fields, file, fileField) => {
++ const fd = new FormData(); Object.entries(fields || {}).forEach(([k, v]) => { if (v != null) fd.append(k, v); }); if (file) fd.append(fileField || 'file', file);
++ let tok = null; try { tok = localStorage.getItem('token'); } catch { /* */ }
++ const res = await fetch(`/api${path}`, { method: 'POST', body: fd, credentials: 'same-origin', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
++ let data = {}; try { data = await res.json(); } catch { /* */ }
++ if (!res.ok) throw new Error(data.error || `Upload failed (${res.status})`);
++ return data;
++ },
++ };
++
++ // Resume a journey that was interrupted by sign-in.
++ function resumePending() {
++ const p = store('pending'); if (!p || !p.id) return;
++ if (Date.now() - (p.when || 0) > 30 * 60000) { try { localStorage.removeItem('ovg:pending'); } catch { /* */ } return; }
++ (async () => { for (let i = 0; i < 40 && !cfg.me(); i++) await new Promise(r => setTimeout(r, 250)); if (!cfg.me()) return; try { localStorage.removeItem('ovg:pending'); } catch { /* */ } open(p.id, p.opts || {}); })();
++ }
++ // ?guide=<journey>[:step] opens a journey on any page.
++ function fromUrl() {
++ let q = null; try { q = new URLSearchParams(location.search).get('guide'); } catch { /* */ }
++ if (!q) return false;
++ const [id, step] = q.split(':');
++ try { history.replaceState(null, '', location.pathname + location.hash); } catch { /* */ }
++ setTimeout(() => open(id, { step }), 400);
++ return true;
++ }
++ const _push = history.pushState;
++ history.pushState = function () { const r = _push.apply(this, arguments); setTimeout(() => { if (!S.open) fromUrl(); }, 60); return r; };
++ function boot() { if (!fromUrl()) resumePending(); }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 300)); else setTimeout(boot, 300);
++
++ window.OVGuide = {
++ register: (id, journey) => { J.set(id, { ...journey, id }); },
++ has: (id) => J.has(id), list: () => [...J.keys()],
++ open, close, next, back, skip, goTo, spotlight, closeSpotlight: closeSpot,
++ progress: (id) => store(id) || {}, forget: (id) => { try { localStorage.removeItem('ovg:' + id); } catch { /* */ } },
++ configure: (o) => Object.assign(cfg, o || {}),
++ ui, state: S, cfg,
++ };
++})();
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 327543f6..f3671a83 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1451,6 +1451,46 @@ router.get('/managed/:managedStreamId/profile', requireAuth, (req, res) => {
});
// List own managed streams
++// ── Setup progress: everything a streamer can set up, and what they have done ─────────────
++// Drives the Go Live setup hub and the home page's "next up" button. Every check is wrapped so
++// a missing table or column just reads as "not done" instead of breaking the hub.
++router.get('/setup-progress', requireAuth, async (req, res) => {
++ const uid = req.user.id;
++ const safe = (fn, d) => { try { const v = fn(); return v == null ? d : v; } catch { return d; } };
++ const slots = safe(() => db.getManagedStreamsByUserId(uid), []);
++ const sessions = safe(() => db.get('SELECT COUNT(*) AS n FROM streams WHERE user_id = ?', [uid]).n, 0);
++ const restreams = safe(() => db.getRestreamDestinationsByUserId(uid).length, 0);
++ const rs = slots.some(sl => safe(() => { const r = db.getRobotStreamerIntegrationBySlot(uid, sl.id); return !!(r && (r.robot_id || r.stream_name)); }, false));
++ const user = safe(() => db.getUserById(uid), {}) || {};
++ const channel = safe(() => db.getChannelByUserId(uid), {}) || {};
++ const emotes = safe(() => db.get('SELECT COUNT(*) AS n FROM emotes WHERE user_id = ?', [uid]).n, 0);
++ const sounds = safe(() => db.get('SELECT COUNT(*) AS n FROM channel_sounds WHERE channel_owner_id = ?', [uid]).n, 0);
++ const goals = safe(() => db.get('SELECT COUNT(*) AS n FROM donation_goals WHERE user_id = ? AND is_active = 1', [uid]).n, 0);
++ const powerchat = safe(() => !!db.get('SELECT 1 FROM powerchat_connections WHERE user_id = ? LIMIT 1', [uid]), false);
++ const followers = safe(() => db.getFollowerCount(uid), 0);
++ let panels = 0; try { const p = channel.panels ? JSON.parse(channel.panels) : []; panels = Array.isArray(p) ? p.length : 0; } catch { panels = 0; }
++ const offline = !!(channel.offline_screen_type && channel.offline_screen_type !== 'none');
++ const methodSet = slots.some(sl => !!sl.streaming_method);
++ const tasks = [
++ { id: 'slot', group: 'Stream', title: 'Create your stream slot', why: 'Your show gets its own key, settings, VODs and restreams.', done: slots.length > 0, count: slots.length },
++ { id: 'method', group: 'Stream', title: 'Choose how you stream', why: 'Browser, OBS over RTMP, or OBS over WHIP.', done: methodSet },
++ { id: 'restream', group: 'Stream', title: 'Mirror to another platform', why: 'Twitch, YouTube, Kick, RobotStreamer or any RTMP — all at once.', done: restreams > 0 || rs, count: restreams + (rs ? 1 : 0) },
++ { id: 'golive', group: 'Stream', title: 'Go live for the first time', why: 'Nothing teaches like the first ten minutes.', done: sessions > 0, count: sessions },
++ { id: 'profile', group: 'Look & feel', title: 'Avatar and bio', why: 'First thing people see on your channel and in chat.', done: !!(user.avatar_url && String(user.bio || '').trim()) },
++ { id: 'offline', group: 'Look & feel', title: 'Offline screen', why: 'What visitors see when you are not live — image, video or your own HTML.', done: offline },
++ { id: 'emote', group: 'Look & feel', title: 'Upload a custom emote', why: 'Your chat, your inside jokes. Animated works too.', done: emotes > 0, count: emotes },
++ { id: 'sound', group: 'Look & feel', title: 'Add a sound command', why: 'Viewers type !boom and your stream plays it.', done: sounds > 0, count: sounds },
++ { id: 'goal', group: 'Community & money', title: 'Set a donation goal', why: 'A visible target turns tips into a team effort.', done: goals > 0, count: goals },
++ { id: 'powerchat', group: 'Community & money', title: 'Connect PowerChat for real tips', why: 'Card and crypto tips with on-stream alerts.', done: powerchat },
++ { id: 'panels', group: 'Community & money', title: 'Fill in your About panels', why: 'Links, schedule, rules — the stuff under the player.', done: panels > 0, count: panels },
++ { id: 'share', group: 'Grow', title: 'Get your first follower', why: 'Share your channel link; followers get pinged when you go live.', done: followers > 0, count: followers },
++ ];
++ const done = tasks.filter(t => t.done).length;
++ const next = tasks.find(t => !t.done) || null;
++ res.set('Cache-Control', 'private, no-store');
++ res.json({ tasks, done, total: tasks.length, next, username: user.username });
++});
++
router.get('/managed', requireAuth, (req, res) => {
try {
const managed = db.getManagedStreamsByUserId(req.user.id);