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 — @@ -1115,10 +1229,6 @@ -
-
- WS — -
@@ -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); });