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)

OP OpenVibers committed 16 days ago · 414ed864ed · 14 files, +264 −21

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 2452bf51..4e885b44 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 65be5cb9..009beb1e 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 4673745c..329d301b 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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; }
++}
ddiff --git a/public/css/messenger.css b/public/css/messenger.css
iindex 80d1921d..5eb6992c 100644
---- a/public/css/messenger.css
++++ b/public/css/messenger.css
@@@ -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; } }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex c3dab52a..81675a0a 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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);
--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; }
ddiff --git a/public/fragments/broadcast.html b/public/fragments/broadcast.html
iindex 789bcd8f..56638ea5 100644
---- a/public/fragments/broadcast.html
++++ b/public/fragments/broadcast.html
@@@ -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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 2cdfe161..8594cacc 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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/:id
showPage('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
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex 78b00d66..c2eafe15 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -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 };
})();
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex c320e5cc..d8835750 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -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);
ddiff --git a/public/js/messenger.js b/public/js/messenger.js
iindex 01430399..fc109df5 100644
---- a/public/js/messenger.js
++++ b/public/js/messenger.js
@@@ -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 access
window.messengerWidget = {
open: () => { if (!panelOpen) togglePanel(); },
++ toggle: () => togglePanel(),
close: closePanel,
openDm: window.openMessengerDm,
};
ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex 475721c2..d70ae9f6 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -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'),
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex ac4550cb..acf92445 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 120, // measured 94.9
htmlBrotliKB: 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)
};
ddiff --git a/server/streaming/live-events.js b/server/streaming/live-events.js
iindex 3ef4c548..d7889304 100644
---- a/server/streaming/live-events.js
++++ b/server/streaming/live-events.js
@@@ -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 };
ddiff --git a/test/go-live-link.test.js b/test/go-live-link.test.js
nnew file mode 100644
iindex 00000000..5354d507
---- /dev/null
++++ b/test/go-live-link.test.js
@@@ -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');
++})();