openvibers/openvibe.live Commits

Viewer counts add up every platform (OpenVibe, RobotStreamer, Twitch, Kick, YouTube), and a tap lists each (#375)

OP OpenVibers committed 18 days ago · 9cbb640aee · 19 files, +592 −143

The owner, 2026-10-26, after streaming from a phone: the viewer count left out Twitch, Kick, YouTube and RobotStreamer,
and tapping it should show each platform's count, OpenVibe's own viewers too.

Cause: the go-live page asked for platform counts with its restream panel's destination list, which it loaded before a
slot was picked (GET /restream/destinations with no slot: an empty list), so it never asked. Production logs for
stream 2794 (18:40-19:14 UTC) show no viewer-count or RobotStreamer request at all.

Now one list for both sides, from the server (server/streaming/viewer-places.js): each live stream in
GET /streams/channel/:username has viewer_places, OpenVibe first (the larger of Chat's count and the player sockets'),
then RobotStreamer and the slot's restream platforms. The channel player's count and the broadcaster's are the sum;
the broadcaster's page reads that answer every 20 s. A tap on the player's count or the phone studio's opens a panel
listing each place with links (public/js/viewer-places.js). Also: a live restream with 0 viewers shows 0 instead of
nothing, one sending with no count from its platform shows as live, YouTube's count no longer drops out between its
5-minute polls, and the OBS/JSMPEG/WHIP panels show the count (they always said 0).

Changes

ddiff --git a/docs/broadcasting.md b/docs/broadcasting.md
iindex 8819aee7..ca5b9790 100644
---- a/docs/broadcasting.md
++++ b/docs/broadcasting.md
@@@ -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
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
The broadcast page includes an embedded chat sidebar with:
- Full chat functionality (emotes, GIFs, TTS)
ddiff --git a/docs/go-live-in-your-browser.md b/docs/go-live-in-your-browser.md
iindex 5dad6902..1f752710 100644
---- a/docs/go-live-in-your-browser.md
++++ b/docs/go-live-in-your-browser.md
@@@ -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 or
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,
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex b8ec3708..1d1f19d9 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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; }
ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex ea1cf7e8..3654203a 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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;
}
ddiff --git a/public/css/features/viewer-places.css b/public/css/features/viewer-places.css
nnew file mode 100644
iindex 00000000..232dd6d2
---- /dev/null
++++ b/public/css/features/viewer-places.css
@@@ -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; } }
ddiff --git a/public/features.json b/public/features.json
iindex b05b8158..f6d571e2 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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",
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex acfcc87e..7133493b 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 4d589937..d5d34c33 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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 = n
const 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 = '';
ddiff --git a/public/js/app.js b/public/js/app.js
iindex a3eb428d..21bdf54b 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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) {
ddiff --git a/public/js/broadcast-studio.js b/public/js/broadcast-studio.js
iindex c7d3722e..b0e4d37c 100644
---- a/public/js/broadcast-studio.js
++++ b/public/js/broadcast-studio.js
@@@ -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';
ddiff --git a/public/js/broadcast.js b/public/js/broadcast.js
iindex 6b8723c6..d47a4ebc 100644
---- a/public/js/broadcast.js
++++ b/public/js/broadcast.js
@@@ -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 ─────────────────────────────────────── */
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex cd1ebce2..925f966f 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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 element
if (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);
}
/**
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex 1530a905..a5626848 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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.
ddiff --git a/public/js/viewer-places.js b/public/js/viewer-places.js
nnew file mode 100644
iindex 00000000..c1ae0dbd
---- /dev/null
++++ b/public/js/viewer-places.js
@@@ -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 };
++})();
ddiff --git a/server/streaming/restream-manager.js b/server/streaming/restream-manager.js
iindex 271fe2d0..fc92ab20 100644
---- a/server/streaming/restream-manager.js
++++ b/server/streaming/restream-manager.js
@@@ -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
}
}
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 26ebfec7..d6d48838 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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.
ddiff --git a/server/streaming/viewer-places.js b/server/streaming/viewer-places.js
nnew file mode 100644
iindex 00000000..2f6f08d5
---- /dev/null
++++ b/server/streaming/viewer-places.js
@@@ -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 };
ddiff --git a/test/restream-slot-scope.test.js b/test/restream-slot-scope.test.js
iindex c728bca0..d2e40791 100644
---- a/test/restream-slot-scope.test.js
++++ b/test/restream-slot-scope.test.js
@@@ -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');
ddiff --git a/test/viewer-places.test.js b/test/viewer-places.test.js
nnew file mode 100644
iindex 00000000..b6ba062a
---- /dev/null
++++ b/test/viewer-places.test.js
@@@ -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');