From ffcdd4300476b2cca53c30d00a0e10e437a458d6 Mon Sep 17 00:00:00 2001 From: fishtank-dashboard Date: Wed, 18 Mar 2026 12:58:47 -0700 Subject: [PATCH] added clip trimming, chat popout + minor changes --- chat-popout.html | 383 ++++++++++++++++++++++ fishtank-dashboard.html | 706 ++++++++++++++++++++++++++++++++++++---- server.js | 18 +- 3 files changed, 1037 insertions(+), 70 deletions(-) create mode 100644 chat-popout.html diff --git a/chat-popout.html b/chat-popout.html new file mode 100644 index 0000000..a2347d5 --- /dev/null +++ b/chat-popout.html @@ -0,0 +1,383 @@ + + + + + +FISHTANK // CHAT + + + + +
+ +
+
+ CONNECTING +
+
0 messages
+
+ +
+
+
๐Ÿ’ฌ
+ Waiting for messages... +
+
+ + + + + + diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html index 8800afa..da39556 100644 --- a/fishtank-dashboard.html +++ b/fishtank-dashboard.html @@ -22,12 +22,19 @@ * { margin: 0; padding: 0; box-sizing: border-box; } + html, body { + height: 100%; + overflow: hidden; + } + body { background: var(--bg); color: var(--text); font-family: 'DM Sans', sans-serif; - min-height: 100vh; - overflow-x: hidden; + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; } body::before { @@ -158,9 +165,11 @@ grid-template-rows: 320px 1fr; grid-template-areas: "poll stocks camman" "tts cameras cameras"; gap: 1px; - height: calc(100vh - 73px); + flex: 1; + min-height: 0; background: var(--border); transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease; + overflow: hidden; } .main.stocks-collapsed { @@ -282,24 +291,18 @@ display: flex; flex-direction: column; overflow: hidden; - } - - .cameras-panel { - background: #000; - display: flex; - flex-direction: column; - overflow: hidden; + min-height: 0; + height: 100%; } .cam-featured-wrap { - flex-shrink: 0; background: #000; width: 100%; - aspect-ratio: 16/9; - max-height: 65%; overflow: hidden; - min-height: 0; position: relative; + /* Flex child: take all space minus the grid's fixed height */ + flex: 1 1 auto; + min-height: 0; } .cam-featured-wrap .cam-cell { @@ -308,16 +311,25 @@ aspect-ratio: unset; } + .cam-featured-wrap video { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + .camera-grid { display: grid; grid-template-columns: repeat(9, 1fr); gap: 2px; padding: 2px; - overflow-y: auto; - flex: 1; - align-content: start; + overflow: hidden; + flex: 0 0 auto; + /* Row height set dynamically by JS to match actual panel width */ } + + .camera-grid::-webkit-scrollbar { width: 4px; } .camera-grid::-webkit-scrollbar-track { background: transparent; } .camera-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } @@ -620,6 +632,8 @@ font-size: 10px; color: var(--muted); margin-top: 4px; + display: flex; + align-items: center; } /* SFX */ @@ -735,6 +749,127 @@ font-size: 12px; } + /* Clip editor modal */ + .clip-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + } + + .clip-modal { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + width: min(860px, 95vw); + padding: 24px; + display: flex; + flex-direction: column; + gap: 18px; + } + + .clip-modal-title { + font-family: 'Bebas Neue', sans-serif; + font-size: 20px; + letter-spacing: 3px; + color: var(--accent); + } + + .clip-modal video { + width: 100%; + border-radius: 4px; + background: #000; + max-height: 420px; + } + + .clip-trim-row { + display: flex; + flex-direction: column; + gap: 10px; + } + + .clip-trim-labels { + display: flex; + justify-content: space-between; + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + color: var(--muted); + } + + .clip-trim-labels span { color: var(--accent); } + + .clip-range-wrap { + position: relative; + height: 36px; + display: flex; + align-items: center; + } + + .clip-range-track { + position: absolute; + left: 0; right: 0; + height: 4px; + background: var(--border); + border-radius: 2px; + } + + .clip-range-fill { + position: absolute; + height: 4px; + background: var(--accent); + border-radius: 2px; + pointer-events: none; + } + + .clip-range-wrap input[type=range] { + position: absolute; + width: 100%; + height: 4px; + background: transparent; + -webkit-appearance: none; + pointer-events: none; + } + + .clip-range-wrap input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg); + cursor: pointer; + pointer-events: all; + } + + .clip-range-wrap input[type=range]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg); + cursor: pointer; + pointer-events: all; + } + + .clip-modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .clip-export-progress { + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + color: var(--accent); + display: none; + align-items: center; + gap: 8px; + margin-right: auto; + } + /* Camera reconnecting overlay */ .cam-reconnecting { position: absolute; @@ -808,6 +943,127 @@ padding: 3px 8px; } + /* Clip editor modal */ + .clip-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + } + + .clip-modal { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + width: min(860px, 95vw); + padding: 24px; + display: flex; + flex-direction: column; + gap: 18px; + } + + .clip-modal-title { + font-family: 'Bebas Neue', sans-serif; + font-size: 20px; + letter-spacing: 3px; + color: var(--accent); + } + + .clip-modal video { + width: 100%; + border-radius: 4px; + background: #000; + max-height: 420px; + } + + .clip-trim-row { + display: flex; + flex-direction: column; + gap: 10px; + } + + .clip-trim-labels { + display: flex; + justify-content: space-between; + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + color: var(--muted); + } + + .clip-trim-labels span { color: var(--accent); } + + .clip-range-wrap { + position: relative; + height: 36px; + display: flex; + align-items: center; + } + + .clip-range-track { + position: absolute; + left: 0; right: 0; + height: 4px; + background: var(--border); + border-radius: 2px; + } + + .clip-range-fill { + position: absolute; + height: 4px; + background: var(--accent); + border-radius: 2px; + pointer-events: none; + } + + .clip-range-wrap input[type=range] { + position: absolute; + width: 100%; + height: 4px; + background: transparent; + -webkit-appearance: none; + pointer-events: none; + } + + .clip-range-wrap input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg); + cursor: pointer; + pointer-events: all; + } + + .clip-range-wrap input[type=range]::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--bg); + cursor: pointer; + pointer-events: all; + } + + .clip-modal-actions { + display: flex; + gap: 10px; + justify-content: flex-end; + } + + .clip-export-progress { + font-family: 'Share Tech Mono', monospace; + font-size: 11px; + color: var(--accent); + display: none; + align-items: center; + gap: 8px; + margin-right: auto; + } + /* Camera reconnecting overlay */ .cam-reconnecting { position: absolute; @@ -1048,6 +1304,14 @@ } initCameras(); initCamerman(); + // Apply saved thumbnail interval from dropdown + const iv = document.getElementById('intervalSelect'); + if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value); + // Set initial grid row heights and recalc on resize + setTimeout(recalcGridRows, 100); + window.addEventListener('resize', recalcGridRows); + // Set initial grid row height + setTimeout(recalcGridRows, 100); }); function initCamerman() { @@ -1125,6 +1389,14 @@ } initCameras(); initCamerman(); + // Apply saved thumbnail interval from dropdown + const iv = document.getElementById('intervalSelect'); + if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value); + // Set initial grid row heights and recalc on resize + setTimeout(recalcGridRows, 100); + window.addEventListener('resize', recalcGridRows); + // Set initial grid row height + setTimeout(recalcGridRows, 100); }); function initCamerman() { @@ -1210,6 +1482,7 @@
+
@@ -1223,11 +1496,12 @@
- + + + + +
@@ -1487,14 +1761,25 @@ function applyPollVote(scores) { // scores = [{value, score}, ...] from poll:vote WS event - if (!pollCache) return; // no poll loaded yet, ignore - if (pollCache.currentPoll) { - pollCache.currentPoll.scores = scores; - } else { - // No current poll in cache yet โ€” trigger a full fetch + if (!pollCache) { fetchPoll(); return; } + + // Check if options have changed โ€” means a new poll started + const cachedOptions = (pollCache.currentPoll && pollCache.currentPoll.scores || []) + .map(s => s.value).sort().join('|'); + const incomingOptions = scores.map(s => s.value).sort().join('|'); + + if (!pollCache.currentPoll || cachedOptions !== incomingOptions) { + // New poll โ€” re-fetch to get the question and metadata + console.log('[Poll] Options changed, fetching new poll...'); + fetchPoll(); + return; + } + + // Same poll โ€” just update scores and re-render + pollCache.currentPoll.scores = scores; renderPoll(pollCache); } @@ -1550,10 +1835,9 @@ ${msg.voice} ${ROOM_NAMES[msg.room] || msg.room || ''} ${msg.status.toUpperCase()} - โฌก ${msg.cost}
${msg.message}
-
${formatTime(msg.createdAt)} ยท ${timeAgo(msg.createdAt)}
+
${formatTime(msg.createdAt)} ยท ${timeAgo(msg.createdAt)}โฌก ${msg.cost}
`; } else { // SFX item โ€” fields: sound, url, displayName, cost, room, createdAt (unix ms) @@ -1884,17 +2168,39 @@ function makeHls(slug, video, muted, retryDelay) { retryDelay = retryDelay || 2000; - const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 }); + const hls = new Hls({ + lowLatencyMode: true, + maxBufferLength: 8, + maxMaxBufferLength: 16, + fragLoadingTimeOut: 10000, + manifestLoadingTimeOut: 10000, + levelLoadingTimeOut: 10000, + fragLoadingMaxRetry: 3, + manifestLoadingMaxRetry: 3, + }); hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8'); hls.attachMedia(video); + + let reconnectOverlayTimer = null; + hls.on(Hls.Events.MANIFEST_PARSED, () => { video.muted = muted; video.play().catch(() => {}); + }); + + // Hide overlay as soon as frames start playing + video.addEventListener('playing', () => { + if (reconnectOverlayTimer) { clearTimeout(reconnectOverlayTimer); reconnectOverlayTimer = null; } hideReconnecting(video); }); + hls.on(Hls.Events.ERROR, (e, d) => { if (!d.fatal) return; - showReconnecting(video); + // Only show reconnecting overlay after a short grace period + // so brief blips on a healthy stream don't flash it + if (!reconnectOverlayTimer) { + reconnectOverlayTimer = setTimeout(() => showReconnecting(video), 1500); + } hls.destroy(); const nextDelay = Math.min(retryDelay * 1.5, 10000); setTimeout(() => { @@ -1940,6 +2246,11 @@ const overlay = document.getElementById('featPlayOverlay'); if (overlay) overlay.classList.add('hidden'); featuredIdx = idx; + // Restart buffer so clips target the new camera + stopBuffer(); + bufferChunks = []; + bufferInitChunk = null; + setTimeout(() => ensureBuffer(), 500); // Refresh overlays and viewer counts for new featured cam if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) { updateContestantOverlays(window._lastContestantData); @@ -2001,6 +2312,7 @@ // โ”€โ”€ Rolling 60s buffer (for CLIP) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let bufferRecorder = null; let bufferChunks = []; // { data, ts } + let bufferInitChunk = null; // WebM init segment, kept permanently const BUFFER_SECS = 65; // keep a bit extra let mirrorVideo = null; @@ -2064,13 +2376,25 @@ try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); } catch(e) { return; } bufferChunks = []; + bufferInitChunk = null; // reset so first chunk of new recorder is captured as init bufferRecorder.ondataavailable = e => { if (!e.data || e.data.size === 0) return; const now = Date.now(); + if (!bufferInitChunk) { + // First chunk is always the WebM init segment โ€” store separately + bufferInitChunk = e.data; + return; + } bufferChunks.push({ data: e.data, ts: now }); const cutoff = now - BUFFER_SECS * 1000; bufferChunks = bufferChunks.filter(c => c.ts >= cutoff); }; + bufferRecorder._getBlob = (mimeType) => { + const chunks = bufferInitChunk + ? [bufferInitChunk, ...bufferChunks.map(c => c.data)] + : bufferChunks.map(c => c.data); + return new Blob(chunks, { type: mimeType }); + }; bufferRecorder.start(250); } @@ -2193,19 +2517,238 @@ } function saveClip() { - ensureBuffer(); - if (!bufferChunks.length) { alert('No buffer yet โ€” wait a moment after the stream starts.'); return; } const mimeType = bufferRecorder ? bufferRecorder.mimeType : 'video/webm'; - const blob = new Blob(bufferChunks.map(c => c.data), { type: mimeType }); - const url = URL.createObjectURL(blob); + if (!bufferChunks.length) { alert('No buffer yet โ€” wait a moment after the stream starts.'); return; } + // Calculate real duration from chunk timestamps (WebM header duration is unreliable) + const realDuration = (bufferChunks[bufferChunks.length - 1].ts - bufferChunks[0].ts) / 1000; + const blob = bufferRecorder._getBlob ? bufferRecorder._getBlob(mimeType) + : new Blob(bufferChunks.map(c => c.data), { type: mimeType }); const wrap = document.getElementById('camFeaturedWrap'); const label = wrap ? wrap.querySelector('.cam-label') : null; const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'clip'; + openClipEditor(blob, mimeType, camName, realDuration); + } + + function openClipEditor(blob, mimeType, camName, realDuration) { + const blobUrl = URL.createObjectURL(blob); + + const backdrop = document.createElement('div'); + backdrop.className = 'clip-modal-backdrop'; + backdrop.innerHTML = ` +
+
โœ‚ EDIT CLIP
+ +
+
+ IN: 0.0s + DURATION: โ€” + OUT: โ€” +
+
+
+
+ + +
+
+
+
+
+ Exporting... +
+ + + +
+
`; + document.body.appendChild(backdrop); + + const video = document.getElementById('clipEditorVideo'); + const inSlider = document.getElementById('clipInSlider'); + const outSlider= document.getElementById('clipOutSlider'); + const fill = document.getElementById('clipRangeFill'); + + video._blobUrl = blobUrl; + video._mimeType = mimeType; + video._camName = camName; + video._realDur = realDuration || null; + + let playbackWatcher = null; + + function getDur() { + if (video._realDur && isFinite(video._realDur)) return video._realDur; + if (video.duration && isFinite(video.duration)) return video.duration; + return null; + } + + function updateTrimUI(seekVideo) { + const d = getDur(); + if (!d) return; + const inTime = parseFloat(inSlider.value); + const outTime = parseFloat(outSlider.value); + document.getElementById('clipInLabel').textContent = inTime.toFixed(1) + 's'; + document.getElementById('clipOutLabel').textContent = outTime.toFixed(1) + 's'; + const trimDur = Math.max(0, outTime - inTime); + const estMB = (trimDur * 4000000 / 8 / 1048576).toFixed(1); + document.getElementById('clipDurLabel').textContent = trimDur.toFixed(1) + 's (~' + estMB + ' MB)'; + fill.style.left = (inTime / d * 100) + '%'; + fill.style.width = (trimDur / d * 100) + '%'; + if (seekVideo && video.paused) video.currentTime = inTime; + } + + function initSliders(d) { + inSlider.min = '0'; inSlider.max = d.toFixed(1); inSlider.step = '0.1'; inSlider.value = '0'; + outSlider.min = '0'; outSlider.max = d.toFixed(1); outSlider.step = '0.1'; outSlider.value = d.toFixed(1); + updateTrimUI(true); + } + + video.addEventListener('loadedmetadata', () => { + const d = getDur(); + if (d) initSliders(d); + }); + + video.addEventListener('error', () => { + document.getElementById('clipDurLabel').textContent = 'Load error โ€” use SAVE FULL'; + }); + + inSlider.addEventListener('input', () => { + if (parseFloat(inSlider.value) >= parseFloat(outSlider.value) - 1) + inSlider.value = (parseFloat(outSlider.value) - 1).toFixed(1); + updateTrimUI(true); + }); + + outSlider.addEventListener('input', () => { + if (parseFloat(outSlider.value) <= parseFloat(inSlider.value) + 1) + outSlider.value = (parseFloat(inSlider.value) + 1).toFixed(1); + updateTrimUI(false); + }); + + function stopPlayback() { + video.pause(); + if (playbackWatcher) { clearInterval(playbackWatcher); playbackWatcher = null; } + } + + function playFromIn() { + stopPlayback(); + const inTime = parseFloat(inSlider.value); + const outTime = parseFloat(outSlider.value); + video.currentTime = inTime; + // Wait for seek to complete before playing + video.addEventListener('seeked', function onSeeked() { + video.removeEventListener('seeked', onSeeked); + video.play().catch(() => {}); + playbackWatcher = setInterval(() => { + if (video.currentTime >= outTime - 0.05) { + stopPlayback(); + video.currentTime = parseFloat(inSlider.value); + } + }, 50); + }, { once: true }); + } + + video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback()); + video.load(); + } + + function closeClipEditor() { + const backdrop = document.querySelector('.clip-modal-backdrop'); + if (!backdrop) return; + const video = document.getElementById('clipEditorVideo'); + if (video && video._blobUrl) URL.revokeObjectURL(video._blobUrl); + backdrop.remove(); + // Reset buffer so next clip starts clean + stopBuffer(); + bufferChunks = []; + bufferInitChunk = null; + setTimeout(() => ensureBuffer(), 500); + } + + function downloadClipFull() { + const video = document.getElementById('clipEditorVideo'); + if (!video) return; const a = document.createElement('a'); - a.href = url; - a.download = camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm'; + a.href = video._blobUrl; + a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm'; a.click(); - URL.revokeObjectURL(url); + closeClipEditor(); + } + + function exportTrimmedClip() { + const video = document.getElementById('clipEditorVideo'); + const inSlider = document.getElementById('clipInSlider'); + const outSlider = document.getElementById('clipOutSlider'); + if (!video) return; + + const inTime = parseFloat(inSlider.value); + const outTime = parseFloat(outSlider.value); + if (!isFinite(inTime) || !isFinite(outTime) || outTime <= inTime) return; + + const progress = document.getElementById('clipExportProgress'); + const exportLabel = document.getElementById('clipExportLabel'); + const saveBtn = document.getElementById('clipSaveTrimBtn'); + const fullBtn = document.getElementById('clipSaveFullBtn'); + progress.style.display = 'flex'; + saveBtn.disabled = true; + fullBtn.disabled = true; + exportLabel.textContent = 'Seeking...'; + + video.pause(); + video.muted = true; + + const mimeType = video._mimeType; + const camName = video._camName; + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + let drawInterval = null; + + function startRecording() { + canvas.width = video.videoWidth || 960; + canvas.height = video.videoHeight || 540; + + const stream = canvas.captureStream(30); + const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); + const chunks = []; + + recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); }; + recorder.onstop = () => { + const trimmed = new Blob(chunks, { type: mimeType }); + const url = URL.createObjectURL(trimmed); + const a = document.createElement('a'); + a.href = url; + a.download = camName + '_trimmed_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm'; + a.click(); + URL.revokeObjectURL(url); + closeClipEditor(); + }; + + exportLabel.textContent = 'Exporting...'; + recorder.start(100); + + drawInterval = setInterval(() => { + if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const remaining = outTime - video.currentTime; + exportLabel.textContent = 'Exporting... ' + Math.max(0, remaining).toFixed(1) + 's'; + if (video.currentTime >= outTime - 0.05) { + clearInterval(drawInterval); + recorder.stop(); + } + }, 1000 / 30); + + // Safety net + setTimeout(() => { + if (recorder.state === 'recording') { clearInterval(drawInterval); recorder.stop(); } + }, (outTime - inTime + 3) * 1000); + } + + // Seek first, only start recording once seek is complete and frame is ready + video.currentTime = inTime; + video.addEventListener('seeked', function onSeeked() { + video.removeEventListener('seeked', onSeeked); + // Draw one frame to warm up canvas, then start recording and play + if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width || video.videoWidth || 960, canvas.height || video.videoHeight || 540); + startRecording(); + video.play().catch(() => {}); + }, { once: true }); } // โ”€โ”€ Record (forward, manual stop) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -2406,24 +2949,21 @@ } + function applyFeatureToggle(feature, enabled) { + const dotId = feature === 'tts' ? 'ttsEnabledDot' : feature === 'sfx' ? 'sfxEnabledDot' : null; + if (!dotId) return; + const dot = document.getElementById(dotId); + if (!dot) return; + dot.className = 'dot' + (enabled ? ' live' : ' error'); + dot.title = feature.toUpperCase() + (enabled ? ' enabled' : ' disabled'); + } + async function fetchFeatureToggles() { try { const r = await fetch(BASE + '/v1/feature-toggles', { headers: headers() }); if (!r.ok) throw new Error(r.status); const data = await r.json(); - const toggles = data.featureToggles || []; - const tts = toggles.find(f => f.feature === 'tts'); - const sfx = toggles.find(f => f.feature === 'sfx'); - const ttsDot = document.getElementById('ttsEnabledDot'); - const sfxDot = document.getElementById('sfxEnabledDot'); - if (ttsDot && tts) { - ttsDot.className = 'dot' + (tts.enabled ? ' live' : ' error'); - ttsDot.title = tts.enabled ? 'TTS enabled' : 'TTS disabled'; - } - if (sfxDot && sfx) { - sfxDot.className = 'dot' + (sfx.enabled ? ' live' : ' error'); - sfxDot.title = sfx.enabled ? 'SFX enabled' : 'SFX disabled'; - } + (data.featureToggles || []).forEach(f => applyFeatureToggle(f.feature, f.enabled)); } catch(e) { console.error('Feature toggles error:', e); } @@ -2441,21 +2981,20 @@ document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' ET'; } - // Slow tick โ€” stocks + feature toggles only (60s) + // Stocks tick โ€” hardcoded 60s let slowIntervalId = null; async function slowTick() { if (!getToken()) return; - await Promise.all([fetchStocks(), fetchFeatureToggles()]); + await fetchStocks(); } function startPolling() { if (!getToken()) return; - // Initial fast loads fetchPoll(); fetchTTSHistory(); + fetchFeatureToggles(); slowTick(); - // Slow interval for stocks + toggles if (!slowIntervalId) { slowIntervalId = setInterval(slowTick, 60000); } @@ -2466,12 +3005,11 @@ slowIntervalId = null; } - // intervalSelect now controls stocks refresh rate + // intervalSelect controls thumbnail refresh rate function changeInterval(val) { - if (slowIntervalId) { - clearInterval(slowIntervalId); - slowIntervalId = setInterval(slowTick, parseInt(val)); - } + const ms = parseInt(val); + if (window._thumbInterval) clearInterval(window._thumbInterval); + window._thumbInterval = setInterval(refreshAllThumbnails, ms); } function clearHistory() { @@ -2495,6 +3033,14 @@ } initCameras(); initCamerman(); + // Apply saved thumbnail interval from dropdown + const iv = document.getElementById('intervalSelect'); + if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value); + // Set initial grid row heights and recalc on resize + setTimeout(recalcGridRows, 100); + window.addEventListener('resize', recalcGridRows); + // Set initial grid row height + setTimeout(recalcGridRows, 100); }); function initCamerman() { @@ -2609,6 +3155,11 @@ updateViewerCounts(msg.data); } + if (msg.event === 'feature-toggles:update') { + if (msg.data && msg.data.feature !== undefined) { + applyFeatureToggle(msg.data.feature, msg.data.enabled); + } + } if (msg.event === 'poll:vote') { // data is [{value, score}, ...] โ€” update scores in cached poll if (Array.isArray(msg.data)) applyPollVote(msg.data); @@ -2695,6 +3246,38 @@ // โ”€โ”€ Notification popup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let notifTimer = null; + // โ”€โ”€ Grid row height recalculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + function recalcGridRows() { + const grid = document.getElementById('cameraGrid'); + if (!grid) return; + const w = grid.clientWidth; + if (!w) return; + const cols = 9; + const gap = 2; + const pad = 2; + const cellW = (w - pad * 2 - gap * (cols - 1)) / cols; + const cellH = Math.floor(cellW * 9 / 16); + grid.style.gridTemplateRows = `repeat(2, ${cellH}px)`; + } + + window.addEventListener('resize', recalcGridRows); + // โ”€โ”€ End grid row height โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + // โ”€โ”€ Grid row recalculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + function recalcGridRows() { + const grid = document.getElementById('cameraGrid'); + if (!grid) return; + const gridW = grid.clientWidth; + if (!gridW) return; + const cols = 9; + const gap = 2; + const pad = 2; + const thumbW = (gridW - pad * 2 - gap * (cols - 1)) / cols; + const thumbH = Math.floor(thumbW * 9 / 16); + grid.style.gridTemplateRows = `repeat(2, ${thumbH}px)`; + } + // โ”€โ”€ End grid row recalculation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // โ”€โ”€ Stocks panel collapse โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ let stocksCollapsed = false; @@ -2713,6 +3296,7 @@ btn.textContent = 'โ—€ POLL / TTS'; btn.title = 'Hide poll and TTS panels'; } + [50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d)); }; window.toggleStocks = function toggleStocks() { @@ -2727,9 +3311,9 @@ main.classList.remove('stocks-collapsed'); btn.textContent = 'โ–ฒ STOCKS'; btn.title = 'Hide stocks panel'; - // Redraw chart since it was hidden if (stocksChart) { stocksChart.resize(); } } + [50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d)); }; // โ”€โ”€ End stocks panel collapse โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/server.js b/server.js index f1c16e9..019d76a 100644 --- a/server.js +++ b/server.js @@ -76,15 +76,12 @@ function sendBinary(buf) { function sendAuthFrame() { if (!ftToken) { - console.log('[WS] No token โ€” connecting unauthenticated (limited events)'); return; } - console.log('[WS] Sending auth frame with token'); sendBinary(buildAuthFrame(ftToken)); } function sendSubscriptions() { - console.log('[WS] Subscribing to chat:presence and presence'); sendBinary(buildSubscribeFrame('chat:presence')); sendBinary(buildSubscribeFrame('presence')); } @@ -203,7 +200,6 @@ function mpDecode(buf, offset = 0) { // negative fixint if ((b & 0xe0) === 0xe0) return [b - 256, offset]; - console.log(`[MSGPACK] Unknown type byte: 0x${b.toString(16)} at offset ${offset-1}`); return [null, offset]; } @@ -232,20 +228,16 @@ function handleBinaryFrame(buf) { // Skip internal room/presence bookkeeping if (eventName === 'chat:room') { - console.log(`[WS] Room assigned: ${JSON.stringify(eventPayload)}`); return; } if (eventName !== 'chat:message') { - console.log(` -[WS EVENT] "${eventName}" ${JSON.stringify(eventPayload).slice(0, 160)}`); } broadcast({ _ft: 'event', event: eventName, data: eventPayload }); return; } // Anything else โ€” log raw for debugging - console.log(`[WS PACKET] type=${type} data=${JSON.stringify(data).slice(0, 200)}`); } catch(e) { console.log('[WS] Binary decode error:', e.message, e.stack); @@ -296,7 +288,6 @@ function connectFishtankWS(token) { // Server ping โ€” pong back if (msg === '2') { ftSocket.send('3'); return; } - console.log(`[WS TEXT] ${msg.slice(0, 200)}`); }); ftSocket.on('close', (code, reason) => { @@ -376,6 +367,15 @@ const server = http.createServer((req, res) => { }); return; } + if (parsed.pathname === '/chat') { + const file = path.join(__dirname, 'chat-popout.html'); + fs.readFile(file, (err, data) => { + if (err) { res.writeHead(404); res.end('Chat not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(data); + }); return; + } + // Token registration from dashboard if (parsed.pathname === '/ws-token' && req.method === 'POST') { let body = '';