diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html
index aee86f5..8800afa 100644
--- a/fishtank-dashboard.html
+++ b/fishtank-dashboard.html
@@ -735,6 +735,118 @@
font-size: 12px;
}
+ /* Camera reconnecting overlay */
+ .cam-reconnecting {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0,0,0,0.7);
+ gap: 10px;
+ pointer-events: none;
+ z-index: 5;
+ }
+
+ .cam-reconnecting-spinner {
+ width: 24px;
+ height: 24px;
+ border: 2px solid rgba(255,255,255,0.15);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+
+ .cam-reconnecting-label {
+ font-family: 'Share Tech Mono', monospace;
+ font-size: 9px;
+ color: var(--muted);
+ letter-spacing: 1px;
+ }
+
+ .cam-featured-wrap .cam-reconnecting-spinner {
+ width: 36px;
+ height: 36px;
+ }
+
+ .cam-featured-wrap .cam-reconnecting-label {
+ font-size: 11px;
+ }
+
+ /* Viewer count badge */
+ .cam-viewers {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ background: rgba(0,0,0,0.65);
+ color: #fff;
+ font-family: 'Share Tech Mono', monospace;
+ font-size: 9px;
+ padding: 2px 5px;
+ border-radius: 3px;
+ pointer-events: none;
+ letter-spacing: 0.5px;
+ display: flex;
+ align-items: center;
+ gap: 3px;
+ }
+
+ .cam-viewers::before {
+ content: '';
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--accent2);
+ flex-shrink: 0;
+ }
+
+ .cam-featured-wrap .cam-viewers {
+ top: 8px;
+ right: 8px;
+ font-size: 11px;
+ padding: 3px 8px;
+ }
+
+ /* Camera reconnecting overlay */
+ .cam-reconnecting {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0,0,0,0.7);
+ gap: 10px;
+ pointer-events: none;
+ z-index: 5;
+ }
+
+ .cam-reconnecting-spinner {
+ width: 24px;
+ height: 24px;
+ border: 2px solid rgba(255,255,255,0.15);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+
+ .cam-reconnecting-label {
+ font-family: 'Share Tech Mono', monospace;
+ font-size: 9px;
+ color: var(--muted);
+ letter-spacing: 1px;
+ }
+
+ .cam-featured-wrap .cam-reconnecting-spinner {
+ width: 36px;
+ height: 36px;
+ }
+
+ .cam-featured-wrap .cam-reconnecting-label {
+ font-size: 11px;
+ }
+
/* Viewer count badge */
.cam-viewers {
position: absolute;
@@ -1103,6 +1215,8 @@
NO TOKEN
+
+
WS —
LINK API
CONNECT
@@ -1115,10 +1229,6 @@
2m
5m
-
@@ -1756,6 +1866,22 @@
// directorCell = which grid index currently shows the Director stream (null = director is in featured)
let directorCell = null;
+ function showReconnecting(video) {
+ const cell = video.closest('.cam-cell') || video.closest('.cam-featured-wrap');
+ if (!cell || cell.querySelector('.cam-reconnecting')) return;
+ const overlay = document.createElement('div');
+ overlay.className = 'cam-reconnecting';
+ overlay.innerHTML = '
RECONNECTING
';
+ cell.appendChild(overlay);
+ }
+
+ function hideReconnecting(video) {
+ const cell = video.closest('.cam-cell') || video.closest('.cam-featured-wrap');
+ if (!cell) return;
+ const overlay = cell.querySelector('.cam-reconnecting');
+ if (overlay) overlay.remove();
+ }
+
function makeHls(slug, video, muted, retryDelay) {
retryDelay = retryDelay || 2000;
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
@@ -1764,11 +1890,12 @@
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.muted = muted;
video.play().catch(() => {});
+ hideReconnecting(video);
});
hls.on(Hls.Events.ERROR, (e, d) => {
if (!d.fatal) return;
+ showReconnecting(video);
hls.destroy();
- // Retry indefinitely with backoff — cap at 10s, keep trying forever
const nextDelay = Math.min(retryDelay * 1.5, 10000);
setTimeout(() => {
if (!video.isConnected) return;
@@ -1817,6 +1944,9 @@
if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) {
updateContestantOverlays(window._lastContestantData);
}
+ if (typeof updateViewerCounts === 'function' && window._lastPresenceData) {
+ updateViewerCounts(window._lastPresenceData);
+ }
}
@@ -2474,6 +2604,10 @@
if (msg.event === 'contestants:cams') {
updateContestantOverlays(msg.data.data || msg.data);
}
+ if (msg.event === 'presence') {
+ window._lastPresenceData = msg.data;
+ updateViewerCounts(msg.data);
+ }
if (msg.event === 'poll:vote') {
// data is [{value, score}, ...] — update scores in cached poll
@@ -2599,6 +2733,45 @@
};
// ── End stocks panel collapse ─────────────────────────────────
+ // ── Viewer counts ────────────────────────────────────────────
+ window.updateViewerCounts = function(data) {
+ const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1;
+ if (typeof CAMERAS !== 'undefined') {
+ CAMERAS.forEach(([name, slug], i) => {
+ if (i === (typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1)) return;
+ if (i === cammanIdx) return;
+ const cell = document.getElementById('cam-' + i);
+ if (cell) setViewerBadge(cell, data[slug]);
+ });
+ }
+ const featWrap = document.getElementById('camFeaturedWrap');
+ if (featWrap && typeof CAMERAS !== 'undefined' && typeof featuredIdx !== 'undefined') {
+ const slug = CAMERAS[featuredIdx] ? CAMERAS[featuredIdx][1] : null;
+ const featCell = featWrap.querySelector('.cam-cell') || featWrap;
+ if (slug) {
+ const dirSlug = CAMERAS[typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1][1];
+ setViewerBadge(featCell, data[slug]);
+ }
+ }
+ };
+
+ function setViewerBadge(cell, count) {
+ let badge = cell.querySelector('.cam-viewers');
+ if (count === undefined || count === null || count === 0) {
+ if (badge) badge.remove();
+ return;
+ }
+ if (!badge) {
+ badge = document.createElement('div');
+ badge.className = 'cam-viewers';
+ cell.appendChild(badge);
+ }
+ badge.textContent = count >= 1000
+ ? (count / 1000).toFixed(1) + 'k'
+ : count.toString();
+ }
+ // ── End viewer counts ─────────────────────────────────────────
+
// ── Contestant cam overlays ──────────────────────────────────
const AVATAR_CDN = 'https://cdn.fishtank.live/images/contestants/s5/';
diff --git a/server.js b/server.js
index d0d9a74..f1c16e9 100644
--- a/server.js
+++ b/server.js
@@ -89,6 +89,22 @@ function sendSubscriptions() {
sendBinary(buildSubscribeFrame('presence'));
}
+// Presence is client-driven — must re-request every 30s to get updated counts
+let presenceTimer = null;
+
+function startPresencePolling() {
+ if (presenceTimer) clearInterval(presenceTimer);
+ presenceTimer = setInterval(() => {
+ if (ftSocket && ftSocket.readyState === 1) {
+ sendBinary(buildSubscribeFrame('presence'));
+ }
+ }, 30000);
+}
+
+function stopPresencePolling() {
+ if (presenceTimer) { clearInterval(presenceTimer); presenceTimer = null; }
+}
+
// ── Simple msgpack decoder (enough for fishtank events) ─────
function mpDecode(buf, offset = 0) {
if (offset >= buf.length) return [null, offset];
@@ -205,6 +221,7 @@ function handleBinaryFrame(buf) {
namespaceReady = true;
broadcast({ _ft: 'ws_status', status: 'connected' });
sendSubscriptions();
+ startPresencePolling();
return;
}
@@ -286,6 +303,7 @@ function connectFishtankWS(token) {
console.log(`[WS] Disconnected (${code} ${reason || ''}). Reconnecting in 5s...`);
broadcast({ _ft: 'ws_status', status: 'disconnected' });
namespaceReady = false;
+ stopPresencePolling();
reconnectTimer = setTimeout(() => connectFishtankWS(null), 5000);
});