mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-05-02 06:22:02 -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; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: 'DM Sans', sans-serif;
|
font-family: 'DM Sans', sans-serif;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
overflow-x: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
@@ -158,9 +165,11 @@
|
|||||||
grid-template-rows: 320px 1fr;
|
grid-template-rows: 320px 1fr;
|
||||||
grid-template-areas: "poll stocks camman" "tts cameras cameras";
|
grid-template-areas: "poll stocks camman" "tts cameras cameras";
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
height: calc(100vh - 73px);
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease;
|
transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main.stocks-collapsed {
|
.main.stocks-collapsed {
|
||||||
@@ -282,24 +291,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
.cameras-panel {
|
|
||||||
background: #000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cam-featured-wrap {
|
.cam-featured-wrap {
|
||||||
flex-shrink: 0;
|
|
||||||
background: #000;
|
background: #000;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16/9;
|
|
||||||
max-height: 65%;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
|
||||||
position: relative;
|
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 {
|
.cam-featured-wrap .cam-cell {
|
||||||
@@ -308,16 +311,25 @@
|
|||||||
aspect-ratio: unset;
|
aspect-ratio: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cam-featured-wrap video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.camera-grid {
|
.camera-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(9, 1fr);
|
grid-template-columns: repeat(9, 1fr);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
overflow-y: auto;
|
overflow: hidden;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
align-content: start;
|
/* Row height set dynamically by JS to match actual panel width */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.camera-grid::-webkit-scrollbar { width: 4px; }
|
.camera-grid::-webkit-scrollbar { width: 4px; }
|
||||||
.camera-grid::-webkit-scrollbar-track { background: transparent; }
|
.camera-grid::-webkit-scrollbar-track { background: transparent; }
|
||||||
.camera-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
.camera-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
@@ -620,6 +632,8 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* SFX */
|
/* SFX */
|
||||||
@@ -735,6 +749,127 @@
|
|||||||
font-size: 12px;
|
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 */
|
/* Camera reconnecting overlay */
|
||||||
.cam-reconnecting {
|
.cam-reconnecting {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -808,6 +943,127 @@
|
|||||||
padding: 3px 8px;
|
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 */
|
/* Camera reconnecting overlay */
|
||||||
.cam-reconnecting {
|
.cam-reconnecting {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1048,6 +1304,14 @@
|
|||||||
}
|
}
|
||||||
initCameras();
|
initCameras();
|
||||||
initCamerman();
|
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() {
|
function initCamerman() {
|
||||||
@@ -1125,6 +1389,14 @@
|
|||||||
}
|
}
|
||||||
initCameras();
|
initCameras();
|
||||||
initCamerman();
|
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() {
|
function initCamerman() {
|
||||||
@@ -1210,6 +1482,7 @@
|
|||||||
<div style="display:flex;align-items:center;gap:8px;">
|
<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="toggleLeftPanels()" id="leftCollapseBtn">◀ POLL / TTS</button>
|
||||||
<button class="stocks-collapse-btn" onclick="toggleStocks()" id="stocksCollapseBtn">▲ STOCKS</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>
|
||||||
|
|
||||||
<div id="apiControl" style="display:flex;align-items:center;gap:8px;">
|
<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>
|
<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>
|
||||||
<div class="status-bar">
|
<div class="status-bar">
|
||||||
<select class="interval-select" id="intervalSelect" onchange="changeInterval(this.value)" title="Stocks refresh interval">
|
<select class="interval-select" id="intervalSelect" onchange="changeInterval(this.value)" title="Thumbnail refresh interval">
|
||||||
<option value="30000">30s</option>
|
<option value="5000">5s</option>
|
||||||
<option value="60000" selected>60s</option>
|
<option value="10000">10s</option>
|
||||||
<option value="120000">2m</option>
|
<option value="15000">15s</option>
|
||||||
<option value="300000">5m</option>
|
<option value="30000" selected>30s</option>
|
||||||
|
<option value="60000">60s</option>
|
||||||
</select>
|
</select>
|
||||||
<span id="lastUpdated" style="font-size:13px;color:#e2e8f0;font-family:'Share Tech Mono',monospace;"></span>
|
<span id="lastUpdated" style="font-size:13px;color:#e2e8f0;font-family:'Share Tech Mono',monospace;"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1487,14 +1761,25 @@
|
|||||||
|
|
||||||
function applyPollVote(scores) {
|
function applyPollVote(scores) {
|
||||||
// scores = [{value, score}, ...] from poll:vote WS event
|
// scores = [{value, score}, ...] from poll:vote WS event
|
||||||
if (!pollCache) return; // no poll loaded yet, ignore
|
if (!pollCache) {
|
||||||
if (pollCache.currentPoll) {
|
|
||||||
pollCache.currentPoll.scores = scores;
|
|
||||||
} else {
|
|
||||||
// No current poll in cache yet — trigger a full fetch
|
|
||||||
fetchPoll();
|
fetchPoll();
|
||||||
return;
|
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);
|
renderPoll(pollCache);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1550,10 +1835,9 @@
|
|||||||
<span class="tts-voice">${msg.voice}</span>
|
<span class="tts-voice">${msg.voice}</span>
|
||||||
<span class="tts-room ${hasRoom ? 'linked' : ''}">${ROOM_NAMES[msg.room] || msg.room || ''}</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-status ${msg.status}">${msg.status.toUpperCase()}</span>
|
||||||
<span class="tts-cost">⬡ ${msg.cost}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tts-message">${msg.message}</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>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
// SFX item — fields: sound, url, displayName, cost, room, createdAt (unix ms)
|
// SFX item — fields: sound, url, displayName, cost, room, createdAt (unix ms)
|
||||||
@@ -1884,17 +2168,39 @@
|
|||||||
|
|
||||||
function makeHls(slug, video, muted, retryDelay) {
|
function makeHls(slug, video, muted, retryDelay) {
|
||||||
retryDelay = retryDelay || 2000;
|
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.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8');
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
|
|
||||||
|
let reconnectOverlayTimer = null;
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
video.muted = muted;
|
video.muted = muted;
|
||||||
video.play().catch(() => {});
|
video.play().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide overlay as soon as frames start playing
|
||||||
|
video.addEventListener('playing', () => {
|
||||||
|
if (reconnectOverlayTimer) { clearTimeout(reconnectOverlayTimer); reconnectOverlayTimer = null; }
|
||||||
hideReconnecting(video);
|
hideReconnecting(video);
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.ERROR, (e, d) => {
|
hls.on(Hls.Events.ERROR, (e, d) => {
|
||||||
if (!d.fatal) return;
|
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();
|
hls.destroy();
|
||||||
const nextDelay = Math.min(retryDelay * 1.5, 10000);
|
const nextDelay = Math.min(retryDelay * 1.5, 10000);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1940,6 +2246,11 @@
|
|||||||
const overlay = document.getElementById('featPlayOverlay');
|
const overlay = document.getElementById('featPlayOverlay');
|
||||||
if (overlay) overlay.classList.add('hidden');
|
if (overlay) overlay.classList.add('hidden');
|
||||||
featuredIdx = idx;
|
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
|
// Refresh overlays and viewer counts for new featured cam
|
||||||
if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) {
|
if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) {
|
||||||
updateContestantOverlays(window._lastContestantData);
|
updateContestantOverlays(window._lastContestantData);
|
||||||
@@ -2001,6 +2312,7 @@
|
|||||||
// ── Rolling 60s buffer (for CLIP) ──────────────────────────
|
// ── Rolling 60s buffer (for CLIP) ──────────────────────────
|
||||||
let bufferRecorder = null;
|
let bufferRecorder = null;
|
||||||
let bufferChunks = []; // { data, ts }
|
let bufferChunks = []; // { data, ts }
|
||||||
|
let bufferInitChunk = null; // WebM init segment, kept permanently
|
||||||
const BUFFER_SECS = 65; // keep a bit extra
|
const BUFFER_SECS = 65; // keep a bit extra
|
||||||
|
|
||||||
let mirrorVideo = null;
|
let mirrorVideo = null;
|
||||||
@@ -2064,13 +2376,25 @@
|
|||||||
try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
|
try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
|
||||||
catch(e) { return; }
|
catch(e) { return; }
|
||||||
bufferChunks = [];
|
bufferChunks = [];
|
||||||
|
bufferInitChunk = null; // reset so first chunk of new recorder is captured as init
|
||||||
bufferRecorder.ondataavailable = e => {
|
bufferRecorder.ondataavailable = e => {
|
||||||
if (!e.data || e.data.size === 0) return;
|
if (!e.data || e.data.size === 0) return;
|
||||||
const now = Date.now();
|
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 });
|
bufferChunks.push({ data: e.data, ts: now });
|
||||||
const cutoff = now - BUFFER_SECS * 1000;
|
const cutoff = now - BUFFER_SECS * 1000;
|
||||||
bufferChunks = bufferChunks.filter(c => c.ts >= cutoff);
|
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);
|
bufferRecorder.start(250);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2193,19 +2517,238 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveClip() {
|
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 mimeType = bufferRecorder ? bufferRecorder.mimeType : 'video/webm';
|
||||||
const blob = new Blob(bufferChunks.map(c => c.data), { type: mimeType });
|
if (!bufferChunks.length) { alert('No buffer yet — wait a moment after the stream starts.'); return; }
|
||||||
const url = URL.createObjectURL(blob);
|
// 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 wrap = document.getElementById('camFeaturedWrap');
|
||||||
const label = wrap ? wrap.querySelector('.cam-label') : null;
|
const label = wrap ? wrap.querySelector('.cam-label') : null;
|
||||||
const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'clip';
|
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');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
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) ──────────────────────────
|
// ── 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() {
|
async function fetchFeatureToggles() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(BASE + '/v1/feature-toggles', { headers: headers() });
|
const r = await fetch(BASE + '/v1/feature-toggles', { headers: headers() });
|
||||||
if (!r.ok) throw new Error(r.status);
|
if (!r.ok) throw new Error(r.status);
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
const toggles = data.featureToggles || [];
|
(data.featureToggles || []).forEach(f => applyFeatureToggle(f.feature, f.enabled));
|
||||||
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';
|
|
||||||
}
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('Feature toggles error:', 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';
|
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;
|
let slowIntervalId = null;
|
||||||
|
|
||||||
async function slowTick() {
|
async function slowTick() {
|
||||||
if (!getToken()) return;
|
if (!getToken()) return;
|
||||||
await Promise.all([fetchStocks(), fetchFeatureToggles()]);
|
await fetchStocks();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (!getToken()) return;
|
if (!getToken()) return;
|
||||||
// Initial fast loads
|
|
||||||
fetchPoll();
|
fetchPoll();
|
||||||
fetchTTSHistory();
|
fetchTTSHistory();
|
||||||
|
fetchFeatureToggles();
|
||||||
slowTick();
|
slowTick();
|
||||||
// Slow interval for stocks + toggles
|
|
||||||
if (!slowIntervalId) {
|
if (!slowIntervalId) {
|
||||||
slowIntervalId = setInterval(slowTick, 60000);
|
slowIntervalId = setInterval(slowTick, 60000);
|
||||||
}
|
}
|
||||||
@@ -2466,12 +3005,11 @@
|
|||||||
slowIntervalId = null;
|
slowIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// intervalSelect now controls stocks refresh rate
|
// intervalSelect controls thumbnail refresh rate
|
||||||
function changeInterval(val) {
|
function changeInterval(val) {
|
||||||
if (slowIntervalId) {
|
const ms = parseInt(val);
|
||||||
clearInterval(slowIntervalId);
|
if (window._thumbInterval) clearInterval(window._thumbInterval);
|
||||||
slowIntervalId = setInterval(slowTick, parseInt(val));
|
window._thumbInterval = setInterval(refreshAllThumbnails, ms);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHistory() {
|
function clearHistory() {
|
||||||
@@ -2495,6 +3033,14 @@
|
|||||||
}
|
}
|
||||||
initCameras();
|
initCameras();
|
||||||
initCamerman();
|
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() {
|
function initCamerman() {
|
||||||
@@ -2609,6 +3155,11 @@
|
|||||||
updateViewerCounts(msg.data);
|
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') {
|
if (msg.event === 'poll:vote') {
|
||||||
// data is [{value, score}, ...] — update scores in cached poll
|
// data is [{value, score}, ...] — update scores in cached poll
|
||||||
if (Array.isArray(msg.data)) applyPollVote(msg.data);
|
if (Array.isArray(msg.data)) applyPollVote(msg.data);
|
||||||
@@ -2695,6 +3246,38 @@
|
|||||||
// ── Notification popup ───────────────────────────────────────
|
// ── Notification popup ───────────────────────────────────────
|
||||||
let notifTimer = null;
|
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 ────────────────────────────────────
|
// ── Stocks panel collapse ────────────────────────────────────
|
||||||
let stocksCollapsed = false;
|
let stocksCollapsed = false;
|
||||||
|
|
||||||
@@ -2713,6 +3296,7 @@
|
|||||||
btn.textContent = '◀ POLL / TTS';
|
btn.textContent = '◀ POLL / TTS';
|
||||||
btn.title = 'Hide poll and TTS panels';
|
btn.title = 'Hide poll and TTS panels';
|
||||||
}
|
}
|
||||||
|
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleStocks = function toggleStocks() {
|
window.toggleStocks = function toggleStocks() {
|
||||||
@@ -2727,9 +3311,9 @@
|
|||||||
main.classList.remove('stocks-collapsed');
|
main.classList.remove('stocks-collapsed');
|
||||||
btn.textContent = '▲ STOCKS';
|
btn.textContent = '▲ STOCKS';
|
||||||
btn.title = 'Hide stocks panel';
|
btn.title = 'Hide stocks panel';
|
||||||
// Redraw chart since it was hidden
|
|
||||||
if (stocksChart) { stocksChart.resize(); }
|
if (stocksChart) { stocksChart.resize(); }
|
||||||
}
|
}
|
||||||
|
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
|
||||||
};
|
};
|
||||||
// ── End stocks panel collapse ─────────────────────────────────
|
// ── End stocks panel collapse ─────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
18
server.js
18
server.js
@@ -76,15 +76,12 @@ function sendBinary(buf) {
|
|||||||
|
|
||||||
function sendAuthFrame() {
|
function sendAuthFrame() {
|
||||||
if (!ftToken) {
|
if (!ftToken) {
|
||||||
console.log('[WS] No token — connecting unauthenticated (limited events)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log('[WS] Sending auth frame with token');
|
|
||||||
sendBinary(buildAuthFrame(ftToken));
|
sendBinary(buildAuthFrame(ftToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendSubscriptions() {
|
function sendSubscriptions() {
|
||||||
console.log('[WS] Subscribing to chat:presence and presence');
|
|
||||||
sendBinary(buildSubscribeFrame('chat:presence'));
|
sendBinary(buildSubscribeFrame('chat:presence'));
|
||||||
sendBinary(buildSubscribeFrame('presence'));
|
sendBinary(buildSubscribeFrame('presence'));
|
||||||
}
|
}
|
||||||
@@ -203,7 +200,6 @@ function mpDecode(buf, offset = 0) {
|
|||||||
// negative fixint
|
// negative fixint
|
||||||
if ((b & 0xe0) === 0xe0) return [b - 256, offset];
|
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];
|
return [null, offset];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,20 +228,16 @@ function handleBinaryFrame(buf) {
|
|||||||
|
|
||||||
// Skip internal room/presence bookkeeping
|
// Skip internal room/presence bookkeeping
|
||||||
if (eventName === 'chat:room') {
|
if (eventName === 'chat:room') {
|
||||||
console.log(`[WS] Room assigned: ${JSON.stringify(eventPayload)}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventName !== 'chat:message') {
|
if (eventName !== 'chat:message') {
|
||||||
console.log(`
|
|
||||||
[WS EVENT] "${eventName}" ${JSON.stringify(eventPayload).slice(0, 160)}`);
|
|
||||||
}
|
}
|
||||||
broadcast({ _ft: 'event', event: eventName, data: eventPayload });
|
broadcast({ _ft: 'event', event: eventName, data: eventPayload });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anything else — log raw for debugging
|
// Anything else — log raw for debugging
|
||||||
console.log(`[WS PACKET] type=${type} data=${JSON.stringify(data).slice(0, 200)}`);
|
|
||||||
|
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.log('[WS] Binary decode error:', e.message, e.stack);
|
console.log('[WS] Binary decode error:', e.message, e.stack);
|
||||||
@@ -296,7 +288,6 @@ function connectFishtankWS(token) {
|
|||||||
// Server ping — pong back
|
// Server ping — pong back
|
||||||
if (msg === '2') { ftSocket.send('3'); return; }
|
if (msg === '2') { ftSocket.send('3'); return; }
|
||||||
|
|
||||||
console.log(`[WS TEXT] ${msg.slice(0, 200)}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ftSocket.on('close', (code, reason) => {
|
ftSocket.on('close', (code, reason) => {
|
||||||
@@ -376,6 +367,15 @@ const server = http.createServer((req, res) => {
|
|||||||
}); return;
|
}); 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
|
// Token registration from dashboard
|
||||||
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
|
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
|
||||||
let body = '';
|
let body = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user