openvibers/openvibe.live Commits

Popout chat: channel-follow mode, live auto-swap, pretty /chat URLs

OP OpenVibers committed 26 Aug 2026 · 8fad5621ca · 4 files, +160 −23

The popout now has two real modes — a channel-scoped chat and a pinned
stream id — and never strands the viewer on a dead stream: a 20s watcher
follows the channel's live state, hopping to whatever stream is live when
the pinned one ends, down to channel-level chat when nothing is, and back
into the stream when the streamer returns. Every swap posts a local system
notice and updates the address bar via history.replaceState (swaps replace,
they don't stack). URLs are now /chat/global, /chat/<user> and
/chat/<user>/<id>; the popout buttons open those directly, and legacy
/popout-chat.html?mode=stream&stream=N links resolve the streamer and
self-upgrade. Concurrent slots never flap: the watcher stays in its current
stream while that one is still live.

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

Changes

ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex 0416253e..6764fa08 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -1331,7 +1331,7 @@ function _wsOpenPopoutChat() {
// Use the managed stream's live session ID if live, otherwise open global chat for the channel
const liveSessionId = _wsGetLiveSessionId(ms.id);
if (liveSessionId && typeof popoutChat === 'function') {
-- popoutChat('stream', liveSessionId);
++ popoutChat('stream', liveSessionId, (typeof currentUser !== 'undefined' && currentUser && currentUser.username) || null);
} else {
// Even when not live, open a popout chat scoped to the channel
if (typeof popoutChat === 'function') {
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 483c8241..a58fba4c 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -6597,8 +6597,10 @@ function _getChatPopoutContainers(mode) {
].filter(Boolean);
}
--function popoutChat(mode = 'global', streamId = null) {
-- const key = mode === 'stream' ? `stream-${streamId}` : 'global';
++function popoutChat(mode = 'global', streamId = null, username = null) {
++ // Key by channel, not stream id: the popout follows the channel's live state, so
++ // re-clicking the button after a stream swap should focus the same window.
++ const key = mode === 'stream' ? `chan-${(username || streamId)}` : 'global';
// If already open and alive, focus it
const existing = _popoutChatWindows.get(key);
@@@ -6607,18 +6609,22 @@ function popoutChat(mode = 'global', streamId = null) {
return;
}
-- const title = mode === 'stream' ? `Stream Chat — ${streamId}` : 'Global Chat';
const w = 400, h = 600;
const left = window.screenX + window.outerWidth - w - 20;
const top = window.screenY + 80;
-- // Build popout URL — loads main page with #chat-popout route
-- const params = new URLSearchParams();
-- params.set('popout', '1');
-- params.set('mode', mode);
-- if (streamId) params.set('stream', streamId);
++ // Pretty URL: /chat/<username>[/<streamId>] (the popout auto-follows the channel's
++ // live state from there). Legacy query form only when no username is known.
++ let url;
++ if (mode === 'stream' && username) {
++ url = `/chat/${encodeURIComponent(username)}${streamId ? '/' + streamId : ''}`;
++ } else if (mode === 'stream' && streamId) {
++ url = `/popout-chat.html?popout=1&mode=stream&stream=${encodeURIComponent(streamId)}`; // popout resolves + upgrades
++ } else {
++ url = '/chat/global';
++ }
-- const popup = window.open(`/popout-chat.html?${params.toString()}`, `openvibe_chat_${key}`,
++ const popup = window.open(url, `openvibe_chat_${key}`,
`width=${w},height=${h},left=${left},top=${top},resizable=yes,scrollbars=no`);
if (!popup) {
toast('Popup blocked — please allow popups for this site', 'error');
@@@ -6649,7 +6655,11 @@ function popoutStreamChat() {
toast('Not in a stream chat', 'error');
return;
}
-- popoutChat('stream', chatStreamId);
++ // Pass the channel identity so the popout gets a pretty /chat/<user>/<id> URL and
++ // can follow the channel's live state when this stream ends.
++ const username = (typeof currentStreamData !== 'undefined' && currentStreamData && currentStreamData.username)
++ || (typeof chatChannel !== 'undefined' && chatChannel) || null;
++ popoutChat('stream', chatStreamId, username);
}
/* ── Chat Users List ──────────────────────────────────────────── */
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 63a82a18..860654c8 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -274,16 +274,50 @@
<script>
'use strict';
++ // ── URL parsing ──────────────────────────────────────────────────────────
++ // Pretty form: /chat/global · /chat/<username> · /chat/<username>/<streamId>
++ // Legacy form: /popout-chat.html?popout=1&mode=stream&stream=<id> (self-upgrades)
const params = new URLSearchParams(window.location.search);
-- const mode = params.get('mode') || 'global';
-- const streamId = params.get('stream') || null;
-- const streamTitle = params.get('title') || streamId || '';
-- const chatLabel = mode === 'stream'
-- ? ('Chat' + (streamTitle ? ' — ' + streamTitle : ''))
-- : 'Global Chat';
++ let mode = 'global'; // 'global' | 'channel' (username-scoped, live-following)
++ let channelName = null; // streamer username
++ let channelUserId = null; // resolved streamer user id
++ let pinnedStreamId = null; // requested stream id (only honored while it's live)
++ let activeStreamId = null; // stream id the chat is currently joined to (null = channel chat)
-- document.title = chatLabel + ' — OpenVibe.Live';
-- document.getElementById('pc-title').textContent = chatLabel;
++ (function parseUrl() {
++ const seg = window.location.pathname.split('/').filter(Boolean); // ['chat', user?, id?]
++ if (seg[0] === 'chat') {
++ if (!seg[1] || seg[1].toLowerCase() === 'global') { mode = 'global'; return; }
++ mode = 'channel';
++ channelName = decodeURIComponent(seg[1]);
++ pinnedStreamId = seg[2] ? parseInt(seg[2], 10) || null : null;
++ return;
++ }
++ // Legacy query params
++ const qMode = params.get('mode') || 'global';
++ if (qMode === 'stream' && params.get('stream')) {
++ mode = 'channel';
++ pinnedStreamId = parseInt(params.get('stream'), 10) || null; // username resolved at boot
++ }
++ })();
++
++ function prettyPath() {
++ if (mode !== 'channel' || !channelName) return '/chat/global';
++ return '/chat/' + encodeURIComponent(channelName) + (activeStreamId ? '/' + activeStreamId : '');
++ }
++ // Keep the address bar honest without polluting history — swaps replace, they
++ // don't stack, so Back still leaves the popout instead of replaying old streams.
++ function syncUrl() {
++ try { history.replaceState({ mode, channelName, streamId: activeStreamId }, '', prettyPath()); } catch { /* */ }
++ }
++ function syncTitle(liveLabel) {
++ const label = mode === 'channel'
++ ? ((channelName || 'Chat') + (activeStreamId ? (liveLabel ? ' — ' + liveLabel : ' — live') : ' — channel chat'))
++ : 'Global Chat';
++ document.title = label + ' — OpenVibe.Live';
++ document.getElementById('pc-title').textContent = label;
++ }
++ syncTitle();
// ── Settings panel: delegate entirely to the shared chat.js settings system ──
// chat.js already called loadChatSettings() on boot (persisted to openvibe_chat_settings),
@@@ -366,9 +400,7 @@
});
}
-- if (typeof initChat === 'function') {
-- initChat(mode === 'stream' && streamId ? streamId : null);
-- }
++ joinTarget();
const input = document.getElementById('pc-input');
const sendBtn = document.getElementById('pc-send');
if (input) {
@@@ -401,9 +433,95 @@
} catch {}
}
++ // ── Channel-follow: join the right room and keep following the live state ──
++ function joinTarget() {
++ if (typeof initChat !== 'function') return;
++ if (mode === 'channel') initChat(activeStreamId, channelUserId);
++ else initChat(null);
++ }
++
++ // A locally-rendered notice (never sent to the server). initChat's history
++ // hydrate clears the container shortly after a swap, so post it after settle.
++ function localNotice(text) {
++ setTimeout(() => { try { if (typeof addSystemMessage === 'function') addSystemMessage(text); } catch { /* */ } }, 1600);
++ }
++
++ async function fetchLiveState() {
++ const r = await fetch(`/api/streams/channel/${encodeURIComponent(channelName)}/live`);
++ if (!r.ok) throw new Error('channel lookup failed');
++ const data = await r.json();
++ if (data.channel) {
++ channelUserId = data.channel.user_id || channelUserId;
++ channelName = data.channel.username || channelName;
++ }
++ return data.streams || [];
++ }
++
++ // Decide which stream the popout SHOULD be in right now:
++ // pinned stream while it's live → the stream we're already in while it's live
++ // (concurrent slots can reorder by viewer count — never flap between them) →
++ // any other live stream → channel chat (null).
++ function desiredTarget(liveStreams) {
++ const liveIds = liveStreams.map(s => Number(s.id));
++ if (pinnedStreamId && liveIds.includes(Number(pinnedStreamId))) return Number(pinnedStreamId);
++ if (activeStreamId != null && liveIds.includes(Number(activeStreamId))) return activeStreamId;
++ return liveIds.length ? liveIds[0] : null;
++ }
++
++ let _watchTimer = null;
++ async function watchLiveState() {
++ if (mode !== 'channel') return;
++ try {
++ const live = await fetchLiveState();
++ const want = desiredTarget(live);
++ if (want !== activeStreamId) {
++ const wasStream = activeStreamId != null;
++ activeStreamId = want;
++ if (want == null) pinnedStreamId = null; // the pin died with its stream
++ joinTarget();
++ syncUrl();
++ syncTitle(live.find(s => Number(s.id) === want)?.title);
++ if (want == null) {
++ localNotice(`🔀 The stream ended — you're now in ${channelName}'s channel chat. You'll hop back in automatically when they go live.`);
++ } else if (wasStream) {
++ localNotice(`🔀 That stream ended — switched you to ${channelName}'s current live stream.`);
++ } else {
++ localNotice(`🔴 ${channelName} just went live — switched you to the live stream chat.`);
++ }
++ }
++ } catch { /* transient — retry next tick */ }
++ _watchTimer = setTimeout(watchLiveState, 20000);
++ }
++
++ async function resolveAndBoot() {
++ if (mode === 'channel') {
++ try {
++ // Legacy ?stream=<id> URLs carry no username — resolve it from the stream.
++ if (!channelName && pinnedStreamId) {
++ const r = await fetch(`/api/streams/${pinnedStreamId}`);
++ if (r.ok) {
++ const s = await r.json();
++ channelName = s.username || (s.stream && s.stream.username) || null;
++ channelUserId = s.user_id || (s.stream && s.stream.user_id) || null;
++ }
++ }
++ if (!channelName) { mode = 'global'; syncTitle(); }
++ else {
++ const live = await fetchLiveState();
++ activeStreamId = desiredTarget(live);
++ if (activeStreamId == null) pinnedStreamId = null;
++ syncUrl(); // upgrades legacy URLs to /chat/<user>[/<id>] too
++ syncTitle(live.find(s => Number(s.id) === activeStreamId)?.title);
++ }
++ } catch { /* boot with whatever we parsed */ }
++ }
++ initPopoutChat();
++ if (mode === 'channel') _watchTimer = setTimeout(watchLiveState, 20000);
++ }
++
function startPopoutChat() {
inheritTheme();
-- initPopoutChat();
++ resolveAndBoot();
}
startPopoutChat();
ddiff --git a/server/index.js b/server/index.js
iindex 549891d5..6cf44786 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -663,6 +663,15 @@ app.get(['/kiosk', '/kiosk.html'], (req, res) => {
res.sendFile(path.join(__dirname, '../public/kiosk.html'));
});
++// Popout chat pretty URLs: /chat/global, /chat/:username (channel chat) and
++// /chat/:username/:streamId (pinned stream, auto-follows the channel's live state).
++// The legacy /popout-chat.html?mode=…&stream=… form keeps working (static file) and
++// self-upgrades to the pretty URL client-side.
++app.get(['/chat', '/chat/*'], (req, res) => {
++ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
++ res.sendFile(path.join(__dirname, '../public/popout-chat.html'));
++});
++
// ── SPA Fallback ─────────────────────────────────────────────
app.get('*', (req, res) => {
// Don't serve HTML for API routes