mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-05-02 05:52:02 -04:00
2068 lines
65 KiB
HTML
2068 lines
65 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; }
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: 'DM Sans', sans-serif;
|
|
min-height: 100vh;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
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;
|
|
height: calc(100vh - 73px);
|
|
background: var(--border);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.cameras-panel {
|
|
background: #000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.cam-featured-wrap {
|
|
flex-shrink: 0;
|
|
background: #000;
|
|
width: 100%;
|
|
aspect-ratio: 16/9;
|
|
max-height: 65%;
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
.cam-featured-wrap .cam-cell {
|
|
width: 100%;
|
|
height: 100%;
|
|
aspect-ratio: unset;
|
|
}
|
|
|
|
.camera-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(9, 1fr);
|
|
gap: 2px;
|
|
padding: 2px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
align-content: start;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
</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);
|
|
// Auto-login if token already saved
|
|
if (getToken()) {
|
|
setApiStatus('live');
|
|
scheduleTokenRefresh();
|
|
startPolling();
|
|
} else {
|
|
setApiStatus('none');
|
|
}
|
|
initCameras();
|
|
initCamerman();
|
|
});
|
|
|
|
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) { document.getElementById('cammanLabel').textContent = 'CAMERAMAN · OFFLINE'; }
|
|
});
|
|
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);
|
|
// Auto-login if token already saved
|
|
if (getToken()) {
|
|
setApiStatus('live');
|
|
scheduleTokenRefresh();
|
|
startPolling();
|
|
} else {
|
|
setApiStatus('none');
|
|
}
|
|
initCameras();
|
|
initCamerman();
|
|
});
|
|
|
|
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) { document.getElementById('cammanLabel').textContent = 'CAMERAMAN · OFFLINE'; }
|
|
});
|
|
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>
|
|
|
|
<header>
|
|
<div class="logo">FISH<span>TANK</span> // MONITOR</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>
|
|
<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)">
|
|
<option value="5000">5s</option>
|
|
<option value="10000" selected>10s</option>
|
|
<option value="30000">30s</option>
|
|
<option value="60000">60s</option>
|
|
</select>
|
|
<div class="refresh-ring" id="spinner"></div>
|
|
<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" 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" 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" 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 Panel -->
|
|
<div class="panel" style="grid-area:tts;">
|
|
<div class="panel-header">
|
|
<div class="panel-title">TTS MESSAGES <div class="dot" id="ttsEnabledDot" style="display:inline-block;margin-left:8px;vertical-align:middle;"></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 = [];
|
|
|
|
|
|
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);
|
|
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();
|
|
}
|
|
|
|
|
|
async function fetchPoll() {
|
|
try {
|
|
const r = await fetch(BASE + '/v1/poll', { headers: headers() });
|
|
if (!r.ok) throw new Error(r.status);
|
|
const data = await r.json();
|
|
renderPoll(data);
|
|
} catch(e) {
|
|
console.error('Poll error:', e);
|
|
}
|
|
}
|
|
|
|
async function fetchTTS() {
|
|
try {
|
|
const r = await fetch(BASE + '/v1/tts', { headers: headers() });
|
|
if (!r.ok) throw new Error(r.status);
|
|
const data = await r.json();
|
|
// Accumulate new TTS messages at the top
|
|
const newTts = (data.ttsMessages || []).filter(m => !seenTtsIds.has(m.id));
|
|
newTts.forEach(m => { seenTtsIds.add(m.id); ttsHistory.unshift(m); });
|
|
|
|
renderTTS(ttsHistory);
|
|
} catch(e) {
|
|
console.error('TTS error:', e);
|
|
}
|
|
}
|
|
|
|
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 renderTTS(messages) {
|
|
const body = document.getElementById('ttsBody');
|
|
if (!messages.length) {
|
|
body.innerHTML = '<div class="empty"><div class="empty-icon">💬</div>No TTS messages</div>';
|
|
return;
|
|
}
|
|
|
|
// Only re-render if new messages were added
|
|
if (messages.length === lastTtsRenderCount) return;
|
|
lastTtsRenderCount = messages.length;
|
|
|
|
document.getElementById('ttsMeta').textContent = `${messages.length} accumulated`;
|
|
|
|
// Only the very first item (most recent) gets the highlight
|
|
const newestId = messages.length ? messages[0].id : null;
|
|
|
|
let html = '';
|
|
messages.forEach(msg => {
|
|
const isNew = msg.id === newestId;
|
|
|
|
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>
|
|
<span class="tts-cost">⬡ ${msg.cost}</span>
|
|
</div>
|
|
<div class="tts-message">${msg.message}</div>
|
|
<div class="tts-time">${formatTime(msg.createdAt)} · ${timeAgo(msg.createdAt)}</div>
|
|
</div>`;
|
|
});
|
|
|
|
body.innerHTML = html;
|
|
}
|
|
|
|
|
|
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 makeHls(slug, video, muted) {
|
|
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
|
|
hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8');
|
|
hls.attachMedia(video);
|
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
|
video.muted = muted;
|
|
video.play().catch(() => {});
|
|
});
|
|
hls.on(Hls.Events.ERROR, (e, d) => { if (d.fatal) hls.destroy(); });
|
|
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 });
|
|
featuredIdx = idx;
|
|
}
|
|
|
|
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 }
|
|
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 = [];
|
|
bufferRecorder.ondataavailable = e => {
|
|
if (!e.data || e.data.size === 0) return;
|
|
const now = Date.now();
|
|
bufferChunks.push({ data: e.data, ts: now });
|
|
const cutoff = now - BUFFER_SECS * 1000;
|
|
bufferChunks = bufferChunks.filter(c => c.ts >= cutoff);
|
|
};
|
|
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() {
|
|
ensureBuffer();
|
|
if (!bufferChunks.length) { alert('No buffer yet — wait a moment after the stream starts.'); return; }
|
|
const mimeType = bufferRecorder ? bufferRecorder.mimeType : 'video/webm';
|
|
const blob = new Blob(bufferChunks.map(c => c.data), { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
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';
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ── 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();
|
|
featCell.appendChild(featVideo);
|
|
featCell.appendChild(featLabel);
|
|
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);
|
|
}
|
|
|
|
|
|
async function fetchFeatureToggles() {
|
|
try {
|
|
const r = await fetch(BASE + '/v1/feature-toggles', { headers: headers() });
|
|
if (!r.ok) throw new Error(r.status);
|
|
const data = await r.json();
|
|
const tts = (data.featureToggles || []).find(f => f.feature === 'tts');
|
|
const dot = document.getElementById('ttsEnabledDot');
|
|
if (dot && tts) {
|
|
dot.className = 'dot' + (tts.enabled ? ' live' : ' error');
|
|
dot.title = tts.enabled ? 'TTS enabled' : 'TTS disabled';
|
|
}
|
|
} 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';
|
|
}
|
|
|
|
async function tick() {
|
|
updateClock();
|
|
if (!getToken()) return;
|
|
document.getElementById('spinner').classList.add('active');
|
|
await Promise.all([fetchPoll(), fetchTTS(), fetchStocks(), fetchFeatureToggles()]);
|
|
document.getElementById('spinner').classList.remove('active');
|
|
}
|
|
|
|
function changeInterval(val) {
|
|
if (!intervalId) return;
|
|
clearInterval(intervalId);
|
|
intervalId = setInterval(tick, parseInt(val));
|
|
}
|
|
|
|
function changeInterval(val) {
|
|
if (!intervalId) return;
|
|
clearInterval(intervalId);
|
|
intervalId = setInterval(tick, parseInt(val));
|
|
}
|
|
|
|
function startPolling() {
|
|
if (!getToken()) return;
|
|
if (intervalId) return;
|
|
tick();
|
|
const iv = document.getElementById('intervalSelect');
|
|
intervalId = setInterval(tick, iv ? parseInt(iv.value) : 10000);
|
|
}
|
|
|
|
function stopPolling() {
|
|
clearInterval(intervalId);
|
|
intervalId = null;
|
|
}
|
|
|
|
function clearHistory() {
|
|
ttsHistory = [];
|
|
sfxHistory = [];
|
|
seenTtsIds = new Set();
|
|
seenSfxIds = new Set();
|
|
document.getElementById('ttsBody').innerHTML = '<div class="empty"><div class="empty-icon">💬</div>History cleared</div>';
|
|
document.getElementById('sfxBody').innerHTML = '<div class="empty"><div class="empty-icon">🔊</div>History cleared</div>';
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
// Auto-login if token already saved
|
|
if (getToken()) {
|
|
setApiStatus('live');
|
|
scheduleTokenRefresh();
|
|
startPolling();
|
|
} else {
|
|
setApiStatus('none');
|
|
}
|
|
initCameras();
|
|
initCamerman();
|
|
});
|
|
|
|
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) { document.getElementById('cammanLabel').textContent = 'CAMERAMAN · OFFLINE'; }
|
|
});
|
|
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>
|
|
|
|
</body>
|
|
</html>
|