diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html index 92b601d..f5b2789 100644 --- a/fishtank-dashboard.html +++ b/fishtank-dashboard.html @@ -161,9 +161,9 @@ position: relative; z-index: 1; display: grid; - grid-template-columns: 320px 1fr 320px; + grid-template-columns: 320px 1fr 300px; grid-template-rows: 320px 1fr; - grid-template-areas: "poll stocks camman" "tts cameras cameras"; + grid-template-areas: "poll stocks chat" "tts cameras chat"; gap: 1px; flex: 1; min-height: 0; @@ -173,24 +173,24 @@ } .main.stocks-collapsed { - grid-template-columns: 320px 1fr 0px; - grid-template-areas: "poll cameras cameras" "tts cameras cameras"; + grid-template-columns: 320px 1fr 300px; + grid-template-areas: "poll cameras chat" "tts cameras chat"; } .main.stocks-collapsed .stocks-hide { display: none; } .main.left-collapsed { - grid-template-columns: 0px 1fr 320px; - grid-template-areas: "stocks stocks camman" "cameras cameras camman"; + grid-template-columns: 0px 1fr 300px; + grid-template-areas: "stocks stocks chat" "cameras cameras chat"; } .main.left-collapsed .left-hide { display: none; } .main.left-collapsed.stocks-collapsed { - grid-template-columns: 0px 1fr 0px; - grid-template-areas: "cameras cameras cameras" "cameras cameras cameras"; + grid-template-columns: 0px 1fr 300px; + grid-template-areas: "cameras cameras chat" "cameras cameras chat"; } .main.left-collapsed.stocks-collapsed .stocks-hide { display: none; @@ -998,6 +998,139 @@ margin-top: 2px; } + /* Inline chat panel */ + .chat-panel { + grid-area: chat; + display: flex; + flex-direction: column; + background: var(--panel); + border-left: 1px solid var(--border); + overflow: hidden; + min-height: 0; + } + + .main.chat-collapsed .chat-panel { + display: none; + } + + .main.chat-collapsed { + grid-template-columns: 320px 1fr 0px !important; + grid-template-areas: "poll stocks ." "tts cameras ." !important; + } + .main.chat-collapsed.stocks-collapsed { + grid-template-areas: "poll cameras ." "tts cameras ." !important; + } + .main.chat-collapsed.left-collapsed { + grid-template-columns: 0px 1fr 0px !important; + grid-template-areas: "stocks stocks ." "cameras cameras ." !important; + } + .main.chat-collapsed.left-collapsed.stocks-collapsed { + grid-template-columns: 0px 1fr 0px !important; + grid-template-areas: "cameras cameras cameras" "cameras cameras cameras" !important; + } + + #inlineChatFeed { + flex: 1; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + } + + #inlineChatFeed::-webkit-scrollbar { width: 4px; } + #inlineChatFeed::-webkit-scrollbar-track { background: transparent; } + #inlineChatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + + #inlineChatFeed .msg { + padding: 3px 6px; + border-radius: 3px; + font-size: 11px; + line-height: 1.4; + border-left: 2px solid transparent; + } + + #inlineChatFeed .msg.fish { border-left-color: var(--accent3); background: rgba(255,230,0,0.04); } + #inlineChatFeed .msg.admin { border-left-color: var(--accent2); background: rgba(255,61,113,0.06); } + #inlineChatFeed .msg.mod { border-left-color: var(--green); background: rgba(0,255,136,0.05); } + + #inlineChatFeed .msg-header { + display: flex; + align-items: baseline; + gap: 4px; + flex-wrap: wrap; + } + + #inlineChatFeed .msg-user { + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + font-weight: bold; + color: var(--accent); + } + + #inlineChatFeed .msg-endorsement { + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + font-weight: bold; + letter-spacing: 1px; + opacity: 0.85; + } + + #inlineChatFeed .msg-time { + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + color: var(--muted); + margin-left: auto; + } + + #inlineChatFeed .msg-text { + color: var(--text); + word-break: break-word; + } + + #inlineChatFeed .badge { + font-family: 'Share Tech Mono', monospace; + font-size: 8px; + padding: 1px 4px; + border-radius: 2px; + font-weight: bold; + letter-spacing: 0.5px; + } + + #inlineChatFeed .badge.admin { background: var(--accent2); color: #fff; } + #inlineChatFeed .badge.mod { background: var(--green); color: #000; } + #inlineChatFeed .badge.fish { background: var(--accent3); color: #000; } + #inlineChatFeed .badge.gm { background: var(--accent); color: #000; } + + .chat-footer { + padding: 4px 8px; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + color: var(--muted); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + #inlineChatScrollBtn { + display: none; + position: absolute; + bottom: 28px; + right: 8px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 3px; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + padding: 3px 8px; + cursor: pointer; + z-index: 10; + } + /* Viewer count badge */ .cam-viewers { position: absolute; @@ -1272,6 +1405,139 @@ margin-top: 2px; } + /* Inline chat panel */ + .chat-panel { + grid-area: chat; + display: flex; + flex-direction: column; + background: var(--panel); + border-left: 1px solid var(--border); + overflow: hidden; + min-height: 0; + } + + .main.chat-collapsed .chat-panel { + display: none; + } + + .main.chat-collapsed { + grid-template-columns: 320px 1fr 0px !important; + grid-template-areas: "poll stocks ." "tts cameras ." !important; + } + .main.chat-collapsed.stocks-collapsed { + grid-template-areas: "poll cameras ." "tts cameras ." !important; + } + .main.chat-collapsed.left-collapsed { + grid-template-columns: 0px 1fr 0px !important; + grid-template-areas: "stocks stocks ." "cameras cameras ." !important; + } + .main.chat-collapsed.left-collapsed.stocks-collapsed { + grid-template-columns: 0px 1fr 0px !important; + grid-template-areas: "cameras cameras cameras" "cameras cameras cameras" !important; + } + + #inlineChatFeed { + flex: 1; + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; + } + + #inlineChatFeed::-webkit-scrollbar { width: 4px; } + #inlineChatFeed::-webkit-scrollbar-track { background: transparent; } + #inlineChatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + + #inlineChatFeed .msg { + padding: 3px 6px; + border-radius: 3px; + font-size: 11px; + line-height: 1.4; + border-left: 2px solid transparent; + } + + #inlineChatFeed .msg.fish { border-left-color: var(--accent3); background: rgba(255,230,0,0.04); } + #inlineChatFeed .msg.admin { border-left-color: var(--accent2); background: rgba(255,61,113,0.06); } + #inlineChatFeed .msg.mod { border-left-color: var(--green); background: rgba(0,255,136,0.05); } + + #inlineChatFeed .msg-header { + display: flex; + align-items: baseline; + gap: 4px; + flex-wrap: wrap; + } + + #inlineChatFeed .msg-user { + font-family: 'Share Tech Mono', monospace; + font-size: 10px; + font-weight: bold; + color: var(--accent); + } + + #inlineChatFeed .msg-endorsement { + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + font-weight: bold; + letter-spacing: 1px; + opacity: 0.85; + } + + #inlineChatFeed .msg-time { + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + color: var(--muted); + margin-left: auto; + } + + #inlineChatFeed .msg-text { + color: var(--text); + word-break: break-word; + } + + #inlineChatFeed .badge { + font-family: 'Share Tech Mono', monospace; + font-size: 8px; + padding: 1px 4px; + border-radius: 2px; + font-weight: bold; + letter-spacing: 0.5px; + } + + #inlineChatFeed .badge.admin { background: var(--accent2); color: #fff; } + #inlineChatFeed .badge.mod { background: var(--green); color: #000; } + #inlineChatFeed .badge.fish { background: var(--accent3); color: #000; } + #inlineChatFeed .badge.gm { background: var(--accent); color: #000; } + + .chat-footer { + padding: 4px 8px; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + color: var(--muted); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + } + + #inlineChatScrollBtn { + display: none; + position: absolute; + bottom: 28px; + right: 8px; + background: var(--accent); + color: var(--bg); + border: none; + border-radius: 3px; + font-family: 'Share Tech Mono', monospace; + font-size: 9px; + padding: 3px 8px; + cursor: pointer; + z-index: 10; + } + /* Viewer count badge */ .cam-viewers { position: absolute; @@ -1472,7 +1738,6 @@ setApiStatus('none'); } initCameras(); - initCamerman(); // Apply saved thumbnail interval from dropdown const iv = document.getElementById('intervalSelect'); if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value); @@ -1487,67 +1752,6 @@ }, 500); }); - function initCamerman() { - const video = document.getElementById('cammanVideo'); - if (!video) return; - const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); - if (typeof Hls !== 'undefined' && Hls.isSupported()) { - if (hlsInstances['camman']) hlsInstances['camman'].destroy(); - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); - hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); - hls.on(Hls.Events.ERROR, (e, d) => { - if (d.fatal) { - const label = document.getElementById('cammanLabel'); - if (label) label.textContent = 'CAMERAMAN · RECONNECTING...'; - hls.destroy(); - setTimeout(() => { - if (video.isConnected) initCamerman(); - }, 3000); - } - }); - hlsInstances['camman'] = hls; - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = 'http://localhost:3000/cam/cameraman2-5/index.m3u8'; - video.play().catch(() => {}); - } - } - - // Update cameraman panel label when it gets swapped with featured - const _origSetFeatured = setFeatured; - setFeatured = function(i) { - _origSetFeatured(i); - // If cameraman is now in featured, show director in camman panel - // If cameraman is what's being swapped back, restore its own stream - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); - const cammanVideo = document.getElementById('cammanVideo'); - const cammanLabel = document.getElementById('cammanLabel'); - if (!cammanVideo) return; - - if (i === cammanIdx) { - // Cameraman just went to featured - show director in camman panel - const dirSlug = CAMERAS[DEFAULT_IDX][1]; - if (hlsInstances['camman']) hlsInstances['camman'].destroy(); - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/' + dirSlug + '/index.m3u8'); - hls.attachMedia(cammanVideo); - hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); - hlsInstances['camman'] = hls; - cammanLabel.textContent = 'DIRECTOR MODE'; - } else if (directorCell === cammanIdx) { - // Director is in camman panel cell — keep it - } else { - // Restore camman panel to cameraman stream - if (hlsInstances['camman']) hlsInstances['camman'].destroy(); - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); - hls.attachMedia(cammanVideo); - hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); - hlsInstances['camman'] = hls; - cammanLabel.textContent = 'CAMERAMAN'; - } - }; @@ -1659,7 +1801,7 @@
- +
@@ -1716,12 +1858,7 @@
-
-
- -
CAMERAMAN
-
-
+
@@ -1740,6 +1877,19 @@
+
+
+
💬
Waiting for messages...
+
+ + +
+
CAMERAS
@@ -2428,6 +2578,7 @@ function setThumbStream(gridIdx, slug, labelOverride) { const cell = document.getElementById('cam-' + gridIdx); if (!cell) return; + cell.dataset.slug = slug; // keep slug in sync for viewer count sorting const label = cell.querySelector('.cam-label'); label.textContent = (labelOverride || CAMERAS[gridIdx][0]).toUpperCase(); if (thumbMode) { @@ -2456,7 +2607,7 @@ featuredIdx = idx; // Restart buffer for new camera — reset audio context too (new video element) stopBuffer(); - bufferAudioCtx = null; + if (bufferAudioCtx) { try { bufferAudioCtx.close(); } catch(e) {} bufferAudioCtx = null; } if (bufferCanvasInterval) { clearInterval(bufferCanvasInterval); bufferCanvasInterval = null; } if (bufferCanvas) { bufferCanvas.remove(); bufferCanvas = null; } setTimeout(() => ensureBuffer(), 500); @@ -2543,22 +2694,26 @@ const featVideo = wrap.querySelector('video'); if (!featVideo) return null; - // Always use a 960x540 canvas for the buffer — fixes background tab throttling - // and ensures consistent 540p output regardless of source resolution + // Always use a 1280x720 canvas for the buffer — fixes background tab throttling + // and ensures consistent 720p output regardless of source resolution if (!bufferCanvas || !bufferCanvas.isConnected) { if (bufferCanvas) bufferCanvas.remove(); bufferCanvas = document.createElement('canvas'); - bufferCanvas.width = 960; - bufferCanvas.height = 540; + bufferCanvas.width = 1280; + bufferCanvas.height = 720; bufferCanvas.style.cssText = 'position:fixed;top:-9999px;'; document.body.appendChild(bufferCanvas); } const ctx = bufferCanvas.getContext('2d'); if (bufferCanvasInterval) clearInterval(bufferCanvasInterval); + // Cancel any previous rVFC loop by invalidating it via a shared token + if (window._bufferDrawToken) window._bufferDrawToken.cancelled = true; + const drawToken = { cancelled: false }; + window._bufferDrawToken = drawToken; function drawFrame() { - if (!bufferCanvas || !bufferCanvas.isConnected) return; - if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 960, 540); + if (drawToken.cancelled || !bufferCanvas || !bufferCanvas.isConnected) return; + if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 1280, 720); if (typeof featVideo.requestVideoFrameCallback === 'function') { featVideo.requestVideoFrameCallback(drawFrame); } @@ -2567,7 +2722,7 @@ featVideo.requestVideoFrameCallback(drawFrame); } else { bufferCanvasInterval = setInterval(() => { - if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 960, 540); + if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 1280, 720); }, 1000 / 30); } @@ -2681,7 +2836,8 @@ // Reset clip button if (clipBtn) { clipBtn.style.borderColor = ''; clipBtn.style.color = ''; clipBtn.title = ''; } // Restart buffer fresh when returning — old frames are degraded - setTimeout(() => { stopBuffer(); bufferAudioCtx = null; setTimeout(startBuffer, 300); }, 100); + // Keep bufferAudioCtx alive — createMediaElementSource can only be called once per element + setTimeout(() => { stopBuffer(); setTimeout(startBuffer, 300); }, 100); } }); @@ -2821,12 +2977,14 @@ inSlider.addEventListener('input', () => { if (parseFloat(inSlider.value) >= parseFloat(outSlider.value) - 1) inSlider.value = (parseFloat(outSlider.value) - 1).toFixed(1); + if (!video.paused) stopPlayback(); updateTrimUI(true); }); outSlider.addEventListener('input', () => { if (parseFloat(outSlider.value) <= parseFloat(inSlider.value) + 1) outSlider.value = (parseFloat(inSlider.value) + 1).toFixed(1); + if (!video.paused) stopPlayback(); updateTrimUI(false); }); @@ -2955,9 +3113,9 @@ : (MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm'); function startRecording() { - // Export at 540p regardless of source resolution — maximises duration at given bitrate - canvas.width = 960; - canvas.height = 540; + // Export at 720p regardless of source resolution + canvas.width = 1280; + canvas.height = 720; const stream = canvas.captureStream(30); @@ -3057,7 +3215,7 @@ // Switch back to thumbnail mode — destroy all live thumb streams, refresh canvases const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); CAMERAS.forEach(([name, slug], i) => { - if (i === DEFAULT_IDX || i === cammanIdx) return; + if (i === DEFAULT_IDX) return; if (hlsInstances[i]) { hlsInstances[i].destroy(); delete hlsInstances[i]; } // Replace video with canvas if needed const cell = document.getElementById('cam-' + i); @@ -3078,7 +3236,7 @@ if (window._thumbInterval) { clearInterval(window._thumbInterval); window._thumbInterval = null; } const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); CAMERAS.forEach(([name, slug], i) => { - if (i === DEFAULT_IDX || i === cammanIdx) return; + if (i === DEFAULT_IDX) return; const cell = document.getElementById('cam-' + i); if (!cell) return; let canvas = cell.querySelector('canvas'); @@ -3131,7 +3289,7 @@ async function refreshAllThumbnails() { const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); const tasks = CAMERAS.map(([name, slug], i) => { - if (i === DEFAULT_IDX || i === cammanIdx) return null; + if (i === DEFAULT_IDX) return null; if (slug === CAMERAS[featuredIdx][1]) return null; return captureThumb(slug, 'canvas-' + i); }).filter(Boolean); @@ -3198,10 +3356,11 @@ // Build thumb grid — skip Director Mode and Cameraman const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman2-5"); CAMERAS.forEach(([name, slug], i) => { - if (i === DEFAULT_IDX || i === cammanIdx) return; + if (i === DEFAULT_IDX) return; const cell = document.createElement('div'); cell.className = 'cam-cell'; cell.id = 'cam-' + i; + cell.dataset.slug = slug; const canvas = document.createElement('canvas'); canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;'; canvas.id = 'canvas-' + i; @@ -3349,7 +3508,6 @@ setApiStatus('none'); } initCameras(); - initCamerman(); // Apply saved thumbnail interval from dropdown const iv = document.getElementById('intervalSelect'); if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value); @@ -3364,67 +3522,6 @@ }, 500); }); - function initCamerman() { - const video = document.getElementById('cammanVideo'); - if (!video) return; - const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); - if (typeof Hls !== 'undefined' && Hls.isSupported()) { - if (hlsInstances['camman']) hlsInstances['camman'].destroy(); - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); - hls.attachMedia(video); - hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {})); - hls.on(Hls.Events.ERROR, (e, d) => { - if (d.fatal) { - const label = document.getElementById('cammanLabel'); - if (label) label.textContent = 'CAMERAMAN · RECONNECTING...'; - hls.destroy(); - setTimeout(() => { - if (video.isConnected) initCamerman(); - }, 3000); - } - }); - hlsInstances['camman'] = hls; - } else if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.src = 'http://localhost:3000/cam/cameraman2-5/index.m3u8'; - video.play().catch(() => {}); - } - } - - // Update cameraman panel label when it gets swapped with featured - const _origSetFeatured = setFeatured; - setFeatured = function(i) { - _origSetFeatured(i); - // If cameraman is now in featured, show director in camman panel - // If cameraman is what's being swapped back, restore its own stream - const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman2-5'); - const cammanVideo = document.getElementById('cammanVideo'); - const cammanLabel = document.getElementById('cammanLabel'); - if (!cammanVideo) return; - - if (i === cammanIdx) { - // Cameraman just went to featured - show director in camman panel - const dirSlug = CAMERAS[DEFAULT_IDX][1]; - if (hlsInstances['camman']) hlsInstances['camman'].destroy(); - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/' + dirSlug + '/index.m3u8'); - hls.attachMedia(cammanVideo); - hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); - hlsInstances['camman'] = hls; - cammanLabel.textContent = 'DIRECTOR MODE'; - } else if (directorCell === cammanIdx) { - // Director is in camman panel cell — keep it - } else { - // Restore camman panel to cameraman stream - if (hlsInstances['camman']) hlsInstances['camman'].destroy(); - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); - hls.loadSource('http://localhost:3000/cam/cameraman2-5/index.m3u8'); - hls.attachMedia(cammanVideo); - hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); }); - hlsInstances['camman'] = hls; - cammanLabel.textContent = 'CAMERAMAN'; - } - }; @@ -3459,6 +3556,7 @@ if (msg._ft === 'ws_status') { setWsStatus(msg.status); + inlineChatSetStatus(msg.status === 'connected' ? 'connected' : 'disconnected'); return; } @@ -3475,6 +3573,10 @@ window._lastPresenceData = msg.data; autoDiscoverCameras(msg.data); updateViewerCounts(msg.data); + const totalEl = document.getElementById('inlineChatViewers'); + if (totalEl && msg.data.total !== undefined) { + totalEl.textContent = msg.data.total.toLocaleString() + ' in chat'; + } } if (msg.event === 'feature-toggles:update') { @@ -3486,7 +3588,7 @@ // data is [{value, score}, ...] — update scores in cached poll if (Array.isArray(msg.data)) applyPollVote(msg.data); } - if (msg.event === 'tts:update') { + if (msg.event === 'tts:update' || msg.event === 'tts:insert') { // Full TTS object — add to history if not seen const m = msg.data; if (m && m.id && !seenTtsIds.has(m.id)) { @@ -3500,7 +3602,7 @@ if (idx > -1) { ttsHistory[idx] = m; lastCombinedRenderCount = 0; renderCombined(); } } } - if (msg.event === 'sfx:insert') { + if (msg.event === 'sfx:insert' || msg.event === 'sfx:update') { const m = msg.data; if (!m || !m.id) return; if (!seenSfxIds.has(m.id)) { @@ -3513,6 +3615,16 @@ lastCombinedRenderCount = 0; renderCombined(); } + if (msg.event === 'chat:message') { + const data = msg.data; + const messages = Array.isArray(data) ? data : [data]; + messages.forEach(m => { + if (isRealChatMessage(m)) { + addMessage(m); + inlineChatAddMessage(m); + } + }); + } } if (msg._ft === 'raw') { @@ -3598,6 +3710,113 @@ }; // ── End grid row recalculation ──────────────────────────────── + // ── Chat message helpers (shared by inline panel and popout forwarding) ──── + function isRealChatMessage(msg) { + if (msg.user && msg.user.id === 'tts') return false; + if (msg.metadata && msg.metadata.type === 'item') return false; + if (!msg.message || typeof msg.message !== 'string') return false; + return true; + } + + // addMessage forwards to the chat popout window if open + function addMessage(msg) { + if (window._chatPopout && !window._chatPopout.closed) { + try { window._chatPopout.postMessage({ _ft: 'chat', msg }, '*'); } catch(e) {} + } + } + // ── End chat helpers ───────────────────────────────────────── + + // ── Inline chat ────────────────────────────────────────────── + let inlineChatCount = 0; + let inlineChatAutoScroll = true; + let chatCollapsed = false; + + const inlineFeed = document.getElementById('inlineChatFeed'); + const inlineChatScrollBtn = document.getElementById('inlineChatScrollBtn'); + + inlineFeed.addEventListener('scroll', () => { + const atBottom = inlineFeed.scrollHeight - inlineFeed.scrollTop - inlineFeed.clientHeight < 60; + inlineChatAutoScroll = atBottom; + inlineChatScrollBtn.style.display = atBottom ? 'none' : 'block'; + }); + + window.inlineChatScrollToBottom = function inlineChatScrollToBottom() { + inlineFeed.scrollTop = inlineFeed.scrollHeight; + inlineChatAutoScroll = true; + inlineChatScrollBtn.style.display = 'none'; + }; + + function inlineChatSetStatus(state) { + const dot = document.getElementById('inlineChatDot'); + const label = document.getElementById('inlineChatStatus'); + if (!dot || !label) return; + if (state === 'connected') { dot.className = 'dot live'; label.textContent = 'LIVE'; label.style.color = 'var(--green)'; } + else if (state === 'disconnected') { dot.className = 'dot error'; label.textContent = 'OFFLINE'; label.style.color = 'var(--accent2)'; } + else { dot.className = 'dot'; label.textContent = 'CONNECTING'; label.style.color = 'var(--muted)'; } + } + + function inlineChatAddMessage(msg) { + const empty = inlineFeed.querySelector('.empty'); + if (empty) empty.remove(); + + const user = msg.user || {}; + const meta = msg.metadata || {}; + const isAdmin = meta.isAdmin || false; + const isMod = meta.isMod || false; + const isFish = meta.isFish || false; + const isGM = meta.isGrandMarshall || false; + + const cls = ['msg', isAdmin?'admin':'', isMod?'mod':'', isFish?'fish':'', isGM?'grand-marshall':''].filter(Boolean).join(' '); + + const nameStyle = user.customUsernameColor ? `style="color:${user.customUsernameColor}"` : ''; + const endorsement = user.endorsement + ? `${user.endorsement}` : ''; + const badges = [ + isAdmin ? 'ADMIN' : '', + isMod ? 'MOD' : '', + isFish ? 'FISH' : '', + isGM ? 'GM' : '', + ].filter(Boolean).join(''); + const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) : ''; + + const div = document.createElement('div'); + div.className = cls; + div.innerHTML = ` +
+ ${user.displayName||'unknown'} + ${user.clan?`[${user.clan}]`:''} + ${endorsement}${badges} + ${ts?`${ts}`:''} +
+
${msg.message.replace(/&/g,'&').replace(//g,'>')}
`; + + inlineFeed.appendChild(div); + // Keep max 500 messages to avoid memory growth + while (inlineFeed.children.length > 500) inlineFeed.removeChild(inlineFeed.firstChild); + inlineChatCount++; + const countEl = document.getElementById('inlineChatCount'); + if (countEl) countEl.textContent = inlineChatCount.toLocaleString() + ' messages'; + if (inlineChatAutoScroll) inlineFeed.scrollTop = inlineFeed.scrollHeight; + } + + window.toggleChat = function() { + chatCollapsed = !chatCollapsed; + const main = document.querySelector('.main'); + const btn = document.getElementById('chatCollapseBtn'); + if (chatCollapsed) { + main.classList.add('chat-collapsed'); + btn.textContent = '💬 CHAT'; + btn.title = 'Show chat panel'; + } else { + main.classList.remove('chat-collapsed'); + btn.textContent = '💬 CHAT'; + btn.title = 'Hide chat panel'; + inlineFeed.scrollTop = inlineFeed.scrollHeight; + } + setTimeout(() => window.recalcGridRows && window.recalcGridRows(), 50); + }; + // ── End inline chat ─────────────────────────────────────────── + // ── Stocks panel collapse ──────────────────────────────────── let stocksCollapsed = false; @@ -3645,7 +3864,7 @@ function autoDiscoverCameras(presenceData) { // presence keys are camera slugs (plus "total") - const knownSpecial = new Set(['total', 'cameraman2-5']); + const knownSpecial = new Set(['total']); let added = false; Object.keys(presenceData).forEach(slug => { @@ -3656,8 +3875,7 @@ console.log('[CAM] Auto-discovered new camera:', slug); KNOWN_CAMERA_SLUGS.add(slug); const label = slugToLabel(slug); - const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman2-5'); - CAMERAS.splice(cammanIdx, 0, [label, slug]); + CAMERAS.push([label, slug]); added = true; }); @@ -3667,11 +3885,12 @@ const grid = document.getElementById('cameraGrid'); const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman2-5'); CAMERAS.forEach(([name, slug], i) => { - if (i === DEFAULT_IDX || i === cammanIdx) return; + if (i === DEFAULT_IDX) return; if (document.getElementById('cam-' + i)) return; // already exists const cell = document.createElement('div'); cell.className = 'cam-cell'; cell.id = 'cam-' + i; + cell.dataset.slug = slug; const canvas = document.createElement('canvas'); canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;'; canvas.id = 'canvas-' + i; @@ -3693,22 +3912,31 @@ // ── Viewer counts ──────────────────────────────────────────── window.updateViewerCounts = function(data) { const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman2-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]); + // Use data-slug from each cell — it may differ from CAMERAS[i] if director has swapped in + const grid = document.getElementById('cameraGrid'); + if (grid) { + grid.querySelectorAll('.cam-cell').forEach(cell => { + const slug = cell.dataset.slug; + if (slug) setViewerBadge(cell, data[slug]); }); } + + // Sort grid cells by viewer count descending + if (grid && typeof CAMERAS !== 'undefined') { + const cells = Array.from(grid.querySelectorAll('.cam-cell')); + cells.sort((a, b) => { + const aSlug = a.dataset.slug; + const bSlug = b.dataset.slug; + return (data[bSlug] || 0) - (data[aSlug] || 0); + }); + cells.forEach(cell => grid.appendChild(cell)); + } + 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]); - } + if (slug) setViewerBadge(featCell, data[slug]); } };