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

383
chat-popout.html Normal file
View File

@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FISHTANK // CHAT</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Bebas+Neue&family=DM+Sans:wght@300;400;500&display=swap');
:root {
--bg: #080b0f;
--panel: #0d1117;
--border: #1a2332;
--accent: #00e5ff;
--accent2: #ff3d71;
--accent3: #ffe600;
--text: #c9d4e0;
--muted: #4a5568;
--green: #00ff88;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--text);
font-family: 'DM Sans', sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
header {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--panel);
flex-shrink: 0;
}
.logo {
font-family: 'Bebas Neue', sans-serif;
font-size: 18px;
letter-spacing: 3px;
color: var(--accent);
}
.logo span { color: var(--accent2); }
.status {
display: flex;
align-items: center;
gap: 6px;
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--muted);
}
.dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--muted);
}
.dot.live {
background: var(--green);
box-shadow: 0 0 8px var(--green);
animation: pulse 2s infinite;
}
.dot.error { background: var(--accent2); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.msg-count {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--muted);
}
#chatFeed {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 0;
}
#chatFeed::-webkit-scrollbar { width: 4px; }
#chatFeed::-webkit-scrollbar-track { background: transparent; }
#chatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.msg {
padding: 7px 14px;
border-bottom: 1px solid rgba(26,35,50,0.5);
animation: slideIn 0.2s ease;
line-height: 1.4;
}
.msg:hover { background: rgba(255,255,255,0.02); }
@keyframes slideIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-header {
display: flex;
align-items: baseline;
gap: 7px;
margin-bottom: 2px;
flex-wrap: wrap;
}
.msg-user {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
font-weight: bold;
flex-shrink: 0;
}
.msg-clan {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
background: rgba(255,255,255,0.05);
padding: 1px 4px;
border-radius: 2px;
}
.msg-medals {
display: flex;
gap: 2px;
align-items: center;
}
.msg-endorsement {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
font-weight: bold;
letter-spacing: 1px;
opacity: 0.85;
}
.msg-time {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
margin-left: auto;
flex-shrink: 0;
}
.msg-text {
font-size: 13px;
color: var(--text);
word-break: break-word;
}
.msg-text .mention {
color: var(--accent);
font-weight: 500;
}
.msg.fish { border-left: 2px solid var(--accent3); background: rgba(255,230,0,0.04); }
.msg.admin { border-left: 2px solid var(--accent2); background: rgba(255,61,113,0.06); }
.msg.mod { border-left: 2px solid var(--green); background: rgba(0,255,136,0.05); }
.badge {
font-family: 'Share Tech Mono', monospace;
font-size: 8px;
padding: 1px 5px;
border-radius: 2px;
letter-spacing: 1px;
flex-shrink: 0;
}
.badge.admin { background: rgba(255,61,113,0.2); color: var(--accent2); border: 1px solid rgba(255,61,113,0.4); }
.badge.mod { background: rgba(0,255,136,0.1); color: var(--green); border: 1px solid rgba(0,255,136,0.3); }
.badge.fish { background: rgba(255,230,0,0.1); color: var(--accent3); border: 1px solid rgba(255,230,0,0.3); }
.badge.gm { background: rgba(255,61,113,0.15); color: var(--accent2); border: 1px solid rgba(255,61,113,0.3); }
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--muted);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
gap: 10px;
}
.empty-icon { font-size: 32px; opacity: 0.3; }
.scroll-btn {
position: fixed;
bottom: 12px;
right: 12px;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 4px;
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
padding: 5px 10px;
cursor: pointer;
letter-spacing: 1px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
z-index: 10;
}
.scroll-btn.visible { opacity: 1; pointer-events: auto; }
</style>
</head>
<body>
<header>
<div class="logo">FISH<span>TANK</span> // CHAT</div>
<div class="status">
<div class="dot" id="wsDot"></div>
<span id="wsStatus">CONNECTING</span>
</div>
<div class="msg-count" id="msgCount">0 messages</div>
</header>
<div id="chatFeed">
<div class="empty">
<div class="empty-icon">💬</div>
<span>Waiting for messages...</span>
</div>
</div>
<button class="scroll-btn" id="scrollBtn" onclick="scrollToBottom()">▼ NEW MESSAGES</button>
<script>
const feed = document.getElementById('chatFeed');
const scrollBtn = document.getElementById('scrollBtn');
let msgCount = 0;
let autoScroll = true;
let ws = null;
let reconnectTimer = null;
feed.addEventListener('scroll', () => {
const atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 60;
autoScroll = atBottom;
scrollBtn.classList.toggle('visible', !atBottom);
});
function scrollToBottom() {
feed.scrollTop = feed.scrollHeight;
autoScroll = true;
scrollBtn.classList.remove('visible');
}
function setStatus(state) {
const dot = document.getElementById('wsDot');
const label = document.getElementById('wsStatus');
const map = {
connected: { cls: 'live', text: 'LIVE' },
disconnected: { cls: 'error', text: 'OFFLINE' },
connecting: { cls: '', text: 'CONNECTING' },
};
const s = map[state] || map.connecting;
dot.className = 'dot ' + s.cls;
label.textContent = s.text;
label.style.color = s.cls === 'live' ? 'var(--green)' : s.cls === 'error' ? 'var(--accent2)' : 'var(--muted)';
}
function formatTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function isRealChatMessage(msg) {
// Filter out TTS messages (user.id === 'tts')
if (msg.user && msg.user.id === 'tts') return false;
// Filter out item use messages (metadata.type === 'item')
if (msg.metadata && msg.metadata.type === 'item') return false;
// Must have a real message string
if (!msg.message || typeof msg.message !== 'string') return false;
return true;
}
function addMessage(msg) {
const empty = feed.querySelector('.empty');
if (empty) empty.remove();
const user = msg.user || {};
const meta = msg.metadata || {};
const isFish = meta.isFish || false;
const isGM = meta.isGrandMarshall || false;
const isEpic = meta.isEpic || false;
const isAdmin = meta.isAdmin || false;
const isMod = meta.isMod || false;
const cls = ['msg',
isAdmin ? 'admin' : '',
isMod ? 'mod' : '',
isFish ? 'fish' : '',
isGM ? 'grand-marshall' : '',
isEpic ? 'epic' : '',
].filter(Boolean).join(' ');
// Username color: custom > endorsement color > default
let nameStyle = '';
if (user.customUsernameColor) {
nameStyle = `style="color:${user.customUsernameColor}"`;
}
// Endorsement tag (replaces medals)
const endorsement = user.endorsement
? `<span class="msg-endorsement" style="color:${user.endorsementColor || '#888'}">${user.endorsement}</span>`
: '';
// Role badges for special users only
const badges = [
isAdmin ? '<span class="badge admin">ADMIN</span>' : '',
isMod ? '<span class="badge mod">MOD</span>' : '',
isFish ? '<span class="badge fish">FISH</span>' : '',
isGM ? '<span class="badge gm">GM</span>' : '',
].filter(Boolean).join('');
const clan = user.clan ? `<span class="msg-clan">[${user.clan}]</span>` : '';
const ts = msg.timestamp ? formatTime(msg.timestamp) : '';
const div = document.createElement('div');
div.className = cls;
div.innerHTML = `
<div class="msg-header">
<span class="msg-user" ${nameStyle}>${user.displayName || 'unknown'}</span>
${clan}
${endorsement}
${badges}
${ts ? `<span class="msg-time">${ts}</span>` : ''}
</div>
<div class="msg-text">${escapeHtml(String(msg.message))}</div>`;
feed.appendChild(div);
msgCount++;
document.getElementById('msgCount').textContent = msgCount.toLocaleString() + ' messages';
if (autoScroll) feed.scrollTop = feed.scrollHeight;
}
function escapeHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function connect() {
if (ws) { ws.close(); ws = null; }
setStatus('connecting');
ws = new WebSocket('ws://localhost:3000');
ws.addEventListener('open', () => setStatus('connected'));
ws.addEventListener('message', e => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg._ft === 'ws_status') {
setStatus(msg.status === 'connected' ? 'connected' : 'disconnected');
return;
}
if (msg._ft === 'event' && msg.event === 'chat:message') {
const data = msg.data;
const messages = Array.isArray(data) ? data : [data];
messages.forEach(m => { if (isRealChatMessage(m)) addMessage(m); });
}
});
ws.addEventListener('close', () => {
setStatus('disconnected');
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, 3000);
});
ws.addEventListener('error', () => setStatus('disconnected'));
}
connect();
</script>
</body>
</html>

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 = video._blobUrl;
a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
a.click();
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 + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
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 ─────────────────────────────────

View File

@@ -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 = '';