openvibers/openvibe.live Commits
Going live hands over the link (a card with Copy, the channel's chat says openvibe.live/@name, the studio's address is /@name/studio), DMs in the navbar instead of a bubble, the bar clear at the top on every page; openvibe-shared v4.25.0 (#423)
Changes
@@@ -25,7 +25,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.25.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2688,9 +2688,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.23.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",-- "integrity": "sha512-oLcUXmHOOC2CCpAuwCN95TIoadukLG5lC6YHPhPbYKcS/rZh0iwLbEA0aAzYHjalKGrqu9mw896LcFc3cd5xWg==",++ "version": "4.25.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.25.0",++ "integrity": "sha512-f9YDJbdjJ5OEcbTmQX26miVI7DsuCpDMheDKZPKESZoevf84zYpgnYI8nRpp2H2YYa4kJCN6YrIKXi+cqTGabQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -44,7 +44,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.25.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -3068,3 +3068,43 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }@media (max-width: 768px) {#bc-info-bar #bc-rs-viewer-count, #bc-info-bar #bc-platform-viewer-badges { display: none !important; }}++++/* The go-live card (broadcast-studio.js celebrate; the owner, 2026-10-28: streamers copied the studio's address to++ share their stream): you're live, and the link to share, one tap to copy. Over the studio on a phone, at the top on a++ computer; it goes by itself after 20 seconds. */++.bcs-livecard { position: fixed; z-index: 1600; top: calc(64px + env(safe-area-inset-top, 0px)); left: 50%; width: min(420px, calc(100vw - 24px)); box-sizing: border-box; padding: 14px 14px 14px 16px; border-radius: 18px;++ background: color-mix(in srgb, var(--bg-secondary, #0f1626) 92%, transparent); -webkit-backdrop-filter: blur(16px) saturate(1.4); backdrop-filter: blur(16px) saturate(1.4);++ border: 1px solid color-mix(in srgb, var(--live-red, #ef4444) 42%, var(--border, #1f2d47)); box-shadow: 0 24px 60px rgba(0,0,0,.55); color: var(--text-primary); overflow: hidden;++ transform: translateX(-50%); animation: bcs-gl-in .45s cubic-bezier(.2,1.3,.3,1) both; }++.bcs-livecard.is-out { animation: bcs-gl-out .25s ease-in forwards; }++@keyframes bcs-gl-in { from { opacity: 0; transform: translate(-50%, -14px) scale(.94); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }++@keyframes bcs-gl-out { to { opacity: 0; transform: translate(-50%, -10px) scale(.97); } }++.bcs-gl-head { display: flex; align-items: center; gap: 10px; }++.bcs-gl-head b { flex: 1; font-size: 1.15rem; font-weight: 800; letter-spacing: -.2px; }++.bcs-gl-dot { position: relative; flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--live-red, #ef4444); }++.bcs-gl-dot::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid var(--live-red, #ef4444); animation: bcs-gl-ping 1.4s ease-out infinite; }++@keyframes bcs-gl-ping { from { transform: scale(.5); opacity: .9; } to { transform: scale(1.6); opacity: 0; } }++.bcs-gl-x { all: unset; box-sizing: border-box; cursor: pointer; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary); }++.bcs-gl-x:hover, .bcs-gl-x:focus-visible { background: rgba(255,255,255,.08); color: var(--text-primary); }++.bcs-gl-sub { margin: 6px 0 10px; font-size: .92rem; color: var(--text-secondary); }++.bcs-gl-link { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; border-radius: 14px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); }++.bcs-gl-link code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 1rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-primary); background: none; user-select: all; -webkit-user-select: all; }++.bcs-gl-copy, .bcs-gl-share { all: unset; box-sizing: border-box; cursor: pointer; flex: none; display: inline-flex; align-items: center; gap: 4px; height: 42px; padding: 0 16px; border-radius: 11px; font-weight: 700; font-size: .95rem;++ background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); transition: transform .15s, background .2s; }++.bcs-gl-copy:active, .bcs-gl-share:active { transform: scale(.96); }++.bcs-gl-copy.is-copied, .bc-ctrl-btn.is-copied { background: var(--success, #16a34a); color: #fff; }++.bcs-gl-copy:focus-visible, .bcs-gl-share:focus-visible { outline: 2px solid var(--accent-light, #60a5fa); outline-offset: 2px; }++.bcs-gl-more { display: flex; justify-content: flex-end; margin-top: 8px; }++.bcs-gl-share { height: 34px; background: rgba(255,255,255,.08); color: var(--text-primary); }++.bcs-gl-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,.06); }++.bcs-gl-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--live-red, #ef4444), var(--accent, #3b82f6)); transform-origin: left; transition: transform .2s linear; }++.bcs-gl-burst { position: absolute; left: 22px; top: 21px; width: 0; height: 0; pointer-events: none; }++.bcs-gl-burst i { position: absolute; width: 5px; height: 5px; margin: -2.5px; border-radius: 50%; background: hsl(var(--a), 90%, 66%); opacity: 0; animation: bcs-gl-spark .9s cubic-bezier(.2,.8,.3,1) var(--d) forwards; }++@keyframes bcs-gl-spark { 0% { opacity: 1; transform: rotate(var(--a)) translateX(0); } 100% { opacity: 0; transform: rotate(var(--a)) translateX(48px) scale(.4); } }++@media (prefers-reduced-motion: reduce) { .bcs-livecard, .bcs-gl-dot::after, .bcs-gl-burst i { animation: none !important; } .bcs-gl-burst { display: none; } }++/* A phone: the link on its own line, whole, and Copy the width of the card (an easy tap while holding the phone). */++@media (max-width: 480px) {++ .bcs-livecard .bcs-gl-link { flex-wrap: wrap; padding: 10px; gap: 10px; }++ .bcs-livecard .bcs-gl-link code { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; font-size: 1.05rem; }++ .bcs-livecard .bcs-gl-copy { flex: 1; justify-content: center; height: 46px; }++}@@@ -922,3 +922,8 @@background: var(--bg-tertiary);color: var(--text-primary);}++++/* Opened from the navbar's messages button (Shared 4.25): under the bar on the right; on a phone the whole screen. */++.messenger-panel.from-bar { top: calc(var(--navbar-h, 56px) + 8px); right: 12px; left: auto; bottom: auto; max-height: calc(100vh - var(--navbar-h, 56px) - 24px); max-height: calc(100dvh - var(--navbar-h, 56px) - 24px); }++@media (max-width: 480px) { .messenger-panel.from-bar { top: 0; right: 0; bottom: 0; left: 0; max-height: none; height: 100%; height: 100dvh; } }++@media (max-width: 768px) and (min-width: 481px) { .messenger-panel.from-bar { right: 8px; } }@@@ -9943,9 +9943,10 @@ body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transfbackdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);--ovnav-h: var(--navbar-h);}--/* Over the home hero at the very top (body.ov-nav-overlay from app.js, .ovnav-top from the shared navbar): no background-- at all, the hero shows through under the navbar's own soft scrim. */--body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top { background: transparent; border-bottom-color: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }++/* At the very top of any page (.ovnav-top from the shared navbar) the bar has no background, as on every other site, and++ the frosted bar comes back as you scroll (the owner, 2026-10-28: the effect "doesn't work on mobile"). Over the home++ hero (body.ov-nav-overlay from app.js) the hero shows through under the navbar's own soft scrim. */++.openvibe-navbar.navbar.ovnav-top { background: transparent; border-bottom-color: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link { font-size: 0.86rem; padding: 7px 11px; border-radius: 10px; }.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }.openvibe-navbar.navbar .openvibe-navbar-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; }@@@ -724,6 +724,9 @@ WantedBy=multi-user.target</code><button class="bc-ctrl-btn" id="bc-btn-toggle-preview" onclick="toggleBroadcastPreview()" title="Hide your own preview (you stay live; it saves battery)"><i class="fa-solid fa-eye-slash"></i> Hide preview</button>++ <button class="bc-ctrl-btn" onclick="window.bcStudio && bcStudio.copyLink(this)" title="Copy your stream's link (openvibe.live/@you): it always opens your stream">++ <i class="fa-solid fa-link"></i><span> Copy link</span>++ </button><button class="bc-ctrl-btn" onclick="openViewerPreview()" title="Open your channel the way viewers see it"><i class="fa-solid fa-tv"></i> Watch as viewer</button>@@@ -945,6 +945,17 @@ function _ovHoldsMedia() {/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);++/**++ * The studio shows its address as /@name/studio, not /broadcast (the owner, 2026-10-28: streamers copied the address++ * bar to share their stream, and /broadcast opened nobody's). Copied and shared, it opens the channel for everyone++ * else; for its owner, a reload or a phone bringing the tab back opens the studio again. A slot's own path++ * (/broadcast/<slot>) stays as it is.++ */++function showStudioAddress() {++ if (location.pathname !== '/broadcast' || !currentUser || !currentUser.username) return;++ try { history.replaceState(history.state, '', `/@${encodeURIComponent(currentUser.username)}/studio${location.search}${location.hash}`); } catch { /* the address stays /broadcast */ }++}++/** A full address on Live itself (the shared navbar's "My channel" is https://openvibe.live/@name) as its path. */function sameSitePath(url) {if (!/^https?:\/\//i.test(String(url || ''))) return url;@@@ -1123,7 +1134,7 @@ function routeFromURL() {} else if (segments[0] === 'broadcast') {// Show the shell first so the route feels instant, then fill it once its bundle is in.showPage('broadcast');-- whenRouteReady('broadcast', () => { if (typeof loadBroadcastPage === 'function') loadBroadcastPage(); });++ whenRouteReady('broadcast', () => { if (typeof loadBroadcastPage === 'function') loadBroadcastPage(); showStudioAddress(); });} else if (segments[0] === 'admin') {window.location.href = `${getOpenVibeToolsUrl()}/admin`;return;@@@ -1185,6 +1196,12 @@ function routeFromURL() {// Legacy stream URL: /stream/:idshowPage('stream');whenRouteReady('stream', () => openStream(segments[1]));++ } else if (segments.length === 2 && segments[0].startsWith('@') && segments[1] === 'studio') {++ // The studio's own address (showStudioAddress): its owner's studio, and the channel for anyone it was shared with.++ const username = normalizeChannelUsername(segments[0]);++ const mine = !!(currentUser && String(currentUser.username || '').toLowerCase() === username.toLowerCase());++ navigate(mine ? `/broadcast${location.search}` : `/@${username}`, true);++ return;} else if (segments.length >= 1 && segments[0].startsWith('@')) {// Channel page: /@username or /@username/:managedStreamIdOrSlug// Backward compat: /@username?stream=sessionId@@@ -303,14 +303,76 @@Promise.resolve(createBroadcastClip()).catch(() => null).finally(() => look(false));}-- /** Copies the channel link. (The system share sheet takes a phone away from this tab, which pauses the camera.) */-- function share() {++ /** Your stream's address, the one to share: openvibe.live/@name (never a stream's slug, never /broadcast). */++ function streamLink() {const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : null;-- if (!user) return;-- const url = `${location.origin}/@${encodeURIComponent(user)}`;-- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(() => say('Link copied. Paste it anywhere to share your stream.', 'success'), () => say(url));-- else if (typeof copyToClipboard === 'function') copyToClipboard(url);-- else say(url);++ return user ? `${location.origin}/@${encodeURIComponent(user)}` : null;++ }++++ /** Copies the channel link, and says so on the button that asked. (The system share sheet takes a phone away from++ * this tab, which pauses the camera, so a phone copies.) → whether it was copied. */++ function copyLink(btn, { quiet = false } = {}) {++ const url = streamLink();++ if (!url) return Promise.resolve(false);++ const label = btn && btn.querySelector ? btn.querySelector('span') : null;++ const done = (ok) => {++ if (label) {++ const was = label.dataset.was || label.textContent;++ label.dataset.was = was;++ if (ok) { btn.classList.add('is-copied'); label.textContent = ' Copied'; setTimeout(() => { btn.classList.remove('is-copied'); label.textContent = was; }, 2200); }++ }++ if (!quiet) say(ok ? 'Link copied. Paste it anywhere to share your stream.' : url, ok ? 'success' : 'info');++ return ok;++ };++ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(url).then(() => done(true), () => done(false));++ if (typeof copyToClipboard === 'function') { copyToClipboard(url); return Promise.resolve(done(true)); }++ return Promise.resolve(done(false));++ }++ function share() { copyLink(null); }++++ /**++ * The moment a stream from this browser goes live (the owner, 2026-10-28: "I've seen them struggle to share the++ * URL… they try and copy the URL that's in the browser"): a card says you're live and hands over the link,++ * openvibe.live/@name, with one tap to copy (never the system share sheet, which takes a phone off this tab). It goes by itself after 20 seconds, a hand++ * or focus on it holds it, and it shows once per stream.++ */++ const CARD_MS = 20000;++ function celebrate(streamId) {++ const url = streamLink();++ if (!url) return false;++ try { const key = `ov-golive-card:${streamId || 'x'}`; if (streamId && sessionStorage.getItem(key)) return false; sessionStorage.setItem(key, '1'); } catch { /* still show it */ }++ document.querySelectorAll('.bcs-livecard').forEach((n) => n.remove());++ const copy = el('button', { type: 'button', class: 'bcs-gl-copy' }, icon('fa-copy'), el('span', { text: ' Copy link' }));++ const close = el('button', { type: 'button', class: 'bcs-gl-x', 'aria-label': 'Close' }, icon('fa-xmark'));++ const sub = el('p', { class: 'bcs-gl-sub', text: 'Share this link. It always opens your stream:' });++ const code = el('code', { text: url.replace(/^https?:\/\//, '') });++ const bar = el('i');++ const card = el('div', { class: 'bcs-livecard', role: 'status', 'aria-live': 'polite' },++ el('div', { class: 'bcs-gl-burst', 'aria-hidden': 'true' }, ...Array.from({ length: 10 }, (_, i) => el('i', { style: `--a:${i * 36}deg;--d:${(i % 3) * 50}ms` }))),++ el('div', { class: 'bcs-gl-head' }, el('span', { class: 'bcs-gl-dot', 'aria-hidden': 'true' }), el('b', { text: 'You\u2019re live!' }), close),++ sub,++ el('div', { class: 'bcs-gl-link' }, code, copy),++ el('div', { class: 'bcs-gl-bar', 'aria-hidden': 'true' }, bar));++ document.body.append(card);++ let left = CARD_MS, last = Date.now(), held = false;++ const end = () => { clearInterval(timer); card.classList.add('is-out'); setTimeout(() => card.remove(), 260); };++ const timer = setInterval(() => {++ const now = Date.now();++ if (!held) left -= now - last;++ last = now;++ bar.style.transform = `scaleX(${Math.max(0, left / CARD_MS)})`;++ if (left <= 0 || !card.isConnected) end();++ }, 200);++ for (const ev of ['pointerenter', 'touchstart', 'focusin']) card.addEventListener(ev, () => { held = true; }, { passive: true });++ for (const ev of ['pointerleave', 'focusout']) card.addEventListener(ev, () => { held = false; last = Date.now(); });++ close.addEventListener('click', end);++ copy.addEventListener('click', () => copyLink(copy, { quiet: true }).then((ok) => {++ if (ok) { sub.textContent = 'Copied. Paste it in a group chat, on Discord or in a post.'; left = Math.min(left, 4000); return; }++ // No clipboard here: the link is selected, so a long press or Ctrl+C copies it.++ try { const r = document.createRange(); r.selectNodeContents(code); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); } catch { /* */ }++ sub.textContent = 'Press and hold the link to copy it.';++ }));++ return true;}// ── Staying on this tab ──@@@ -805,5 +867,5 @@if (NARROW.addEventListener) NARROW.addEventListener('change', tick);ensureReturn();-- window.bcStudio = { toggleMic, toggleCamera, flip, showViewer, phone: () => NARROW.matches, open: () => { st.dismissedFor = null; tick(); }, _state: st };++ window.bcStudio = { toggleMic, toggleCamera, flip, showViewer, copyLink, celebrate, streamLink, phone: () => NARROW.matches, open: () => { st.dismissedFor = null; tick(); }, _state: st };})();@@@ -1632,7 +1632,8 @@ async function createNewStream() {startRestreamStatusPolling();// Show/hide screen share live controls_syncScreenShareLiveControls(ss);-- toast('You are now LIVE!', 'success');++ // You're live: a card hands over the link to share (broadcast-studio.js celebrate); the plain toast without it.++ if (!(window.bcStudio && typeof bcStudio.celebrate === 'function' && bcStudio.celebrate(streamData.id))) toast('You are now LIVE!', 'success');} else if (method === 'webrtc' && broadcastState.selectedWebRTCSub === 'obs') {showWHIPInstructions(streamData); startHeartbeat(streamData.id);ensureBroadcastChat(streamData.id);@@@ -228,7 +228,26 @@}// ── Inject HTML ──────────────────────────────────────────────++ // The navbar's messages button (Shared 4.25; the owner, 2026-10-28: the floating DM button was "buggy as hell on++ // mobile… app style"): the unread count goes there and it opens this panel; an older navbar keeps the bubble.++ const inBar = () => !!(window.OpenVibeNavbar && typeof window.OpenVibeNavbar.setMessages === 'function');++ function dropBubble() {++ if (!$toggle || !inBar()) return;++ $toggle.remove(); $toggle = null; $badge = null;++ if ($panel) $panel.classList.add('from-bar');++ updateBadge();++ }++function injectWidget() {++ if (inBar()) {++ $panel = document.createElement('div');++ $panel.className = 'messenger-panel from-bar';++ $panel.id = 'messenger-panel';++ document.body.appendChild($panel);++ renderInbox();++ return;++ }++ setTimeout(dropBubble, 1500); // the navbar may finish loading after this widget// Toggle button$toggle = document.createElement('button');$toggle.className = 'messenger-toggle';@@@ -867,6 +886,7 @@}function updateBadge() {++ if (inBar()) window.OpenVibeNavbar.setMessages(unreadTotal);if (!$badge) return;$badge.textContent = unreadTotal > 0 ? (unreadTotal > 99 ? '99+' : String(unreadTotal)) : '';$badge.dataset.count = unreadTotal;@@@ -1256,6 +1276,7 @@// Expose for programmatic accesswindow.messengerWidget = {open: () => { if (!panelOpen) togglePanel(); },++ toggle: () => togglePanel(),close: closePanel,openDm: window.openMessengerDm,};@@@ -82,6 +82,8 @@{ id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', valueId: 'nav-coins-amount', label: 'OpenCoins', title: 'OpenCoins: click to open your inventory', onClick: () => call('openCosmeticsModal') },{ id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', valueId: 'nav-balance-amount', label: 'Vibes', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },],++ // DMs beside the bell (Shared 4.25): the button opens the messenger panel; without it, Chat's inbox.++ messages: { onOpen: () => { if (window.messengerWidget && window.messengerWidget.toggle) window.messengerWidget.toggle(); else location.href = 'https://openvibe.chat/messages'; }, href: 'https://openvibe.chat/messages' },// Same-origin links are routes of this single-page app.onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),onLogout: () => call('logout'),@@@ -22,8 +22,8 @@ const BUDGETS = {htmlRawKB: 120, // measured 94.9htmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1150, // measured ~1000-- jsBrotliKB: 272, // measured 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)++ jsRawKB: 1165, // measured 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsBrotliKB: 275, // measured 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)};@@@ -71,6 +71,30 @@ function announceGoLive(stream, streamer) {});console.log(`[LiveEvents] Announced go-live: ${(streamer && streamer.username) || stream.username} (${clients.size} subscribers)`);} catch (e) { console.warn('[LiveEvents] announce failed:', e.message); }++ if (!repeat) postLiveLink(stream, streamer);++}++++/**++ * The channel's own chat says where to watch, the moment a stream goes live: openvibe.live/@name, plain and easy to copy++ * (the owner, 2026-10-28: "maybe it says it in chat… their live URL… not the slug… easier to copy paste"). Not on a++ * quick reconnect within the hour (the repeat above), and once per stream (Chat keeps the key).++ */++function postLiveLink(stream, streamer) {++ const username = (streamer && streamer.username) || stream.username;++ const channelUserId = stream.user_id || (streamer && streamer.id);++ if (!username || !channelUserId || !stream.id) return;++ let base = 'https://openvibe.live';++ try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* the public address */ }++ const url = `${base}/@${encodeURIComponent(username)}`;++ const name = (streamer && streamer.display_name) || stream.display_name || username;++ try {++ const delivery = require('../chat/chat-delivery');++ delivery.after(delivery.message({++ stream_id: stream.id, channel_user_id: channelUserId, user_id: null, username: 'OpenVibe.Live',++ message: `\u{1F534} ${name} is live. Share the stream: ${url}`.slice(0, 480), message_type: 'system',++ metadata: { kind: 'live_link', url }, key: `live:link:${stream.id}`,++ }), () => {});++ } catch (e) { console.warn('[LiveEvents] live link line failed:', e.message); }}/** End every open SSE stream. Without this, server.close() never called back on shutdown. */@@@ -79,4 +103,4 @@ function closeAll() {clients.clear();}--module.exports = { subscribe, broadcast, announceGoLive, closeAll, clientCount: () => clients.size };++module.exports = { subscribe, broadcast, announceGoLive, postLiveLink, closeAll, clientCount: () => clients.size };nnew file mode 100644@@@ -0,0 +1,67 @@++/**++ * Going live hands over the link to share (the owner, 2026-10-28: streamers copied the studio's /broadcast address):++ * the channel's chat says "… is live. Share the stream: …/@name" once (server/streaming/live-events.js postLiveLink),++ * the studio shows its address as /@name/studio, which opens the channel for anyone else (public/js/app.js), and the++ * studio's go-live card hands over the link (public/js/broadcast-studio.js celebrate).++ *++ * node test/go-live-link.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++process.env.NODE_ENV = 'test';++++(async () => {++ let failures = 0;++ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err.stack}`); } };++++ const lines = [];++ require.cache[require.resolve('../server/chat/chat-delivery')] = {++ id: 'chat-delivery', loaded: true,++ exports: { message: async (line) => { lines.push(line); return 1; }, after: (v, fn) => (v && v.then ? v.then(fn) : fn(v)) },++ };++ const liveEvents = require('../server/streaming/live-events');++++ await check('a stream going live puts its plain link in the channel\'s chat, once', async () => {++ liveEvents.announceGoLive({ id: 501, user_id: 9, managed_stream_id: 77, managed_stream_slug: 'my-second-stream' }, { id: 9, username: 'goosely', display_name: 'Goosely' });++ await new Promise((r) => setImmediate(r));++ assert.strictEqual(lines.length, 1);++ const l = lines[0];++ assert.match(l.message, /^🔴 Goosely is live\. Share the stream: https?:\/\/[^ ]+\/@goosely$/);++ assert.ok(!l.message.includes('my-second-stream'), 'never the stream\'s slug');++ assert.deepStrictEqual([l.stream_id, l.channel_user_id, l.user_id, l.message_type, l.key], [501, 9, null, 'system', 'live:link:501']);++ assert.strictEqual(l.metadata.kind, 'live_link');++ liveEvents.announceGoLive({ id: 502, user_id: 9, managed_stream_id: 77 }, { id: 9, username: 'goosely' });++ await new Promise((r) => setImmediate(r));++ assert.strictEqual(lines.length, 1, 'a quick reconnect within the hour says nothing again');++ });++++ await check('the studio shows /@name/studio, which opens the studio for its owner and the channel for anyone else', async () => {++ const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');++ const fn = /function showStudioAddress\(\) \{[\s\S]*?\n\}/.exec(app);++ assert.ok(fn, 'showStudioAddress is in app.js');++ assert.match(app, /loadBroadcastPage\(\); showStudioAddress\(\);/, 'the studio route shows it');++ const calls = [];++ const run = new Function('location', 'currentUser', 'history', `${fn[0]}; showStudioAddress();`);++ run({ pathname: '/broadcast', search: '', hash: '' }, { username: 'goosely' }, { state: null, replaceState: (s, t, u) => calls.push(u) });++ run({ pathname: '/broadcast/slot-2', search: '', hash: '' }, { username: 'goosely' }, { state: null, replaceState: (s, t, u) => calls.push(u) });++ run({ pathname: '/broadcast', search: '', hash: '' }, null, { state: null, replaceState: (s, t, u) => calls.push(u) });++ assert.deepStrictEqual(calls, ['/@goosely/studio'], 'only the plain studio of a signed-in streamer');++ assert.match(app, /segments\[1\] === 'studio'\) \{[\s\S]{0,400}navigate\(mine \? `\/broadcast\$\{location\.search\}` : `\/@\$\{username\}`, true\)/, 'the route sends everyone else to the channel');++ });++++ await check('the studio hands over the link when it goes live, and on the desktop control row', async () => {++ const studio = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-studio.js'), 'utf8');++ assert.match(studio, /function celebrate\(streamId\)/);++ assert.match(studio, /window\.bcStudio = \{[^}]*copyLink, celebrate, streamLink/);++ const bc = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');++ assert.match(bc, /bcStudio\.celebrate\(streamData\.id\)/);++ const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'broadcast.html'), 'utf8');++ assert.match(frag, /onclick="window\.bcStudio && bcStudio\.copyLink\(this\)"/);++ });++++ if (failures) { console.log(`\ngo-live link: ${failures} failed`); process.exit(1); }++ console.log('\ngo-live link: all checks passed');++})();
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.25.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2688,9 +2688,9 @@
@@ -2688,9 +2688,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.23.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
- "integrity": "sha512-oLcUXmHOOC2CCpAuwCN95TIoadukLG5lC6YHPhPbYKcS/rZh0iwLbEA0aAzYHjalKGrqu9mw896LcFc3cd5xWg==",
+ "version": "4.25.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.25.0",
+ "integrity": "sha512-f9YDJbdjJ5OEcbTmQX26miVI7DsuCpDMheDKZPKESZoevf84zYpgnYI8nRpp2H2YYa4kJCN6YrIKXi+cqTGabQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.25.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3068,3 +3068,43 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
@@ -3068,3 +3068,43 @@ body.bc-golive-dock-on .messenger-toggle { display: none !important; }
@media (max-width: 768px) {
@media (max-width: 768px) {
#bc-info-bar #bc-rs-viewer-count, #bc-info-bar #bc-platform-viewer-badges { display: none !important; }
#bc-info-bar #bc-rs-viewer-count, #bc-info-bar #bc-platform-viewer-badges { display: none !important; }
}
}
+
+/* The go-live card (broadcast-studio.js celebrate; the owner, 2026-10-28: streamers copied the studio's address to
+ share their stream): you're live, and the link to share, one tap to copy. Over the studio on a phone, at the top on a
+ computer; it goes by itself after 20 seconds. */
+.bcs-livecard { position: fixed; z-index: 1600; top: calc(64px + env(safe-area-inset-top, 0px)); left: 50%; width: min(420px, calc(100vw - 24px)); box-sizing: border-box; padding: 14px 14px 14px 16px; border-radius: 18px;
+ background: color-mix(in srgb, var(--bg-secondary, #0f1626) 92%, transparent); -webkit-backdrop-filter: blur(16px) saturate(1.4); backdrop-filter: blur(16px) saturate(1.4);
+ border: 1px solid color-mix(in srgb, var(--live-red, #ef4444) 42%, var(--border, #1f2d47)); box-shadow: 0 24px 60px rgba(0,0,0,.55); color: var(--text-primary); overflow: hidden;
+ transform: translateX(-50%); animation: bcs-gl-in .45s cubic-bezier(.2,1.3,.3,1) both; }
+.bcs-livecard.is-out { animation: bcs-gl-out .25s ease-in forwards; }
+@keyframes bcs-gl-in { from { opacity: 0; transform: translate(-50%, -14px) scale(.94); } to { opacity: 1; transform: translate(-50%, 0) scale(1); } }
+@keyframes bcs-gl-out { to { opacity: 0; transform: translate(-50%, -10px) scale(.97); } }
+.bcs-gl-head { display: flex; align-items: center; gap: 10px; }
+.bcs-gl-head b { flex: 1; font-size: 1.15rem; font-weight: 800; letter-spacing: -.2px; }
+.bcs-gl-dot { position: relative; flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--live-red, #ef4444); }
+.bcs-gl-dot::after { content: ""; position: absolute; inset: -6px; border-radius: 50%; border: 2px solid var(--live-red, #ef4444); animation: bcs-gl-ping 1.4s ease-out infinite; }
+@keyframes bcs-gl-ping { from { transform: scale(.5); opacity: .9; } to { transform: scale(1.6); opacity: 0; } }
+.bcs-gl-x { all: unset; box-sizing: border-box; cursor: pointer; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; color: var(--text-secondary); }
+.bcs-gl-x:hover, .bcs-gl-x:focus-visible { background: rgba(255,255,255,.08); color: var(--text-primary); }
+.bcs-gl-sub { margin: 6px 0 10px; font-size: .92rem; color: var(--text-secondary); }
+.bcs-gl-link { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 12px; border-radius: 14px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); }
+.bcs-gl-link code { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 1rem/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-primary); background: none; user-select: all; -webkit-user-select: all; }
+.bcs-gl-copy, .bcs-gl-share { all: unset; box-sizing: border-box; cursor: pointer; flex: none; display: inline-flex; align-items: center; gap: 4px; height: 42px; padding: 0 16px; border-radius: 11px; font-weight: 700; font-size: .95rem;
+ background: var(--accent-strong, var(--accent, #3472d8)); color: var(--on-accent-strong, var(--on-accent, #fff)); transition: transform .15s, background .2s; }
+.bcs-gl-copy:active, .bcs-gl-share:active { transform: scale(.96); }
+.bcs-gl-copy.is-copied, .bc-ctrl-btn.is-copied { background: var(--success, #16a34a); color: #fff; }
+.bcs-gl-copy:focus-visible, .bcs-gl-share:focus-visible { outline: 2px solid var(--accent-light, #60a5fa); outline-offset: 2px; }
+.bcs-gl-more { display: flex; justify-content: flex-end; margin-top: 8px; }
+.bcs-gl-share { height: 34px; background: rgba(255,255,255,.08); color: var(--text-primary); }
+.bcs-gl-bar { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255,255,255,.06); }
+.bcs-gl-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--live-red, #ef4444), var(--accent, #3b82f6)); transform-origin: left; transition: transform .2s linear; }
+.bcs-gl-burst { position: absolute; left: 22px; top: 21px; width: 0; height: 0; pointer-events: none; }
+.bcs-gl-burst i { position: absolute; width: 5px; height: 5px; margin: -2.5px; border-radius: 50%; background: hsl(var(--a), 90%, 66%); opacity: 0; animation: bcs-gl-spark .9s cubic-bezier(.2,.8,.3,1) var(--d) forwards; }
+@keyframes bcs-gl-spark { 0% { opacity: 1; transform: rotate(var(--a)) translateX(0); } 100% { opacity: 0; transform: rotate(var(--a)) translateX(48px) scale(.4); } }
+@media (prefers-reduced-motion: reduce) { .bcs-livecard, .bcs-gl-dot::after, .bcs-gl-burst i { animation: none !important; } .bcs-gl-burst { display: none; } }
+/* A phone: the link on its own line, whole, and Copy the width of the card (an easy tap while holding the phone). */
+@media (max-width: 480px) {
+ .bcs-livecard .bcs-gl-link { flex-wrap: wrap; padding: 10px; gap: 10px; }
+ .bcs-livecard .bcs-gl-link code { flex-basis: 100%; white-space: normal; overflow-wrap: anywhere; text-align: center; font-size: 1.05rem; }
+ .bcs-livecard .bcs-gl-copy { flex: 1; justify-content: center; height: 46px; }
+}
@@ -922,3 +922,8 @@
@@ -922,3 +922,8 @@
background: var(--bg-tertiary);
background: var(--bg-tertiary);
color: var(--text-primary);
color: var(--text-primary);
}
}
+
+/* Opened from the navbar's messages button (Shared 4.25): under the bar on the right; on a phone the whole screen. */
+.messenger-panel.from-bar { top: calc(var(--navbar-h, 56px) + 8px); right: 12px; left: auto; bottom: auto; max-height: calc(100vh - var(--navbar-h, 56px) - 24px); max-height: calc(100dvh - var(--navbar-h, 56px) - 24px); }
+@media (max-width: 480px) { .messenger-panel.from-bar { top: 0; right: 0; bottom: 0; left: 0; max-height: none; height: 100%; height: 100dvh; } }
+@media (max-width: 768px) and (min-width: 481px) { .messenger-panel.from-bar { right: 8px; } }
@@ -9943,9 +9943,10 @@ body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transf
@@ -9943,9 +9943,10 @@ body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transf
backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
--ovnav-h: var(--navbar-h);
--ovnav-h: var(--navbar-h);
}
}
-/* Over the home hero at the very top (body.ov-nav-overlay from app.js, .ovnav-top from the shared navbar): no background
- at all, the hero shows through under the navbar's own soft scrim. */
-body.ov-nav-overlay .openvibe-navbar.navbar.ovnav-top { background: transparent; border-bottom-color: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
+/* At the very top of any page (.ovnav-top from the shared navbar) the bar has no background, as on every other site, and
+ the frosted bar comes back as you scroll (the owner, 2026-10-28: the effect "doesn't work on mobile"). Over the home
+ hero (body.ov-nav-overlay from app.js) the hero shows through under the navbar's own soft scrim. */
+.openvibe-navbar.navbar.ovnav-top { background: transparent; border-bottom-color: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link { font-size: 0.86rem; padding: 7px 11px; border-radius: 10px; }
.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link { font-size: 0.86rem; padding: 7px 11px; border-radius: 10px; }
.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }
.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }
.openvibe-navbar.navbar .openvibe-navbar-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; }
.openvibe-navbar.navbar .openvibe-navbar-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; }
@@ -724,6 +724,9 @@ WantedBy=multi-user.target</code>
@@ -724,6 +724,9 @@ WantedBy=multi-user.target</code>
<button class="bc-ctrl-btn" id="bc-btn-toggle-preview" onclick="toggleBroadcastPreview()" title="Hide your own preview (you stay live; it saves battery)">
<button class="bc-ctrl-btn" id="bc-btn-toggle-preview" onclick="toggleBroadcastPreview()" title="Hide your own preview (you stay live; it saves battery)">
<i class="fa-solid fa-eye-slash"></i> Hide preview
<i class="fa-solid fa-eye-slash"></i> Hide preview
</button>
</button>
+ <button class="bc-ctrl-btn" onclick="window.bcStudio && bcStudio.copyLink(this)" title="Copy your stream's link (openvibe.live/@you): it always opens your stream">
+ <i class="fa-solid fa-link"></i><span> Copy link</span>
+ </button>
<button class="bc-ctrl-btn" onclick="openViewerPreview()" title="Open your channel the way viewers see it">
<button class="bc-ctrl-btn" onclick="openViewerPreview()" title="Open your channel the way viewers see it">
<i class="fa-solid fa-tv"></i> Watch as viewer
<i class="fa-solid fa-tv"></i> Watch as viewer
</button>
</button>
@@ -945,6 +945,17 @@ function _ovHoldsMedia() {
@@ -945,6 +945,17 @@ function _ovHoldsMedia() {
/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */
/** A URL that leaves Live (an OpenVibe.Video watch page, another OpenVibe site): the browser goes there, not the SPA. */
const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);
const isOffSite = (url) => /^https?:\/\//i.test(String(url || '')) && !String(url).startsWith(`${location.origin}/`);
+/**
+ * The studio shows its address as /@name/studio, not /broadcast (the owner, 2026-10-28: streamers copied the address
+ * bar to share their stream, and /broadcast opened nobody's). Copied and shared, it opens the channel for everyone
+ * else; for its owner, a reload or a phone bringing the tab back opens the studio again. A slot's own path
+ * (/broadcast/<slot>) stays as it is.
+ */
+function showStudioAddress() {
+ if (location.pathname !== '/broadcast' || !currentUser || !currentUser.username) return;
+ try { history.replaceState(history.state, '', `/@${encodeURIComponent(currentUser.username)}/studio${location.search}${location.hash}`); } catch { /* the address stays /broadcast */ }
+}
+
/** A full address on Live itself (the shared navbar's "My channel" is https://openvibe.live/@name) as its path. */
/** A full address on Live itself (the shared navbar's "My channel" is https://openvibe.live/@name) as its path. */
function sameSitePath(url) {
function sameSitePath(url) {
if (!/^https?:\/\//i.test(String(url || ''))) return url;
if (!/^https?:\/\//i.test(String(url || ''))) return url;
@@ -1123,7 +1134,7 @@ function routeFromURL() {
@@ -1123,7 +1134,7 @@ function routeFromURL() {
} else if (segments[0] === 'broadcast') {
} else if (segments[0] === 'broadcast') {
// Show the shell first so the route feels instant, then fill it once its bundle is in.
// Show the shell first so the route feels instant, then fill it once its bundle is in.
showPage('broadcast');
showPage('broadcast');
- whenRouteReady('broadcast', () => { if (typeof loadBroadcastPage === 'function') loadBroadcastPage(); });
+ whenRouteReady('broadcast', () => { if (typeof loadBroadcastPage === 'function') loadBroadcastPage(); showStudioAddress(); });
} else if (segments[0] === 'admin') {
} else if (segments[0] === 'admin') {
window.location.href = `${getOpenVibeToolsUrl()}/admin`;
window.location.href = `${getOpenVibeToolsUrl()}/admin`;
return;
return;
@@ -1185,6 +1196,12 @@ function routeFromURL() {
@@ -1185,6 +1196,12 @@ function routeFromURL() {
// Legacy stream URL: /stream/:id
// Legacy stream URL: /stream/:id
showPage('stream');
showPage('stream');
whenRouteReady('stream', () => openStream(segments[1]));
whenRouteReady('stream', () => openStream(segments[1]));
+ } else if (segments.length === 2 && segments[0].startsWith('@') && segments[1] === 'studio') {
+ // The studio's own address (showStudioAddress): its owner's studio, and the channel for anyone it was shared with.
+ const username = normalizeChannelUsername(segments[0]);
+ const mine = !!(currentUser && String(currentUser.username || '').toLowerCase() === username.toLowerCase());
+ navigate(mine ? `/broadcast${location.search}` : `/@${username}`, true);
+ return;
} else if (segments.length >= 1 && segments[0].startsWith('@')) {
} else if (segments.length >= 1 && segments[0].startsWith('@')) {
// Channel page: /@username or /@username/:managedStreamIdOrSlug
// Channel page: /@username or /@username/:managedStreamIdOrSlug
// Backward compat: /@username?stream=sessionId
// Backward compat: /@username?stream=sessionId
@@ -303,14 +303,76 @@
@@ -303,14 +303,76 @@
Promise.resolve(createBroadcastClip()).catch(() => null).finally(() => look(false));
Promise.resolve(createBroadcastClip()).catch(() => null).finally(() => look(false));
}
}
- /** Copies the channel link. (The system share sheet takes a phone away from this tab, which pauses the camera.) */
- function share() {
+ /** Your stream's address, the one to share: openvibe.live/@name (never a stream's slug, never /broadcast). */
+ function streamLink() {
const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : null;
const user = typeof currentUser !== 'undefined' && currentUser ? currentUser.username : null;
- if (!user) return;
- const url = `${location.origin}/@${encodeURIComponent(user)}`;
- if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(url).then(() => say('Link copied. Paste it anywhere to share your stream.', 'success'), () => say(url));
- else if (typeof copyToClipboard === 'function') copyToClipboard(url);
- else say(url);
+ return user ? `${location.origin}/@${encodeURIComponent(user)}` : null;
+ }
+
+ /** Copies the channel link, and says so on the button that asked. (The system share sheet takes a phone away from
+ * this tab, which pauses the camera, so a phone copies.) → whether it was copied. */
+ function copyLink(btn, { quiet = false } = {}) {
+ const url = streamLink();
+ if (!url) return Promise.resolve(false);
+ const label = btn && btn.querySelector ? btn.querySelector('span') : null;
+ const done = (ok) => {
+ if (label) {
+ const was = label.dataset.was || label.textContent;
+ label.dataset.was = was;
+ if (ok) { btn.classList.add('is-copied'); label.textContent = ' Copied'; setTimeout(() => { btn.classList.remove('is-copied'); label.textContent = was; }, 2200); }
+ }
+ if (!quiet) say(ok ? 'Link copied. Paste it anywhere to share your stream.' : url, ok ? 'success' : 'info');
+ return ok;
+ };
+ if (navigator.clipboard && navigator.clipboard.writeText) return navigator.clipboard.writeText(url).then(() => done(true), () => done(false));
+ if (typeof copyToClipboard === 'function') { copyToClipboard(url); return Promise.resolve(done(true)); }
+ return Promise.resolve(done(false));
+ }
+ function share() { copyLink(null); }
+
+ /**
+ * The moment a stream from this browser goes live (the owner, 2026-10-28: "I've seen them struggle to share the
+ * URL… they try and copy the URL that's in the browser"): a card says you're live and hands over the link,
+ * openvibe.live/@name, with one tap to copy (never the system share sheet, which takes a phone off this tab). It goes by itself after 20 seconds, a hand
+ * or focus on it holds it, and it shows once per stream.
+ */
+ const CARD_MS = 20000;
+ function celebrate(streamId) {
+ const url = streamLink();
+ if (!url) return false;
+ try { const key = `ov-golive-card:${streamId || 'x'}`; if (streamId && sessionStorage.getItem(key)) return false; sessionStorage.setItem(key, '1'); } catch { /* still show it */ }
+ document.querySelectorAll('.bcs-livecard').forEach((n) => n.remove());
+ const copy = el('button', { type: 'button', class: 'bcs-gl-copy' }, icon('fa-copy'), el('span', { text: ' Copy link' }));
+ const close = el('button', { type: 'button', class: 'bcs-gl-x', 'aria-label': 'Close' }, icon('fa-xmark'));
+ const sub = el('p', { class: 'bcs-gl-sub', text: 'Share this link. It always opens your stream:' });
+ const code = el('code', { text: url.replace(/^https?:\/\//, '') });
+ const bar = el('i');
+ const card = el('div', { class: 'bcs-livecard', role: 'status', 'aria-live': 'polite' },
+ el('div', { class: 'bcs-gl-burst', 'aria-hidden': 'true' }, ...Array.from({ length: 10 }, (_, i) => el('i', { style: `--a:${i * 36}deg;--d:${(i % 3) * 50}ms` }))),
+ el('div', { class: 'bcs-gl-head' }, el('span', { class: 'bcs-gl-dot', 'aria-hidden': 'true' }), el('b', { text: 'You\u2019re live!' }), close),
+ sub,
+ el('div', { class: 'bcs-gl-link' }, code, copy),
+ el('div', { class: 'bcs-gl-bar', 'aria-hidden': 'true' }, bar));
+ document.body.append(card);
+ let left = CARD_MS, last = Date.now(), held = false;
+ const end = () => { clearInterval(timer); card.classList.add('is-out'); setTimeout(() => card.remove(), 260); };
+ const timer = setInterval(() => {
+ const now = Date.now();
+ if (!held) left -= now - last;
+ last = now;
+ bar.style.transform = `scaleX(${Math.max(0, left / CARD_MS)})`;
+ if (left <= 0 || !card.isConnected) end();
+ }, 200);
+ for (const ev of ['pointerenter', 'touchstart', 'focusin']) card.addEventListener(ev, () => { held = true; }, { passive: true });
+ for (const ev of ['pointerleave', 'focusout']) card.addEventListener(ev, () => { held = false; last = Date.now(); });
+ close.addEventListener('click', end);
+ copy.addEventListener('click', () => copyLink(copy, { quiet: true }).then((ok) => {
+ if (ok) { sub.textContent = 'Copied. Paste it in a group chat, on Discord or in a post.'; left = Math.min(left, 4000); return; }
+ // No clipboard here: the link is selected, so a long press or Ctrl+C copies it.
+ try { const r = document.createRange(); r.selectNodeContents(code); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(r); } catch { /* */ }
+ sub.textContent = 'Press and hold the link to copy it.';
+ }));
+ return true;
}
}
// ── Staying on this tab ──
// ── Staying on this tab ──
@@ -805,5 +867,5 @@
@@ -805,5 +867,5 @@
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
if (NARROW.addEventListener) NARROW.addEventListener('change', tick);
ensureReturn();
ensureReturn();
- window.bcStudio = { toggleMic, toggleCamera, flip, showViewer, phone: () => NARROW.matches, open: () => { st.dismissedFor = null; tick(); }, _state: st };
+ window.bcStudio = { toggleMic, toggleCamera, flip, showViewer, copyLink, celebrate, streamLink, phone: () => NARROW.matches, open: () => { st.dismissedFor = null; tick(); }, _state: st };
})();
})();
@@ -1632,7 +1632,8 @@ async function createNewStream() {
@@ -1632,7 +1632,8 @@ async function createNewStream() {
startRestreamStatusPolling();
startRestreamStatusPolling();
// Show/hide screen share live controls
// Show/hide screen share live controls
_syncScreenShareLiveControls(ss);
_syncScreenShareLiveControls(ss);
- toast('You are now LIVE!', 'success');
+ // You're live: a card hands over the link to share (broadcast-studio.js celebrate); the plain toast without it.
+ if (!(window.bcStudio && typeof bcStudio.celebrate === 'function' && bcStudio.celebrate(streamData.id))) toast('You are now LIVE!', 'success');
} else if (method === 'webrtc' && broadcastState.selectedWebRTCSub === 'obs') {
} else if (method === 'webrtc' && broadcastState.selectedWebRTCSub === 'obs') {
showWHIPInstructions(streamData); startHeartbeat(streamData.id);
showWHIPInstructions(streamData); startHeartbeat(streamData.id);
ensureBroadcastChat(streamData.id);
ensureBroadcastChat(streamData.id);
@@ -228,7 +228,26 @@
@@ -228,7 +228,26 @@
}
}
// ── Inject HTML ──────────────────────────────────────────────
// ── Inject HTML ──────────────────────────────────────────────
+ // The navbar's messages button (Shared 4.25; the owner, 2026-10-28: the floating DM button was "buggy as hell on
+ // mobile… app style"): the unread count goes there and it opens this panel; an older navbar keeps the bubble.
+ const inBar = () => !!(window.OpenVibeNavbar && typeof window.OpenVibeNavbar.setMessages === 'function');
+ function dropBubble() {
+ if (!$toggle || !inBar()) return;
+ $toggle.remove(); $toggle = null; $badge = null;
+ if ($panel) $panel.classList.add('from-bar');
+ updateBadge();
+ }
+
function injectWidget() {
function injectWidget() {
+ if (inBar()) {
+ $panel = document.createElement('div');
+ $panel.className = 'messenger-panel from-bar';
+ $panel.id = 'messenger-panel';
+ document.body.appendChild($panel);
+ renderInbox();
+ return;
+ }
+ setTimeout(dropBubble, 1500); // the navbar may finish loading after this widget
// Toggle button
// Toggle button
$toggle = document.createElement('button');
$toggle = document.createElement('button');
$toggle.className = 'messenger-toggle';
$toggle.className = 'messenger-toggle';
@@ -867,6 +886,7 @@
@@ -867,6 +886,7 @@
}
}
function updateBadge() {
function updateBadge() {
+ if (inBar()) window.OpenVibeNavbar.setMessages(unreadTotal);
if (!$badge) return;
if (!$badge) return;
$badge.textContent = unreadTotal > 0 ? (unreadTotal > 99 ? '99+' : String(unreadTotal)) : '';
$badge.textContent = unreadTotal > 0 ? (unreadTotal > 99 ? '99+' : String(unreadTotal)) : '';
$badge.dataset.count = unreadTotal;
$badge.dataset.count = unreadTotal;
@@ -1256,6 +1276,7 @@
@@ -1256,6 +1276,7 @@
// Expose for programmatic access
// Expose for programmatic access
window.messengerWidget = {
window.messengerWidget = {
open: () => { if (!panelOpen) togglePanel(); },
open: () => { if (!panelOpen) togglePanel(); },
+ toggle: () => togglePanel(),
close: closePanel,
close: closePanel,
openDm: window.openMessengerDm,
openDm: window.openMessengerDm,
};
};
@@ -82,6 +82,8 @@
@@ -82,6 +82,8 @@
{ id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', valueId: 'nav-coins-amount', label: 'OpenCoins', title: 'OpenCoins: click to open your inventory', onClick: () => call('openCosmeticsModal') },
{ id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', valueId: 'nav-coins-amount', label: 'OpenCoins', title: 'OpenCoins: click to open your inventory', onClick: () => call('openCosmeticsModal') },
{ id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', valueId: 'nav-balance-amount', label: 'Vibes', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },
{ id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', valueId: 'nav-balance-amount', label: 'Vibes', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },
],
],
+ // DMs beside the bell (Shared 4.25): the button opens the messenger panel; without it, Chat's inbox.
+ messages: { onOpen: () => { if (window.messengerWidget && window.messengerWidget.toggle) window.messengerWidget.toggle(); else location.href = 'https://openvibe.chat/messages'; }, href: 'https://openvibe.chat/messages' },
// Same-origin links are routes of this single-page app.
// Same-origin links are routes of this single-page app.
onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),
onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),
onLogout: () => call('logout'),
onLogout: () => call('logout'),
@@ -22,8 +22,8 @@ const BUDGETS = {
@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1150, // measured ~1000
- jsBrotliKB: 272, // measured 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
+ jsRawKB: 1165, // measured 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsBrotliKB: 275, // measured 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
@@ -71,6 +71,30 @@ function announceGoLive(stream, streamer) {
@@ -71,6 +71,30 @@ function announceGoLive(stream, streamer) {
});
});
console.log(`[LiveEvents] Announced go-live: ${(streamer && streamer.username) || stream.username} (${clients.size} subscribers)`);
console.log(`[LiveEvents] Announced go-live: ${(streamer && streamer.username) || stream.username} (${clients.size} subscribers)`);
} catch (e) { console.warn('[LiveEvents] announce failed:', e.message); }
} catch (e) { console.warn('[LiveEvents] announce failed:', e.message); }
+ if (!repeat) postLiveLink(stream, streamer);
+}
+
+/**
+ * The channel's own chat says where to watch, the moment a stream goes live: openvibe.live/@name, plain and easy to copy
+ * (the owner, 2026-10-28: "maybe it says it in chat… their live URL… not the slug… easier to copy paste"). Not on a
+ * quick reconnect within the hour (the repeat above), and once per stream (Chat keeps the key).
+ */
+function postLiveLink(stream, streamer) {
+ const username = (streamer && streamer.username) || stream.username;
+ const channelUserId = stream.user_id || (streamer && streamer.id);
+ if (!username || !channelUserId || !stream.id) return;
+ let base = 'https://openvibe.live';
+ try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* the public address */ }
+ const url = `${base}/@${encodeURIComponent(username)}`;
+ const name = (streamer && streamer.display_name) || stream.display_name || username;
+ try {
+ const delivery = require('../chat/chat-delivery');
+ delivery.after(delivery.message({
+ stream_id: stream.id, channel_user_id: channelUserId, user_id: null, username: 'OpenVibe.Live',
+ message: `\u{1F534} ${name} is live. Share the stream: ${url}`.slice(0, 480), message_type: 'system',
+ metadata: { kind: 'live_link', url }, key: `live:link:${stream.id}`,
+ }), () => {});
+ } catch (e) { console.warn('[LiveEvents] live link line failed:', e.message); }
}
}
/** End every open SSE stream. Without this, server.close() never called back on shutdown. */
/** End every open SSE stream. Without this, server.close() never called back on shutdown. */
@@ -79,4 +103,4 @@ function closeAll() {
@@ -79,4 +103,4 @@ function closeAll() {
clients.clear();
clients.clear();
}
}
-module.exports = { subscribe, broadcast, announceGoLive, closeAll, clientCount: () => clients.size };
+module.exports = { subscribe, broadcast, announceGoLive, postLiveLink, closeAll, clientCount: () => clients.size };
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+/**
+ * Going live hands over the link to share (the owner, 2026-10-28: streamers copied the studio's /broadcast address):
+ * the channel's chat says "… is live. Share the stream: …/@name" once (server/streaming/live-events.js postLiveLink),
+ * the studio shows its address as /@name/studio, which opens the channel for anyone else (public/js/app.js), and the
+ * studio's go-live card hands over the link (public/js/broadcast-studio.js celebrate).
+ *
+ * node test/go-live-link.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+process.env.NODE_ENV = 'test';
+
+(async () => {
+ let failures = 0;
+ const check = async (name, fn) => { try { await fn(); console.log(` ✓ ${name}`); } catch (err) { failures++; console.log(` ✗ ${name}\n ${err.stack}`); } };
+
+ const lines = [];
+ require.cache[require.resolve('../server/chat/chat-delivery')] = {
+ id: 'chat-delivery', loaded: true,
+ exports: { message: async (line) => { lines.push(line); return 1; }, after: (v, fn) => (v && v.then ? v.then(fn) : fn(v)) },
+ };
+ const liveEvents = require('../server/streaming/live-events');
+
+ await check('a stream going live puts its plain link in the channel\'s chat, once', async () => {
+ liveEvents.announceGoLive({ id: 501, user_id: 9, managed_stream_id: 77, managed_stream_slug: 'my-second-stream' }, { id: 9, username: 'goosely', display_name: 'Goosely' });
+ await new Promise((r) => setImmediate(r));
+ assert.strictEqual(lines.length, 1);
+ const l = lines[0];
+ assert.match(l.message, /^🔴 Goosely is live\. Share the stream: https?:\/\/[^ ]+\/@goosely$/);
+ assert.ok(!l.message.includes('my-second-stream'), 'never the stream\'s slug');
+ assert.deepStrictEqual([l.stream_id, l.channel_user_id, l.user_id, l.message_type, l.key], [501, 9, null, 'system', 'live:link:501']);
+ assert.strictEqual(l.metadata.kind, 'live_link');
+ liveEvents.announceGoLive({ id: 502, user_id: 9, managed_stream_id: 77 }, { id: 9, username: 'goosely' });
+ await new Promise((r) => setImmediate(r));
+ assert.strictEqual(lines.length, 1, 'a quick reconnect within the hour says nothing again');
+ });
+
+ await check('the studio shows /@name/studio, which opens the studio for its owner and the channel for anyone else', async () => {
+ const app = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
+ const fn = /function showStudioAddress\(\) \{[\s\S]*?\n\}/.exec(app);
+ assert.ok(fn, 'showStudioAddress is in app.js');
+ assert.match(app, /loadBroadcastPage\(\); showStudioAddress\(\);/, 'the studio route shows it');
+ const calls = [];
+ const run = new Function('location', 'currentUser', 'history', `${fn[0]}; showStudioAddress();`);
+ run({ pathname: '/broadcast', search: '', hash: '' }, { username: 'goosely' }, { state: null, replaceState: (s, t, u) => calls.push(u) });
+ run({ pathname: '/broadcast/slot-2', search: '', hash: '' }, { username: 'goosely' }, { state: null, replaceState: (s, t, u) => calls.push(u) });
+ run({ pathname: '/broadcast', search: '', hash: '' }, null, { state: null, replaceState: (s, t, u) => calls.push(u) });
+ assert.deepStrictEqual(calls, ['/@goosely/studio'], 'only the plain studio of a signed-in streamer');
+ assert.match(app, /segments\[1\] === 'studio'\) \{[\s\S]{0,400}navigate\(mine \? `\/broadcast\$\{location\.search\}` : `\/@\$\{username\}`, true\)/, 'the route sends everyone else to the channel');
+ });
+
+ await check('the studio hands over the link when it goes live, and on the desktop control row', async () => {
+ const studio = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-studio.js'), 'utf8');
+ assert.match(studio, /function celebrate\(streamId\)/);
+ assert.match(studio, /window\.bcStudio = \{[^}]*copyLink, celebrate, streamLink/);
+ const bc = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast.js'), 'utf8');
+ assert.match(bc, /bcStudio\.celebrate\(streamData\.id\)/);
+ const frag = fs.readFileSync(path.join(__dirname, '..', 'public', 'fragments', 'broadcast.html'), 'utf8');
+ assert.match(frag, /onclick="window\.bcStudio && bcStudio\.copyLink\(this\)"/);
+ });
+
+ if (failures) { console.log(`\ngo-live link: ${failures} failed`); process.exit(1); }
+ console.log('\ngo-live link: all checks passed');
+})();