openvibers/openvibe.live Commits
Viewer counts add up every platform (OpenVibe, RobotStreamer, Twitch, Kick, YouTube), and a tap lists each (#375)
Changes
@@@ -69,6 +69,15 @@ Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, chooseone-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classicpage, with a button back). The studio drives the same functions as the page and reads its counters.++### Viewer counts across platforms++A live stream's viewers are counted wherever it goes out (`server/streaming/viewer-places.js`): OpenVibe itself (the++larger of Chat's count and the player sockets'), RobotStreamer, and each restream destination of the stream's slot++(Twitch through Helix, Kick through its API, YouTube with a `youtube_api_key` site setting; a platform that gives no++number is listed as live without one). `GET /api/streams/channel/:username` gives each live stream these++`viewer_places`. The channel player's count and the broadcaster's (the go-live page, the OBS/JSMPEG/WHIP panels and the++phone studio) are their sum, and a tap on the player's count or the studio's lists them (`public/js/viewer-places.js`).++The broadcaster's page reads the same answer every 20 seconds, so both sides see one number.++### Stream ChatThe broadcast page includes an embedded chat sidebar with:- Full chat functionality (emotes, GIFs, TTS)@@@ -33,6 +33,8 @@ You don't need any of the following:- The camera fills the screen, and chat runs over the bottom of the picture, where you can read and answer it.- The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection.++ The number watching counts everywhere your stream goes: OpenVibe, RobotStreamer, Twitch, Kick and YouTube. Tap it++ to see each one. Viewers see the same count on your channel's player, and can tap it too.- The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide orshow it), Clip (the last 30 seconds) and More.- **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip,@@@ -2569,6 +2569,9 @@ body.bc-studio-on .toast-container { z-index: 1500; }font-variant-numeric: tabular-nums; white-space: nowrap;}.bcs-pill b { font-weight: 700; }++/* The count is a button: a tap lists where the stream is watched (viewer-places.js). */++.bcs-viewers { -webkit-tap-highlight-color: transparent; }++.bcs-viewers[aria-expanded="true"] { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.4); }.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }.bcs-pill.is-ok .bcs-dot, .bcs-restream.is-ok::before { background: #22c55e; }.bcs-pill.is-warn .bcs-dot, .bcs-restream.is-warn::before { background: #f59e0b; }@@@ -517,6 +517,13 @@padding: 0 6px;font-variant-numeric: tabular-nums;}++/* The viewer count is a button: a tap lists where the stream is watched (viewer-places.js). */++button.viewer-badge {++ background: none; border: 0; border-radius: 6px; margin: 0; min-height: 28px;++ font-family: inherit; line-height: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent;++}++button.viewer-badge:hover, button.viewer-badge[aria-expanded="true"] { background: rgba(255,255,255,0.14); color: #fff; }++button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }.dvr-progress-wrap:hover .dvr-progress-bar {height: 6px;}nnew file mode 100644@@@ -0,0 +1,30 @@++/* Where a live stream is watched: the panel a tap on a viewer count opens (public/js/viewer-places.js). It sits on the++ player or the live studio's camera, so it is dark like their controls on every theme. */++.ovp {++ position: fixed; z-index: 1250; min-width: 196px; max-width: min(300px, calc(100vw - 16px)); overflow-y: auto;++ padding: 8px 0 6px; border-radius: 14px; color: #fff; font-size: 0.86rem; line-height: 1.3;++ background: rgba(12, 15, 24, 0.94); border: 1px solid rgba(255, 255, 255, 0.14);++ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);++ animation: ovp-in 0.16s ease-out;++}++@keyframes ovp-in { from { opacity: 0; transform: translateY(4px); } }++.ovp-head {++ display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 14px 6px;++ font-size: 0.74rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(255, 255, 255, 0.65);++ border-bottom: 1px solid rgba(255, 255, 255, 0.1);++}++.ovp-total { display: inline-flex; align-items: center; gap: 6px; font-size: 0.9rem; letter-spacing: 0; text-transform: none; color: #fff; }++.ovp-total b, .ovp-n { font-variant-numeric: tabular-nums; }++.ovp-list { list-style: none; margin: 4px 0 0; padding: 0; }++.ovp-row { display: flex; align-items: center; gap: 10px; min-height: 36px; padding: 0 14px; }++.ovp-ico { display: inline-grid; place-items: center; width: 20px; flex: none; color: var(--ovp-c, var(--accent)); font-size: 1rem; }++.ovp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ovp-link {++ display: inline-flex; align-items: center; gap: 6px; min-width: 0; min-height: 36px; color: inherit; text-decoration: none;++}++.ovp-link:hover .ovp-name, .ovp-link:focus-visible .ovp-name { text-decoration: underline; }++.ovp-link:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; border-radius: 4px; }++.ovp-out { font-size: 0.68rem; opacity: 0.55; }++.ovp-n { margin-left: auto; padding-left: 12px; font-weight: 700; }++.ovp-n.is-live { font-size: 0.72rem; font-weight: 700; color: #fca5a5; text-transform: uppercase; letter-spacing: 0.05em; }++@media (prefers-reduced-motion: reduce) { .ovp { animation: none; } }@@@ -89,6 +89,14 @@"openChannelUploadModal"]},++ "viewerPlaces": {++ "css": [++ "/css/features/viewer-places.css"++ ],++ "js": [++ "/js/viewer-places.js"++ ]++ },"channel": {"fragment": "channel","css": [@@@ -98,7 +106,8 @@"player","context","chatExtras",-- "uploads"++ "uploads",++ "viewerPlaces"],"js": ["/js/channel-socials.js",@@@ -195,7 +204,8 @@"chatExtras","guide","aiViewers",-- "clipJobs"++ "clipJobs",++ "viewerPlaces"],"js": ["/js/broadcast-state.js",@@@ -43,9 +43,9 @@<span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span><div class="vc-spacer"></div><button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button>-- <span class="viewer-badge" id="viewer-count-badge">-- <i class="fa-solid fa-eye"></i> <span id="vc-viewers">0</span>-- </span>++ <button type="button" class="viewer-badge" id="viewer-count-badge" aria-haspopup="dialog" aria-expanded="false" title="Watching now: tap to see where">++ <i class="fa-solid fa-eye" aria-hidden="true"></i> <span id="vc-viewers">0</span>++ </button><span class="uptime-badge" id="uptime-badge"><i class="fa-solid fa-clock"></i> <span id="vc-uptime">0:00:00</span></span>@@@ -5,8 +5,58 @@route (public/features.json); it runs after app.js and relies on app.js globals.═══════════════════════════════════════════════════════════════ */let _streamSwitchToken = 0;--/** Cached native HS viewer count from WebSocket — updated by stream-player.js WS handler */--let _cachedHsViewerCount = 0;++++/* The player's viewer count: the watched stream's viewers everywhere it goes out, added up, and a tap lists them++ (viewer-places.js; owner, 2026-10-26: on a phone it left out Twitch, Kick, YouTube and RobotStreamer). The server's++ places (GET /streams/channel, on load and every poll) give the platforms. OpenVibe's own count also comes live from++ the player's socket ('viewer-count', stream-player.js) and from chat ('user-count', chat.js): the larger one shows. */++const _viewerCount = { streamId: null, places: [], socket: 0, chat: 0 };++++/** The watched stream's places, OpenVibe first with its live count. */++function _watchedViewerPlaces() {++ const others = _viewerCount.places.filter((p) => p.key !== 'openvibe');++ return [{ key: 'openvibe', name: 'OpenVibe', count: Math.max(_viewerCount.socket, _viewerCount.chat) }, ...others];++}++++/** A live count of OpenVibe viewers from 'socket' or 'chat'. A count for another stream than the watched one is ignored. */++function setLiveViewerCount(source, count, streamId) {++ if (streamId != null && _viewerCount.streamId != null && String(streamId) !== String(_viewerCount.streamId)) return;++ _viewerCount[source === 'socket' ? 'socket' : 'chat'] = Math.max(0, Number(count) || 0);++ _paintViewerCount();++}++++function _setWatchedViewerPlaces(stream) {++ if (String(stream.id) !== String(_viewerCount.streamId)) Object.assign(_viewerCount, { streamId: stream.id, places: [], socket: 0, chat: 0 });++ const places = stream.viewer_places && Array.isArray(stream.viewer_places.places) ? stream.viewer_places.places : null;++ if (places) {++ _viewerCount.places = places;++ const here = places.find((p) => p.key === 'openvibe');++ if (here) _viewerCount.chat = Number(here.count) || 0;++ } else {++ // A server from before viewer_places (a deploy in progress): its own count, and one line for the rest.++ _viewerCount.places = stream.external_viewer_count ? [{ key: 'custom', name: 'Other platforms', count: stream.external_viewer_count }] : [];++ _viewerCount.chat = stream.viewer_count || 0;++ }++ _paintViewerCount();++}++++function _paintViewerCount() {++ const places = _watchedViewerPlaces();++ const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);++ const el = document.getElementById('vc-viewers');++ if (el) el.textContent = total;++ const badge = document.getElementById('viewer-count-badge');++ if (badge) {++ const where = places.length > 1 && window.OVViewerPlaces ? `: ${OVViewerPlaces.summary(places)}` : '';++ badge.title = `${total} watching now${where}`;++ badge.setAttribute('aria-label', `${total} watching now${where}. Show where`);++ if (!badge.dataset.bound) {++ badge.dataset.bound = '1';++ badge.addEventListener('click', () => { if (window.OVViewerPlaces) OVViewerPlaces.toggle(badge, _watchedViewerPlaces); });++ }++ }++ if (window.OVViewerPlaces) OVViewerPlaces.update();++}function _updateMicOnlyOverlay(browserMode, streamingMethod) {const container = document.getElementById('video-container');@@@ -1454,17 +1504,8 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = nconst watched = liveStreams.find(s => String(s.id) === String(typeof currentStreamId !== 'undefined' ? currentStreamId : ''))|| liveStreams[0] || null;const watchedMsId = watched?.managed_stream_id ?? null;-- const watchedNative = watched?.viewer_count || 0;-- const watchedExternal = watched?.external_viewer_count || 0;---- // Cache the WATCHED slot's external count so the live WS update adds the right number.-- _cachedExternalViewerCount = watchedExternal;-- // Main viewer badge = this slot's native + this slot's restream viewers.-- const vcEl = document.getElementById('vc-viewers');-- if (vcEl) {-- const bestHs = Math.max(_cachedHsViewerCount || 0, watchedNative);-- vcEl.textContent = bestHs + watchedExternal;-- }++ // Main viewer badge = this slot's viewers on OpenVibe and on every platform it is restreamed to.++ if (watched) _setWatchedViewerPlaces(watched);let html = '';@@@ -30,8 +30,6 @@ function getOpenVibeNetworkUrl(service) {function getOpenVibeToolsUrl() {return getOpenVibeNetworkUrl('tools');}--/** Cached external viewer count (Kick/Twitch/RS) — updated by cumulative viewer poll */--let _cachedExternalViewerCount = 0;/* ── Capability helpers ────────────────────────────────────── */function mergeUserWithCapabilities(user, capabilities) {@@@ -264,6 +264,15 @@hiddenAt = 0;});++ // ── Where people are watching (owner, 2026-10-26): the count adds up OpenVibe, RobotStreamer, Twitch, Kick and++ // YouTube (broadcast.js broadcastViewerPlaces), and a tap on it lists them (viewer-places.js), under the pill.++ function viewerPlaces() { return typeof broadcastViewerPlaces === 'function' ? broadcastViewerPlaces() : []; }++ function toggleViewers() {++ if (!window.OVViewerPlaces) return;++ closeSheet();++ OVViewerPlaces.toggle($('bcs-viewers'), viewerPlaces, { below: true });++ }++// ── The overlay ──function railButton(act, ico, label, onClick) {return el('button', { type: 'button', class: 'bcs-btn', 'data-act': act, onclick: onClick },@@@ -274,7 +283,8 @@el('div', { class: 'bcs-top-l' },el('span', { class: 'bcs-live' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }), 'LIVE'),el('span', { class: 'bcs-pill', id: 'bcs-time', title: 'Time live' }, '0:00'),-- el('span', { class: 'bcs-pill', id: 'bcs-viewers', title: 'Watching now' }, icon('fa-eye'), el('b', { text: '0' })),++ el('button', { type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers },++ icon('fa-eye'), el('b', { text: '0' })),el('button', { type: 'button', class: 'bcs-pill bcs-restream', id: 'bcs-restream', hidden: true, onclick: () => openSheet('restreams') })),el('div', { class: 'bcs-top-r' },el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', onclick: toggleHealth },@@@ -309,6 +319,7 @@refresh();}function unmount() {++ if (window.OVViewerPlaces) OVViewerPlaces.close();closeSheet();closeConfirm();closeViewer();@@@ -350,7 +361,10 @@$('bcs-time').textContent = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');const viewers = $('bcs-viewers').querySelector('b');viewers.textContent = text('bc-viewer-count') || '0';-- $('bcs-viewers').title = `${viewers.textContent} watching now`;++ const places = viewerPlaces();++ const where = places.length > 1 && window.OVViewerPlaces ? `: ${OVViewerPlaces.summary(places)}` : '';++ $('bcs-viewers').title = `${viewers.textContent} watching now${where}`;++ $('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. Show where`);const rs = restreamSummary();const chip = $('bcs-restream');@@@ -483,6 +497,7 @@function openSheet(section) {closeSheet();++ if (window.OVViewerPlaces) OVViewerPlaces.close();st.lastFocus = document.activeElement;const ss = streamState();const title = (ss && ss.streamData && ss.streamData.title) || 'Your stream';@@@ -1626,8 +1626,7 @@ function showBrowserBroadcast() {document.getElementById('bc-chat-sidebar').style.display = '';const lb = document.getElementById('bc-live-badge'); if (lb) lb.style.display = '';const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';-- _startRsViewerPoll();-- _startRestreamViewerPoll();++ _startViewerPlacesPoll();syncBroadcastLiveButtonVisibility();}@@@ -1642,8 +1641,7 @@ async function showRTMPInstructions(stream) {document.getElementById('bc-webrtc-obs-instructions').style.display = 'none';document.getElementById('bc-chat-sidebar').style.display = '';const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';-- _startRsViewerPoll();-- _startRestreamViewerPoll();++ _startViewerPlacesPoll();syncBroadcastLiveButtonVisibility();try {const data = await api(`/streams/${stream.id}/endpoint`);@@@ -1790,8 +1788,7 @@ async function showJSMPEGInstructions(stream) {document.getElementById('bc-webrtc-obs-instructions').style.display = 'none';document.getElementById('bc-chat-sidebar').style.display = '';const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';-- _startRsViewerPoll();-- _startRestreamViewerPoll();++ _startViewerPlacesPoll();syncBroadcastLiveButtonVisibility();try {const data = await api(`/streams/${stream.id}/endpoint`);@@@ -1846,8 +1843,7 @@ async function showWHIPInstructions(stream) {document.getElementById('bc-webrtc-obs-instructions').style.display = '';document.getElementById('bc-chat-sidebar').style.display = '';const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';-- _startRsViewerPoll();-- _startRestreamViewerPoll();++ _startViewerPlacesPoll();syncBroadcastLiveButtonVisibility();const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);let whipBaseUrl = null;@@@ -2164,8 +2160,8 @@ function cleanupStream(streamId) {_perStreamViewerCounts.delete(streamId);_updateTotalViewerCount();-- // Stop RS viewer polling if no streams left-- if (broadcastState.streams.size === 0) { _stopRsViewerPoll(); _stopRestreamViewerPoll(); }++ // Stop the viewer count poll if no streams are left++ if (broadcastState.streams.size === 0) _stopViewerPlacesPoll();// If this was the active stream, clear the preview.// Do NOT auto-switch to a sibling stream — the caller (endBroadcastTab, stopBroadcast, etc.)@@@ -2343,8 +2339,7 @@ function startGlobalDisplayTimers() {function clearGlobalDisplayTimers() {clearInterval(_globalStatsInterval); _globalStatsInterval = null;clearInterval(_globalUptimeInterval); _globalUptimeInterval = null;-- _stopRsViewerPoll();-- _stopRestreamViewerPoll();++ _stopViewerPlacesPoll();}/* ── Per-Stream Heartbeat ────────────────────────────────────── */@@@ -4364,11 +4359,6 @@ async function handleSignalingMessage(streamId, msg) {/** Per-stream viewer counts from signaling, keyed by streamId */const _perStreamViewerCounts = new Map();--let _rsViewerCount = 0;--let _rsViewerPollTimer = null;--/** Platform viewer counts (Twitch, Kick, etc.) fetched from server */--let _platformViewerCount = 0;--let _platformViewerBreakdown = [];function updateBroadcastStatus(state) {const el = document.getElementById('bc-connection-status'); if (!el) return;@@@ -4399,104 +4389,100 @@ function _updateTabViewerCount(streamId, count) {if (badge) badge.innerHTML = `<i class="fa-solid fa-eye"></i> ${count}`;}--function _updateTotalViewerCount() {-- let hsTotal = 0;-- for (const [, c] of _perStreamViewerCounts) hsTotal += c;-- const total = hsTotal + _rsViewerCount + _platformViewerCount;-- const el = document.getElementById('bc-viewer-count');-- if (el) el.textContent = total;-- // RS viewer count badge (show breakdown when RS viewers exist)-- const rsEl = document.getElementById('bc-rs-viewer-count');-- if (rsEl) {-- if (_rsViewerCount > 0) { rsEl.style.display = ''; rsEl.querySelector('strong').textContent = _rsViewerCount; }-- else rsEl.style.display = 'none';-- }-- // Platform viewer count badges (Twitch/Kick/YouTube)-- _updatePlatformViewerBadges();--}++/* ── Where people are watching: OpenVibe, RobotStreamer and each restream platform ──++ The broadcaster's count is the one viewers see: the server's places for the stream (server/streaming/viewer-places.js,++ in GET /streams/channel/<you>), from its own polls of Twitch, Kick, YouTube and RobotStreamer. This page used to ask++ with its restream panel's destination list, which was empty when the page loaded before a slot was picked, so a++ phone's count left every platform out (owner, 2026-10-26). OpenVibe's own count is the larger of signaling's count++ (instant) and the server's. A tap on the phone studio's count lists the places (broadcast-studio.js). */++let _viewerPlacesTimer = null;++let _viewerPlacesFirst = null;++let _serverViewerPlaces = [];--/** Render platform viewer count badges (Twitch/Kick/YouTube) in the broadcaster info bar */--function _updatePlatformViewerBadges() {-- const container = document.getElementById('bc-platform-viewer-badges');-- if (!container) return;-- if (!_platformViewerBreakdown || _platformViewerBreakdown.length === 0) {-- container.innerHTML = '';-- return;-- }-- const platformIcons = { twitch: 'fa-brands fa-twitch', kick: 'fa-brands fa-kickstarter-k', youtube: 'fa-brands fa-youtube', custom: 'fa-solid fa-globe' };-- const platformColors = { twitch: '#9146ff', kick: '#53fc18', youtube: '#ff0000', custom: '#888' };-- let html = '';-- for (const entry of _platformViewerBreakdown) {-- const icon = platformIcons[entry.platform] || platformIcons.custom;-- const color = platformColors[entry.platform] || platformColors.custom;-- const name = entry.name || entry.platform;-- const countDisplay = entry.count != null ? `<strong>${entry.count}</strong>` : `<strong style="opacity:0.6">N/A</strong>`;-- html += `<div class="bc-info-item" style="color:${color}"><i class="${icon}"></i> ${countDisplay} on ${name}</div>`;-- }-- container.innerHTML = html;++function _startViewerPlacesPoll() {++ if (_viewerPlacesTimer) return;++ _viewerPlacesFirst = setTimeout(_pollViewerPlaces, 3000);++ _viewerPlacesTimer = setInterval(_pollViewerPlaces, 20000);}--async function _startRsViewerPoll() {-- _stopRsViewerPoll();-- // Wait for RS integration to load before checking enabled/robotId-- if (_robotStreamerIntegrationPromise) {-- try { await _robotStreamerIntegrationPromise; } catch {}-- }-- const rs = broadcastState.robotStreamer;-- if (!rs?.enabled || !rs?.robotId || !rs.managedStreamId) return;-- const poll = async () => {-- try {-- const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId, managed_stream_id: rs.managedStreamId } });-- const robots = data.integration?.available_robots || [];-- const robot = robots.find(r => String(r.robot_id) === String(rs.robotId));-- _rsViewerCount = robot ? Number(robot.viewers || 0) : 0;-- } catch { _rsViewerCount = 0; }-- _updateTotalViewerCount();-- };-- poll();-- _rsViewerPollTimer = setInterval(poll, 60000);++function _stopViewerPlacesPoll() {++ clearTimeout(_viewerPlacesFirst); _viewerPlacesFirst = null;++ clearInterval(_viewerPlacesTimer); _viewerPlacesTimer = null;++ _serverViewerPlaces = [];}--function _stopRsViewerPoll() {-- if (_rsViewerPollTimer) { clearInterval(_rsViewerPollTimer); _rsViewerPollTimer = null; }-- _rsViewerCount = 0;++async function _pollViewerPlaces() {++ if (!currentUser || document.hidden) return;++ try {++ const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}?pollOnly=1`);++ const live = (data.streams || []).filter((s) => s && s.is_live);++ // The streams this page sends; a stream from OBS (or another device) is not in it, and then all of yours count.++ const here = live.filter((s) => broadcastState.streams.has(s.id));++ _serverViewerPlaces = _mergeViewerPlaces((here.length ? here : live).map((s) => (s.viewer_places && s.viewer_places.places) || []));++ } catch { /* keep the last answer */ }++ _updateTotalViewerCount();}--/* ── Restream Platform Viewer Count Polling ───────────────────── */--// Server polls Kick/Twitch APIs directly (official API with OAuth tokens).--// Client only fetches the aggregated counts from our server.--let _restreamViewerPollTimer = null;----function _startRestreamViewerPoll() {-- _stopRestreamViewerPoll();-- // Poll every 60s, with initial 10s delay-- setTimeout(async () => {-- await _pollRestreamViewerCounts();-- _restreamViewerPollTimer = setInterval(_pollRestreamViewerCounts, 60000);-- }, 10000);++/** Several streams' places as one list: the same platform's counts add up; null (live, no count) stays null alone. */++function _mergeViewerPlaces(lists) {++ const out = [];++ for (const list of lists) {++ for (const p of list) {++ const same = out.find((o) => o.key === p.key && o.name === p.name);++ if (!same) { out.push({ ...p }); continue; }++ if (p.count != null) same.count = (same.count || 0) + p.count;++ if (!same.url) same.url = p.url;++ }++ }++ return out;}--function _stopRestreamViewerPoll() {-- if (_restreamViewerPollTimer) { clearInterval(_restreamViewerPollTimer); _restreamViewerPollTimer = null; }-- _platformViewerCount = 0;-- _platformViewerBreakdown = [];++/** This page's places, OpenVibe first. */++function broadcastViewerPlaces() {++ let signaled = 0;++ for (const [, c] of _perStreamViewerCounts) signaled += c;++ const server = _serverViewerPlaces.find((p) => p.key === 'openvibe');++ const here = Math.max(signaled, server ? Number(server.count) || 0 : 0);++ return [{ key: 'openvibe', name: 'OpenVibe', count: here }, ..._serverViewerPlaces.filter((p) => p.key !== 'openvibe')];}--async function _pollRestreamViewerCounts() {-- if (!_restreamDestinations || _restreamDestinations.length === 0) return;---- // Platform viewer counts (Kick + Twitch + others) for the slot this panel shows; with no slot,-- // the server counts the slots that are live now.-- try {-- const slotId = _restreamPanelSlotId();-- const data = await api(slotId ? `/restream/viewer-counts?managed_stream_id=${slotId}` : '/restream/viewer-counts');-- _platformViewerCount = data?.total || 0;-- _platformViewerBreakdown = data?.breakdown || [];-- } catch {-- _platformViewerCount = 0;-- _platformViewerBreakdown = [];++function _updateTotalViewerCount() {++ const places = broadcastViewerPlaces();++ const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);++ // The browser view's info bar, and the OBS / JSMPEG / WHIP panels' own counts.++ for (const id of ['bc-viewer-count', 'bc-rtmp-viewer-count', 'bc-jsmpeg-viewer-count', 'bc-whip-viewer-count']) {++ const el = document.getElementById(id);++ if (el) el.textContent = total;}-- _updateTotalViewerCount();++ const rs = places.find((p) => p.key === 'robotstreamer');++ const rsEl = document.getElementById('bc-rs-viewer-count');++ if (rsEl) {++ if (rs && rs.count > 0) { rsEl.style.display = ''; rsEl.querySelector('strong').textContent = rs.count; }++ else rsEl.style.display = 'none';++ }++ _updatePlatformViewerBadges(places);++ if (window.OVViewerPlaces) OVViewerPlaces.update();++}++++/** The info bar's line per place (OpenVibe too, once there is anywhere else): "5 on Twitch". RobotStreamer has its own. */++function _updatePlatformViewerBadges(places) {++ const container = document.getElementById('bc-platform-viewer-badges');++ if (!container) return;++ const shown = places.length > 1 ? places.filter((p) => p.key !== 'robotstreamer') : [];++ const colors = { openvibe: 'var(--accent)', twitch: '#9146ff', kick: '#53fc18', youtube: '#ff0000', custom: '#888' };++ const icons = { openvibe: 'fa-solid fa-house-signal', twitch: 'fa-brands fa-twitch', kick: 'fa-brands fa-kickstarter-k', youtube: 'fa-brands fa-youtube', custom: 'fa-solid fa-globe' };++ container.replaceChildren(...shown.map((p) => {++ const item = document.createElement('div');++ item.className = 'bc-info-item';++ item.style.color = colors[p.key] || colors.custom;++ const icon = document.createElement('i');++ icon.className = icons[p.key] || icons.custom;++ const n = document.createElement('strong');++ n.textContent = p.count == null ? 'live' : p.count;++ if (p.count == null) n.style.opacity = '0.7';++ item.append(icon, ' ', n, ` on ${p.name}`);++ return item;++ }));}/* ── Broadcaster Controls ─────────────────────────────────────── */@@@ -2135,7 +2135,7 @@ function handleChatMessage(msg) {if (typeof goalReachedInWidget === 'function' && msg.goal) goalReachedInWidget(msg.goal);break;case 'user-count':-- updateViewerCount(msg.count);++ updateViewerCount(msg.count, msg.stream_id);updateTabViewerCount(msg.stream_id, msg.count);// Also update broadcast page viewer count elementif (typeof updateViewerCountBroadcast === 'function') updateViewerCountBroadcast(msg.count);@@@ -5236,11 +5236,9 @@ function freezeGifEmote(img) {else img.addEventListener('load', freeze, { once: true });}--function updateViewerCount(count) {-- const el = document.getElementById('vc-viewers');-- // Include cached external viewer count (Kick/Twitch/RS) if available-- const external = (typeof _cachedExternalViewerCount === 'number') ? _cachedExternalViewerCount : 0;-- if (el) el.textContent = count + external;++function updateViewerCount(count, streamId) {++ // The channel player's count, which adds the restream platforms' viewers to it (app-channel.js).++ if (typeof setLiveViewerCount === 'function') setLiveViewerCount('chat', count, streamId);}/**@@@ -1324,10 +1324,8 @@ async function initWebRTC(stream) {showStreamEnded();break;case 'viewer-count':-- const vcEl = document.getElementById('vc-viewers');-- const extVc = (typeof _cachedExternalViewerCount === 'number') ? _cachedExternalViewerCount : 0;-- if (typeof _cachedHsViewerCount !== 'undefined') _cachedHsViewerCount = msg.count || 0;-- if (vcEl) vcEl.textContent = (msg.count || 0) + extVc;++ // The channel's count adds the restream platforms' viewers to it (app-channel.js).++ if (typeof setLiveViewerCount === 'function') setLiveViewerCount('socket', msg.count || 0);break;case 'sfu-source-unavailable':// Server: ingest source is gone or stale — stop aggressive retrying.nnew file mode 100644@@@ -0,0 +1,136 @@++/* Where a live stream is watched, on a tap (owner, 2026-10-26: "tapping it should show the different viewer counts across++ all platforms … and includes natural openvibe viewers too"). The server lists the places, OpenVibe first, then++ RobotStreamer and each restream platform (server/streaming/viewer-places.js); this draws them in a small panel by the++ count that was tapped. Used by the channel player's count (app-channel.js) and the phone live studio's++ (broadcast-studio.js). A place with count null is live there without a number (YouTube without an API key).++++ OVViewerPlaces.toggle(anchor, getPlaces, { below }) opens or closes the panel by `anchor`++ OVViewerPlaces.update() redraws it from getPlaces() (call when counts change)++ OVViewerPlaces.close()++ OVViewerPlaces.total(places), .summary(places) the sum, and "4 on OpenVibe, 5 on Twitch" */++(function () {++ 'use strict';++ const LOOK = {++ openvibe: { mark: true },++ robotstreamer: { icon: 'fa-solid fa-robot', color: '#4fc3f7' },++ twitch: { icon: 'fa-brands fa-twitch', color: '#a970ff' },++ kick: { icon: 'fa-brands fa-kickstarter-k', color: '#53fc18' },++ youtube: { icon: 'fa-brands fa-youtube', color: '#ff4e45' },++ custom: { icon: 'fa-solid fa-globe', color: '#9ca3af' },++ };++ const GAP = 8;++ let open = null; // { panel, anchor, get, below }++++ function el(tag, attrs, ...kids) {++ const n = document.createElement(tag);++ for (const [k, v] of Object.entries(attrs || {})) {++ if (v == null || v === false) continue;++ if (k === 'class') n.className = v;++ else if (k === 'text') n.textContent = v;++ else n.setAttribute(k, v === true ? '' : v);++ }++ for (const kid of kids) if (kid != null && kid !== false) n.append(kid);++ return n;++ }++ const total = (places) => (places || []).reduce((n, p) => n + (Number(p.count) || 0), 0);++ const fmt = (n) => Number(n).toLocaleString();++ function summary(places) {++ return (places || []).map((p) => (p.count == null ? `live on ${p.name}` : `${fmt(p.count)} on ${p.name}`)).join(', ');++ }++++ function row(p) {++ const look = LOOK[p.key] || LOOK.custom;++ const badge = look.mark++ ? el('span', { class: 'ov-mark', 'data-size': '16', 'data-static': '1', 'aria-hidden': 'true' })++ : el('i', { class: look.icon, 'aria-hidden': 'true' });++ const name = el('span', { class: 'ovp-name', text: p.name });++ const label = p.url && /^https?:\/\//i.test(p.url)++ ? el('a', { class: 'ovp-link', href: p.url, target: '_blank', rel: 'noopener' }, name, el('i', { class: 'fa-solid fa-arrow-up-right-from-square ovp-out', 'aria-hidden': 'true' }))++ : name;++ const n = p.count == null++ ? el('span', { class: 'ovp-n is-live', title: `Live on ${p.name}; it does not give a viewer count` }, 'live')++ : el('span', { class: 'ovp-n', text: fmt(p.count) });++ const li = el('li', { class: 'ovp-row' }, el('span', { class: 'ovp-ico' }, badge), label, n);++ if (look.color) li.style.setProperty('--ovp-c', look.color);++ return li;++ }++++ function draw() {++ if (!open) return;++ const places = open.get() || [];++ const list = open.panel.querySelector('.ovp-list');++ list.replaceChildren(...places.map(row));++ open.panel.querySelector('.ovp-total-n').textContent = fmt(total(places));++ place();++ }++++ /** Next to the anchor: over it, or under it (`below`, or when over it is too little room: a phone's player sits++ just under the navbar), its edge lined up with the anchor's, inside the screen. */++ function place() {++ if (!open) return;++ const { panel, anchor } = open;++ const r = anchor.getBoundingClientRect();++ const vw = window.innerWidth, vh = window.innerHeight;++ // The site's fixed navbar covers the top of the page, except in fullscreen.++ const fs = document.fullscreenElement || document.webkitFullscreenElement;++ const top = fs ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--navbar-h')) || 0;++ const over = r.top - top - GAP * 2, under = vh - r.bottom - GAP * 2;++ panel.style.maxHeight = 'none';++ const h = panel.offsetHeight;++ let below = open.below;++ if (below ? h > under && over > under : h > over && under > over) below = !below;++ const room = below ? under : over;++ panel.style.maxHeight = `${Math.max(120, room)}px`;++ const w = panel.offsetWidth;++ // A count at the top (the studio's) lines up the left edges; one in a bottom bar (the player's) the right ones.++ const left = Math.min(Math.max(GAP, open.below ? r.left : r.right - w), vw - w - GAP);++ panel.style.left = `${Math.max(GAP, left)}px`;++ if (below) { panel.style.top = `${r.bottom + GAP}px`; panel.style.bottom = ''; }++ else { panel.style.bottom = `${vh - r.top + GAP}px`; panel.style.top = ''; }++ }++++ function onOutside(e) {++ if (!open) return;++ if (open.panel.contains(e.target) || open.anchor.contains(e.target)) return;++ close();++ }++ function onKey(e) { if (e.key === 'Escape' && open) { const a = open.anchor; close(); if (a.focus) a.focus(); } }++++ function show(anchor, get, opts) {++ close();++ const panel = el('div', { class: 'ovp', role: 'dialog', 'aria-label': 'Where people are watching' },++ el('div', { class: 'ovp-head' }, el('span', { text: 'Watching now' }),++ el('span', { class: 'ovp-total' }, el('i', { class: 'fa-solid fa-eye', 'aria-hidden': 'true' }), el('b', { class: 'ovp-total-n', text: '0' }))),++ el('ul', { class: 'ovp-list' }));++ // In fullscreen only the fullscreen element shows: the panel goes inside it.++ const fs = document.fullscreenElement || document.webkitFullscreenElement;++ (fs && fs.contains(anchor) ? fs : document.body).append(panel);++ open = { panel, anchor, get, below: !!(opts && opts.below) };++ anchor.setAttribute('aria-expanded', 'true');++ document.addEventListener('pointerdown', onOutside, true);++ document.addEventListener('keydown', onKey);++ window.addEventListener('resize', place);++ draw();++ }++ function close() {++ if (!open) return;++ open.anchor.setAttribute('aria-expanded', 'false');++ open.panel.remove();++ open = null;++ document.removeEventListener('pointerdown', onOutside, true);++ document.removeEventListener('keydown', onKey);++ window.removeEventListener('resize', place);++ }++ function toggle(anchor, get, opts) {++ if (open && open.anchor === anchor) close();++ else show(anchor, get, opts);++ }++ function update() {++ if (!open) return;++ // The anchor went away (the player was rebuilt, the studio closed): so does the panel.++ if (!open.anchor.isConnected) { close(); return; }++ draw();++ }++++ window.OVViewerPlaces = { toggle, update, close, total, summary, isOpen: () => !!open };++})();@@@ -1288,13 +1288,14 @@ class RestreamManager extends EventEmitter {/*** External (Twitch/Kick/YouTube) viewer counts for one of a user's stream slots:-- * { total, breakdown: [{ platform, name, count, destId }] }; count is null when the++ * { total, breakdown: [{ platform, name, count, destId, url }] }; count is null when the* platform is live but its viewer count is unavailable. Slot-only, like control: a slot* counts only its own destinations and no slot (a legacy session) counts only the* unbound ones, so one slot's platform viewers never show on another.*/async getExternalViewerCountsForUser(userId, managedStreamId = null) {const db = require('../db/database');++ const { placeUrl } = require('./viewer-places');const dests = await db.getRestreamDestinationsForSlot(userId, managedStreamId || null) || [];const breakdown = [];let total = 0;@@@ -1302,17 +1303,25 @@ class RestreamManager extends EventEmitter {if (!d.enabled) continue;const count = this.getCachedViewerCount(d.id);const platformLive = this.isPlatformLive(d.id);-- if (count != null && count > 0) {-- breakdown.push({ platform: d.platform, name: d.name || d.platform, count, destId: d.id });++ const place = { platform: d.platform, name: d.name || d.platform, destId: d.id, url: placeUrl(d.platform, d.channel_url) };++ if (count != null && platformLive !== false) {++ // A restream nobody watches yet is 0 there, not "no count".++ breakdown.push({ ...place, count });total += count;-- } else if (platformLive === true) {-- // Platform is live but viewer count is unavailable (e.g. Kick without OAuth, YouTube)-- breakdown.push({ platform: d.platform, name: d.name || d.platform, count: null, destId: d.id });++ } else if (platformLive === true || (platformLive == null && this._restreamLiveTo(d.id))) {++ // Live on the platform, which gives no viewer count (YouTube without an API key, Kick without OAuth).++ breakdown.push({ ...place, count: null });}}return { total, breakdown };}++ /** Whether a restream session to this destination is sending now. */++ _restreamLiveTo(destId) {++ for (const [, s] of this.sessions) if (s.destId === destId && s.status === 'live') return true;++ return false;++ }++async getViewerPollingConfig() {const db = require('../db/database');const hasKickApi = !!(await db.getSetting('kick_client_id') && await db.getSetting('kick_client_secret'));@@@ -1411,8 +1420,12 @@ class RestreamManager extends EventEmitter {count = result.count;platformLive = result.platformLive;}++ } else {++ // Throttled: the last answer stands until the next poll. Without this it went stale after++ // 180 s (getCachedViewerCount), so YouTube's count dropped out for 2 of every 5 minutes.++ const last = this._viewerCountCache.get(destId);++ if (last) last.fetchedAt = Date.now();}-- // else: throttled — leave count/platformLive null so cache persists}}@@@ -28,6 +28,7 @@ const config = require('../config');const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');const jsmpegRelay = require('./jsmpeg-relay');const webrtcSFU = require('./webrtc-sfu');++const { viewerPlaces } = require('./viewer-places');const recorder = require('./recorder');const openreAuthority = require('../openre/authority');const openreMirror = require('../openre/mirror');@@@ -373,7 +374,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {ls.rs_viewers = rsActive ? rsVc : 0;ls.platform_viewers = ext.breakdown;ls.external_viewer_count = ext.total + ls.rs_viewers;-- ls.total_viewer_count = (ls.viewer_count || 0) + ls.external_viewer_count;++ // Where it is watched, OpenVibe first (viewer-places.js): the player's count and the broadcaster's add these++ // up and list them on a tap. OpenVibe's own is the larger of Chat's count and the player sockets'.++ let sockets = 0;++ try { sockets = require('./broadcast-server').getViewerCount(ls.id) || 0; } catch { /* no signaling */ }++ ls.viewer_places = viewerPlaces({ native: Math.max(ls.viewer_count || 0, sockets), rs: rsInfo[ls.id] || null, platforms: ext.breakdown });++ ls.total_viewer_count = ls.viewer_places.total;}// Include restream destination links (Twitch/Kick/YouTube) for live streams.nnew file mode 100644@@@ -0,0 +1,45 @@++/**++ * Where a live stream is being watched: OpenVibe itself, RobotStreamer, and each restream platform of the stream's slot++ * (Twitch, Kick, YouTube, a custom RTMP). The channel player's count and the broadcaster's add these up, and a tap on++ * either lists them (owner, 2026-10-26: the count on a phone left out Twitch, Kick, YouTube and RobotStreamer; "tapping++ * it should show the different viewer counts across all platforms … and includes natural openvibe viewers too").++ *++ * A place whose platform gives no number (YouTube without an API key) has count null: it is listed, as live there, and++ * adds nothing to the total.++ */++'use strict';++++const NAMES = { openvibe: 'OpenVibe', robotstreamer: 'RobotStreamer', twitch: 'Twitch', kick: 'Kick', youtube: 'YouTube' };++++const count = (n) => (n == null ? null : Math.max(0, Math.floor(Number(n)) || 0));++++/** A destination's page for viewers, or null: http(s) only, and YouTube's channel page goes to its live stream. */++function placeUrl(platform, channelUrl) {++ let href = String(channelUrl || '').trim();++ if (!/^https?:\/\/[^\s"'<>]+$/i.test(href)) return null;++ if (platform === 'youtube' && !/\/live\/?$/.test(href) && !/[?&]v=/.test(href)) href = href.replace(/\/+$/, '') + '/live';++ return href;++}++++/**++ * { total, places: [{ key, name, count, url }] }, OpenVibe first.++ * native the stream's own viewers on OpenVibe++ * rs the RobotStreamer restream when it is on: { viewer_count, robot_id }++ * platforms restream-manager's breakdown for the slot: [{ platform, name, count, url }]++ */++function viewerPlaces({ native = 0, rs = null, platforms = [] } = {}) {++ const places = [{ key: 'openvibe', name: NAMES.openvibe, count: count(native) || 0, url: null }];++ if (rs) {++ places.push({++ key: 'robotstreamer', name: NAMES.robotstreamer, count: count(rs.viewer_count) || 0,++ url: rs.robot_id ? `https://robotstreamer.com/robot/${encodeURIComponent(rs.robot_id)}` : null,++ });++ }++ for (const p of platforms || []) {++ const key = NAMES[p.platform] && p.platform !== 'openvibe' && p.platform !== 'robotstreamer' ? p.platform : 'custom';++ places.push({ key, name: NAMES[key] || String(p.name || 'Restream').slice(0, 40), count: count(p.count), url: p.url || null });++ }++ return { total: places.reduce((n, p) => n + (p.count || 0), 0), places };++}++++module.exports = { viewerPlaces, placeUrl };@@@ -111,6 +111,19 @@ let server;assert.strictEqual((await manager.getExternalViewerCountsForUser(1, slotB)).total, 7, 'slot B counts only its own platform viewers');assert.strictEqual((await manager.getExternalViewerCountsForUser(1, slotA)).total, 40);assert.strictEqual((await manager.getExternalViewerCountsForUser(1, null)).total, 3, 'no slot counts only unbound destinations');++ // Where it is watched (viewer-places.js): a restream nobody watches yet is 0 there, not left out; a platform that++ // says it is not live is left out; a restream sending with no count from its platform is listed as null.++ const kickOnly = async () => (await manager.getExternalViewerCountsForUser(1, slotB)).breakdown.map((p) => [p.platform, p.count, p.url]);++ manager.setViewerCount(bKick, 0, true);++ assert.deepStrictEqual(await kickOnly(), [['kick', 0, null]]);++ manager.setViewerCount(bKick, 4, false);++ assert.deepStrictEqual(await kickOnly(), [], 'not live on Kick (yet): no Kick line');++ manager._viewerCountCache.delete(bKick);++ assert.deepStrictEqual(await kickOnly(), [], 'nothing known and nothing sending: no line');++ manager.sessions.set('test:kick', { destId: bKick, status: 'live' });++ assert.deepStrictEqual(await kickOnly(), [['kick', null, null]], 'sending, but Kick gives no count: live there, no number');++ manager.sessions.delete('test:kick');++ manager.setViewerCount(bKick, 7, true);// ── Display: routes ─────────────────────────────────────────────────────────────const express = require('express');nnew file mode 100644@@@ -0,0 +1,139 @@++/**++ * Where a live stream is watched (owner, 2026-10-26: on a phone the viewer count left out Twitch, Kick, YouTube and++ * RobotStreamer, and "tapping it should show the different viewer counts across all platforms … and includes natural++ * openvibe viewers too").++ *++ * - server/streaming/viewer-places.js: OpenVibe first, then RobotStreamer, then the slot's platforms; a place with no++ * number (count null) is listed and adds nothing.++ * - The channel route gives each live stream its viewer_places.++ * - The broadcaster's count comes from that same answer, not from the restream panel's destination list (empty when the++ * go-live page loaded before a slot was picked: no count was asked for at all during the 10-26 phone stream).++ * - The channel player's count is the sum, and the player socket's and chat's live counts both feed OpenVibe's.++ *++ * node test/viewer-places.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const root = path.join(__dirname, '..');++const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');++const { viewerPlaces, placeUrl } = require('../server/streaming/viewer-places');++++// ── The list ──++let v = viewerPlaces({ native: 4 });++assert.deepStrictEqual(v, { total: 4, places: [{ key: 'openvibe', name: 'OpenVibe', count: 4, url: null }] }, 'OpenVibe alone');++++v = viewerPlaces({++ native: 3,++ rs: { viewer_count: 2, robot_id: 'r 1' },++ platforms: [++ { platform: 'twitch', name: 'my twitch', count: 5, url: 'https://twitch.tv/goosely' },++ { platform: 'kick', name: 'Kick', count: 0, url: 'https://kick.com/goosely' },++ { platform: 'youtube', name: 'YT', count: null, url: 'https://youtube.com/@goosely/live' },++ { platform: 'custom', name: 'My relay', count: 1, url: null },++ ],++});++assert.deepStrictEqual(v.places.map((p) => [p.key, p.name, p.count]), [++ ['openvibe', 'OpenVibe', 3], ['robotstreamer', 'RobotStreamer', 2], ['twitch', 'Twitch', 5], ['kick', 'Kick', 0],++ ['youtube', 'YouTube', null], ['custom', 'My relay', 1],++], 'OpenVibe, RobotStreamer, then each platform by its own name');++assert.strictEqual(v.total, 11, 'the total adds every number; YouTube without a count adds nothing');++assert.strictEqual(v.places[1].url, 'https://robotstreamer.com/robot/r%201');++assert.strictEqual(viewerPlaces({ native: -2, platforms: [{ platform: 'openvibe', count: 9 }] }).places[1].key, 'custom', 'a destination cannot pose as OpenVibe');++assert.strictEqual(viewerPlaces({ native: -2 }).total, 0);++++assert.strictEqual(placeUrl('youtube', 'https://www.youtube.com/@goosely/'), 'https://www.youtube.com/@goosely/live', 'YouTube goes to the live stream');++assert.strictEqual(placeUrl('youtube', 'https://www.youtube.com/watch?v=abc'), 'https://www.youtube.com/watch?v=abc');++assert.strictEqual(placeUrl('twitch', 'https://twitch.tv/goosely'), 'https://twitch.tv/goosely');++for (const bad of ['javascript:alert(1)', 'twitch.tv/x', '', null, 'https://x.tv/a b', 'https://x.tv/"onmouseover=1']) assert.strictEqual(placeUrl('twitch', bad), null, `${bad} is no link`);++++// ── The channel route ──++const routes = read('server/streaming/routes.js');++assert.match(routes, /ls\.viewer_places = viewerPlaces\(\{ native: Math\.max\(ls\.viewer_count \|\| 0, sockets\), rs: rsInfo\[ls\.id\] \|\| null, platforms: ext\.breakdown \}\);/);++assert.match(routes, /ls\.total_viewer_count = ls\.viewer_places\.total;/);++++// ── The broadcaster: one poll of the channel's answer, merged over its streams ──++const bc = read('public/js/broadcast.js');++assert.ok(!/_pollRestreamViewerCounts|_startRsViewerPoll|integration\/validate', \{ method: 'POST', body: \{ robot_input: rs\.robotId/.test(bc), 'the old destination-list and RobotStreamer polls are gone');++assert.match(bc, /api\(`\/streams\/channel\/\$\{encodeURIComponent\(currentUser\.username\)\}\?pollOnly=1`\)/);++const fnSrc = (src, name) => {++ const m = new RegExp(`\\n((?:async )?function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n\\})\\n`).exec(src);++ assert.ok(m, `${name}() is there`);++ return m[1];++};++const ctx = { Map, Number, Math, String, Array, Object, window: {}, document: { getElementById: () => null } };++vm.createContext(ctx);++vm.runInContext(`const _perStreamViewerCounts = new Map(); let _serverViewerPlaces = [];++${fnSrc(bc, '_mergeViewerPlaces')}++${fnSrc(bc, 'broadcastViewerPlaces')}++this.merge = _mergeViewerPlaces; this.places = broadcastViewerPlaces;++this.setServer = (p) => { _serverViewerPlaces = p; }; this.signal = (id, n) => _perStreamViewerCounts.set(id, n);`, ctx);++const merged = ctx.merge([++ [{ key: 'openvibe', name: 'OpenVibe', count: 2 }, { key: 'twitch', name: 'Twitch', count: 5, url: null }, { key: 'youtube', name: 'YouTube', count: null }],++ [{ key: 'openvibe', name: 'OpenVibe', count: 1 }, { key: 'twitch', name: 'Twitch', count: 1, url: 'https://twitch.tv/b' }, { key: 'youtube', name: 'YouTube', count: null }],++]);++assert.deepStrictEqual(JSON.parse(JSON.stringify(merged)), [++ { key: 'openvibe', name: 'OpenVibe', count: 3 }, { key: 'twitch', name: 'Twitch', count: 6, url: 'https://twitch.tv/b' }, { key: 'youtube', name: 'YouTube', count: null },++], 'two streams: the same platform adds up, no-count stays no-count');++ctx.setServer(merged);++ctx.signal(7, 1);++assert.deepStrictEqual(Array.from(ctx.places(), (p) => p.count), [3, 6, null], "OpenVibe: the server's count when it is larger");++ctx.signal(8, 4);++assert.strictEqual(ctx.places()[0].count, 5, "…and signaling's when that is larger (instant)");++assert.match(bc, /for \(const id of \['bc-viewer-count', 'bc-rtmp-viewer-count', 'bc-jsmpeg-viewer-count', 'bc-whip-viewer-count'\]\)/, 'the OBS, JSMPEG and WHIP panels show it too (they said 0)');++++// ── The phone studio: the pill shows that total, and a tap lists the places under it ──++const studio = read('public/js/broadcast-studio.js');++assert.match(studio, /el\('button', \{ type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers \}/);++assert.match(studio, /OVViewerPlaces\.toggle\(\$\('bcs-viewers'\), viewerPlaces, \{ below: true \}\)/);++++// ── The channel player: the sum, fed live by the socket and by chat ──++const ch = read('public/js/app-channel.js');++const painted = { text: null, title: null };++const badge = { dataset: {}, title: '', setAttribute() {}, addEventListener() {} };++const cctx = {++ Math, Number, String, Object, Array,++ window: { OVViewerPlaces: { summary: (p) => p.map((x) => `${x.count} on ${x.name}`).join(', '), update() {} } },++ document: { getElementById: (id) => (id === 'vc-viewers' ? { set textContent(t) { painted.text = t; } } : id === 'viewer-count-badge' ? badge : null) },++};++cctx.OVViewerPlaces = cctx.window.OVViewerPlaces;++vm.createContext(cctx);++const block = /\nconst _viewerCount = [\s\S]*?\nfunction _paintViewerCount\(\) \{[\s\S]*?\n\}\n/.exec(ch);++assert.ok(block, 'the viewer count block is in app-channel.js');++vm.runInContext(`${block[0]}; this.setPlaces = _setWatchedViewerPlaces; this.live = setLiveViewerCount; this.places = _watchedViewerPlaces;`, cctx);++cctx.setPlaces({ id: 41, viewer_places: { total: 9, places: [{ key: 'openvibe', name: 'OpenVibe', count: 2 }, { key: 'twitch', name: 'Twitch', count: 5 }, { key: 'kick', name: 'Kick', count: 2 }] } });++assert.strictEqual(painted.text, 9, 'the badge adds OpenVibe, Twitch and Kick');++assert.strictEqual(badge.title, '9 watching now: 2 on OpenVibe, 5 on Twitch, 2 on Kick');++cctx.live('socket', 6);++assert.strictEqual(painted.text, 13, 'a live socket count above the server\'s takes OpenVibe\'s place');++cctx.live('chat', 3, 99);++assert.strictEqual(painted.text, 13, 'a count for another stream of the channel is not this one\'s');++cctx.live('socket', 1);++cctx.live('chat', 1, 41);++assert.strictEqual(painted.text, 8, 'both down to 1: 1 + 5 + 2');++cctx.setPlaces({ id: 42, viewer_count: 4, external_viewer_count: 3 });++assert.strictEqual(painted.text, 7, 'another stream starts over; an older server\'s answer still adds up');++assert.match(read('public/js/stream-player.js'), /case 'viewer-count':\n\s+\/\/[^\n]*\n\s+if \(typeof setLiveViewerCount === 'function'\) setLiveViewerCount\('socket', msg\.count \|\| 0\);/);++assert.match(read('public/js/chat.js'), /updateViewerCount\(msg\.count, msg\.stream_id\);/);++assert.match(read('public/fragments/channel.html'), /<button type="button" class="viewer-badge" id="viewer-count-badge" aria-haspopup="dialog" aria-expanded="false"/);++++// ── The panel loads with both pages ──++const features = JSON.parse(read('public/features.json')).features;++assert.deepStrictEqual(features.viewerPlaces, { css: ['/css/features/viewer-places.css'], js: ['/js/viewer-places.js'] });++assert.ok(features.channel.deps.includes('viewerPlaces') && features.broadcast.deps.includes('viewerPlaces'));++const panel = read('public/js/viewer-places.js');++const pctx = { window: {}, document: {}, Number, Object };++vm.createContext(pctx);++vm.runInContext(panel, pctx);++const P = pctx.window.OVViewerPlaces;++const sample = [{ key: 'openvibe', name: 'OpenVibe', count: 3 }, { key: 'twitch', name: 'Twitch', count: 1400 }, { key: 'youtube', name: 'YouTube', count: null }];++assert.strictEqual(P.total(sample), 1403);++assert.strictEqual(P.summary(sample), `3 on OpenVibe, ${(1400).toLocaleString()} on Twitch, live on YouTube`, 'the count\'s label names each place');++assert.strictEqual(P.isOpen(), false);++assert.ok(!/innerHTML/.test(panel), 'the panel builds nodes (destination names are the streamer\'s own text)');++assert.match(panel, /document\.fullscreenElement \|\| document\.webkitFullscreenElement/, 'in fullscreen the panel goes inside the fullscreen player');++++console.log('✅ viewer places: one list for the channel player and the broadcaster, added up, OpenVibe first');
@@ -69,6 +69,15 @@ Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, choose
@@ -69,6 +69,15 @@ Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, choose
one-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classic
one-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classic
page, with a button back). The studio drives the same functions as the page and reads its counters.
page, with a button back). The studio drives the same functions as the page and reads its counters.
+### Viewer counts across platforms
+A live stream's viewers are counted wherever it goes out (`server/streaming/viewer-places.js`): OpenVibe itself (the
+larger of Chat's count and the player sockets'), RobotStreamer, and each restream destination of the stream's slot
+(Twitch through Helix, Kick through its API, YouTube with a `youtube_api_key` site setting; a platform that gives no
+number is listed as live without one). `GET /api/streams/channel/:username` gives each live stream these
+`viewer_places`. The channel player's count and the broadcaster's (the go-live page, the OBS/JSMPEG/WHIP panels and the
+phone studio) are their sum, and a tap on the player's count or the studio's lists them (`public/js/viewer-places.js`).
+The broadcaster's page reads the same answer every 20 seconds, so both sides see one number.
+
### Stream Chat
### Stream Chat
The broadcast page includes an embedded chat sidebar with:
The broadcast page includes an embedded chat sidebar with:
- Full chat functionality (emotes, GIFs, TTS)
- Full chat functionality (emotes, GIFs, TTS)
@@ -33,6 +33,8 @@ You don't need any of the following:
@@ -33,6 +33,8 @@ You don't need any of the following:
- The camera fills the screen, and chat runs over the bottom of the picture, where you can read and answer it.
- The camera fills the screen, and chat runs over the bottom of the picture, where you can read and answer it.
- The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection.
- The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection.
+ The number watching counts everywhere your stream goes: OpenVibe, RobotStreamer, Twitch, Kick and YouTube. Tap it
+ to see each one. Viewers see the same count on your channel's player, and can tap it too.
- The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide or
- The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide or
show it), Clip (the last 30 seconds) and More.
show it), Clip (the last 30 seconds) and More.
- **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip,
- **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip,
@@ -2569,6 +2569,9 @@ body.bc-studio-on .toast-container { z-index: 1500; }
@@ -2569,6 +2569,9 @@ body.bc-studio-on .toast-container { z-index: 1500; }
font-variant-numeric: tabular-nums; white-space: nowrap;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
}
.bcs-pill b { font-weight: 700; }
.bcs-pill b { font-weight: 700; }
+/* The count is a button: a tap lists where the stream is watched (viewer-places.js). */
+.bcs-viewers { -webkit-tap-highlight-color: transparent; }
+.bcs-viewers[aria-expanded="true"] { background: rgba(255,255,255,0.2); border-color: rgba(255,255,255,0.4); }
.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.bcs-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
.bcs-pill.is-ok .bcs-dot, .bcs-restream.is-ok::before { background: #22c55e; }
.bcs-pill.is-ok .bcs-dot, .bcs-restream.is-ok::before { background: #22c55e; }
.bcs-pill.is-warn .bcs-dot, .bcs-restream.is-warn::before { background: #f59e0b; }
.bcs-pill.is-warn .bcs-dot, .bcs-restream.is-warn::before { background: #f59e0b; }
@@ -517,6 +517,13 @@
@@ -517,6 +517,13 @@
padding: 0 6px;
padding: 0 6px;
font-variant-numeric: tabular-nums;
font-variant-numeric: tabular-nums;
}
}
+/* The viewer count is a button: a tap lists where the stream is watched (viewer-places.js). */
+button.viewer-badge {
+ background: none; border: 0; border-radius: 6px; margin: 0; min-height: 28px;
+ font-family: inherit; line-height: inherit; cursor: pointer; -webkit-tap-highlight-color: transparent;
+}
+button.viewer-badge:hover, button.viewer-badge[aria-expanded="true"] { background: rgba(255,255,255,0.14); color: #fff; }
+button.viewer-badge:focus-visible { outline: 2px solid #93c5fd; outline-offset: 1px; }
.dvr-progress-wrap:hover .dvr-progress-bar {
.dvr-progress-wrap:hover .dvr-progress-bar {
height: 6px;
height: 6px;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+/* Where a live stream is watched: the panel a tap on a viewer count opens (public/js/viewer-places.js). It sits on the
+ player or the live studio's camera, so it is dark like their controls on every theme. */
+.ovp {
+ position: fixed; z-index: 1250; min-width: 196px; max-width: min(300px, calc(100vw - 16px)); overflow-y: auto;
+ padding: 8px 0 6px; border-radius: 14px; color: #fff; font-size: 0.86rem; line-height: 1.3;
+ background: rgba(12, 15, 24, 0.94); border: 1px solid rgba(255, 255, 255, 0.14);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
+ animation: ovp-in 0.16s ease-out;
+}
+@keyframes ovp-in { from { opacity: 0; transform: translateY(4px); } }
+.ovp-head {
+ display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 14px 6px;
+ font-size: 0.74rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: rgba(255, 255, 255, 0.65);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+.ovp-total { display: inline-flex; align-items: center; gap: 6px; font-size: 0.9rem; letter-spacing: 0; text-transform: none; color: #fff; }
+.ovp-total b, .ovp-n { font-variant-numeric: tabular-nums; }
+.ovp-list { list-style: none; margin: 4px 0 0; padding: 0; }
+.ovp-row { display: flex; align-items: center; gap: 10px; min-height: 36px; padding: 0 14px; }
+.ovp-ico { display: inline-grid; place-items: center; width: 20px; flex: none; color: var(--ovp-c, var(--accent)); font-size: 1rem; }
+.ovp-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ovp-link {
+ display: inline-flex; align-items: center; gap: 6px; min-width: 0; min-height: 36px; color: inherit; text-decoration: none;
+}
+.ovp-link:hover .ovp-name, .ovp-link:focus-visible .ovp-name { text-decoration: underline; }
+.ovp-link:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; border-radius: 4px; }
+.ovp-out { font-size: 0.68rem; opacity: 0.55; }
+.ovp-n { margin-left: auto; padding-left: 12px; font-weight: 700; }
+.ovp-n.is-live { font-size: 0.72rem; font-weight: 700; color: #fca5a5; text-transform: uppercase; letter-spacing: 0.05em; }
+@media (prefers-reduced-motion: reduce) { .ovp { animation: none; } }
@@ -89,6 +89,14 @@
@@ -89,6 +89,14 @@
"openChannelUploadModal"
"openChannelUploadModal"
]
]
},
},
+ "viewerPlaces": {
+ "css": [
+ "/css/features/viewer-places.css"
+ ],
+ "js": [
+ "/js/viewer-places.js"
+ ]
+ },
"channel": {
"channel": {
"fragment": "channel",
"fragment": "channel",
"css": [
"css": [
@@ -98,7 +106,8 @@
@@ -98,7 +106,8 @@
"player",
"player",
"context",
"context",
"chatExtras",
"chatExtras",
- "uploads"
+ "uploads",
+ "viewerPlaces"
],
],
"js": [
"js": [
"/js/channel-socials.js",
"/js/channel-socials.js",
@@ -195,7 +204,8 @@
@@ -195,7 +204,8 @@
"chatExtras",
"chatExtras",
"guide",
"guide",
"aiViewers",
"aiViewers",
- "clipJobs"
+ "clipJobs",
+ "viewerPlaces"
],
],
"js": [
"js": [
"/js/broadcast-state.js",
"/js/broadcast-state.js",
@@ -43,9 +43,9 @@
@@ -43,9 +43,9 @@
<span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span>
<span class="dvr-time-display" id="dvr-time-display" style="display:none" hidden></span>
<div class="vc-spacer"></div>
<div class="vc-spacer"></div>
<button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button>
<button class="vc-btn dvr-live-btn" id="dvr-live-btn" title="Jump to live" style="display:none"><span class="live-dot"></span>LIVE</button>
- <span class="viewer-badge" id="viewer-count-badge">
- <i class="fa-solid fa-eye"></i> <span id="vc-viewers">0</span>
- </span>
+ <button type="button" class="viewer-badge" id="viewer-count-badge" aria-haspopup="dialog" aria-expanded="false" title="Watching now: tap to see where">
+ <i class="fa-solid fa-eye" aria-hidden="true"></i> <span id="vc-viewers">0</span>
+ </button>
<span class="uptime-badge" id="uptime-badge">
<span class="uptime-badge" id="uptime-badge">
<i class="fa-solid fa-clock"></i> <span id="vc-uptime">0:00:00</span>
<i class="fa-solid fa-clock"></i> <span id="vc-uptime">0:00:00</span>
</span>
</span>
@@ -5,8 +5,58 @@
@@ -5,8 +5,58 @@
route (public/features.json); it runs after app.js and relies on app.js globals.
route (public/features.json); it runs after app.js and relies on app.js globals.
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
let _streamSwitchToken = 0;
let _streamSwitchToken = 0;
-/** Cached native HS viewer count from WebSocket — updated by stream-player.js WS handler */
-let _cachedHsViewerCount = 0;
+
+/* The player's viewer count: the watched stream's viewers everywhere it goes out, added up, and a tap lists them
+ (viewer-places.js; owner, 2026-10-26: on a phone it left out Twitch, Kick, YouTube and RobotStreamer). The server's
+ places (GET /streams/channel, on load and every poll) give the platforms. OpenVibe's own count also comes live from
+ the player's socket ('viewer-count', stream-player.js) and from chat ('user-count', chat.js): the larger one shows. */
+const _viewerCount = { streamId: null, places: [], socket: 0, chat: 0 };
+
+/** The watched stream's places, OpenVibe first with its live count. */
+function _watchedViewerPlaces() {
+ const others = _viewerCount.places.filter((p) => p.key !== 'openvibe');
+ return [{ key: 'openvibe', name: 'OpenVibe', count: Math.max(_viewerCount.socket, _viewerCount.chat) }, ...others];
+}
+
+/** A live count of OpenVibe viewers from 'socket' or 'chat'. A count for another stream than the watched one is ignored. */
+function setLiveViewerCount(source, count, streamId) {
+ if (streamId != null && _viewerCount.streamId != null && String(streamId) !== String(_viewerCount.streamId)) return;
+ _viewerCount[source === 'socket' ? 'socket' : 'chat'] = Math.max(0, Number(count) || 0);
+ _paintViewerCount();
+}
+
+function _setWatchedViewerPlaces(stream) {
+ if (String(stream.id) !== String(_viewerCount.streamId)) Object.assign(_viewerCount, { streamId: stream.id, places: [], socket: 0, chat: 0 });
+ const places = stream.viewer_places && Array.isArray(stream.viewer_places.places) ? stream.viewer_places.places : null;
+ if (places) {
+ _viewerCount.places = places;
+ const here = places.find((p) => p.key === 'openvibe');
+ if (here) _viewerCount.chat = Number(here.count) || 0;
+ } else {
+ // A server from before viewer_places (a deploy in progress): its own count, and one line for the rest.
+ _viewerCount.places = stream.external_viewer_count ? [{ key: 'custom', name: 'Other platforms', count: stream.external_viewer_count }] : [];
+ _viewerCount.chat = stream.viewer_count || 0;
+ }
+ _paintViewerCount();
+}
+
+function _paintViewerCount() {
+ const places = _watchedViewerPlaces();
+ const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);
+ const el = document.getElementById('vc-viewers');
+ if (el) el.textContent = total;
+ const badge = document.getElementById('viewer-count-badge');
+ if (badge) {
+ const where = places.length > 1 && window.OVViewerPlaces ? `: ${OVViewerPlaces.summary(places)}` : '';
+ badge.title = `${total} watching now${where}`;
+ badge.setAttribute('aria-label', `${total} watching now${where}. Show where`);
+ if (!badge.dataset.bound) {
+ badge.dataset.bound = '1';
+ badge.addEventListener('click', () => { if (window.OVViewerPlaces) OVViewerPlaces.toggle(badge, _watchedViewerPlaces); });
+ }
+ }
+ if (window.OVViewerPlaces) OVViewerPlaces.update();
+}
function _updateMicOnlyOverlay(browserMode, streamingMethod) {
function _updateMicOnlyOverlay(browserMode, streamingMethod) {
const container = document.getElementById('video-container');
const container = document.getElementById('video-container');
@@ -1454,17 +1504,8 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n
@@ -1454,17 +1504,8 @@ function updateCumulativeViewers(liveStreams, rsRestream = {}, restreamLinks = n
const watched = liveStreams.find(s => String(s.id) === String(typeof currentStreamId !== 'undefined' ? currentStreamId : ''))
const watched = liveStreams.find(s => String(s.id) === String(typeof currentStreamId !== 'undefined' ? currentStreamId : ''))
|| liveStreams[0] || null;
|| liveStreams[0] || null;
const watchedMsId = watched?.managed_stream_id ?? null;
const watchedMsId = watched?.managed_stream_id ?? null;
- const watchedNative = watched?.viewer_count || 0;
- const watchedExternal = watched?.external_viewer_count || 0;
-
- // Cache the WATCHED slot's external count so the live WS update adds the right number.
- _cachedExternalViewerCount = watchedExternal;
- // Main viewer badge = this slot's native + this slot's restream viewers.
- const vcEl = document.getElementById('vc-viewers');
- if (vcEl) {
- const bestHs = Math.max(_cachedHsViewerCount || 0, watchedNative);
- vcEl.textContent = bestHs + watchedExternal;
- }
+ // Main viewer badge = this slot's viewers on OpenVibe and on every platform it is restreamed to.
+ if (watched) _setWatchedViewerPlaces(watched);
let html = '';
let html = '';
@@ -30,8 +30,6 @@ function getOpenVibeNetworkUrl(service) {
@@ -30,8 +30,6 @@ function getOpenVibeNetworkUrl(service) {
function getOpenVibeToolsUrl() {
function getOpenVibeToolsUrl() {
return getOpenVibeNetworkUrl('tools');
return getOpenVibeNetworkUrl('tools');
}
}
-/** Cached external viewer count (Kick/Twitch/RS) — updated by cumulative viewer poll */
-let _cachedExternalViewerCount = 0;
/* ── Capability helpers ────────────────────────────────────── */
/* ── Capability helpers ────────────────────────────────────── */
function mergeUserWithCapabilities(user, capabilities) {
function mergeUserWithCapabilities(user, capabilities) {
@@ -264,6 +264,15 @@
@@ -264,6 +264,15 @@
hiddenAt = 0;
hiddenAt = 0;
});
});
+ // ── Where people are watching (owner, 2026-10-26): the count adds up OpenVibe, RobotStreamer, Twitch, Kick and
+ // YouTube (broadcast.js broadcastViewerPlaces), and a tap on it lists them (viewer-places.js), under the pill.
+ function viewerPlaces() { return typeof broadcastViewerPlaces === 'function' ? broadcastViewerPlaces() : []; }
+ function toggleViewers() {
+ if (!window.OVViewerPlaces) return;
+ closeSheet();
+ OVViewerPlaces.toggle($('bcs-viewers'), viewerPlaces, { below: true });
+ }
+
// ── The overlay ──
// ── The overlay ──
function railButton(act, ico, label, onClick) {
function railButton(act, ico, label, onClick) {
return el('button', { type: 'button', class: 'bcs-btn', 'data-act': act, onclick: onClick },
return el('button', { type: 'button', class: 'bcs-btn', 'data-act': act, onclick: onClick },
@@ -274,7 +283,8 @@
@@ -274,7 +283,8 @@
el('div', { class: 'bcs-top-l' },
el('div', { class: 'bcs-top-l' },
el('span', { class: 'bcs-live' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }), 'LIVE'),
el('span', { class: 'bcs-live' }, el('span', { class: 'bcs-live-dot', 'aria-hidden': 'true' }), 'LIVE'),
el('span', { class: 'bcs-pill', id: 'bcs-time', title: 'Time live' }, '0:00'),
el('span', { class: 'bcs-pill', id: 'bcs-time', title: 'Time live' }, '0:00'),
- el('span', { class: 'bcs-pill', id: 'bcs-viewers', title: 'Watching now' }, icon('fa-eye'), el('b', { text: '0' })),
+ el('button', { type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers },
+ icon('fa-eye'), el('b', { text: '0' })),
el('button', { type: 'button', class: 'bcs-pill bcs-restream', id: 'bcs-restream', hidden: true, onclick: () => openSheet('restreams') })),
el('button', { type: 'button', class: 'bcs-pill bcs-restream', id: 'bcs-restream', hidden: true, onclick: () => openSheet('restreams') })),
el('div', { class: 'bcs-top-r' },
el('div', { class: 'bcs-top-r' },
el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', onclick: toggleHealth },
el('button', { type: 'button', class: 'bcs-pill bcs-health', id: 'bcs-health', onclick: toggleHealth },
@@ -309,6 +319,7 @@
@@ -309,6 +319,7 @@
refresh();
refresh();
}
}
function unmount() {
function unmount() {
+ if (window.OVViewerPlaces) OVViewerPlaces.close();
closeSheet();
closeSheet();
closeConfirm();
closeConfirm();
closeViewer();
closeViewer();
@@ -350,7 +361,10 @@
@@ -350,7 +361,10 @@
$('bcs-time').textContent = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');
$('bcs-time').textContent = up ? (up[1] === '0' ? `${Number(up[2])}:${up[3]}` : `${up[1]}:${up[2]}:${up[3]}`) : (text('bc-uptime') || '0:00');
const viewers = $('bcs-viewers').querySelector('b');
const viewers = $('bcs-viewers').querySelector('b');
viewers.textContent = text('bc-viewer-count') || '0';
viewers.textContent = text('bc-viewer-count') || '0';
- $('bcs-viewers').title = `${viewers.textContent} watching now`;
+ const places = viewerPlaces();
+ const where = places.length > 1 && window.OVViewerPlaces ? `: ${OVViewerPlaces.summary(places)}` : '';
+ $('bcs-viewers').title = `${viewers.textContent} watching now${where}`;
+ $('bcs-viewers').setAttribute('aria-label', `${viewers.textContent} watching now${where}. Show where`);
const rs = restreamSummary();
const rs = restreamSummary();
const chip = $('bcs-restream');
const chip = $('bcs-restream');
@@ -483,6 +497,7 @@
@@ -483,6 +497,7 @@
function openSheet(section) {
function openSheet(section) {
closeSheet();
closeSheet();
+ if (window.OVViewerPlaces) OVViewerPlaces.close();
st.lastFocus = document.activeElement;
st.lastFocus = document.activeElement;
const ss = streamState();
const ss = streamState();
const title = (ss && ss.streamData && ss.streamData.title) || 'Your stream';
const title = (ss && ss.streamData && ss.streamData.title) || 'Your stream';
@@ -1626,8 +1626,7 @@ function showBrowserBroadcast() {
@@ -1626,8 +1626,7 @@ function showBrowserBroadcast() {
document.getElementById('bc-chat-sidebar').style.display = '';
document.getElementById('bc-chat-sidebar').style.display = '';
const lb = document.getElementById('bc-live-badge'); if (lb) lb.style.display = '';
const lb = document.getElementById('bc-live-badge'); if (lb) lb.style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
- _startRsViewerPoll();
- _startRestreamViewerPoll();
+ _startViewerPlacesPoll();
syncBroadcastLiveButtonVisibility();
syncBroadcastLiveButtonVisibility();
}
}
@@ -1642,8 +1641,7 @@ async function showRTMPInstructions(stream) {
@@ -1642,8 +1641,7 @@ async function showRTMPInstructions(stream) {
document.getElementById('bc-webrtc-obs-instructions').style.display = 'none';
document.getElementById('bc-webrtc-obs-instructions').style.display = 'none';
document.getElementById('bc-chat-sidebar').style.display = '';
document.getElementById('bc-chat-sidebar').style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
- _startRsViewerPoll();
- _startRestreamViewerPoll();
+ _startViewerPlacesPoll();
syncBroadcastLiveButtonVisibility();
syncBroadcastLiveButtonVisibility();
try {
try {
const data = await api(`/streams/${stream.id}/endpoint`);
const data = await api(`/streams/${stream.id}/endpoint`);
@@ -1790,8 +1788,7 @@ async function showJSMPEGInstructions(stream) {
@@ -1790,8 +1788,7 @@ async function showJSMPEGInstructions(stream) {
document.getElementById('bc-webrtc-obs-instructions').style.display = 'none';
document.getElementById('bc-webrtc-obs-instructions').style.display = 'none';
document.getElementById('bc-chat-sidebar').style.display = '';
document.getElementById('bc-chat-sidebar').style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
- _startRsViewerPoll();
- _startRestreamViewerPoll();
+ _startViewerPlacesPoll();
syncBroadcastLiveButtonVisibility();
syncBroadcastLiveButtonVisibility();
try {
try {
const data = await api(`/streams/${stream.id}/endpoint`);
const data = await api(`/streams/${stream.id}/endpoint`);
@@ -1846,8 +1843,7 @@ async function showWHIPInstructions(stream) {
@@ -1846,8 +1843,7 @@ async function showWHIPInstructions(stream) {
document.getElementById('bc-webrtc-obs-instructions').style.display = '';
document.getElementById('bc-webrtc-obs-instructions').style.display = '';
document.getElementById('bc-chat-sidebar').style.display = '';
document.getElementById('bc-chat-sidebar').style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
const ib = document.getElementById('bc-info-bar'); if (ib) ib.style.display = '';
- _startRsViewerPoll();
- _startRestreamViewerPoll();
+ _startViewerPlacesPoll();
syncBroadcastLiveButtonVisibility();
syncBroadcastLiveButtonVisibility();
const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);
const isLocalHost = /^(localhost|127\.|\[::1\])$/.test(location.hostname);
let whipBaseUrl = null;
let whipBaseUrl = null;
@@ -2164,8 +2160,8 @@ function cleanupStream(streamId) {
@@ -2164,8 +2160,8 @@ function cleanupStream(streamId) {
_perStreamViewerCounts.delete(streamId);
_perStreamViewerCounts.delete(streamId);
_updateTotalViewerCount();
_updateTotalViewerCount();
- // Stop RS viewer polling if no streams left
- if (broadcastState.streams.size === 0) { _stopRsViewerPoll(); _stopRestreamViewerPoll(); }
+ // Stop the viewer count poll if no streams are left
+ if (broadcastState.streams.size === 0) _stopViewerPlacesPoll();
// If this was the active stream, clear the preview.
// If this was the active stream, clear the preview.
// Do NOT auto-switch to a sibling stream — the caller (endBroadcastTab, stopBroadcast, etc.)
// Do NOT auto-switch to a sibling stream — the caller (endBroadcastTab, stopBroadcast, etc.)
@@ -2343,8 +2339,7 @@ function startGlobalDisplayTimers() {
@@ -2343,8 +2339,7 @@ function startGlobalDisplayTimers() {
function clearGlobalDisplayTimers() {
function clearGlobalDisplayTimers() {
clearInterval(_globalStatsInterval); _globalStatsInterval = null;
clearInterval(_globalStatsInterval); _globalStatsInterval = null;
clearInterval(_globalUptimeInterval); _globalUptimeInterval = null;
clearInterval(_globalUptimeInterval); _globalUptimeInterval = null;
- _stopRsViewerPoll();
- _stopRestreamViewerPoll();
+ _stopViewerPlacesPoll();
}
}
/* ── Per-Stream Heartbeat ────────────────────────────────────── */
/* ── Per-Stream Heartbeat ────────────────────────────────────── */
@@ -4364,11 +4359,6 @@ async function handleSignalingMessage(streamId, msg) {
@@ -4364,11 +4359,6 @@ async function handleSignalingMessage(streamId, msg) {
/** Per-stream viewer counts from signaling, keyed by streamId */
/** Per-stream viewer counts from signaling, keyed by streamId */
const _perStreamViewerCounts = new Map();
const _perStreamViewerCounts = new Map();
-let _rsViewerCount = 0;
-let _rsViewerPollTimer = null;
-/** Platform viewer counts (Twitch, Kick, etc.) fetched from server */
-let _platformViewerCount = 0;
-let _platformViewerBreakdown = [];
function updateBroadcastStatus(state) {
function updateBroadcastStatus(state) {
const el = document.getElementById('bc-connection-status'); if (!el) return;
const el = document.getElementById('bc-connection-status'); if (!el) return;
@@ -4399,104 +4389,100 @@ function _updateTabViewerCount(streamId, count) {
@@ -4399,104 +4389,100 @@ function _updateTabViewerCount(streamId, count) {
if (badge) badge.innerHTML = `<i class="fa-solid fa-eye"></i> ${count}`;
if (badge) badge.innerHTML = `<i class="fa-solid fa-eye"></i> ${count}`;
}
}
-function _updateTotalViewerCount() {
- let hsTotal = 0;
- for (const [, c] of _perStreamViewerCounts) hsTotal += c;
- const total = hsTotal + _rsViewerCount + _platformViewerCount;
- const el = document.getElementById('bc-viewer-count');
- if (el) el.textContent = total;
- // RS viewer count badge (show breakdown when RS viewers exist)
- const rsEl = document.getElementById('bc-rs-viewer-count');
- if (rsEl) {
- if (_rsViewerCount > 0) { rsEl.style.display = ''; rsEl.querySelector('strong').textContent = _rsViewerCount; }
- else rsEl.style.display = 'none';
- }
- // Platform viewer count badges (Twitch/Kick/YouTube)
- _updatePlatformViewerBadges();
-}
+/* ── Where people are watching: OpenVibe, RobotStreamer and each restream platform ──
+ The broadcaster's count is the one viewers see: the server's places for the stream (server/streaming/viewer-places.js,
+ in GET /streams/channel/<you>), from its own polls of Twitch, Kick, YouTube and RobotStreamer. This page used to ask
+ with its restream panel's destination list, which was empty when the page loaded before a slot was picked, so a
+ phone's count left every platform out (owner, 2026-10-26). OpenVibe's own count is the larger of signaling's count
+ (instant) and the server's. A tap on the phone studio's count lists the places (broadcast-studio.js). */
+let _viewerPlacesTimer = null;
+let _viewerPlacesFirst = null;
+let _serverViewerPlaces = [];
-/** Render platform viewer count badges (Twitch/Kick/YouTube) in the broadcaster info bar */
-function _updatePlatformViewerBadges() {
- const container = document.getElementById('bc-platform-viewer-badges');
- if (!container) return;
- if (!_platformViewerBreakdown || _platformViewerBreakdown.length === 0) {
- container.innerHTML = '';
- return;
- }
- const platformIcons = { twitch: 'fa-brands fa-twitch', kick: 'fa-brands fa-kickstarter-k', youtube: 'fa-brands fa-youtube', custom: 'fa-solid fa-globe' };
- const platformColors = { twitch: '#9146ff', kick: '#53fc18', youtube: '#ff0000', custom: '#888' };
- let html = '';
- for (const entry of _platformViewerBreakdown) {
- const icon = platformIcons[entry.platform] || platformIcons.custom;
- const color = platformColors[entry.platform] || platformColors.custom;
- const name = entry.name || entry.platform;
- const countDisplay = entry.count != null ? `<strong>${entry.count}</strong>` : `<strong style="opacity:0.6">N/A</strong>`;
- html += `<div class="bc-info-item" style="color:${color}"><i class="${icon}"></i> ${countDisplay} on ${name}</div>`;
- }
- container.innerHTML = html;
+function _startViewerPlacesPoll() {
+ if (_viewerPlacesTimer) return;
+ _viewerPlacesFirst = setTimeout(_pollViewerPlaces, 3000);
+ _viewerPlacesTimer = setInterval(_pollViewerPlaces, 20000);
}
}
-async function _startRsViewerPoll() {
- _stopRsViewerPoll();
- // Wait for RS integration to load before checking enabled/robotId
- if (_robotStreamerIntegrationPromise) {
- try { await _robotStreamerIntegrationPromise; } catch {}
- }
- const rs = broadcastState.robotStreamer;
- if (!rs?.enabled || !rs?.robotId || !rs.managedStreamId) return;
- const poll = async () => {
- try {
- const data = await api('/robotstreamer/integration/validate', { method: 'POST', body: { robot_input: rs.robotId, managed_stream_id: rs.managedStreamId } });
- const robots = data.integration?.available_robots || [];
- const robot = robots.find(r => String(r.robot_id) === String(rs.robotId));
- _rsViewerCount = robot ? Number(robot.viewers || 0) : 0;
- } catch { _rsViewerCount = 0; }
- _updateTotalViewerCount();
- };
- poll();
- _rsViewerPollTimer = setInterval(poll, 60000);
+function _stopViewerPlacesPoll() {
+ clearTimeout(_viewerPlacesFirst); _viewerPlacesFirst = null;
+ clearInterval(_viewerPlacesTimer); _viewerPlacesTimer = null;
+ _serverViewerPlaces = [];
}
}
-function _stopRsViewerPoll() {
- if (_rsViewerPollTimer) { clearInterval(_rsViewerPollTimer); _rsViewerPollTimer = null; }
- _rsViewerCount = 0;
+async function _pollViewerPlaces() {
+ if (!currentUser || document.hidden) return;
+ try {
+ const data = await api(`/streams/channel/${encodeURIComponent(currentUser.username)}?pollOnly=1`);
+ const live = (data.streams || []).filter((s) => s && s.is_live);
+ // The streams this page sends; a stream from OBS (or another device) is not in it, and then all of yours count.
+ const here = live.filter((s) => broadcastState.streams.has(s.id));
+ _serverViewerPlaces = _mergeViewerPlaces((here.length ? here : live).map((s) => (s.viewer_places && s.viewer_places.places) || []));
+ } catch { /* keep the last answer */ }
+ _updateTotalViewerCount();
}
}
-/* ── Restream Platform Viewer Count Polling ───────────────────── */
-// Server polls Kick/Twitch APIs directly (official API with OAuth tokens).
-// Client only fetches the aggregated counts from our server.
-let _restreamViewerPollTimer = null;
-
-function _startRestreamViewerPoll() {
- _stopRestreamViewerPoll();
- // Poll every 60s, with initial 10s delay
- setTimeout(async () => {
- await _pollRestreamViewerCounts();
- _restreamViewerPollTimer = setInterval(_pollRestreamViewerCounts, 60000);
- }, 10000);
+/** Several streams' places as one list: the same platform's counts add up; null (live, no count) stays null alone. */
+function _mergeViewerPlaces(lists) {
+ const out = [];
+ for (const list of lists) {
+ for (const p of list) {
+ const same = out.find((o) => o.key === p.key && o.name === p.name);
+ if (!same) { out.push({ ...p }); continue; }
+ if (p.count != null) same.count = (same.count || 0) + p.count;
+ if (!same.url) same.url = p.url;
+ }
+ }
+ return out;
}
}
-function _stopRestreamViewerPoll() {
- if (_restreamViewerPollTimer) { clearInterval(_restreamViewerPollTimer); _restreamViewerPollTimer = null; }
- _platformViewerCount = 0;
- _platformViewerBreakdown = [];
+/** This page's places, OpenVibe first. */
+function broadcastViewerPlaces() {
+ let signaled = 0;
+ for (const [, c] of _perStreamViewerCounts) signaled += c;
+ const server = _serverViewerPlaces.find((p) => p.key === 'openvibe');
+ const here = Math.max(signaled, server ? Number(server.count) || 0 : 0);
+ return [{ key: 'openvibe', name: 'OpenVibe', count: here }, ..._serverViewerPlaces.filter((p) => p.key !== 'openvibe')];
}
}
-async function _pollRestreamViewerCounts() {
- if (!_restreamDestinations || _restreamDestinations.length === 0) return;
-
- // Platform viewer counts (Kick + Twitch + others) for the slot this panel shows; with no slot,
- // the server counts the slots that are live now.
- try {
- const slotId = _restreamPanelSlotId();
- const data = await api(slotId ? `/restream/viewer-counts?managed_stream_id=${slotId}` : '/restream/viewer-counts');
- _platformViewerCount = data?.total || 0;
- _platformViewerBreakdown = data?.breakdown || [];
- } catch {
- _platformViewerCount = 0;
- _platformViewerBreakdown = [];
+function _updateTotalViewerCount() {
+ const places = broadcastViewerPlaces();
+ const total = places.reduce((n, p) => n + (Number(p.count) || 0), 0);
+ // The browser view's info bar, and the OBS / JSMPEG / WHIP panels' own counts.
+ for (const id of ['bc-viewer-count', 'bc-rtmp-viewer-count', 'bc-jsmpeg-viewer-count', 'bc-whip-viewer-count']) {
+ const el = document.getElementById(id);
+ if (el) el.textContent = total;
}
}
- _updateTotalViewerCount();
+ const rs = places.find((p) => p.key === 'robotstreamer');
+ const rsEl = document.getElementById('bc-rs-viewer-count');
+ if (rsEl) {
+ if (rs && rs.count > 0) { rsEl.style.display = ''; rsEl.querySelector('strong').textContent = rs.count; }
+ else rsEl.style.display = 'none';
+ }
+ _updatePlatformViewerBadges(places);
+ if (window.OVViewerPlaces) OVViewerPlaces.update();
+}
+
+/** The info bar's line per place (OpenVibe too, once there is anywhere else): "5 on Twitch". RobotStreamer has its own. */
+function _updatePlatformViewerBadges(places) {
+ const container = document.getElementById('bc-platform-viewer-badges');
+ if (!container) return;
+ const shown = places.length > 1 ? places.filter((p) => p.key !== 'robotstreamer') : [];
+ const colors = { openvibe: 'var(--accent)', twitch: '#9146ff', kick: '#53fc18', youtube: '#ff0000', custom: '#888' };
+ const icons = { openvibe: 'fa-solid fa-house-signal', twitch: 'fa-brands fa-twitch', kick: 'fa-brands fa-kickstarter-k', youtube: 'fa-brands fa-youtube', custom: 'fa-solid fa-globe' };
+ container.replaceChildren(...shown.map((p) => {
+ const item = document.createElement('div');
+ item.className = 'bc-info-item';
+ item.style.color = colors[p.key] || colors.custom;
+ const icon = document.createElement('i');
+ icon.className = icons[p.key] || icons.custom;
+ const n = document.createElement('strong');
+ n.textContent = p.count == null ? 'live' : p.count;
+ if (p.count == null) n.style.opacity = '0.7';
+ item.append(icon, ' ', n, ` on ${p.name}`);
+ return item;
+ }));
}
}
/* ── Broadcaster Controls ─────────────────────────────────────── */
/* ── Broadcaster Controls ─────────────────────────────────────── */
@@ -2135,7 +2135,7 @@ function handleChatMessage(msg) {
@@ -2135,7 +2135,7 @@ function handleChatMessage(msg) {
if (typeof goalReachedInWidget === 'function' && msg.goal) goalReachedInWidget(msg.goal);
if (typeof goalReachedInWidget === 'function' && msg.goal) goalReachedInWidget(msg.goal);
break;
break;
case 'user-count':
case 'user-count':
- updateViewerCount(msg.count);
+ updateViewerCount(msg.count, msg.stream_id);
updateTabViewerCount(msg.stream_id, msg.count);
updateTabViewerCount(msg.stream_id, msg.count);
// Also update broadcast page viewer count element
// Also update broadcast page viewer count element
if (typeof updateViewerCountBroadcast === 'function') updateViewerCountBroadcast(msg.count);
if (typeof updateViewerCountBroadcast === 'function') updateViewerCountBroadcast(msg.count);
@@ -5236,11 +5236,9 @@ function freezeGifEmote(img) {
@@ -5236,11 +5236,9 @@ function freezeGifEmote(img) {
else img.addEventListener('load', freeze, { once: true });
else img.addEventListener('load', freeze, { once: true });
}
}
-function updateViewerCount(count) {
- const el = document.getElementById('vc-viewers');
- // Include cached external viewer count (Kick/Twitch/RS) if available
- const external = (typeof _cachedExternalViewerCount === 'number') ? _cachedExternalViewerCount : 0;
- if (el) el.textContent = count + external;
+function updateViewerCount(count, streamId) {
+ // The channel player's count, which adds the restream platforms' viewers to it (app-channel.js).
+ if (typeof setLiveViewerCount === 'function') setLiveViewerCount('chat', count, streamId);
}
}
/**
/**
@@ -1324,10 +1324,8 @@ async function initWebRTC(stream) {
@@ -1324,10 +1324,8 @@ async function initWebRTC(stream) {
showStreamEnded();
showStreamEnded();
break;
break;
case 'viewer-count':
case 'viewer-count':
- const vcEl = document.getElementById('vc-viewers');
- const extVc = (typeof _cachedExternalViewerCount === 'number') ? _cachedExternalViewerCount : 0;
- if (typeof _cachedHsViewerCount !== 'undefined') _cachedHsViewerCount = msg.count || 0;
- if (vcEl) vcEl.textContent = (msg.count || 0) + extVc;
+ // The channel's count adds the restream platforms' viewers to it (app-channel.js).
+ if (typeof setLiveViewerCount === 'function') setLiveViewerCount('socket', msg.count || 0);
break;
break;
case 'sfu-source-unavailable':
case 'sfu-source-unavailable':
// Server: ingest source is gone or stale — stop aggressive retrying.
// Server: ingest source is gone or stale — stop aggressive retrying.
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+/* Where a live stream is watched, on a tap (owner, 2026-10-26: "tapping it should show the different viewer counts across
+ all platforms … and includes natural openvibe viewers too"). The server lists the places, OpenVibe first, then
+ RobotStreamer and each restream platform (server/streaming/viewer-places.js); this draws them in a small panel by the
+ count that was tapped. Used by the channel player's count (app-channel.js) and the phone live studio's
+ (broadcast-studio.js). A place with count null is live there without a number (YouTube without an API key).
+
+ OVViewerPlaces.toggle(anchor, getPlaces, { below }) opens or closes the panel by `anchor`
+ OVViewerPlaces.update() redraws it from getPlaces() (call when counts change)
+ OVViewerPlaces.close()
+ OVViewerPlaces.total(places), .summary(places) the sum, and "4 on OpenVibe, 5 on Twitch" */
+(function () {
+ 'use strict';
+ const LOOK = {
+ openvibe: { mark: true },
+ robotstreamer: { icon: 'fa-solid fa-robot', color: '#4fc3f7' },
+ twitch: { icon: 'fa-brands fa-twitch', color: '#a970ff' },
+ kick: { icon: 'fa-brands fa-kickstarter-k', color: '#53fc18' },
+ youtube: { icon: 'fa-brands fa-youtube', color: '#ff4e45' },
+ custom: { icon: 'fa-solid fa-globe', color: '#9ca3af' },
+ };
+ const GAP = 8;
+ let open = null; // { panel, anchor, get, below }
+
+ function el(tag, attrs, ...kids) {
+ const n = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v == null || v === false) continue;
+ if (k === 'class') n.className = v;
+ else if (k === 'text') n.textContent = v;
+ else n.setAttribute(k, v === true ? '' : v);
+ }
+ for (const kid of kids) if (kid != null && kid !== false) n.append(kid);
+ return n;
+ }
+ const total = (places) => (places || []).reduce((n, p) => n + (Number(p.count) || 0), 0);
+ const fmt = (n) => Number(n).toLocaleString();
+ function summary(places) {
+ return (places || []).map((p) => (p.count == null ? `live on ${p.name}` : `${fmt(p.count)} on ${p.name}`)).join(', ');
+ }
+
+ function row(p) {
+ const look = LOOK[p.key] || LOOK.custom;
+ const badge = look.mark
+ ? el('span', { class: 'ov-mark', 'data-size': '16', 'data-static': '1', 'aria-hidden': 'true' })
+ : el('i', { class: look.icon, 'aria-hidden': 'true' });
+ const name = el('span', { class: 'ovp-name', text: p.name });
+ const label = p.url && /^https?:\/\//i.test(p.url)
+ ? el('a', { class: 'ovp-link', href: p.url, target: '_blank', rel: 'noopener' }, name, el('i', { class: 'fa-solid fa-arrow-up-right-from-square ovp-out', 'aria-hidden': 'true' }))
+ : name;
+ const n = p.count == null
+ ? el('span', { class: 'ovp-n is-live', title: `Live on ${p.name}; it does not give a viewer count` }, 'live')
+ : el('span', { class: 'ovp-n', text: fmt(p.count) });
+ const li = el('li', { class: 'ovp-row' }, el('span', { class: 'ovp-ico' }, badge), label, n);
+ if (look.color) li.style.setProperty('--ovp-c', look.color);
+ return li;
+ }
+
+ function draw() {
+ if (!open) return;
+ const places = open.get() || [];
+ const list = open.panel.querySelector('.ovp-list');
+ list.replaceChildren(...places.map(row));
+ open.panel.querySelector('.ovp-total-n').textContent = fmt(total(places));
+ place();
+ }
+
+ /** Next to the anchor: over it, or under it (`below`, or when over it is too little room: a phone's player sits
+ just under the navbar), its edge lined up with the anchor's, inside the screen. */
+ function place() {
+ if (!open) return;
+ const { panel, anchor } = open;
+ const r = anchor.getBoundingClientRect();
+ const vw = window.innerWidth, vh = window.innerHeight;
+ // The site's fixed navbar covers the top of the page, except in fullscreen.
+ const fs = document.fullscreenElement || document.webkitFullscreenElement;
+ const top = fs ? 0 : parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--navbar-h')) || 0;
+ const over = r.top - top - GAP * 2, under = vh - r.bottom - GAP * 2;
+ panel.style.maxHeight = 'none';
+ const h = panel.offsetHeight;
+ let below = open.below;
+ if (below ? h > under && over > under : h > over && under > over) below = !below;
+ const room = below ? under : over;
+ panel.style.maxHeight = `${Math.max(120, room)}px`;
+ const w = panel.offsetWidth;
+ // A count at the top (the studio's) lines up the left edges; one in a bottom bar (the player's) the right ones.
+ const left = Math.min(Math.max(GAP, open.below ? r.left : r.right - w), vw - w - GAP);
+ panel.style.left = `${Math.max(GAP, left)}px`;
+ if (below) { panel.style.top = `${r.bottom + GAP}px`; panel.style.bottom = ''; }
+ else { panel.style.bottom = `${vh - r.top + GAP}px`; panel.style.top = ''; }
+ }
+
+ function onOutside(e) {
+ if (!open) return;
+ if (open.panel.contains(e.target) || open.anchor.contains(e.target)) return;
+ close();
+ }
+ function onKey(e) { if (e.key === 'Escape' && open) { const a = open.anchor; close(); if (a.focus) a.focus(); } }
+
+ function show(anchor, get, opts) {
+ close();
+ const panel = el('div', { class: 'ovp', role: 'dialog', 'aria-label': 'Where people are watching' },
+ el('div', { class: 'ovp-head' }, el('span', { text: 'Watching now' }),
+ el('span', { class: 'ovp-total' }, el('i', { class: 'fa-solid fa-eye', 'aria-hidden': 'true' }), el('b', { class: 'ovp-total-n', text: '0' }))),
+ el('ul', { class: 'ovp-list' }));
+ // In fullscreen only the fullscreen element shows: the panel goes inside it.
+ const fs = document.fullscreenElement || document.webkitFullscreenElement;
+ (fs && fs.contains(anchor) ? fs : document.body).append(panel);
+ open = { panel, anchor, get, below: !!(opts && opts.below) };
+ anchor.setAttribute('aria-expanded', 'true');
+ document.addEventListener('pointerdown', onOutside, true);
+ document.addEventListener('keydown', onKey);
+ window.addEventListener('resize', place);
+ draw();
+ }
+ function close() {
+ if (!open) return;
+ open.anchor.setAttribute('aria-expanded', 'false');
+ open.panel.remove();
+ open = null;
+ document.removeEventListener('pointerdown', onOutside, true);
+ document.removeEventListener('keydown', onKey);
+ window.removeEventListener('resize', place);
+ }
+ function toggle(anchor, get, opts) {
+ if (open && open.anchor === anchor) close();
+ else show(anchor, get, opts);
+ }
+ function update() {
+ if (!open) return;
+ // The anchor went away (the player was rebuilt, the studio closed): so does the panel.
+ if (!open.anchor.isConnected) { close(); return; }
+ draw();
+ }
+
+ window.OVViewerPlaces = { toggle, update, close, total, summary, isOpen: () => !!open };
+})();
@@ -1288,13 +1288,14 @@ class RestreamManager extends EventEmitter {
@@ -1288,13 +1288,14 @@ class RestreamManager extends EventEmitter {
/**
/**
* External (Twitch/Kick/YouTube) viewer counts for one of a user's stream slots:
* External (Twitch/Kick/YouTube) viewer counts for one of a user's stream slots:
- * { total, breakdown: [{ platform, name, count, destId }] }; count is null when the
+ * { total, breakdown: [{ platform, name, count, destId, url }] }; count is null when the
* platform is live but its viewer count is unavailable. Slot-only, like control: a slot
* platform is live but its viewer count is unavailable. Slot-only, like control: a slot
* counts only its own destinations and no slot (a legacy session) counts only the
* counts only its own destinations and no slot (a legacy session) counts only the
* unbound ones, so one slot's platform viewers never show on another.
* unbound ones, so one slot's platform viewers never show on another.
*/
*/
async getExternalViewerCountsForUser(userId, managedStreamId = null) {
async getExternalViewerCountsForUser(userId, managedStreamId = null) {
const db = require('../db/database');
const db = require('../db/database');
+ const { placeUrl } = require('./viewer-places');
const dests = await db.getRestreamDestinationsForSlot(userId, managedStreamId || null) || [];
const dests = await db.getRestreamDestinationsForSlot(userId, managedStreamId || null) || [];
const breakdown = [];
const breakdown = [];
let total = 0;
let total = 0;
@@ -1302,17 +1303,25 @@ class RestreamManager extends EventEmitter {
@@ -1302,17 +1303,25 @@ class RestreamManager extends EventEmitter {
if (!d.enabled) continue;
if (!d.enabled) continue;
const count = this.getCachedViewerCount(d.id);
const count = this.getCachedViewerCount(d.id);
const platformLive = this.isPlatformLive(d.id);
const platformLive = this.isPlatformLive(d.id);
- if (count != null && count > 0) {
- breakdown.push({ platform: d.platform, name: d.name || d.platform, count, destId: d.id });
+ const place = { platform: d.platform, name: d.name || d.platform, destId: d.id, url: placeUrl(d.platform, d.channel_url) };
+ if (count != null && platformLive !== false) {
+ // A restream nobody watches yet is 0 there, not "no count".
+ breakdown.push({ ...place, count });
total += count;
total += count;
- } else if (platformLive === true) {
- // Platform is live but viewer count is unavailable (e.g. Kick without OAuth, YouTube)
- breakdown.push({ platform: d.platform, name: d.name || d.platform, count: null, destId: d.id });
+ } else if (platformLive === true || (platformLive == null && this._restreamLiveTo(d.id))) {
+ // Live on the platform, which gives no viewer count (YouTube without an API key, Kick without OAuth).
+ breakdown.push({ ...place, count: null });
}
}
}
}
return { total, breakdown };
return { total, breakdown };
}
}
+ /** Whether a restream session to this destination is sending now. */
+ _restreamLiveTo(destId) {
+ for (const [, s] of this.sessions) if (s.destId === destId && s.status === 'live') return true;
+ return false;
+ }
+
async getViewerPollingConfig() {
async getViewerPollingConfig() {
const db = require('../db/database');
const db = require('../db/database');
const hasKickApi = !!(await db.getSetting('kick_client_id') && await db.getSetting('kick_client_secret'));
const hasKickApi = !!(await db.getSetting('kick_client_id') && await db.getSetting('kick_client_secret'));
@@ -1411,8 +1420,12 @@ class RestreamManager extends EventEmitter {
@@ -1411,8 +1420,12 @@ class RestreamManager extends EventEmitter {
count = result.count;
count = result.count;
platformLive = result.platformLive;
platformLive = result.platformLive;
}
}
+ } else {
+ // Throttled: the last answer stands until the next poll. Without this it went stale after
+ // 180 s (getCachedViewerCount), so YouTube's count dropped out for 2 of every 5 minutes.
+ const last = this._viewerCountCache.get(destId);
+ if (last) last.fetchedAt = Date.now();
}
}
- // else: throttled — leave count/platformLive null so cache persists
}
}
}
}
@@ -28,6 +28,7 @@ const config = require('../config');
@@ -28,6 +28,7 @@ const config = require('../config');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const jsmpegRelay = require('./jsmpeg-relay');
const jsmpegRelay = require('./jsmpeg-relay');
const webrtcSFU = require('./webrtc-sfu');
const webrtcSFU = require('./webrtc-sfu');
+const { viewerPlaces } = require('./viewer-places');
const recorder = require('./recorder');
const recorder = require('./recorder');
const openreAuthority = require('../openre/authority');
const openreAuthority = require('../openre/authority');
const openreMirror = require('../openre/mirror');
const openreMirror = require('../openre/mirror');
@@ -373,7 +374,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -373,7 +374,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
ls.rs_viewers = rsActive ? rsVc : 0;
ls.rs_viewers = rsActive ? rsVc : 0;
ls.platform_viewers = ext.breakdown;
ls.platform_viewers = ext.breakdown;
ls.external_viewer_count = ext.total + ls.rs_viewers;
ls.external_viewer_count = ext.total + ls.rs_viewers;
- ls.total_viewer_count = (ls.viewer_count || 0) + ls.external_viewer_count;
+ // Where it is watched, OpenVibe first (viewer-places.js): the player's count and the broadcaster's add these
+ // up and list them on a tap. OpenVibe's own is the larger of Chat's count and the player sockets'.
+ let sockets = 0;
+ try { sockets = require('./broadcast-server').getViewerCount(ls.id) || 0; } catch { /* no signaling */ }
+ ls.viewer_places = viewerPlaces({ native: Math.max(ls.viewer_count || 0, sockets), rs: rsInfo[ls.id] || null, platforms: ext.breakdown });
+ ls.total_viewer_count = ls.viewer_places.total;
}
}
// Include restream destination links (Twitch/Kick/YouTube) for live streams.
// Include restream destination links (Twitch/Kick/YouTube) for live streams.
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+/**
+ * Where a live stream is being watched: OpenVibe itself, RobotStreamer, and each restream platform of the stream's slot
+ * (Twitch, Kick, YouTube, a custom RTMP). The channel player's count and the broadcaster's add these up, and a tap on
+ * either lists them (owner, 2026-10-26: the count on a phone left out Twitch, Kick, YouTube and RobotStreamer; "tapping
+ * it should show the different viewer counts across all platforms … and includes natural openvibe viewers too").
+ *
+ * A place whose platform gives no number (YouTube without an API key) has count null: it is listed, as live there, and
+ * adds nothing to the total.
+ */
+'use strict';
+
+const NAMES = { openvibe: 'OpenVibe', robotstreamer: 'RobotStreamer', twitch: 'Twitch', kick: 'Kick', youtube: 'YouTube' };
+
+const count = (n) => (n == null ? null : Math.max(0, Math.floor(Number(n)) || 0));
+
+/** A destination's page for viewers, or null: http(s) only, and YouTube's channel page goes to its live stream. */
+function placeUrl(platform, channelUrl) {
+ let href = String(channelUrl || '').trim();
+ if (!/^https?:\/\/[^\s"'<>]+$/i.test(href)) return null;
+ if (platform === 'youtube' && !/\/live\/?$/.test(href) && !/[?&]v=/.test(href)) href = href.replace(/\/+$/, '') + '/live';
+ return href;
+}
+
+/**
+ * { total, places: [{ key, name, count, url }] }, OpenVibe first.
+ * native the stream's own viewers on OpenVibe
+ * rs the RobotStreamer restream when it is on: { viewer_count, robot_id }
+ * platforms restream-manager's breakdown for the slot: [{ platform, name, count, url }]
+ */
+function viewerPlaces({ native = 0, rs = null, platforms = [] } = {}) {
+ const places = [{ key: 'openvibe', name: NAMES.openvibe, count: count(native) || 0, url: null }];
+ if (rs) {
+ places.push({
+ key: 'robotstreamer', name: NAMES.robotstreamer, count: count(rs.viewer_count) || 0,
+ url: rs.robot_id ? `https://robotstreamer.com/robot/${encodeURIComponent(rs.robot_id)}` : null,
+ });
+ }
+ for (const p of platforms || []) {
+ const key = NAMES[p.platform] && p.platform !== 'openvibe' && p.platform !== 'robotstreamer' ? p.platform : 'custom';
+ places.push({ key, name: NAMES[key] || String(p.name || 'Restream').slice(0, 40), count: count(p.count), url: p.url || null });
+ }
+ return { total: places.reduce((n, p) => n + (p.count || 0), 0), places };
+}
+
+module.exports = { viewerPlaces, placeUrl };
@@ -111,6 +111,19 @@ let server;
@@ -111,6 +111,19 @@ let server;
assert.strictEqual((await manager.getExternalViewerCountsForUser(1, slotB)).total, 7, 'slot B counts only its own platform viewers');
assert.strictEqual((await manager.getExternalViewerCountsForUser(1, slotB)).total, 7, 'slot B counts only its own platform viewers');
assert.strictEqual((await manager.getExternalViewerCountsForUser(1, slotA)).total, 40);
assert.strictEqual((await manager.getExternalViewerCountsForUser(1, slotA)).total, 40);
assert.strictEqual((await manager.getExternalViewerCountsForUser(1, null)).total, 3, 'no slot counts only unbound destinations');
assert.strictEqual((await manager.getExternalViewerCountsForUser(1, null)).total, 3, 'no slot counts only unbound destinations');
+ // Where it is watched (viewer-places.js): a restream nobody watches yet is 0 there, not left out; a platform that
+ // says it is not live is left out; a restream sending with no count from its platform is listed as null.
+ const kickOnly = async () => (await manager.getExternalViewerCountsForUser(1, slotB)).breakdown.map((p) => [p.platform, p.count, p.url]);
+ manager.setViewerCount(bKick, 0, true);
+ assert.deepStrictEqual(await kickOnly(), [['kick', 0, null]]);
+ manager.setViewerCount(bKick, 4, false);
+ assert.deepStrictEqual(await kickOnly(), [], 'not live on Kick (yet): no Kick line');
+ manager._viewerCountCache.delete(bKick);
+ assert.deepStrictEqual(await kickOnly(), [], 'nothing known and nothing sending: no line');
+ manager.sessions.set('test:kick', { destId: bKick, status: 'live' });
+ assert.deepStrictEqual(await kickOnly(), [['kick', null, null]], 'sending, but Kick gives no count: live there, no number');
+ manager.sessions.delete('test:kick');
+ manager.setViewerCount(bKick, 7, true);
// ── Display: routes ─────────────────────────────────────────────────────────────
// ── Display: routes ─────────────────────────────────────────────────────────────
const express = require('express');
const express = require('express');
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+/**
+ * Where a live stream is watched (owner, 2026-10-26: on a phone the viewer count left out Twitch, Kick, YouTube and
+ * RobotStreamer, and "tapping it should show the different viewer counts across all platforms … and includes natural
+ * openvibe viewers too").
+ *
+ * - server/streaming/viewer-places.js: OpenVibe first, then RobotStreamer, then the slot's platforms; a place with no
+ * number (count null) is listed and adds nothing.
+ * - The channel route gives each live stream its viewer_places.
+ * - The broadcaster's count comes from that same answer, not from the restream panel's destination list (empty when the
+ * go-live page loaded before a slot was picked: no count was asked for at all during the 10-26 phone stream).
+ * - The channel player's count is the sum, and the player socket's and chat's live counts both feed OpenVibe's.
+ *
+ * node test/viewer-places.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const root = path.join(__dirname, '..');
+const read = (p) => fs.readFileSync(path.join(root, p), 'utf8');
+const { viewerPlaces, placeUrl } = require('../server/streaming/viewer-places');
+
+// ── The list ──
+let v = viewerPlaces({ native: 4 });
+assert.deepStrictEqual(v, { total: 4, places: [{ key: 'openvibe', name: 'OpenVibe', count: 4, url: null }] }, 'OpenVibe alone');
+
+v = viewerPlaces({
+ native: 3,
+ rs: { viewer_count: 2, robot_id: 'r 1' },
+ platforms: [
+ { platform: 'twitch', name: 'my twitch', count: 5, url: 'https://twitch.tv/goosely' },
+ { platform: 'kick', name: 'Kick', count: 0, url: 'https://kick.com/goosely' },
+ { platform: 'youtube', name: 'YT', count: null, url: 'https://youtube.com/@goosely/live' },
+ { platform: 'custom', name: 'My relay', count: 1, url: null },
+ ],
+});
+assert.deepStrictEqual(v.places.map((p) => [p.key, p.name, p.count]), [
+ ['openvibe', 'OpenVibe', 3], ['robotstreamer', 'RobotStreamer', 2], ['twitch', 'Twitch', 5], ['kick', 'Kick', 0],
+ ['youtube', 'YouTube', null], ['custom', 'My relay', 1],
+], 'OpenVibe, RobotStreamer, then each platform by its own name');
+assert.strictEqual(v.total, 11, 'the total adds every number; YouTube without a count adds nothing');
+assert.strictEqual(v.places[1].url, 'https://robotstreamer.com/robot/r%201');
+assert.strictEqual(viewerPlaces({ native: -2, platforms: [{ platform: 'openvibe', count: 9 }] }).places[1].key, 'custom', 'a destination cannot pose as OpenVibe');
+assert.strictEqual(viewerPlaces({ native: -2 }).total, 0);
+
+assert.strictEqual(placeUrl('youtube', 'https://www.youtube.com/@goosely/'), 'https://www.youtube.com/@goosely/live', 'YouTube goes to the live stream');
+assert.strictEqual(placeUrl('youtube', 'https://www.youtube.com/watch?v=abc'), 'https://www.youtube.com/watch?v=abc');
+assert.strictEqual(placeUrl('twitch', 'https://twitch.tv/goosely'), 'https://twitch.tv/goosely');
+for (const bad of ['javascript:alert(1)', 'twitch.tv/x', '', null, 'https://x.tv/a b', 'https://x.tv/"onmouseover=1']) assert.strictEqual(placeUrl('twitch', bad), null, `${bad} is no link`);
+
+// ── The channel route ──
+const routes = read('server/streaming/routes.js');
+assert.match(routes, /ls\.viewer_places = viewerPlaces\(\{ native: Math\.max\(ls\.viewer_count \|\| 0, sockets\), rs: rsInfo\[ls\.id\] \|\| null, platforms: ext\.breakdown \}\);/);
+assert.match(routes, /ls\.total_viewer_count = ls\.viewer_places\.total;/);
+
+// ── The broadcaster: one poll of the channel's answer, merged over its streams ──
+const bc = read('public/js/broadcast.js');
+assert.ok(!/_pollRestreamViewerCounts|_startRsViewerPoll|integration\/validate', \{ method: 'POST', body: \{ robot_input: rs\.robotId/.test(bc), 'the old destination-list and RobotStreamer polls are gone');
+assert.match(bc, /api\(`\/streams\/channel\/\$\{encodeURIComponent\(currentUser\.username\)\}\?pollOnly=1`\)/);
+const fnSrc = (src, name) => {
+ const m = new RegExp(`\\n((?:async )?function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n\\})\\n`).exec(src);
+ assert.ok(m, `${name}() is there`);
+ return m[1];
+};
+const ctx = { Map, Number, Math, String, Array, Object, window: {}, document: { getElementById: () => null } };
+vm.createContext(ctx);
+vm.runInContext(`const _perStreamViewerCounts = new Map(); let _serverViewerPlaces = [];
+${fnSrc(bc, '_mergeViewerPlaces')}
+${fnSrc(bc, 'broadcastViewerPlaces')}
+this.merge = _mergeViewerPlaces; this.places = broadcastViewerPlaces;
+this.setServer = (p) => { _serverViewerPlaces = p; }; this.signal = (id, n) => _perStreamViewerCounts.set(id, n);`, ctx);
+const merged = ctx.merge([
+ [{ key: 'openvibe', name: 'OpenVibe', count: 2 }, { key: 'twitch', name: 'Twitch', count: 5, url: null }, { key: 'youtube', name: 'YouTube', count: null }],
+ [{ key: 'openvibe', name: 'OpenVibe', count: 1 }, { key: 'twitch', name: 'Twitch', count: 1, url: 'https://twitch.tv/b' }, { key: 'youtube', name: 'YouTube', count: null }],
+]);
+assert.deepStrictEqual(JSON.parse(JSON.stringify(merged)), [
+ { key: 'openvibe', name: 'OpenVibe', count: 3 }, { key: 'twitch', name: 'Twitch', count: 6, url: 'https://twitch.tv/b' }, { key: 'youtube', name: 'YouTube', count: null },
+], 'two streams: the same platform adds up, no-count stays no-count');
+ctx.setServer(merged);
+ctx.signal(7, 1);
+assert.deepStrictEqual(Array.from(ctx.places(), (p) => p.count), [3, 6, null], "OpenVibe: the server's count when it is larger");
+ctx.signal(8, 4);
+assert.strictEqual(ctx.places()[0].count, 5, "…and signaling's when that is larger (instant)");
+assert.match(bc, /for \(const id of \['bc-viewer-count', 'bc-rtmp-viewer-count', 'bc-jsmpeg-viewer-count', 'bc-whip-viewer-count'\]\)/, 'the OBS, JSMPEG and WHIP panels show it too (they said 0)');
+
+// ── The phone studio: the pill shows that total, and a tap lists the places under it ──
+const studio = read('public/js/broadcast-studio.js');
+assert.match(studio, /el\('button', \{ type: 'button', class: 'bcs-pill bcs-viewers', id: 'bcs-viewers', 'aria-haspopup': 'dialog', 'aria-expanded': 'false', onclick: toggleViewers \}/);
+assert.match(studio, /OVViewerPlaces\.toggle\(\$\('bcs-viewers'\), viewerPlaces, \{ below: true \}\)/);
+
+// ── The channel player: the sum, fed live by the socket and by chat ──
+const ch = read('public/js/app-channel.js');
+const painted = { text: null, title: null };
+const badge = { dataset: {}, title: '', setAttribute() {}, addEventListener() {} };
+const cctx = {
+ Math, Number, String, Object, Array,
+ window: { OVViewerPlaces: { summary: (p) => p.map((x) => `${x.count} on ${x.name}`).join(', '), update() {} } },
+ document: { getElementById: (id) => (id === 'vc-viewers' ? { set textContent(t) { painted.text = t; } } : id === 'viewer-count-badge' ? badge : null) },
+};
+cctx.OVViewerPlaces = cctx.window.OVViewerPlaces;
+vm.createContext(cctx);
+const block = /\nconst _viewerCount = [\s\S]*?\nfunction _paintViewerCount\(\) \{[\s\S]*?\n\}\n/.exec(ch);
+assert.ok(block, 'the viewer count block is in app-channel.js');
+vm.runInContext(`${block[0]}; this.setPlaces = _setWatchedViewerPlaces; this.live = setLiveViewerCount; this.places = _watchedViewerPlaces;`, cctx);
+cctx.setPlaces({ id: 41, viewer_places: { total: 9, places: [{ key: 'openvibe', name: 'OpenVibe', count: 2 }, { key: 'twitch', name: 'Twitch', count: 5 }, { key: 'kick', name: 'Kick', count: 2 }] } });
+assert.strictEqual(painted.text, 9, 'the badge adds OpenVibe, Twitch and Kick');
+assert.strictEqual(badge.title, '9 watching now: 2 on OpenVibe, 5 on Twitch, 2 on Kick');
+cctx.live('socket', 6);
+assert.strictEqual(painted.text, 13, 'a live socket count above the server\'s takes OpenVibe\'s place');
+cctx.live('chat', 3, 99);
+assert.strictEqual(painted.text, 13, 'a count for another stream of the channel is not this one\'s');
+cctx.live('socket', 1);
+cctx.live('chat', 1, 41);
+assert.strictEqual(painted.text, 8, 'both down to 1: 1 + 5 + 2');
+cctx.setPlaces({ id: 42, viewer_count: 4, external_viewer_count: 3 });
+assert.strictEqual(painted.text, 7, 'another stream starts over; an older server\'s answer still adds up');
+assert.match(read('public/js/stream-player.js'), /case 'viewer-count':\n\s+\/\/[^\n]*\n\s+if \(typeof setLiveViewerCount === 'function'\) setLiveViewerCount\('socket', msg\.count \|\| 0\);/);
+assert.match(read('public/js/chat.js'), /updateViewerCount\(msg\.count, msg\.stream_id\);/);
+assert.match(read('public/fragments/channel.html'), /<button type="button" class="viewer-badge" id="viewer-count-badge" aria-haspopup="dialog" aria-expanded="false"/);
+
+// ── The panel loads with both pages ──
+const features = JSON.parse(read('public/features.json')).features;
+assert.deepStrictEqual(features.viewerPlaces, { css: ['/css/features/viewer-places.css'], js: ['/js/viewer-places.js'] });
+assert.ok(features.channel.deps.includes('viewerPlaces') && features.broadcast.deps.includes('viewerPlaces'));
+const panel = read('public/js/viewer-places.js');
+const pctx = { window: {}, document: {}, Number, Object };
+vm.createContext(pctx);
+vm.runInContext(panel, pctx);
+const P = pctx.window.OVViewerPlaces;
+const sample = [{ key: 'openvibe', name: 'OpenVibe', count: 3 }, { key: 'twitch', name: 'Twitch', count: 1400 }, { key: 'youtube', name: 'YouTube', count: null }];
+assert.strictEqual(P.total(sample), 1403);
+assert.strictEqual(P.summary(sample), `3 on OpenVibe, ${(1400).toLocaleString()} on Twitch, live on YouTube`, 'the count\'s label names each place');
+assert.strictEqual(P.isOpen(), false);
+assert.ok(!/innerHTML/.test(panel), 'the panel builds nodes (destination names are the streamer\'s own text)');
+assert.match(panel, /document\.fullscreenElement \|\| document\.webkitFullscreenElement/, 'in fullscreen the panel goes inside the fullscreen player');
+
+console.log('✅ viewer places: one list for the channel player and the broadcaster, added up, OpenVibe first');