added clip trimming, chat popout + minor changes

This commit is contained in:
fishtank-dashboard
2026-03-18 12:58:47 -07:00
committed by GitHub
parent 1c63a3c95f
commit ffcdd43004
3 changed files with 1037 additions and 70 deletions
+645 -61
View File
@@ -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 @@
<div style="display:flex;align-items:center;gap:8px;">
<button class="stocks-collapse-btn" onclick="toggleLeftPanels()" id="leftCollapseBtn">◀ POLL / TTS</button>
<button class="stocks-collapse-btn" onclick="toggleStocks()" id="stocksCollapseBtn">▲ STOCKS</button>
<button class="stocks-collapse-btn" onclick="window.open('http://localhost:3000/chat','fishtank-chat','width=400,height=700,resizable=yes')" title="Open chat popout">💬 CHAT</button>
</div>
<div id="apiControl" style="display:flex;align-items:center;gap:8px;">
@@ -1223,11 +1496,12 @@
<button id="logoutBtn" onclick="logout()" style="display:none;background:transparent;border:1px solid var(--border);color:var(--muted);font-family:'Share Tech Mono',monospace;font-size:10px;padding:4px 12px;border-radius:4px;cursor:pointer;letter-spacing:1px;">LOGOUT</button>
</div>
<div class="status-bar">
<select class="interval-select" id="intervalSelect" onchange="changeInterval(this.value)" title="Stocks refresh interval">
<option value="30000">30s</option>
<option value="60000" selected>60s</option>
<option value="120000">2m</option>
<option value="300000">5m</option>
<select class="interval-select" id="intervalSelect" onchange="changeInterval(this.value)" title="Thumbnail refresh interval">
<option value="5000">5s</option>
<option value="10000">10s</option>
<option value="15000">15s</option>
<option value="30000" selected>30s</option>
<option value="60000">60s</option>
</select>
<span id="lastUpdated" style="font-size:13px;color:#e2e8f0;font-family:'Share Tech Mono',monospace;"></span>
</div>
@@ -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 @@
<span class="tts-voice">${msg.voice}</span>
<span class="tts-room ${hasRoom ? 'linked' : ''}">${ROOM_NAMES[msg.room] || msg.room || ''}</span>
<span class="tts-status ${msg.status}">${msg.status.toUpperCase()}</span>
<span class="tts-cost">⬡ ${msg.cost}</span>
</div>
<div class="tts-message">${msg.message}</div>
<div class="tts-time">${formatTime(msg.createdAt)} · ${timeAgo(msg.createdAt)}</div>
<div class="tts-time">${formatTime(msg.createdAt)} · ${timeAgo(msg.createdAt)}<span class="tts-cost" style="margin-left:auto;">⬡ ${msg.cost}</span></div>
</div>`;
} 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 = `
<div class="clip-modal">
<div class="clip-modal-title">✂ EDIT CLIP</div>
<video id="clipEditorVideo" src="${blobUrl}" preload="auto" playsinline></video>
<div class="clip-trim-row">
<div class="clip-trim-labels">
<span>IN: <span id="clipInLabel">0.0s</span></span>
<span>DURATION: <span id="clipDurLabel">—</span></span>
<span>OUT: <span id="clipOutLabel">—</span></span>
</div>
<div class="clip-range-wrap">
<div class="clip-range-track"></div>
<div class="clip-range-fill" id="clipRangeFill"></div>
<input type="range" id="clipInSlider" min="0" max="100" step="0.1" value="0">
<input type="range" id="clipOutSlider" min="0" max="100" step="0.1" value="100">
</div>
</div>
<div class="clip-modal-actions">
<div class="clip-export-progress" id="clipExportProgress">
<div class="refresh-ring active" style="width:12px;height:12px;border-width:2px;"></div>
<span id="clipExportLabel">Exporting...</span>
</div>
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()">SAVE FULL</button>
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
</div>
</div>`;
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 ─────────────────────────────────