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
Changes
ddeleted file mode 100644@@@ -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; } }nnew file mode 100644@@@ -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; } }@@@ -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>ddeleted file mode 100644@@@ -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 & 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);--})();nnew file mode 100644@@@ -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 & 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);++ })();++})();@@@ -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'));nnew file mode 100644@@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,++ };++})();@@@ -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);
deleted file mode 100644
deleted file mode 100644
@@ -1,102 +0,0 @@
@@ -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; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -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; } }
@@ -69,7 +69,7 @@
@@ -69,7 +69,7 @@
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/messenger.css?v=1">
<link rel="stylesheet" href="/css/home-fx.css?v=6">
<link rel="stylesheet" href="/css/home-fx.css?v=6">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
<link rel="stylesheet" href="/css/home-tour.css?v=2">
- <link rel="stylesheet" href="/css/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/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/recap.css?v=1">
<link rel="stylesheet" href="/css/context-cards.css?v=3">
<link rel="stylesheet" href="/css/context-cards.css?v=3">
@@ -205,6 +205,7 @@
@@ -205,6 +205,7 @@
<a href="#" onclick="event.preventDefault();captureScreenshot()"><i class="fa-solid fa-camera"></i> Screenshot</a>
<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>
<a href="/documentation" onclick="return handleLinkClick(event, '/documentation')"><i class="fa-solid fa-book"></i> Documentation</a>
<hr>
<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();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();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>
<a href="#" onclick="event.preventDefault();logout()"><i class="fa-solid fa-right-from-bracket"></i> Logout</a>
@@ -2031,7 +2032,7 @@
@@ -2031,7 +2032,7 @@
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<!-- ═══ STEP 0: Stream Manager (existing streams + create new) ═══ -->
<div id="bc-stream-manager" class="broadcast-tab-content active" data-tab="go-live">
<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>
<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 -->
<!-- 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
@@ -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/messenger.js?v=2" defer></script>
<script src="/js/ov-mark.js?v=1" 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-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) ── -->
<!-- ── OpenVibe shared libs (notifications, account sync, themes) ── -->
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/theme-loader.js" defer></script>
deleted file mode 100644
deleted file mode 100644
@@ -1,356 +0,0 @@
@@ -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 & 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);
-})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,271 @@
@@ -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 & 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);
+ })();
+})();
@@ -63,10 +63,13 @@
@@ -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-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">
<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-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>
</div>
</section>`;
</section>`;
wire();
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;
let raf = 0;
const redraw = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(wire); };
const redraw = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(wire); };
if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));
if ('ResizeObserver' in window) new ResizeObserver(redraw).observe(el.querySelector('#tour-stage'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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,
+ };
+})();
@@ -1451,6 +1451,46 @@ router.get('/managed/:managedStreamId/profile', requireAuth, (req, res) => {
@@ -1451,6 +1451,46 @@ router.get('/managed/:managedStreamId/profile', requireAuth, (req, res) => {
});
});
// List own managed streams
// 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) => {
router.get('/managed', requireAuth, (req, res) => {
try {
try {
const managed = db.getManagedStreamsByUserId(req.user.id);
const managed = db.getManagedStreamsByUserId(req.user.id);