Files
fishtank-dashboard/fishtank-dashboard.html
2026-03-18 12:58:47 -07:00

3467 lines
109 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FISHTANK // MONITOR</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;
--orange: #ff8c00;
}
* { 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;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 20% 0%, rgba(0,229,255,0.04) 0%, transparent 60%),
radial-gradient(ellipse 60% 40% at 80% 100%, rgba(255,61,113,0.04) 0%, transparent 60%);
pointer-events: none;
z-index: 0;
}
/* Scanline effect */
header {
position: relative;
z-index: 1;
padding: 20px 32px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(13,17,23,0.9);
backdrop-filter: blur(10px);
}
.logo {
font-family: 'Bebas Neue', sans-serif;
font-size: 28px;
letter-spacing: 4px;
color: var(--accent);
text-shadow: 0 0 20px rgba(0,229,255,0.5);
}
.logo span { color: var(--accent2); }
.status-bar {
display: flex;
align-items: center;
gap: 24px;
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--muted);
}
.status-dot {
display: flex;
align-items: center;
gap: 6px;
}
.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; }
}
.controls {
display: flex;
align-items: center;
gap: 12px;
}
.token-input {
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
padding: 6px 12px;
border-radius: 4px;
width: 280px;
outline: none;
transition: border-color 0.2s;
}
.token-input:focus { border-color: var(--accent); }
.btn {
background: transparent;
border: 1px solid var(--accent);
color: var(--accent);
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn:hover {
background: var(--accent);
color: var(--bg);
}
.btn.stop {
border-color: var(--accent2);
color: var(--accent2);
}
.btn.stop:hover {
background: var(--accent2);
color: white;
}
.main {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 320px 1fr 320px;
grid-template-rows: 320px 1fr;
grid-template-areas: "poll stocks camman" "tts cameras cameras";
gap: 1px;
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 {
grid-template-columns: 320px 1fr 0px;
grid-template-areas: "poll cameras cameras" "tts cameras cameras";
}
.main.stocks-collapsed .stocks-hide {
display: none;
}
.main.left-collapsed {
grid-template-columns: 0px 1fr 320px;
grid-template-areas: "stocks stocks camman" "cameras cameras camman";
}
.main.left-collapsed .left-hide {
display: none;
}
.main.left-collapsed.stocks-collapsed {
grid-template-columns: 0px 1fr 0px;
grid-template-areas: "cameras cameras cameras" "cameras cameras cameras";
}
.main.left-collapsed.stocks-collapsed .stocks-hide {
display: none;
}
.stocks-collapse-btn {
background: transparent;
border: none;
color: var(--muted);
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
letter-spacing: 1px;
transition: color 0.2s;
flex-shrink: 0;
}
.stocks-collapse-btn:hover { color: var(--accent); }
.panel {
background: var(--panel);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel.full-width {
grid-column: 1 / -1;
}
.panel-header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.panel-title {
font-family: 'Bebas Neue', sans-serif;
font-size: 16px;
letter-spacing: 3px;
color: var(--accent);
}
.panel-title.red { color: var(--accent2); }
.panel-title.yellow { color: var(--accent3); }
.panel-meta {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--muted);
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 0;
}
.panel-body::-webkit-scrollbar { width: 4px; }
.panel-body::-webkit-scrollbar-track { background: transparent; }
.panel-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.range-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
padding: 3px 10px;
border-radius: 3px;
cursor: pointer;
letter-spacing: 1px;
transition: all 0.2s;
}
.range-btn:hover {
border-color: var(--green);
color: var(--green);
}
.range-btn.active {
border-color: var(--green);
color: var(--green);
background: rgba(0,255,136,0.08);
}
/* CAMERAS */
.cameras-panel {
background: #000;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
height: 100%;
}
.cam-featured-wrap {
background: #000;
width: 100%;
overflow: hidden;
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 {
width: 100%;
height: 100%;
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: 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; }
.cam-cell {
position: relative;
background: #000;
aspect-ratio: 16/9;
cursor: pointer;
overflow: hidden;
}
.cam-cell video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cam-featured-wrap .cam-cell video {
object-fit: contain;
}
.cam-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 4px 6px;
background: linear-gradient(transparent, rgba(0,0,0,0.85));
font-family: 'Share Tech Mono', monospace;
font-size: 13px;
color: #fff;
letter-spacing: 1px;
opacity: 0.8;
}
.cam-cell.featured {
grid-column: 1 / -1;
aspect-ratio: 16/9;
z-index: 2;
}
.cam-offline {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
background: #0a0a0a;
}
/* STOCKS */
.chart-container {
position: relative;
padding: 16px;
height: 100%;
box-sizing: border-box;
}
.stocks-legend {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding: 12px 20px 0;
flex-shrink: 0;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--text);
cursor: pointer;
opacity: 1;
transition: opacity 0.2s;
}
.legend-item.hidden { opacity: 0.3; }
.legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-price {
color: var(--muted);
font-size: 10px;
}
.legend-change {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
}
.legend-change.up { color: var(--green); }
.legend-change.down { color: var(--accent2); }
.legend-change.flat { color: var(--muted); }
/* POLL */
.poll-container {
padding: 24px;
}
.poll-question {
font-family: 'Bebas Neue', sans-serif;
font-size: 22px;
letter-spacing: 2px;
color: white;
margin-bottom: 20px;
line-height: 1.2;
}
.poll-option {
margin-bottom: 14px;
}
.poll-option-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
}
.poll-option-name { color: var(--text); }
.poll-option-score {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
color: var(--accent);
}
.poll-bar-track {
height: 6px;
background: var(--border);
border-radius: 3px;
overflow: hidden;
}
.poll-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent2));
border-radius: 3px;
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 8px rgba(0,229,255,0.4);
}
.poll-bar-fill.winner {
background: linear-gradient(90deg, var(--accent3), var(--orange));
box-shadow: 0 0 8px rgba(255,230,0,0.4);
}
.poll-total {
margin-top: 16px;
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--muted);
}
.last-poll {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--border);
}
.last-poll-label {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--muted);
letter-spacing: 2px;
margin-bottom: 8px;
}
.last-poll-question {
font-size: 13px;
color: var(--text);
margin-bottom: 6px;
}
.last-poll-winner {
font-family: 'Bebas Neue', sans-serif;
font-size: 18px;
letter-spacing: 2px;
color: var(--accent3);
}
/* TTS */
.tts-item {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
animation: slideIn 0.3s ease;
transition: background 0.2s;
}
.tts-item:hover { background: rgba(255,255,255,0.02); }
.tts-item.clickable {
cursor: pointer;
}
.tts-item.clickable:hover {
background: rgba(0,229,255,0.05);
border-left: 2px solid var(--accent);
}
.tts-room.linked {
color: var(--accent2);
background: rgba(255,61,113,0.1);
cursor: pointer;
}
.tts-item.new {
border-left: 2px solid var(--accent);
background: rgba(0,229,255,0.03);
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.tts-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.tts-user {
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
color: var(--accent);
font-weight: bold;
}
.tts-voice {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--muted);
background: var(--border);
padding: 2px 7px;
border-radius: 3px;
}
.tts-room {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--accent2);
background: rgba(255,61,113,0.1);
padding: 2px 7px;
border-radius: 3px;
}
.tts-cost {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--accent3);
margin-left: auto;
}
.tts-status {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
padding: 2px 7px;
border-radius: 3px;
}
.tts-status.played {
color: var(--green);
background: rgba(0,255,136,0.1);
}
.tts-status.pending {
color: var(--accent3);
background: rgba(255,230,0,0.1);
}
.tts-status.rejected {
color: var(--accent2);
background: rgba(255,61,113,0.1);
}
.tts-message {
font-size: 13px;
color: var(--text);
line-height: 1.5;
}
.tts-time {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--muted);
margin-top: 4px;
display: flex;
align-items: center;
}
/* SFX */
.sfx-item {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
animation: slideIn 0.3s ease;
}
.sfx-icon {
font-size: 16px;
flex-shrink: 0;
}
.sfx-info { flex: 1; min-width: 0; }
.sfx-name {
font-size: 13px;
color: var(--text);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sfx-meta-row {
display: flex;
gap: 8px;
margin-top: 3px;
align-items: center;
}
.sfx-user {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--accent);
}
.sfx-cost {
font-family: 'Share Tech Mono', monospace;
font-size: 10px;
color: var(--accent3);
}
.sfx-play {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
font-size: 11px;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
flex-shrink: 0;
}
.sfx-play:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Empty state */
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--muted);
font-family: 'Share Tech Mono', monospace;
font-size: 12px;
gap: 8px;
}
.empty-icon { font-size: 32px; opacity: 0.3; }
/* Refresh indicator */
.refresh-ring {
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
visibility: hidden;
}
.refresh-ring.active { visibility: visible; }
@keyframes spin {
to { transform: rotate(360deg); }
}
.interval-select {
background: var(--panel);
border: 1px solid var(--border);
color: var(--muted);
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
padding: 5px 8px;
border-radius: 4px;
outline: none;
cursor: pointer;
}
.no-poll {
padding: 24px;
color: var(--muted);
font-family: 'Share Tech Mono', monospace;
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;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.7);
gap: 10px;
pointer-events: none;
z-index: 5;
}
.cam-reconnecting-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.cam-reconnecting-label {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
letter-spacing: 1px;
}
.cam-featured-wrap .cam-reconnecting-spinner {
width: 36px;
height: 36px;
}
.cam-featured-wrap .cam-reconnecting-label {
font-size: 11px;
}
/* Viewer count badge */
.cam-viewers {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.65);
color: #fff;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
padding: 2px 5px;
border-radius: 3px;
pointer-events: none;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 3px;
}
.cam-viewers::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent2);
flex-shrink: 0;
}
.cam-featured-wrap .cam-viewers {
top: 8px;
right: 8px;
font-size: 11px;
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;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.7);
gap: 10px;
pointer-events: none;
z-index: 5;
}
.cam-reconnecting-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255,255,255,0.15);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.cam-reconnecting-label {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
color: var(--muted);
letter-spacing: 1px;
}
.cam-featured-wrap .cam-reconnecting-spinner {
width: 36px;
height: 36px;
}
.cam-featured-wrap .cam-reconnecting-label {
font-size: 11px;
}
/* Viewer count badge */
.cam-viewers {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0,0,0,0.65);
color: #fff;
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
padding: 2px 5px;
border-radius: 3px;
pointer-events: none;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 3px;
}
.cam-viewers::before {
content: '';
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent2);
flex-shrink: 0;
}
.cam-featured-wrap .cam-viewers {
top: 8px;
right: 8px;
font-size: 11px;
padding: 3px 8px;
}
/* Featured cam play button overlay */
.cam-play-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.45);
cursor: pointer;
z-index: 10;
transition: opacity 0.2s;
}
.cam-play-overlay.hidden {
display: none;
}
.cam-play-btn {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(0,229,255,0.15);
border: 2px solid var(--accent);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, transform 0.2s;
}
.cam-play-overlay:hover .cam-play-btn {
background: rgba(0,229,255,0.3);
transform: scale(1.08);
}
.cam-play-btn svg {
width: 28px;
height: 28px;
fill: var(--accent);
margin-left: 4px;
}
/* Contestant cam overlays */
.cam-contestants {
position: absolute;
bottom: 22px;
left: 4px;
right: 4px;
display: flex;
flex-wrap: wrap;
gap: 2px;
pointer-events: none;
}
.cam-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1.5px solid rgba(0,0,0,0.6);
object-fit: cover;
background: var(--panel);
pointer-events: auto;
cursor: default;
flex-shrink: 0;
}
.cam-avatar:hover::after {
content: attr(title);
}
/* Featured cam — bigger avatars */
.cam-featured-wrap .cam-contestants {
bottom: 28px;
left: 8px;
gap: 4px;
}
.cam-featured-wrap .cam-avatar {
width: 32px;
height: 32px;
border-width: 2px;
}
/* Global notification popup */
#notifPopup {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%) translateY(-20px);
z-index: 9999;
background: var(--panel);
border: 1px solid var(--accent);
border-left: 4px solid var(--accent);
box-shadow: 0 0 30px rgba(0,229,255,0.2);
padding: 14px 24px 14px 20px;
max-width: 560px;
min-width: 300px;
border-radius: 4px;
display: flex;
align-items: flex-start;
gap: 14px;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
}
#notifPopup.show {
opacity: 1;
pointer-events: auto;
transform: translateX(-50%) translateY(0);
}
.notif-icon {
font-size: 18px;
flex-shrink: 0;
margin-top: 1px;
}
.notif-body { flex: 1; }
.notif-label {
font-family: 'Share Tech Mono', monospace;
font-size: 9px;
letter-spacing: 2px;
color: var(--accent);
margin-bottom: 5px;
}
.notif-message {
font-family: 'DM Sans', sans-serif;
font-size: 14px;
color: #fff;
line-height: 1.4;
}
.notif-subtitle {
font-family: 'Share Tech Mono', monospace;
font-size: 11px;
color: var(--muted);
margin-top: 4px;
}
.notif-close {
background: none;
border: none;
color: var(--muted);
cursor: pointer;
font-size: 16px;
padding: 0;
flex-shrink: 0;
line-height: 1;
margin-top: 1px;
}
.notif-close:hover { color: var(--text); }
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js">
document.addEventListener("DOMContentLoaded", () => {
updateClock();
setInterval(updateClock, 1000);
if (getToken()) {
setApiStatus('live');
scheduleTokenRefresh();
startPolling();
} else {
setApiStatus('none');
}
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() {
const video = document.getElementById('cammanVideo');
if (!video) return;
const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
hls.on(Hls.Events.ERROR, (e, d) => {
if (d.fatal) {
const label = document.getElementById('cammanLabel');
if (label) label.textContent = 'CAMERAMAN · RECONNECTING...';
hls.destroy();
setTimeout(() => {
if (video.isConnected) initCamerman();
}, 3000);
}
});
hlsInstances['camman'] = hls;
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = 'http://localhost:3000/cam/cameraman-5/index.m3u8';
video.play().catch(() => {});
}
}
// Update cameraman panel label when it gets swapped with featured
const _origSetFeatured = setFeatured;
setFeatured = function(i) {
_origSetFeatured(i);
// If cameraman is now in featured, show director in camman panel
// If cameraman is what's being swapped back, restore its own stream
const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
const cammanVideo = document.getElementById('cammanVideo');
const cammanLabel = document.getElementById('cammanLabel');
if (!cammanVideo) return;
if (i === cammanIdx) {
// Cameraman just went to featured - show director in camman panel
const dirSlug = CAMERAS[DEFAULT_IDX][1];
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/' + dirSlug + '/index.m3u8');
hls.attachMedia(cammanVideo);
hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); });
hlsInstances['camman'] = hls;
cammanLabel.textContent = 'DIRECTOR MODE';
} else if (directorCell === cammanIdx) {
// Director is in camman panel cell — keep it
} else {
// Restore camman panel to cameraman stream
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8');
hls.attachMedia(cammanVideo);
hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); });
hlsInstances['camman'] = hls;
cammanLabel.textContent = 'CAMERAMAN';
}
};
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js">
document.addEventListener("DOMContentLoaded", () => {
updateClock();
setInterval(updateClock, 1000);
if (getToken()) {
setApiStatus('live');
scheduleTokenRefresh();
startPolling();
} else {
setApiStatus('none');
}
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() {
const video = document.getElementById('cammanVideo');
if (!video) return;
const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
hls.on(Hls.Events.ERROR, (e, d) => {
if (d.fatal) {
const label = document.getElementById('cammanLabel');
if (label) label.textContent = 'CAMERAMAN · RECONNECTING...';
hls.destroy();
setTimeout(() => {
if (video.isConnected) initCamerman();
}, 3000);
}
});
hlsInstances['camman'] = hls;
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = 'http://localhost:3000/cam/cameraman-5/index.m3u8';
video.play().catch(() => {});
}
}
// Update cameraman panel label when it gets swapped with featured
const _origSetFeatured = setFeatured;
setFeatured = function(i) {
_origSetFeatured(i);
// If cameraman is now in featured, show director in camman panel
// If cameraman is what's being swapped back, restore its own stream
const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
const cammanVideo = document.getElementById('cammanVideo');
const cammanLabel = document.getElementById('cammanLabel');
if (!cammanVideo) return;
if (i === cammanIdx) {
// Cameraman just went to featured - show director in camman panel
const dirSlug = CAMERAS[DEFAULT_IDX][1];
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/' + dirSlug + '/index.m3u8');
hls.attachMedia(cammanVideo);
hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); });
hlsInstances['camman'] = hls;
cammanLabel.textContent = 'DIRECTOR MODE';
} else if (directorCell === cammanIdx) {
// Director is in camman panel cell — keep it
} else {
// Restore camman panel to cameraman stream
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8');
hls.attachMedia(cammanVideo);
hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); });
hlsInstances['camman'] = hls;
cammanLabel.textContent = 'CAMERAMAN';
}
};
</script>
</head>
<body>
<!-- Global notification popup -->
<div id="notifPopup">
<div class="notif-icon">📣</div>
<div class="notif-body">
<div class="notif-label">NOTIFICATION</div>
<div class="notif-message" id="notifMessage"></div>
<div class="notif-subtitle" id="notifSubtitle"></div>
</div>
<button class="notif-close" onclick="hideNotif()"></button>
</div>
<header>
<div class="logo">FISH<span>TANK</span> // MONITOR</div>
<!-- Panel toggle buttons — centered -->
<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;">
<div class="dot" id="apiDot" title="API status"></div>
<span id="apiStatus" style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">NO TOKEN</span>
<div class="dot" id="wsDot" title="WebSocket status" style="margin-left:4px;"></div>
<span id="wsLabel" style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">WS —</span>
<input id="inlineToken" type="password" placeholder="Paste token or full cookie value..." style="display:none;background:var(--panel);border:1px solid var(--accent);color:var(--text);font-family:'Share Tech Mono',monospace;font-size:10px;padding:4px 10px;border-radius:4px;width:260px;outline:none;" onkeydown="if(event.key==='Enter')inlineConnect();if(event.key==='Escape')closeInlineToken();" />
<button id="linkApiBtn" onclick="toggleInlineToken()" style="background:transparent;border:1px solid var(--green);color:var(--green);font-family:'Share Tech Mono',monospace;font-size:10px;padding:4px 12px;border-radius:4px;cursor:pointer;letter-spacing:1px;">LINK API</button>
<button id="connectInlineBtn" onclick="inlineConnect()" style="display:none;background:var(--green);color:var(--bg);border:none;font-family:'Share Tech Mono',monospace;font-size:10px;padding:4px 12px;border-radius:4px;cursor:pointer;letter-spacing:1px;">CONNECT</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 class="status-bar">
<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>
</header>
<div class="main">
<!-- Poll Panel -->
<div class="panel left-hide" style="grid-area:poll;">
<div class="panel-header">
<div class="panel-title yellow">ACTIVE POLL</div>
<div class="panel-meta" id="pollMeta"></div>
</div>
<div class="panel-body" id="pollBody">
<div class="empty"><div class="empty-icon">📊</div>Waiting for data...</div>
</div>
</div>
<!-- Stocks Panel -->
<div class="panel stocks-hide" style="overflow:hidden;grid-area:stocks;">
<div class="panel-header">
<div class="panel-title" style="color:var(--green)">STOCKS · <span id="stocksRangeLabel">TODAY</span></div>
<div style="display:flex;align-items:center;gap:8px;">
<button class="range-btn active" id="rangeToday" onclick="setStocksRange('today')">1D</button>
<button class="range-btn" id="rangeHour" onclick="setStocksRange('hour')">1H</button>
<button class="range-btn" id="rangeWeek" onclick="setStocksRange('week')">1W</button>
<div class="panel-meta" id="stocksMeta"></div>
</div>
</div>
<div class="stocks-legend" id="stocksLegend"></div>
<div class="panel-body" style="overflow:hidden; padding:0;">
<div class="chart-container">
<canvas id="stocksChart"></canvas>
</div>
</div>
</div>
<!-- Cameraman Panel -->
<div class="panel stocks-hide" style="grid-area:camman;overflow:hidden;background:#000;padding:0;cursor:pointer;" onclick="setFeatured(CAMERAS.findIndex(([,s])=>s==='cameraman-5'))" title="Click to feature Cameraman">
<div style="position:relative;width:100%;height:100%;">
<video id="cammanVideo" muted playsinline autoplay style="width:100%;height:100%;object-fit:contain;display:block;"></video>
<div class="cam-label" id="cammanLabel" style="position:absolute;bottom:0;left:0;right:0;">CAMERAMAN</div>
</div>
</div>
<!-- TTS + SFX Panel -->
<div class="panel left-hide" style="grid-area:tts;">
<div class="panel-header">
<div class="panel-title" style="display:flex;align-items:center;gap:8px;">
<span>TTS</span>
<div class="dot" id="ttsEnabledDot" style="flex-shrink:0;" title="TTS status"></div>
<span style="color:var(--accent2);">SFX</span>
<div class="dot" id="sfxEnabledDot" style="flex-shrink:0;" title="SFX status"></div>
</div>
<div class="panel-meta" id="ttsMeta"></div>
</div>
<div class="panel-body" id="ttsBody">
<div class="empty"><div class="empty-icon">💬</div>Waiting for data...</div>
</div>
</div>
<!-- Camera Panel -->
<div class="cameras-panel" style="grid-area:cameras;">
<div class="panel-header">
<div class="panel-title" style="color:var(--accent)">CAMERAS</div>
<div style="display:flex;align-items:center;gap:8px;margin-left:auto;">
<button class="range-btn" id="thumbToggle" onclick="toggleThumbMode()" style="font-size:10px;">THUMBS</button>
<button class="range-btn" id="sizeLimitToggle" onclick="toggleSizeLimit()" style="font-size:10px;color:var(--muted);border-color:var(--muted);">3.9MB LIMIT</button>
<span id="recSize" style="font-family:'Share Tech Mono',monospace;font-size:10px;color:#e2e8f0;display:none;min-width:52px;"></span>
<button class="range-btn" onclick="takeScreenshot()" style="font-size:10px;">📷 SNAP</button>
<button class="range-btn" id="clipBtn" onclick="saveClip()" style="font-size:10px;">✂ CLIP</button>
<button class="range-btn" id="recordBtn" onclick="startRecord()" style="font-size:10px;">⏺ REC</button>
<button class="range-btn" id="audioToggle" onclick="toggleRecordAudio()" style="font-size:10px;border-color:var(--muted);color:var(--muted);">🔇 MUTED</button>
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">VOL</span>
<input type="range" id="featuredVolume" min="0" max="1" step="0.01" value="1" style="width:120px;accent-color:var(--accent);cursor:pointer;" oninput="setFeaturedVolume(this.value)">
<div class="panel-meta" id="camMeta"></div>
</div>
</div>
<div class="camera-grid" id="cameraGrid"></div>
</div>
</div>
<script>
const BASE = 'http://localhost:3000/api';
let intervalId = null;
let seenTtsIds = new Set();
let ttsHistory = [];
let seenSfxIds = new Set();
let sfxHistory = [];
const STORAGE_KEY = 'ft_token';
const REFRESH_KEY = 'ft_refresh';
function getToken() {
return localStorage.getItem(STORAGE_KEY) || '';
}
function parseJwtExpiry(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000;
} catch(e) { return 0; }
}
async function refreshToken() {
const refreshTok = localStorage.getItem(REFRESH_KEY);
if (!refreshTok) return;
try {
const r = await fetch('http://localhost:3000/supabase/auth/v1/token?grant_type=refresh_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshTok })
});
if (!r.ok) throw new Error('refresh failed');
const data = await r.json();
localStorage.setItem(STORAGE_KEY, data.access_token);
if (data.refresh_token) localStorage.setItem(REFRESH_KEY, data.refresh_token);
setApiStatus('live');
scheduleTokenRefresh();
} catch(e) {
console.warn('Token refresh failed:', e);
}
}
let refreshTimer = null;
function scheduleTokenRefresh() {
if (refreshTimer) clearTimeout(refreshTimer);
const token = getToken();
if (!token) return;
const expiry = parseJwtExpiry(token);
const delay = Math.max(expiry - Date.now() - 60000, 5000);
refreshTimer = setTimeout(refreshToken, delay);
}
function setApiStatus(state) {
const dot = document.getElementById('apiDot');
const status = document.getElementById('apiStatus');
const linkBtn = document.getElementById('linkApiBtn');
const logoutBtn = document.getElementById('logoutBtn');
if (!dot) return;
if (state === 'live') {
dot.className = 'dot live';
status.textContent = 'API LIVE';
status.style.color = 'var(--green)';
linkBtn.style.display = 'none';
logoutBtn.style.display = '';
} else if (state === 'error') {
dot.className = 'dot error';
status.textContent = 'TOKEN EXPIRED';
status.style.color = 'var(--accent2)';
linkBtn.style.display = '';
linkBtn.textContent = 'RELINK API';
logoutBtn.style.display = 'none';
} else {
dot.className = 'dot';
status.textContent = 'NO TOKEN';
status.style.color = 'var(--muted)';
linkBtn.style.display = '';
linkBtn.textContent = 'LINK API';
logoutBtn.style.display = 'none';
}
}
function toggleInlineToken() {
const input = document.getElementById('inlineToken');
const connectBtn = document.getElementById('connectInlineBtn');
const showing = input.style.display !== 'none';
input.style.display = showing ? 'none' : '';
connectBtn.style.display = showing ? 'none' : '';
if (!showing) input.focus();
}
function closeInlineToken() {
document.getElementById('inlineToken').style.display = 'none';
document.getElementById('connectInlineBtn').style.display = 'none';
}
async function inlineConnect() {
const input = document.getElementById('inlineToken');
const raw = input.value.trim();
if (!raw) return;
let accessToken = raw;
let refreshTok = null;
try {
const decoded = decodeURIComponent(raw);
const arr = JSON.parse(decoded);
if (Array.isArray(arr) && arr.length >= 2) {
accessToken = arr[0];
refreshTok = arr[1];
}
} catch(e) {}
const r = await fetch('http://localhost:3000/api/v1/settings/chat', {
headers: { 'Authorization': 'Bearer ' + accessToken }
}).catch(() => null);
if (r && r.ok) {
localStorage.setItem(STORAGE_KEY, accessToken);
localStorage.setItem('ft_raw_cookie', raw); // save original paste for WS auth
if (refreshTok) localStorage.setItem(REFRESH_KEY, refreshTok);
input.value = '';
closeInlineToken();
setApiStatus('live');
scheduleTokenRefresh();
startPolling();
} else {
input.style.borderColor = 'var(--accent2)';
setTimeout(() => input.style.borderColor = 'var(--accent)', 1500);
}
}
function logout() {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(REFRESH_KEY);
if (refreshTimer) clearTimeout(refreshTimer);
stopPolling();
setApiStatus('none');
}
function skipLogin() {}
function headers() {
return { 'Authorization': `Bearer ${getToken()}` };
}
function timeAgo(ts) {
const diff = Date.now() - ts;
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
function formatTime(ts) {
return new Date(ts).toLocaleTimeString();
}
// Cached poll state — question/last from API, scores updated by WS
let pollCache = null; // { currentPoll: { poll, scores }, lastPoll }
async function fetchPoll() {
try {
const r = await fetch(BASE + '/v1/poll', { headers: headers() });
if (!r.ok) throw new Error(r.status);
pollCache = await r.json();
renderPoll(pollCache);
} catch(e) {
console.error('Poll error:', e);
}
}
function applyPollVote(scores) {
// scores = [{value, score}, ...] from poll:vote WS event
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);
}
// One-time backfill of TTS/SFX history on login
async function fetchTTSHistory() {
try {
const r = await fetch(BASE + '/v1/tts', { headers: headers() });
if (!r.ok) throw new Error(r.status);
const data = await r.json();
const newTts = (data.ttsMessages || []).filter(m => !seenTtsIds.has(m.id));
newTts.forEach(m => { seenTtsIds.add(m.id); ttsHistory.push(m); });
const newSfx = (data.sfxMessages || []).filter(m => !seenSfxIds.has(m.id));
newSfx.forEach(m => { seenSfxIds.add(m.id); sfxHistory.push(m); });
renderCombined();
} catch(e) {
console.error('TTS history error:', e);
}
}
let lastCombinedRenderCount = 0;
function renderCombined() {
const body = document.getElementById('ttsBody');
// Tag each item with its type
const tts = ttsHistory.map(m => ({ ...m, _type: 'tts' }));
const sfx = sfxHistory.map(m => ({ ...m, _type: 'sfx' }));
// Merge and sort by timestamp descending (newest first)
const all = [...tts, ...sfx].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
if (!all.length) {
body.innerHTML = '<div class="empty"><div class="empty-icon">💬</div>No messages yet</div>';
return;
}
lastCombinedRenderCount = all.length; // kept for reference, no longer used as guard
document.getElementById('ttsMeta').textContent = `${all.length} accumulated`;
const newestId = all[0].id;
let html = '';
all.forEach((msg, idx) => {
const isNew = msg.id === newestId;
if (msg._type === 'tts') {
const hasRoom = msg.room && CAMERAS.some(([n, s]) => s === msg.room);
html += `
<div class="tts-item ${isNew ? 'new' : ''} ${hasRoom ? 'clickable' : ''}" ${hasRoom ? `onclick="switchToRoom('${msg.room}')"` : ''}>
<div class="tts-meta">
<span class="tts-user">${msg.displayName}</span>
<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>
</div>
<div class="tts-message">${msg.message}</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)
const hasRoom = msg.room && msg.room !== 'site' && CAMERAS.some(([n, s]) => s === msg.room);
const roomLabel = msg.room === 'site' ? 'Site-wide' : (ROOM_NAMES[msg.room] || msg.room);
html += `
<div class="sfx-item ${isNew ? 'new' : ''} ${hasRoom ? 'clickable' : ''}" style="${hasRoom ? 'cursor:pointer;' : ''}" ${hasRoom ? `onclick="switchToRoom('${msg.room}')"` : ''}>
<div class="sfx-icon">🔊</div>
<div class="sfx-info">
<div class="sfx-name">${msg.sound || 'SFX'}</div>
<div class="sfx-meta-row">
<span class="sfx-user">${msg.displayName || ''}</span>
<span class="sfx-cost">⬡ ${msg.cost}</span>
<span class="tts-room ${hasRoom ? 'linked' : ''}">${roomLabel}</span>
<span class="tts-status ${msg.status}">${msg.status.toUpperCase()}</span>
</div>
<div class="tts-time">${formatTime(msg.createdAt)} · ${timeAgo(msg.createdAt)}</div>
</div>
${msg.url ? `<button class="sfx-play" onclick="event.stopPropagation();new Audio('${msg.url}').play()">▶</button>` : ''}
</div>`;
}
});
body.innerHTML = html;
}
function renderPoll(data) {
const body = document.getElementById('pollBody');
const current = data.currentPoll;
const last = data.lastPoll;
if (!current && !last) {
body.innerHTML = '<div class="no-poll">No active poll</div>';
return;
}
let html = '<div class="poll-container">';
if (current && current.poll) {
const scores = current.scores || [];
const total = scores.reduce((sum, s) => sum + s.score, 0);
const maxScore = Math.max(...scores.map(s => s.score), 1);
html += `<div class="poll-question">${current.poll.question}</div>`;
if (current.poll.narrative) {
html += `<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--accent2);background:rgba(255,61,113,0.1);border:1px solid rgba(255,61,113,0.3);padding:3px 10px;border-radius:3px;display:inline-block;margin-bottom:12px;letter-spacing:2px;">NARRATIVE POLL</div>`;
}
// Sort by score descending
const sorted = [...scores].sort((a, b) => b.score - a.score);
sorted.forEach((opt, i) => {
const pct = total > 0 ? Math.round((opt.score / total) * 100) : 0;
const isLeading = i === 0;
html += `
<div class="poll-option">
<div class="poll-option-label">
<span class="poll-option-name">${opt.value}</span>
<span class="poll-option-score">${opt.score.toLocaleString()} · ${pct}%</span>
</div>
<div class="poll-bar-track">
<div class="poll-bar-fill ${isLeading ? 'winner' : ''}" style="width:${pct}%"></div>
</div>
</div>`;
});
html += `<div class="poll-total">TOTAL VOTES: ${total.toLocaleString()}</div>`;
document.getElementById('pollMeta').textContent = `LIVE · ${total} votes`;
}
if (last) {
html += `
<div class="last-poll">
<div class="last-poll-label">LAST POLL</div>
<div class="last-poll-question">${last.question}</div>
<div class="last-poll-winner">✓ ${last.winner}</div>
</div>`;
}
html += '</div>';
body.innerHTML = html;
}
let lastTtsRenderCount = 0;
function playAudio(url) {
new Audio(url).play();
}
const TICKER_COLORS = {
ANIS: '#00e5ff',
BASH: '#ff3d71',
BEZO: '#ffe600',
CHTY: '#00ff88',
EMMA: '#ff8c00',
JD: '#a78bfa',
JDRA: '#f472b6',
LAND: '#34d399',
TWIN: '#fb923c',
VICT: '#60a5fa',
};
let stocksChart = null;
let hiddenTickers = new Set();
let stocksRange = 'today';
async function fetchStocks() {
try {
const r = await fetch(BASE + `/v1/stocks/prices?range=${stocksRange}`, { headers: headers() });
if (!r.ok) throw new Error(r.status);
const data = await r.json();
renderStocksChart(data.prices || {});
} catch(e) {
console.error('Stocks error:', e);
}
}
function renderStocksChart(prices) {
const tickers = Object.keys(prices);
if (!tickers.length) return;
// Get all timestamps sorted
const allTimestamps = [...new Set(
tickers.flatMap(t => Object.keys(prices[t]).map(Number))
)].sort((a, b) => a - b);
const labels = allTimestamps.map(ts =>
new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
);
const datasets = tickers.map(ticker => {
const priceMap = prices[ticker];
const data = allTimestamps.map(ts => priceMap[ts] ?? null);
const color = TICKER_COLORS[ticker] || '#ffffff';
const lastPrice = data.filter(v => v !== null).at(-1);
return { ticker, data, color, lastPrice };
});
// Sort legend by last price desc
const sorted = [...datasets].sort((a, b) => (b.lastPrice || 0) - (a.lastPrice || 0));
// Build legend with % change from earliest to latest
const legend = document.getElementById('stocksLegend');
legend.innerHTML = sorted.map(d => {
const validPrices = d.data.filter(v => v !== null);
const earliest = validPrices[0];
const latest = validPrices[validPrices.length - 1];
let changePct = null, changeClass = '', arrow = '';
if (earliest && latest) {
const pct = ((latest - earliest) / earliest * 100);
changePct = (pct >= 0 ? '+' : '') + pct.toFixed(1) + '%';
changeClass = pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat';
arrow = pct > 0 ? '▲' : pct < 0 ? '▼' : '—';
}
return `
<div class="legend-item ${hiddenTickers.has(d.ticker) ? 'hidden' : ''}" onclick="toggleTicker('${d.ticker}')" id="leg-${d.ticker}">
<div class="legend-dot" style="background:${d.color}"></div>
<span>${d.ticker}</span>
<span class="legend-price">${d.lastPrice ?? '—'}</span>
${changePct !== null ? `<span class="legend-change ${changeClass}">${arrow} ${changePct}</span>` : ''}
</div>`;
}).join('');
document.getElementById('stocksMeta').textContent = `${tickers.length} stocks`;
const chartDatasets = datasets.map(d => ({
label: d.ticker,
data: d.data,
borderColor: d.color,
backgroundColor: 'transparent',
borderWidth: 1.5,
pointRadius: 0,
pointHoverRadius: 4,
tension: 0.3,
hidden: hiddenTickers.has(d.ticker),
spanGaps: true,
}));
const ctx = document.getElementById('stocksChart').getContext('2d');
if (stocksChart) {
stocksChart.data.labels = labels;
stocksChart.data.datasets = chartDatasets;
stocksChart.update('none');
} else {
stocksChart = new Chart(ctx, {
type: 'line',
data: { labels, datasets: chartDatasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: '#0d1117',
borderColor: '#1a2332',
borderWidth: 1,
titleColor: '#4a5568',
bodyColor: '#c9d4e0',
titleFont: { family: 'Share Tech Mono', size: 10 },
bodyFont: { family: 'Share Tech Mono', size: 11 },
callbacks: {
title: items => items[0].label,
label: item => ` ${item.dataset.label}: ${item.raw ?? '—'}`,
}
}
},
scales: {
x: {
ticks: {
color: '#4a5568',
font: { family: 'Share Tech Mono', size: 9 },
maxTicksLimit: 8,
maxRotation: 0,
},
grid: { color: '#1a2332' },
border: { color: '#1a2332' },
},
y: {
ticks: {
color: '#4a5568',
font: { family: 'Share Tech Mono', size: 9 },
},
grid: { color: '#1a2332' },
border: { color: '#1a2332' },
}
}
}
});
}
}
function setStocksRange(range) {
stocksRange = range;
const labels = { today: 'TODAY', hour: '1 HOUR', week: 'THIS WEEK' };
document.getElementById('stocksRangeLabel').textContent = labels[range];
['today', 'hour', 'week'].forEach(r => {
const btn = document.getElementById('range' + r.charAt(0).toUpperCase() + r.slice(1));
if (btn) btn.classList.toggle('active', r === range);
});
// Destroy chart so it rebuilds with fresh axes
if (stocksChart) { stocksChart.destroy(); stocksChart = null; }
fetchStocks();
}
function toggleTicker(ticker) {
if (hiddenTickers.has(ticker)) {
hiddenTickers.delete(ticker);
} else {
hiddenTickers.add(ticker);
}
if (stocksChart) {
const ds = stocksChart.data.datasets.find(d => d.label === ticker);
if (ds) {
ds.hidden = hiddenTickers.has(ticker);
stocksChart.update();
}
}
const leg = document.getElementById('leg-' + ticker);
if (leg) leg.classList.toggle('hidden', hiddenTickers.has(ticker));
}
const CAMERAS = [
["Dorm", "dmrm-5"],
["Director Mode", "dirc-5"],
["Confessional", "cfsl-5"],
["Balcony", "bkny-5"],
["Foyer", "foyr-5"],
["Closet", "dmcl-5"],
["Glassroom", "gsrm-5"],
["Bar", "brrr-5"],
["Corridor", "codr-5"],
["Bar PTZ", "brpz-5"],
["Market Alternate", "mrke2-5"],
["Kitchen", "ktch-5"],
["Bar Alternate", "brrr2-5"],
["Dorm Alternate", "dmrm2-5"],
["Jacuzzi", "jckz-5"],
["Dining Room", "dnrm-5"],
["Market", "mrke-5"],
["Hallway Down", "hwdn-5"],
["Hallway Up", "hwup-5"],
["Cameraman", "cameraman-5"],
];
const ROOM_NAMES = {
"dmrm-5": "Dorm", "dirc-5": "Director Mode", "cfsl-5": "Confessional",
"bkny-5": "Balcony", "foyr-5": "Foyer", "dmcl-5": "Closet",
"gsrm-5": "Glassroom", "brrr-5": "Bar", "codr-5": "Corridor",
"brpz-5": "Bar PTZ", "mrke2-5": "Market Alternate", "ktch-5": "Kitchen",
"brrr2-5": "Bar Alternate", "dmrm2-5": "Dorm Alternate", "jckz-5": "Jacuzzi",
"dnrm-5": "Dining Room", "mrke-5": "Market", "hwdn-5": "Hallway Down",
"hwup-5": "Hallway Up",
"cameraman-5": "Cameraman", "site": "Site-wide",
};
const DEFAULT_IDX = 1; // Director Mode
const hlsInstances = {};
let featuredIdx = DEFAULT_IDX;
// directorCell = which grid index currently shows the Director stream (null = director is in featured)
let directorCell = null;
function showReconnecting(video) {
const cell = video.closest('.cam-cell') || video.closest('.cam-featured-wrap');
if (!cell || cell.querySelector('.cam-reconnecting')) return;
const overlay = document.createElement('div');
overlay.className = 'cam-reconnecting';
overlay.innerHTML = '<div class="cam-reconnecting-spinner"></div><div class="cam-reconnecting-label">RECONNECTING</div>';
cell.appendChild(overlay);
}
function hideReconnecting(video) {
const cell = video.closest('.cam-cell') || video.closest('.cam-featured-wrap');
if (!cell) return;
const overlay = cell.querySelector('.cam-reconnecting');
if (overlay) overlay.remove();
}
function makeHls(slug, video, muted, retryDelay) {
retryDelay = retryDelay || 2000;
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;
// 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(() => {
if (!video.isConnected) return;
const newHls = makeHls(slug, video, muted, nextDelay);
for (const key in hlsInstances) {
if (hlsInstances[key] === hls) {
hlsInstances[key] = newHls;
break;
}
}
}, retryDelay);
});
return hls;
}
function setThumbStream(gridIdx, slug, labelOverride) {
const cell = document.getElementById('cam-' + gridIdx);
if (!cell) return;
const label = cell.querySelector('.cam-label');
label.textContent = (labelOverride || CAMERAS[gridIdx][0]).toUpperCase();
if (thumbMode) {
captureThumb(slug, 'canvas-' + gridIdx);
} else {
const video = cell.querySelector('video');
if (video) {
if (hlsInstances[gridIdx]) hlsInstances[gridIdx].destroy();
hlsInstances[gridIdx] = makeHls(slug, video, true);
}
}
}
function setFeaturedStream(idx) {
const [name, slug] = CAMERAS[idx];
const wrap = document.getElementById('camFeaturedWrap');
const video = wrap.querySelector('video');
const label = wrap.querySelector('.cam-label');
label.textContent = name.toUpperCase();
if (hlsInstances['featured']) hlsInstances['featured'].destroy();
hlsInstances['featured'] = makeHls(slug, video, false);
video.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) video.volume = parseFloat(s.value); }, { once: true });
// Hide play overlay once user has interacted — browser will allow autoplay after first click
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);
}
if (typeof updateViewerCounts === 'function' && window._lastPresenceData) {
updateViewerCounts(window._lastPresenceData);
}
}
function setFeatured(i) {
// Clicking the cell that currently holds Director — go back to Director
if (directorCell === i) {
// Restore clicked cell to its own stream
setThumbStream(i, CAMERAS[i][1]);
directorCell = null;
setFeaturedStream(DEFAULT_IDX);
return;
}
// Clicking any other cell
// Restore previous directorCell to its own stream
if (directorCell !== null) {
setThumbStream(directorCell, CAMERAS[directorCell][1]);
}
// Put Director stream into clicked cell
setThumbStream(i, CAMERAS[DEFAULT_IDX][1], CAMERAS[DEFAULT_IDX][0]);
directorCell = i;
// Load clicked cam into featured
setFeaturedStream(i);
}
function switchToRoom(slug) {
// Do nothing if the featured cam is already showing this room
const currentSlug = CAMERAS[featuredIdx][1];
if (currentSlug === slug) return;
const idx = CAMERAS.findIndex(([, s]) => s === slug);
if (idx === -1) return;
// If director is already in featured and we're clicking a TTS for a non-director room,
// just switch — don't use setFeatured's "click director cell to go back" logic
// Use setFeatured but only if it won't trigger the director-return behavior
if (directorCell === idx) {
// This cell has director in it — switching to it means we want that room, not director
// Restore cell to its own stream first, then feature it normally
setThumbStream(idx, CAMERAS[idx][1]);
directorCell = null;
setFeaturedStream(idx);
// Now put director somewhere? No — just leave director out of grid for now
return;
}
setFeatured(idx);
}
// ── 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;
let mirrorCanvas = null;
let mirrorCanvasInterval = null;
function stopBuffer() {
if (bufferRecorder && bufferRecorder.state !== 'inactive') bufferRecorder.stop();
bufferRecorder = null;
bufferChunks = [];
if (mirrorCanvasInterval) { clearInterval(mirrorCanvasInterval); mirrorCanvasInterval = null; }
if (mirrorCanvas) { mirrorCanvas.remove(); mirrorCanvas = null; }
if (mirrorVideo) { if (mirrorVideo._hls) mirrorVideo._hls.destroy(); mirrorVideo.remove(); mirrorVideo = null; }
}
function getRecordStream() {
const wrap = document.getElementById('camFeaturedWrap');
if (!wrap) return null;
const featVideo = wrap.querySelector('video');
if (!featVideo) return null;
if (mirrorCanvasInterval) { clearInterval(mirrorCanvasInterval); mirrorCanvasInterval = null; }
if (mirrorCanvas) { mirrorCanvas.remove(); mirrorCanvas = null; }
mirrorCanvas = document.createElement('canvas');
mirrorCanvas.width = 960;
mirrorCanvas.height = 540;
mirrorCanvas.style.cssText = 'position:fixed;top:-9999px;';
document.body.appendChild(mirrorCanvas);
const ctx = mirrorCanvas.getContext('2d');
mirrorCanvasInterval = setInterval(() => {
if (featVideo.readyState >= 2) {
ctx.drawImage(featVideo, 0, 0, 960, 540);
}
}, 1000 / 30);
const canvasStream = mirrorCanvas.captureStream(30);
if (recordAudio) {
try {
const audioCtx = new AudioContext();
const source = audioCtx.createMediaElementSource(featVideo);
const dest = audioCtx.createMediaStreamDestination();
source.connect(dest);
source.connect(audioCtx.destination);
dest.stream.getAudioTracks().forEach(t => canvasStream.addTrack(t));
} catch(e) {
console.warn('Audio capture failed:', e);
}
}
return canvasStream;
}
function startBuffer() {
stopBuffer();
const stream = getRecordStream();
if (!stream) return;
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';
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);
}
let sizeLimitEnabled = false;
const SIZE_LIMIT_MB = 3.9;
function takeScreenshot() {
const wrap = document.getElementById('camFeaturedWrap');
if (!wrap) return;
const video = wrap.querySelector('video');
if (!video || video.readyState < 2) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 1920;
canvas.height = video.videoHeight || 1080;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const label = wrap.querySelector('.cam-label');
const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'snap';
const ts = new Date().toISOString().replace(/[:.]/g, '-');
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = camName + '_' + ts + '.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');
}
function toggleSizeLimit() {
sizeLimitEnabled = !sizeLimitEnabled;
const btn = document.getElementById('sizeLimitToggle');
if (sizeLimitEnabled) {
btn.style.color = 'var(--accent3)';
btn.style.borderColor = 'var(--accent3)';
} else {
btn.style.color = 'var(--muted)';
btn.style.borderColor = 'var(--muted)';
}
}
// ── Record (forward, manual stop) ──────────────────────────
let mediaRecorder = null;
let clipChunks = [];
let clipTimeout = null;
function startRecord() {
ensureBuffer();
const btn = document.getElementById('recordBtn');
const wrap = document.getElementById('camFeaturedWrap');
if (!wrap) return;
const video = wrap.querySelector('video');
if (!video) return;
if (mediaRecorder && mediaRecorder.state === 'recording') {
stopRecord();
return;
}
const stream = getRecordStream();
if (!stream) { alert('Could not capture stream'); return; }
clipChunks = [];
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';
try { mediaRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
catch(e) { alert('MediaRecorder error: ' + e.message); return; }
mediaRecorder.ondataavailable = e => {
if (e.data && e.data.size > 0) {
clipChunks.push(e.data);
const totalBytes = clipChunks.reduce((s, c) => s + c.size, 0);
const mb = (totalBytes / 1048576).toFixed(1);
const sizeEl = document.getElementById('recSize');
if (sizeEl) {
sizeEl.textContent = mb + ' MB';
sizeEl.style.color = parseFloat(mb) > 3.5 ? 'var(--accent2)' : '#e2e8f0';
}
if (sizeLimitEnabled && totalBytes >= (SIZE_LIMIT_MB - 0.3) * 1048576) {
stopRecord();
}
}
};
mediaRecorder.onstop = () => {
const blob = new Blob(clipChunks, { type: mimeType });
const url = URL.createObjectURL(blob);
const wrap = document.getElementById('camFeaturedWrap');
const label = wrap ? wrap.querySelector('.cam-label') : null;
const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'rec';
const a = document.createElement('a');
a.href = url;
a.download = camName + '_rec_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
a.click();
URL.revokeObjectURL(url);
btn.textContent = '⏺ REC';
btn.style.borderColor = '';
btn.style.color = '';
mediaRecorder = null;
if (clipTimeout) { clearTimeout(clipTimeout); clipTimeout = null; }
const sizeEl = document.getElementById('recSize');
if (sizeEl) sizeEl.style.display = 'none';
};
mediaRecorder.start(250);
btn.textContent = '⏹ STOP';
btn.style.borderColor = 'var(--accent2)';
btn.style.color = 'var(--accent2)';
const sizeEl = document.getElementById('recSize');
if (sizeEl) { sizeEl.textContent = '0.0 MB'; sizeEl.style.display = ''; }
clipTimeout = setTimeout(() => stopRecord(), 3600000);
}
function stopRecord() {
if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
}
function ensureBuffer(force) {
if (force || !bufferRecorder || bufferRecorder.state === 'inactive') {
startBuffer();
}
}
function saveClip() {
const mimeType = bufferRecorder ? bufferRecorder.mimeType : 'video/webm';
if (!bufferChunks.length) { alert('No buffer yet — wait a moment after the stream starts.'); return; }
// Calculate real duration from chunk timestamps (WebM header duration is unreliable)
const realDuration = (bufferChunks[bufferChunks.length - 1].ts - bufferChunks[0].ts) / 1000;
const blob = bufferRecorder._getBlob ? bufferRecorder._getBlob(mimeType)
: new Blob(bufferChunks.map(c => c.data), { type: mimeType });
const wrap = document.getElementById('camFeaturedWrap');
const label = wrap ? wrap.querySelector('.cam-label') : null;
const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'clip';
openClipEditor(blob, mimeType, camName, realDuration);
}
function openClipEditor(blob, mimeType, camName, realDuration) {
const blobUrl = URL.createObjectURL(blob);
const backdrop = document.createElement('div');
backdrop.className = 'clip-modal-backdrop';
backdrop.innerHTML = `
<div class="clip-modal">
<div class="clip-modal-title">✂ EDIT CLIP</div>
<video id="clipEditorVideo" src="${blobUrl}" preload="auto" playsinline></video>
<div class="clip-trim-row">
<div class="clip-trim-labels">
<span>IN: <span id="clipInLabel">0.0s</span></span>
<span>DURATION: <span id="clipDurLabel">—</span></span>
<span>OUT: <span id="clipOutLabel">—</span></span>
</div>
<div class="clip-range-wrap">
<div class="clip-range-track"></div>
<div class="clip-range-fill" id="clipRangeFill"></div>
<input type="range" id="clipInSlider" min="0" max="100" step="0.1" value="0">
<input type="range" id="clipOutSlider" min="0" max="100" step="0.1" value="100">
</div>
</div>
<div class="clip-modal-actions">
<div class="clip-export-progress" id="clipExportProgress">
<div class="refresh-ring active" style="width:12px;height:12px;border-width:2px;"></div>
<span id="clipExportLabel">Exporting...</span>
</div>
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()">SAVE FULL</button>
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const video = document.getElementById('clipEditorVideo');
const inSlider = document.getElementById('clipInSlider');
const outSlider= document.getElementById('clipOutSlider');
const fill = document.getElementById('clipRangeFill');
video._blobUrl = blobUrl;
video._mimeType = mimeType;
video._camName = camName;
video._realDur = realDuration || null;
let playbackWatcher = null;
function getDur() {
if (video._realDur && isFinite(video._realDur)) return video._realDur;
if (video.duration && isFinite(video.duration)) return video.duration;
return null;
}
function updateTrimUI(seekVideo) {
const d = getDur();
if (!d) return;
const inTime = parseFloat(inSlider.value);
const outTime = parseFloat(outSlider.value);
document.getElementById('clipInLabel').textContent = inTime.toFixed(1) + 's';
document.getElementById('clipOutLabel').textContent = outTime.toFixed(1) + 's';
const trimDur = Math.max(0, outTime - inTime);
const estMB = (trimDur * 4000000 / 8 / 1048576).toFixed(1);
document.getElementById('clipDurLabel').textContent = trimDur.toFixed(1) + 's (~' + estMB + ' MB)';
fill.style.left = (inTime / d * 100) + '%';
fill.style.width = (trimDur / d * 100) + '%';
if (seekVideo && video.paused) video.currentTime = inTime;
}
function initSliders(d) {
inSlider.min = '0'; inSlider.max = d.toFixed(1); inSlider.step = '0.1'; inSlider.value = '0';
outSlider.min = '0'; outSlider.max = d.toFixed(1); outSlider.step = '0.1'; outSlider.value = d.toFixed(1);
updateTrimUI(true);
}
video.addEventListener('loadedmetadata', () => {
const d = getDur();
if (d) initSliders(d);
});
video.addEventListener('error', () => {
document.getElementById('clipDurLabel').textContent = 'Load error — use SAVE FULL';
});
inSlider.addEventListener('input', () => {
if (parseFloat(inSlider.value) >= parseFloat(outSlider.value) - 1)
inSlider.value = (parseFloat(outSlider.value) - 1).toFixed(1);
updateTrimUI(true);
});
outSlider.addEventListener('input', () => {
if (parseFloat(outSlider.value) <= parseFloat(inSlider.value) + 1)
outSlider.value = (parseFloat(inSlider.value) + 1).toFixed(1);
updateTrimUI(false);
});
function stopPlayback() {
video.pause();
if (playbackWatcher) { clearInterval(playbackWatcher); playbackWatcher = null; }
}
function playFromIn() {
stopPlayback();
const inTime = parseFloat(inSlider.value);
const outTime = parseFloat(outSlider.value);
video.currentTime = inTime;
// Wait for seek to complete before playing
video.addEventListener('seeked', function onSeeked() {
video.removeEventListener('seeked', onSeeked);
video.play().catch(() => {});
playbackWatcher = setInterval(() => {
if (video.currentTime >= outTime - 0.05) {
stopPlayback();
video.currentTime = parseFloat(inSlider.value);
}
}, 50);
}, { once: true });
}
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
video.load();
}
function closeClipEditor() {
const backdrop = document.querySelector('.clip-modal-backdrop');
if (!backdrop) return;
const video = document.getElementById('clipEditorVideo');
if (video && video._blobUrl) URL.revokeObjectURL(video._blobUrl);
backdrop.remove();
// Reset buffer so next clip starts clean
stopBuffer();
bufferChunks = [];
bufferInitChunk = null;
setTimeout(() => ensureBuffer(), 500);
}
function downloadClipFull() {
const video = document.getElementById('clipEditorVideo');
if (!video) return;
const a = document.createElement('a');
a.href = video._blobUrl;
a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
a.click();
closeClipEditor();
}
function exportTrimmedClip() {
const video = document.getElementById('clipEditorVideo');
const inSlider = document.getElementById('clipInSlider');
const outSlider = document.getElementById('clipOutSlider');
if (!video) return;
const inTime = parseFloat(inSlider.value);
const outTime = parseFloat(outSlider.value);
if (!isFinite(inTime) || !isFinite(outTime) || outTime <= inTime) return;
const progress = document.getElementById('clipExportProgress');
const exportLabel = document.getElementById('clipExportLabel');
const saveBtn = document.getElementById('clipSaveTrimBtn');
const fullBtn = document.getElementById('clipSaveFullBtn');
progress.style.display = 'flex';
saveBtn.disabled = true;
fullBtn.disabled = true;
exportLabel.textContent = 'Seeking...';
video.pause();
video.muted = true;
const mimeType = video._mimeType;
const camName = video._camName;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
let drawInterval = null;
function startRecording() {
canvas.width = video.videoWidth || 960;
canvas.height = video.videoHeight || 540;
const stream = canvas.captureStream(30);
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 });
const chunks = [];
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
recorder.onstop = () => {
const trimmed = new Blob(chunks, { type: mimeType });
const url = URL.createObjectURL(trimmed);
const a = document.createElement('a');
a.href = url;
a.download = camName + '_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) ──────────────────────────
let recordAudio = false;
function toggleRecordAudio() {
recordAudio = !recordAudio;
const btn = document.getElementById('audioToggle');
if (recordAudio) {
btn.textContent = '🔊 AUDIO';
btn.style.borderColor = 'var(--green)';
btn.style.color = 'var(--green)';
} else {
btn.textContent = '🔇 MUTED';
btn.style.borderColor = 'var(--muted)';
btn.style.color = 'var(--muted)';
}
// Audio setting applies on next clip/record
}
let thumbMode = true;
function toggleThumbMode() {
thumbMode = !thumbMode;
const btn = document.getElementById('thumbToggle');
btn.textContent = thumbMode ? 'THUMBS' : 'LIVE';
btn.style.borderColor = thumbMode ? '' : 'var(--green)';
btn.style.color = thumbMode ? '' : 'var(--green)';
if (thumbMode) {
// Switch back to thumbnail mode — destroy all live thumb streams, refresh canvases
const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
CAMERAS.forEach(([name, slug], i) => {
if (i === DEFAULT_IDX || i === cammanIdx) return;
if (hlsInstances[i]) { hlsInstances[i].destroy(); delete hlsInstances[i]; }
// Replace video with canvas if needed
const cell = document.getElementById('cam-' + i);
if (!cell) return;
let video = cell.querySelector('video');
if (video) {
const canvas = document.createElement('canvas');
canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;';
canvas.id = 'canvas-' + i;
cell.replaceChild(canvas, video);
}
});
refreshAllThumbnails();
if (window._thumbInterval) clearInterval(window._thumbInterval);
window._thumbInterval = setInterval(refreshAllThumbnails, 30000);
} else {
// Switch to live mode — replace canvases with live video streams
if (window._thumbInterval) { clearInterval(window._thumbInterval); window._thumbInterval = null; }
const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
CAMERAS.forEach(([name, slug], i) => {
if (i === DEFAULT_IDX || i === cammanIdx) return;
const cell = document.getElementById('cam-' + i);
if (!cell) return;
let canvas = cell.querySelector('canvas');
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
video.autoplay = true;
video.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;';
if (canvas) {
cell.replaceChild(video, canvas);
}
hlsInstances[i] = makeHls(slug, video, true);
});
}
}
function captureThumb(slug, canvasId) {
return new Promise((resolve) => {
const canvas = document.getElementById(canvasId);
if (!canvas) return resolve();
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
let captured = false;
const capture = () => {
if (captured) return;
captured = true;
try {
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
} catch(e) {}
hls.destroy();
resolve();
};
const hls = new Hls({ maxBufferLength: 2, maxMaxBufferLength: 4 });
hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
video.addEventListener('timeupdate', capture, { once: true });
// Timeout fallback
setTimeout(() => { if (!captured) { captured = true; hls.destroy(); resolve(); } }, 8000);
});
}
async function refreshAllThumbnails() {
const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
const tasks = CAMERAS.map(([name, slug], i) => {
if (i === DEFAULT_IDX || i === cammanIdx) return null;
if (slug === CAMERAS[featuredIdx][1]) return null;
return captureThumb(slug, 'canvas-' + i);
}).filter(Boolean);
// Load 4 at a time to avoid overwhelming the proxy
for (let i = 0; i < tasks.length; i += 4) {
await Promise.all(tasks.slice(i, i + 4));
}
}
function initCameras() {
const panel = document.querySelector('.cameras-panel');
const grid = document.getElementById('cameraGrid');
Object.values(hlsInstances).forEach(h => h && h.destroy());
for (const k in hlsInstances) delete hlsInstances[k];
const oldWrap = document.getElementById('camFeaturedWrap');
if (oldWrap) oldWrap.remove();
grid.innerHTML = '';
featuredIdx = DEFAULT_IDX;
directorCell = null;
document.getElementById('camMeta').textContent = (CAMERAS.length - 2) + ' feeds';
// Build featured wrap with Director Mode
const wrap = document.createElement('div');
wrap.id = 'camFeaturedWrap';
wrap.className = 'cam-featured-wrap';
const featCell = document.createElement('div');
featCell.className = 'cam-cell';
const featVideo = document.createElement('video');
featVideo.playsInline = true;
featVideo.autoplay = true;
const featLabel = document.createElement('div');
featLabel.className = 'cam-label';
featLabel.textContent = CAMERAS[DEFAULT_IDX][0].toUpperCase();
// Play button overlay — hidden once video starts
const playOverlay = document.createElement('div');
playOverlay.className = 'cam-play-overlay';
playOverlay.id = 'featPlayOverlay';
playOverlay.innerHTML = '<div class="cam-play-btn"><svg viewBox="0 0 24 24"><polygon points="5,3 19,12 5,21"/></svg></div>';
playOverlay.addEventListener('click', () => {
featVideo.play().catch(() => {});
playOverlay.classList.add('hidden');
});
featVideo.addEventListener('playing', () => playOverlay.classList.add('hidden'), { once: false });
featVideo.addEventListener('pause', () => playOverlay.classList.remove('hidden'));
featCell.appendChild(featVideo);
featCell.appendChild(featLabel);
featCell.appendChild(playOverlay);
wrap.appendChild(featCell);
panel.insertBefore(wrap, grid);
featCell.addEventListener("click", () => { setTimeout(() => ensureBuffer(), 500); }, { once: true });
wrap.addEventListener("dblclick", () => {
if (!document.fullscreenElement) {
wrap.requestFullscreen().catch(() => {});
} else {
document.exitFullscreen();
}
});
hlsInstances['featured'] = makeHls(CAMERAS[DEFAULT_IDX][1], featVideo, false);
featVideo.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) featVideo.volume = parseFloat(s.value); }, { once: true });
// Build thumb grid — skip Director Mode and Cameraman
const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman-5");
CAMERAS.forEach(([name, slug], i) => {
if (i === DEFAULT_IDX || i === cammanIdx) return;
const cell = document.createElement('div');
cell.className = 'cam-cell';
cell.id = 'cam-' + i;
const canvas = document.createElement('canvas');
canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;';
canvas.id = 'canvas-' + i;
const label = document.createElement('div');
label.className = 'cam-label';
label.textContent = name.toUpperCase();
cell.appendChild(canvas);
cell.appendChild(label);
cell.addEventListener('click', () => setFeatured(i));
grid.appendChild(cell);
});
// Start thumbnail refresh cycle
refreshAllThumbnails();
if (window._thumbInterval) clearInterval(window._thumbInterval);
window._thumbInterval = setInterval(refreshAllThumbnails, 30000);
}
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();
(data.featureToggles || []).forEach(f => applyFeatureToggle(f.feature, f.enabled));
} catch(e) {
console.error('Feature toggles error:', e);
}
}
function setFeaturedVolume(val) {
const wrap = document.getElementById('camFeaturedWrap');
if (!wrap) return;
const video = wrap.querySelector('video');
if (video) video.volume = parseFloat(val);
}
function updateClock() {
document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' ET';
}
// Stocks tick — hardcoded 60s
let slowIntervalId = null;
async function slowTick() {
if (!getToken()) return;
await fetchStocks();
}
function startPolling() {
if (!getToken()) return;
fetchPoll();
fetchTTSHistory();
fetchFeatureToggles();
slowTick();
if (!slowIntervalId) {
slowIntervalId = setInterval(slowTick, 60000);
}
}
function stopPolling() {
clearInterval(slowIntervalId);
slowIntervalId = null;
}
// intervalSelect controls thumbnail refresh rate
function changeInterval(val) {
const ms = parseInt(val);
if (window._thumbInterval) clearInterval(window._thumbInterval);
window._thumbInterval = setInterval(refreshAllThumbnails, ms);
}
function clearHistory() {
ttsHistory = [];
sfxHistory = [];
seenTtsIds = new Set();
seenSfxIds = new Set();
lastCombinedRenderCount = 0;
document.getElementById('ttsBody').innerHTML = '<div class="empty"><div class="empty-icon">💬</div>History cleared</div>';
}
document.addEventListener("DOMContentLoaded", () => {
updateClock();
setInterval(updateClock, 1000);
if (getToken()) {
setApiStatus('live');
scheduleTokenRefresh();
startPolling();
} else {
setApiStatus('none');
}
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() {
const video = document.getElementById('cammanVideo');
if (!video) return;
const idx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
if (typeof Hls !== 'undefined' && Hls.isSupported()) {
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8');
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
hls.on(Hls.Events.ERROR, (e, d) => {
if (d.fatal) {
const label = document.getElementById('cammanLabel');
if (label) label.textContent = 'CAMERAMAN · RECONNECTING...';
hls.destroy();
setTimeout(() => {
if (video.isConnected) initCamerman();
}, 3000);
}
});
hlsInstances['camman'] = hls;
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = 'http://localhost:3000/cam/cameraman-5/index.m3u8';
video.play().catch(() => {});
}
}
// Update cameraman panel label when it gets swapped with featured
const _origSetFeatured = setFeatured;
setFeatured = function(i) {
_origSetFeatured(i);
// If cameraman is now in featured, show director in camman panel
// If cameraman is what's being swapped back, restore its own stream
const cammanIdx = CAMERAS.findIndex(([,s]) => s === 'cameraman-5');
const cammanVideo = document.getElementById('cammanVideo');
const cammanLabel = document.getElementById('cammanLabel');
if (!cammanVideo) return;
if (i === cammanIdx) {
// Cameraman just went to featured - show director in camman panel
const dirSlug = CAMERAS[DEFAULT_IDX][1];
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/' + dirSlug + '/index.m3u8');
hls.attachMedia(cammanVideo);
hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); });
hlsInstances['camman'] = hls;
cammanLabel.textContent = 'DIRECTOR MODE';
} else if (directorCell === cammanIdx) {
// Director is in camman panel cell — keep it
} else {
// Restore camman panel to cameraman stream
if (hlsInstances['camman']) hlsInstances['camman'].destroy();
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
hls.loadSource('http://localhost:3000/cam/cameraman-5/index.m3u8');
hls.attachMedia(cammanVideo);
hls.on(Hls.Events.MANIFEST_PARSED, () => { cammanVideo.muted = true; cammanVideo.play().catch(() => {}); });
hlsInstances['camman'] = hls;
cammanLabel.textContent = 'CAMERAMAN';
}
};
</script>
<script>
// ── Fishtank WebSocket client ────────────────────────────────
(function() {
let ftWs = null;
let ftWsReconnectTimer = null;
function connectLocalWS() {
if (ftWs && ftWs.readyState === WebSocket.OPEN) return;
ftWs = new WebSocket('ws://localhost:3000');
ftWs.addEventListener('open', () => {
console.log('[FT-WS] Connected to local proxy');
setWsStatus('connected');
const token = localStorage.getItem('ft_token');
const refresh = localStorage.getItem('ft_refresh');
const rawCookie = localStorage.getItem('ft_raw_cookie');
if (token) {
fetch('http://localhost:3000/ws-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, refresh, rawCookie })
}).catch(() => {});
}
});
ftWs.addEventListener('message', (e) => {
let msg;
try { msg = JSON.parse(e.data); } catch { return; }
if (msg._ft === 'ws_status') {
setWsStatus(msg.status);
return;
}
if (msg._ft === 'event') {
console.log(`[FT-WS EVENT] "${msg.event}"`, msg.data);
if (msg.event === 'notification:global') {
const [message, subtitle] = Array.isArray(msg.data) ? msg.data : [msg.data, ''];
if (message) showNotif(message, subtitle || '');
}
if (msg.event === 'contestants:cams') {
updateContestantOverlays(msg.data.data || msg.data);
}
if (msg.event === 'presence') {
window._lastPresenceData = 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') {
// data is [{value, score}, ...] — update scores in cached poll
if (Array.isArray(msg.data)) applyPollVote(msg.data);
}
if (msg.event === 'tts:update') {
// Full TTS object — add to history if not seen
const m = msg.data;
if (m && m.id && !seenTtsIds.has(m.id)) {
seenTtsIds.add(m.id);
ttsHistory.unshift(m);
lastCombinedRenderCount = 0;
renderCombined();
} else if (m && m.id && seenTtsIds.has(m.id)) {
// Update existing entry (e.g. status changed pending->played)
const idx = ttsHistory.findIndex(t => t.id === m.id);
if (idx > -1) { ttsHistory[idx] = m; lastCombinedRenderCount = 0; renderCombined(); }
}
}
if (msg.event === 'sfx:insert') {
const m = msg.data;
if (!m || !m.id) return;
if (!seenSfxIds.has(m.id)) {
seenSfxIds.add(m.id);
sfxHistory.unshift(m);
} else {
const idx = sfxHistory.findIndex(s => s.id === m.id);
if (idx > -1) sfxHistory[idx] = m;
}
lastCombinedRenderCount = 0;
renderCombined();
}
}
if (msg._ft === 'raw') {
console.log('[FT-WS RAW]', msg.data);
}
});
ftWs.addEventListener('close', () => {
setWsStatus('disconnected');
if (ftWsReconnectTimer) clearTimeout(ftWsReconnectTimer);
ftWsReconnectTimer = setTimeout(connectLocalWS, 3000);
});
ftWs.addEventListener('error', () => setWsStatus('error'));
}
function setWsStatus(status) {
const dot = document.getElementById('wsDot');
const label = document.getElementById('wsLabel');
if (!dot || !label) return;
const map = {
connected: { cls: 'live', text: 'WS LIVE' },
disconnected: { cls: 'error', text: 'WS OFF' },
error: { cls: 'error', text: 'WS ERR' },
};
const s = map[status] || { cls: '', text: 'WS —' };
dot.className = 'dot ' + s.cls;
label.textContent = s.text;
label.style.color = s.cls === 'live' ? 'var(--green)' : s.cls === 'error' ? 'var(--accent2)' : 'var(--muted)';
}
// When user links their API token, also send it to the WS proxy
document.addEventListener('DOMContentLoaded', () => {
connectLocalWS();
// Patch inlineConnect to forward token to proxy after successful login
const _origInlineConnect = window.inlineConnect;
if (_origInlineConnect) {
window.inlineConnect = async function() {
await _origInlineConnect.apply(this, arguments);
const token = localStorage.getItem('ft_token');
if (token) {
fetch('http://localhost:3000/ws-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
}).catch(() => {});
}
};
}
});
// ── 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;
let leftCollapsed = false;
window.toggleLeftPanels = function toggleLeftPanels() {
leftCollapsed = !leftCollapsed;
const main = document.querySelector('.main');
const btn = document.getElementById('leftCollapseBtn');
if (leftCollapsed) {
main.classList.add('left-collapsed');
btn.textContent = '▶ POLL / TTS';
btn.title = 'Show poll and TTS panels';
} else {
main.classList.remove('left-collapsed');
btn.textContent = '◀ POLL / TTS';
btn.title = 'Hide poll and TTS panels';
}
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
};
window.toggleStocks = function toggleStocks() {
stocksCollapsed = !stocksCollapsed;
const main = document.querySelector('.main');
const btn = document.getElementById('stocksCollapseBtn');
if (stocksCollapsed) {
main.classList.add('stocks-collapsed');
btn.textContent = '▼ STOCKS';
btn.title = 'Show stocks panel';
} else {
main.classList.remove('stocks-collapsed');
btn.textContent = '▲ STOCKS';
btn.title = 'Hide stocks panel';
if (stocksChart) { stocksChart.resize(); }
}
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
};
// ── End stocks panel collapse ─────────────────────────────────
// ── Viewer counts ────────────────────────────────────────────
window.updateViewerCounts = function(data) {
const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1;
if (typeof CAMERAS !== 'undefined') {
CAMERAS.forEach(([name, slug], i) => {
if (i === (typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1)) return;
if (i === cammanIdx) return;
const cell = document.getElementById('cam-' + i);
if (cell) setViewerBadge(cell, data[slug]);
});
}
const featWrap = document.getElementById('camFeaturedWrap');
if (featWrap && typeof CAMERAS !== 'undefined' && typeof featuredIdx !== 'undefined') {
const slug = CAMERAS[featuredIdx] ? CAMERAS[featuredIdx][1] : null;
const featCell = featWrap.querySelector('.cam-cell') || featWrap;
if (slug) {
const dirSlug = CAMERAS[typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1][1];
setViewerBadge(featCell, data[slug]);
}
}
};
function setViewerBadge(cell, count) {
let badge = cell.querySelector('.cam-viewers');
if (count === undefined || count === null || count === 0) {
if (badge) badge.remove();
return;
}
if (!badge) {
badge = document.createElement('div');
badge.className = 'cam-viewers';
cell.appendChild(badge);
}
badge.textContent = count >= 1000
? (count / 1000).toFixed(1) + 'k'
: count.toString();
}
// ── End viewer counts ─────────────────────────────────────────
// ── Contestant cam overlays ──────────────────────────────────
const AVATAR_CDN = 'https://cdn.fishtank.live/images/contestants/s5/';
// Build a map of slug -> [contestant, ...] from the latest data
let lastContestantData = {};
window.updateContestantOverlays = function(data) {
window._lastContestantData = data;
lastContestantData = data;
// Build slug -> contestants map
const slugMap = {};
for (const id in data) {
const c = data[id];
if (!c.position) continue;
for (const slug of c.position) {
if (!slugMap[slug]) slugMap[slug] = [];
slugMap[slug].push(c);
}
}
// Update every camera cell in the grid
const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1;
if (typeof CAMERAS !== 'undefined') {
CAMERAS.forEach(([name, slug], i) => {
if (i === (typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1)) return;
if (i === cammanIdx) return;
const cell = document.getElementById('cam-' + i);
if (cell) setOverlay(cell, slug, slugMap[slug] || []);
});
}
// Update featured cam — Director Mode shows all visible contestants
const featWrap = document.getElementById('camFeaturedWrap');
if (featWrap && typeof CAMERAS !== 'undefined' && typeof featuredIdx !== 'undefined') {
const slug = CAMERAS[featuredIdx] ? CAMERAS[featuredIdx][1] : null;
const featCell = featWrap.querySelector('.cam-cell') || featWrap;
const dirSlug = CAMERAS[typeof DEFAULT_IDX !== 'undefined' ? DEFAULT_IDX : 1][1];
if (slug === dirSlug) {
// Director cam — show everyone who has a known position, clickable to jump to their room
const allVisible = Object.values(data).filter(c => c.position && c.position.length);
setOverlay(featCell, slug, allVisible, true);
} else {
setOverlay(featCell, slug, slugMap[slug] || []);
}
}
};
function setOverlay(cell, slug, contestants, clickable) {
// Remove existing overlay
const existing = cell.querySelector('.cam-contestants');
if (existing) existing.remove();
if (!contestants.length) return;
const wrap = document.createElement('div');
wrap.className = 'cam-contestants';
for (const c of contestants) {
const img = document.createElement('img');
img.className = 'cam-avatar';
img.src = AVATAR_CDN + c.avatar;
img.title = c.name + (c.action ? ' · ' + c.action : '') + (c.mood ? ' · ' + c.mood : '');
img.onerror = () => img.style.display = 'none';
if (clickable && c.position && c.position[0]) {
img.style.cursor = 'pointer';
img.style.outline = '1.5px solid transparent';
img.style.transition = 'outline 0.15s';
img.addEventListener('mouseenter', () => img.style.outline = '1.5px solid var(--accent)');
img.addEventListener('mouseleave', () => img.style.outline = '1.5px solid transparent');
const room = c.position[0];
img.addEventListener('click', (e) => {
e.stopPropagation();
if (typeof switchToRoom === 'function') switchToRoom(room);
});
}
wrap.appendChild(img);
}
cell.appendChild(wrap);
}
// ── End contestant overlays ───────────────────────────────────
window.showNotif = function(message, subtitle) {
const popup = document.getElementById('notifPopup');
const msgEl = document.getElementById('notifMessage');
const subEl = document.getElementById('notifSubtitle');
if (!popup) return;
msgEl.textContent = message;
subEl.textContent = subtitle || '';
subEl.style.display = subtitle ? '' : 'none';
popup.classList.add('show');
if (notifTimer) clearTimeout(notifTimer);
notifTimer = setTimeout(hideNotif, 8000);
// Play notification sound
try { new Audio('https://cdn.fishtank.live/sounds/xp.mp3').play(); } catch(e) {}
};
window.hideNotif = function() {
const popup = document.getElementById('notifPopup');
if (popup) popup.classList.remove('show');
if (notifTimer) { clearTimeout(notifTimer); notifTimer = null; }
};
// ── End notification popup ────────────────────────────────────
})();
</script>
</body>
</html>