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
+
+
+
+
+
+ FISHTANK // CHAT
+
+ 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: โ
+
+
+
+
+
+
+
+
+
+
`;
+ 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 = '';