mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-04-30 09:12:04 -04:00
added clip trimming, chat popout + minor changes
This commit is contained in:
committed by
GitHub
parent
1c63a3c95f
commit
ffcdd43004
383
chat-popout.html
Normal file
383
chat-popout.html
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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 ─────────────────────────────────
|
||||
|
||||
|
||||
18
server.js
18
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 = '';
|
||||
|
||||
Reference in New Issue
Block a user