mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-04-30 09:12:04 -04:00
4873 lines
159 KiB
HTML
4873 lines
159 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 300px;
|
|
grid-template-rows: 320px 1fr;
|
|
grid-template-areas: "poll stocks chat" "tts cameras chat";
|
|
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 300px;
|
|
grid-template-areas: "poll cameras chat" "tts cameras chat";
|
|
}
|
|
.main.stocks-collapsed .stocks-hide {
|
|
display: none;
|
|
}
|
|
|
|
.main.left-collapsed {
|
|
grid-template-columns: 0px 1fr 300px;
|
|
grid-template-areas: "stocks stocks chat" "cameras cameras chat";
|
|
}
|
|
.main.left-collapsed .left-hide {
|
|
display: none;
|
|
}
|
|
|
|
.main.left-collapsed.stocks-collapsed {
|
|
grid-template-columns: 0px 1fr 300px;
|
|
grid-template-areas: "cameras cameras chat" "cameras cameras chat";
|
|
}
|
|
.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-y: auto;
|
|
overflow-x: hidden;
|
|
flex: 0 0 auto;
|
|
/* Default 2-row cap using vw — JS overrides with exact px once rendered */
|
|
--thumb-h: calc((100vw - 322px) / 9 * 9 / 16);
|
|
grid-template-rows: repeat(3, var(--thumb-h));
|
|
height: calc(var(--thumb-h) * 2 + 6px);
|
|
max-height: calc(var(--thumb-h) * 2 + 6px);
|
|
}
|
|
|
|
.camera-grid::-webkit-scrollbar { width: 4px; }
|
|
.camera-grid::-webkit-scrollbar-track { background: transparent; }
|
|
.camera-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Contestant row */
|
|
.contestant-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.contestant-avatar {
|
|
position: relative;
|
|
cursor: default;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.contestant-avatar img {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 2px solid transparent;
|
|
display: block;
|
|
transition: filter 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
.contestant-avatar.eliminated img {
|
|
filter: grayscale(100%) brightness(0.5);
|
|
}
|
|
|
|
.contestant-avatar img:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.contestant-tooltip {
|
|
position: absolute;
|
|
bottom: calc(100% + 6px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 6px 10px;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
z-index: 100;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
color: var(--text);
|
|
text-align: center;
|
|
}
|
|
|
|
.contestant-avatar:hover .contestant-tooltip {
|
|
opacity: 1;
|
|
}
|
|
|
|
.contestant-tooltip .ct-name {
|
|
font-family: 'Bebas Neue', sans-serif;
|
|
font-size: 14px;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.contestant-tooltip .ct-job {
|
|
color: var(--muted);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.contestant-tooltip .ct-endorsements {
|
|
color: var(--accent);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.contestant-tooltip .ct-eliminated {
|
|
color: var(--accent2);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Inline chat panel */
|
|
.chat-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--panel);
|
|
border-left: 1px solid var(--border);
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
.main.chat-collapsed .chat-panel {
|
|
display: none;
|
|
}
|
|
|
|
.main.chat-collapsed {
|
|
grid-template-columns: 320px 1fr 0px !important;
|
|
grid-template-areas: "poll stocks ." "tts cameras ." !important;
|
|
}
|
|
.main.chat-collapsed.stocks-collapsed {
|
|
grid-template-areas: "poll cameras ." "tts cameras ." !important;
|
|
}
|
|
.main.chat-collapsed.left-collapsed {
|
|
grid-template-columns: 0px 1fr 0px !important;
|
|
grid-template-areas: "stocks stocks ." "cameras cameras ." !important;
|
|
}
|
|
.main.chat-collapsed.left-collapsed.stocks-collapsed {
|
|
grid-template-columns: 0px 1fr 0px !important;
|
|
grid-template-areas: "cameras cameras cameras" "cameras cameras cameras" !important;
|
|
}
|
|
|
|
#inlineChatFeed {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-height: 0;
|
|
}
|
|
|
|
#inlineChatFeed::-webkit-scrollbar { width: 4px; }
|
|
#inlineChatFeed::-webkit-scrollbar-track { background: transparent; }
|
|
#inlineChatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
#inlineChatFeed .msg {
|
|
padding: 3px 6px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
border-left: 2px solid transparent;
|
|
}
|
|
|
|
#inlineChatFeed .msg.fish { border-left-color: var(--accent3); background: rgba(255,230,0,0.04); }
|
|
#inlineChatFeed .msg.admin { border-left-color: var(--accent2); background: rgba(255,61,113,0.06); }
|
|
#inlineChatFeed .msg.mod { border-left-color: var(--green); background: rgba(0,255,136,0.05); }
|
|
|
|
#inlineChatFeed .msg-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 4px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#inlineChatFeed .msg-user {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
color: var(--accent);
|
|
}
|
|
|
|
#inlineChatFeed .msg-endorsement {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
letter-spacing: 1px;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
#inlineChatFeed .msg-time {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
margin-left: auto;
|
|
}
|
|
|
|
#inlineChatFeed .msg-text {
|
|
color: var(--text);
|
|
word-break: break-word;
|
|
}
|
|
|
|
#inlineChatFeed .badge {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 8px;
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
font-weight: bold;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
#inlineChatFeed .badge.admin { background: var(--accent2); color: #fff; }
|
|
#inlineChatFeed .badge.mod { background: var(--green); color: #000; }
|
|
#inlineChatFeed .badge.fish { background: var(--accent3); color: #000; }
|
|
#inlineChatFeed .badge.gm { background: var(--accent); color: #000; }
|
|
|
|
.chat-footer {
|
|
padding: 4px 8px;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#inlineChatScrollBtn {
|
|
display: none;
|
|
position: absolute;
|
|
bottom: 28px;
|
|
right: 8px;
|
|
background: var(--accent);
|
|
color: var(--bg);
|
|
border: none;
|
|
border-radius: 3px;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
padding: 3px 8px;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
}
|
|
|
|
/* Notification log */
|
|
#notifLog {
|
|
position: fixed;
|
|
top: 73px;
|
|
right: 0;
|
|
width: 320px;
|
|
max-height: calc(100vh - 73px);
|
|
background: var(--panel);
|
|
border-left: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 200;
|
|
transform: translateX(100%);
|
|
transition: transform 0.2s ease;
|
|
}
|
|
#notifLog.open { transform: translateX(0); }
|
|
.notif-log-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-family: 'Bebas Neue', sans-serif;
|
|
font-size: 14px;
|
|
letter-spacing: 2px;
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
.notif-log-clear {
|
|
background: none;
|
|
border: none;
|
|
color: var(--muted);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
}
|
|
.notif-log-clear:hover { color: var(--accent2); }
|
|
#notifLogFeed {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
#notifLogFeed::-webkit-scrollbar { width: 4px; }
|
|
#notifLogFeed::-webkit-scrollbar-track { background: transparent; }
|
|
#notifLogFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
.notif-log-item {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 8px 10px;
|
|
}
|
|
.notif-log-item-msg { font-size: 12px; color: var(--text); }
|
|
.notif-log-item-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
|
.notif-log-item-time {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
}
|
|
#notifLogBtn { position: relative; }
|
|
.notif-log-badge {
|
|
display: none;
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
background: var(--accent2);
|
|
color: #fff;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
border-radius: 8px;
|
|
padding: 1px 5px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Floor plan panel */
|
|
.floor-plan-panel {
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--panel);
|
|
padding: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.floor-plan-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.floor-plan-tab {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--muted);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
letter-spacing: 1px;
|
|
padding: 3px 0;
|
|
cursor: pointer;
|
|
border-radius: 2px;
|
|
transition: all 0.15s;
|
|
}
|
|
.floor-plan-tab.active {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
background: rgba(0,229,255,0.06);
|
|
}
|
|
.floor-plan-wrap {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
.floor-plan-wrap img {
|
|
width: 100%;
|
|
display: block;
|
|
opacity: 0.85;
|
|
}
|
|
.floor-plan-btn {
|
|
position: absolute;
|
|
background: transparent;
|
|
border: none;
|
|
color: #DC6F41;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
line-height: 1.2;
|
|
text-align: center;
|
|
transition: background 0.15s;
|
|
font-weight: bold;
|
|
text-shadow: 0 0 4px rgba(0,0,0,0.8);
|
|
}
|
|
.floor-plan-btn:hover {
|
|
background: rgba(220,111,65,0.25);
|
|
color: #fff;
|
|
}
|
|
.floor-plan-btn.active-room {
|
|
background: rgba(220,111,65,0.35);
|
|
color: #fff;
|
|
outline: 1px solid #DC6F41;
|
|
}
|
|
|
|
/* Camera navigation overlay */
|
|
.cam-nav-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
.cam-nav-overlay svg {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.cam-nav-overlay polygon {
|
|
fill: transparent;
|
|
transition: fill 0.15s;
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
}
|
|
.cam-nav-overlay polygon:hover {
|
|
fill: rgba(255,255,255,0.18);
|
|
}
|
|
.cam-nav-tooltip {
|
|
position: absolute;
|
|
background: rgba(0,0,0,0.85);
|
|
color: var(--accent);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
padding: 4px 8px;
|
|
border-radius: 3px;
|
|
border: 1px solid var(--border);
|
|
pointer-events: none;
|
|
white-space: nowrap;
|
|
z-index: 20;
|
|
display: none;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* Contestant row */
|
|
.contestant-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 8px 16px;
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.contestant-avatar {
|
|
position: relative;
|
|
cursor: default;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.contestant-avatar img {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 50%;
|
|
object-fit: cover;
|
|
border: 2px solid transparent;
|
|
display: block;
|
|
transition: filter 0.2s, border-color 0.2s;
|
|
}
|
|
|
|
.contestant-avatar.eliminated img {
|
|
filter: grayscale(100%) brightness(0.5);
|
|
}
|
|
|
|
.contestant-avatar img:hover {
|
|
border-color: var(--accent);
|
|
}
|
|
|
|
.contestant-tooltip {
|
|
position: absolute;
|
|
bottom: calc(100% + 6px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 6px 10px;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
z-index: 100;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
color: var(--text);
|
|
text-align: center;
|
|
}
|
|
|
|
.contestant-avatar:hover .contestant-tooltip {
|
|
opacity: 1;
|
|
}
|
|
|
|
.contestant-tooltip .ct-name {
|
|
font-family: 'Bebas Neue', sans-serif;
|
|
font-size: 14px;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.contestant-tooltip .ct-job {
|
|
color: var(--muted);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.contestant-tooltip .ct-endorsements {
|
|
color: var(--accent);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.contestant-tooltip .ct-eliminated {
|
|
color: var(--accent2);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* Inline chat panel */
|
|
.chat-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--panel);
|
|
border-left: 1px solid var(--border);
|
|
overflow: hidden;
|
|
min-height: 0;
|
|
}
|
|
|
|
.main.chat-collapsed .chat-panel {
|
|
display: none;
|
|
}
|
|
|
|
.main.chat-collapsed {
|
|
grid-template-columns: 320px 1fr 0px !important;
|
|
grid-template-areas: "poll stocks ." "tts cameras ." !important;
|
|
}
|
|
.main.chat-collapsed.stocks-collapsed {
|
|
grid-template-areas: "poll cameras ." "tts cameras ." !important;
|
|
}
|
|
.main.chat-collapsed.left-collapsed {
|
|
grid-template-columns: 0px 1fr 0px !important;
|
|
grid-template-areas: "stocks stocks ." "cameras cameras ." !important;
|
|
}
|
|
.main.chat-collapsed.left-collapsed.stocks-collapsed {
|
|
grid-template-columns: 0px 1fr 0px !important;
|
|
grid-template-areas: "cameras cameras cameras" "cameras cameras cameras" !important;
|
|
}
|
|
|
|
#inlineChatFeed {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
min-height: 0;
|
|
}
|
|
|
|
#inlineChatFeed::-webkit-scrollbar { width: 4px; }
|
|
#inlineChatFeed::-webkit-scrollbar-track { background: transparent; }
|
|
#inlineChatFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
|
|
#inlineChatFeed .msg {
|
|
padding: 3px 6px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
border-left: 2px solid transparent;
|
|
}
|
|
|
|
#inlineChatFeed .msg.fish { border-left-color: var(--accent3); background: rgba(255,230,0,0.04); }
|
|
#inlineChatFeed .msg.admin { border-left-color: var(--accent2); background: rgba(255,61,113,0.06); }
|
|
#inlineChatFeed .msg.mod { border-left-color: var(--green); background: rgba(0,255,136,0.05); }
|
|
|
|
#inlineChatFeed .msg-header {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 4px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
#inlineChatFeed .msg-user {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
font-weight: bold;
|
|
color: var(--accent);
|
|
}
|
|
|
|
#inlineChatFeed .msg-endorsement {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
font-weight: bold;
|
|
letter-spacing: 1px;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
#inlineChatFeed .msg-time {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
margin-left: auto;
|
|
}
|
|
|
|
#inlineChatFeed .msg-text {
|
|
color: var(--text);
|
|
word-break: break-word;
|
|
}
|
|
|
|
#inlineChatFeed .badge {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 8px;
|
|
padding: 1px 4px;
|
|
border-radius: 2px;
|
|
font-weight: bold;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
#inlineChatFeed .badge.admin { background: var(--accent2); color: #fff; }
|
|
#inlineChatFeed .badge.mod { background: var(--green); color: #000; }
|
|
#inlineChatFeed .badge.fish { background: var(--accent3); color: #000; }
|
|
#inlineChatFeed .badge.gm { background: var(--accent); color: #000; }
|
|
|
|
.chat-footer {
|
|
padding: 4px 8px;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
border-top: 1px solid var(--border);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
#inlineChatScrollBtn {
|
|
display: none;
|
|
position: absolute;
|
|
bottom: 28px;
|
|
right: 8px;
|
|
background: var(--accent);
|
|
color: var(--bg);
|
|
border: none;
|
|
border-radius: 3px;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
padding: 3px 8px;
|
|
cursor: pointer;
|
|
z-index: 10;
|
|
}
|
|
|
|
/* Notification log */
|
|
#notifLog {
|
|
position: fixed;
|
|
top: 73px;
|
|
right: 0;
|
|
width: 320px;
|
|
max-height: calc(100vh - 73px);
|
|
background: var(--panel);
|
|
border-left: 1px solid var(--border);
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 200;
|
|
transform: translateX(100%);
|
|
transition: transform 0.2s ease;
|
|
}
|
|
#notifLog.open { transform: translateX(0); }
|
|
.notif-log-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
font-family: 'Bebas Neue', sans-serif;
|
|
font-size: 14px;
|
|
letter-spacing: 2px;
|
|
color: var(--accent);
|
|
flex-shrink: 0;
|
|
}
|
|
.notif-log-clear {
|
|
background: none;
|
|
border: none;
|
|
color: var(--muted);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
}
|
|
.notif-log-clear:hover { color: var(--accent2); }
|
|
#notifLogFeed {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
#notifLogFeed::-webkit-scrollbar { width: 4px; }
|
|
#notifLogFeed::-webkit-scrollbar-track { background: transparent; }
|
|
#notifLogFeed::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
.notif-log-item {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
padding: 8px 10px;
|
|
}
|
|
.notif-log-item-msg { font-size: 12px; color: var(--text); }
|
|
.notif-log-item-sub { font-size: 10px; color: var(--muted); margin-top: 2px; }
|
|
.notif-log-item-time {
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
color: var(--muted);
|
|
margin-top: 4px;
|
|
}
|
|
#notifLogBtn { position: relative; }
|
|
.notif-log-badge {
|
|
display: none;
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
background: var(--accent2);
|
|
color: #fff;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
border-radius: 8px;
|
|
padding: 1px 5px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Floor plan panel */
|
|
.floor-plan-panel {
|
|
flex-shrink: 0;
|
|
border-bottom: 1px solid var(--border);
|
|
background: var(--panel);
|
|
padding: 6px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
.floor-plan-tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
.floor-plan-tab {
|
|
flex: 1;
|
|
background: transparent;
|
|
border: 1px solid var(--border);
|
|
color: var(--muted);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
letter-spacing: 1px;
|
|
padding: 3px 0;
|
|
cursor: pointer;
|
|
border-radius: 2px;
|
|
transition: all 0.15s;
|
|
}
|
|
.floor-plan-tab.active {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
background: rgba(0,229,255,0.06);
|
|
}
|
|
.floor-plan-wrap {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
.floor-plan-wrap img {
|
|
width: 100%;
|
|
display: block;
|
|
opacity: 0.85;
|
|
}
|
|
.floor-plan-btn {
|
|
position: absolute;
|
|
background: transparent;
|
|
border: none;
|
|
color: #DC6F41;
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 9px;
|
|
cursor: pointer;
|
|
padding: 2px;
|
|
line-height: 1.2;
|
|
text-align: center;
|
|
transition: background 0.15s;
|
|
font-weight: bold;
|
|
text-shadow: 0 0 4px rgba(0,0,0,0.8);
|
|
}
|
|
.floor-plan-btn:hover {
|
|
background: rgba(220,111,65,0.25);
|
|
color: #fff;
|
|
}
|
|
.floor-plan-btn.active-room {
|
|
background: rgba(220,111,65,0.35);
|
|
color: #fff;
|
|
outline: 1px solid #DC6F41;
|
|
}
|
|
|
|
/* Camera navigation overlay */
|
|
.cam-nav-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
}
|
|
.cam-nav-overlay svg {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.cam-nav-overlay polygon {
|
|
fill: transparent;
|
|
transition: fill 0.15s;
|
|
pointer-events: auto;
|
|
cursor: pointer;
|
|
}
|
|
.cam-nav-overlay polygon:hover {
|
|
fill: rgba(255,255,255,0.18);
|
|
}
|
|
.cam-nav-tooltip {
|
|
position: absolute;
|
|
background: rgba(0,0,0,0.85);
|
|
color: var(--accent);
|
|
font-family: 'Share Tech Mono', monospace;
|
|
font-size: 10px;
|
|
padding: 4px 8px;
|
|
border-radius: 3px;
|
|
border: 1px solid var(--border);
|
|
pointer-events: none;
|
|
white-space: nowrap;
|
|
z-index: 20;
|
|
display: none;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
/* 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();
|
|
// Apply saved thumbnail interval from dropdown
|
|
const iv = document.getElementById('intervalSelect');
|
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
|
// Recalc on resize — use lambda so reference resolves at call time not definition time
|
|
window.addEventListener('resize', () => window.recalcGridRows && window.recalcGridRows());
|
|
window.addEventListener('load', () => window.recalcGridRows && window.recalcGridRows());
|
|
// Poll recalc for 3s after load to catch any layout settling
|
|
let _recalcCount = 0;
|
|
const _recalcInit = setInterval(() => {
|
|
if (window.recalcGridRows) window.recalcGridRows();
|
|
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
|
}, 500);
|
|
});
|
|
|
|
</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();
|
|
// Apply saved thumbnail interval from dropdown
|
|
const iv = document.getElementById('intervalSelect');
|
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
|
// Recalc on resize — use lambda so reference resolves at call time not definition time
|
|
window.addEventListener('resize', () => window.recalcGridRows && window.recalcGridRows());
|
|
window.addEventListener('load', () => window.recalcGridRows && window.recalcGridRows());
|
|
// Poll recalc for 3s after load to catch any layout settling
|
|
let _recalcCount = 0;
|
|
const _recalcInit = setInterval(() => {
|
|
if (window.recalcGridRows) window.recalcGridRows();
|
|
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
|
}, 500);
|
|
});
|
|
|
|
</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>
|
|
|
|
<div id="notifLog">
|
|
<div class="notif-log-header">
|
|
<span>📣 NOTIFICATIONS</span>
|
|
<button class="notif-log-clear" onclick="clearNotifLog()">CLEAR</button>
|
|
</div>
|
|
<div id="notifLogFeed">
|
|
<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);text-align:center;padding:20px;">No notifications yet</div>
|
|
</div>
|
|
</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" id="chatCollapseBtn" onclick="toggleChat()" title="Hide chat panel">💬 CHAT</button>
|
|
<button class="stocks-collapse-btn" id="notifLogBtn" onclick="toggleNotifLog()" title="Notification log" style="position:relative;">🔔 NOTIFS<span class="notif-log-badge" id="notifLogBadge">0</span></button>
|
|
<button class="stocks-collapse-btn" id="ytToggleBtn" onclick="toggleYtSource()" title="Switch between fishtank.rip and YouTube" style="border-color:var(--muted);color:var(--muted);">▶ YT SOURCE</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 -->
|
|
|
|
|
|
<!-- 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 style="grid-area:chat;display:flex;flex-direction:column;overflow:hidden;min-height:0;">
|
|
<div class="floor-plan-panel" id="floorPlanPanel">
|
|
<div class="floor-plan-tabs">
|
|
<button class="floor-plan-tab active" id="floorTabDown" onclick="setFloor('down')">DOWNSTAIRS</button>
|
|
<button class="floor-plan-tab" id="floorTabUp" onclick="setFloor('up')">UPSTAIRS</button>
|
|
</div>
|
|
<div class="floor-plan-wrap" id="floorPlanWrap">
|
|
<img id="floorPlanImg" src="https://cdn.fishtank.live/images/map/s5/lower.png" alt="Floor plan">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="chat-panel" id="chatPanel" style="position:relative;flex:1;min-height:0;">
|
|
<div id="inlineChatFeed">
|
|
<div class="empty"><div class="empty-icon">💬</div><span>Waiting for messages...</span></div>
|
|
</div>
|
|
<button id="inlineChatScrollBtn" onclick="inlineChatScrollToBottom()">▼ NEW</button>
|
|
<div class="chat-footer">
|
|
<div class="dot" id="inlineChatDot"></div>
|
|
<span id="inlineChatStatus">CONNECTING</span>
|
|
<span id="inlineChatViewers" style="color:var(--muted);">—</span>
|
|
<span style="margin-left:auto;" id="inlineChatCount">0 messages</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<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" onclick="takeScreenshot()" style="font-size:10px;">📷 SNAP</button>
|
|
<button class="range-btn" id="clipBtn" onclick="saveClip()" style="font-size:10px;">✂ CLIP</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 = contestantTickerColors[ticker] || 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));
|
|
|
|
// Sort legend by last price desc
|
|
const sortedLegend = [...datasets].sort((a, b) => (b.lastPrice || 0) - (a.lastPrice || 0));
|
|
|
|
// Auto-hide eliminated contestants on first render
|
|
sortedLegend.forEach(d => {
|
|
const isEliminated = window._contestantEliminated && window._contestantEliminated[d.ticker];
|
|
if (isEliminated && !hiddenTickers.has(d.ticker)) {
|
|
hiddenTickers.add(d.ticker);
|
|
}
|
|
});
|
|
|
|
const legend = document.getElementById('stocksLegend');
|
|
legend.innerHTML = sortedLegend.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 ? '▼' : '—';
|
|
}
|
|
const photo = window._contestantPhotos && window._contestantPhotos[d.ticker];
|
|
const job = window._contestantJobs && window._contestantJobs[d.ticker];
|
|
const endorsements= window._contestantEndorsements && window._contestantEndorsements[d.ticker];
|
|
const eliminated = window._contestantEliminated && window._contestantEliminated[d.ticker];
|
|
const isHidden = hiddenTickers.has(d.ticker);
|
|
|
|
const tooltipLines = [
|
|
job ? `<div style="color:var(--muted);font-size:9px;">${job}</div>` : '',
|
|
endorsements!== undefined ? `<div style="color:var(--accent);font-size:9px;">⬡ ${endorsements.toLocaleString()} endorsements</div>` : '',
|
|
eliminated ? `<div style="color:var(--accent2);font-size:9px;">ELIMINATED</div>` : '',
|
|
].filter(Boolean).join('');
|
|
|
|
const avatarStyle = `width:22px;height:22px;border-radius:50%;object-fit:cover;border:1.5px solid ${d.color};flex-shrink:0;${eliminated ? 'filter:grayscale(100%) brightness(0.5);' : ''}`;
|
|
const avatarHtml = photo
|
|
? `<div style="position:relative;flex-shrink:0;">
|
|
<img src="${photo}" style="${avatarStyle}" onerror="this.style.display='none'">
|
|
${tooltipLines ? `<div style="position:absolute;bottom:calc(100% + 4px);left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:5px 8px;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity 0.15s;z-index:100;font-family:'Share Tech Mono',monospace;" class="ct-tip">${tooltipLines}</div>` : ''}
|
|
</div>`
|
|
: `<div class="legend-dot" style="background:${d.color}"></div>`;
|
|
|
|
return `
|
|
<div class="legend-item ${isHidden ? 'hidden' : ''}" onclick="toggleTicker('${d.ticker}')" id="leg-${d.ticker}" style="position:relative;"
|
|
onmouseenter="this.querySelector('.ct-tip')&&(this.querySelector('.ct-tip').style.opacity='1')"
|
|
onmouseleave="this.querySelector('.ct-tip')&&(this.querySelector('.ct-tip').style.opacity='0')">
|
|
${avatarHtml}
|
|
<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"],
|
|
["East Wing", "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", "hwdn-5"],
|
|
["West Wing", "hwup-5"],
|
|
["Jungle Room", "br4j-5"],
|
|
["Computer Lab", "bbcl-5"],
|
|
["Job Board", "jobb-5"],
|
|
["Arena", "bare-5"],
|
|
["Cameraman", "cameraman2-5"],
|
|
];
|
|
|
|
// Navigation polygons per camera slug
|
|
// Format: { label, slug, points (normalized 0-1) }
|
|
const CAM_NAV = {
|
|
'dmrm-5': [
|
|
{ label: 'Jacuzzi', slug: 'jckz-5', points: '0.8772,0.4250 0.9055,0.1417 0.9656,0.1885 0.9299,0.4598' },
|
|
{ label: 'Hallway', slug: 'hwdn-5', points: '0.5186,0.0084 0.5206,0.2797 0.4896,0.3001 0.4686,0.3097 0.4626,0.0180' },
|
|
{ label: 'Closet', slug: 'dmcl-5', points: '0.1546,0.5414 0.1283,0.3770 0.1060,0.1657 0.1762,0.1188 0.1925,0.3157 0.2012,0.4154 0.2134,0.4898' },
|
|
{ label: 'Dorm Alt', slug: 'dmrm2-5', points: '0.5274,0.0024 0.5287,0.0648 0.5942,0.0648 0.5936,0.0048' },
|
|
],
|
|
'dmcl-5': [
|
|
{ label: 'Dorm', slug: 'dmrm-5', points: '0.6854,0.0166 0.6273,0.5819 0.7248,0.6865 0.8317,0.1152' },
|
|
],
|
|
'brrr-5': [
|
|
{ label: 'Bar PTZ', slug: 'brpz-5', points: '0.8164,0.0024 0.8184,0.0852 0.7536,0.0864 0.7550,0.0000' },
|
|
{ label: 'Bar Alt', slug: 'brrr2-5', points: '0.3289,0.0636 0.2343,0.1008 0.2289,0.0036 0.3255,0.0036' },
|
|
{ label: 'Kitchen', slug: 'ktch-5', points: '0.9717,0.2641 0.9082,0.2029 0.8799,0.3866 0.8535,0.5258 0.9082,0.5822' },
|
|
{ label: 'Hallway', slug: 'hwdn-5', points: '0.9987,0.3349 0.9542,0.5474 0.8887,0.7611 0.8029,0.9652 0.8002,0.9712 0.7914,0.9976 0.7907,0.9940 0.9987,0.9940' },
|
|
],
|
|
'ktch-5': [
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.0100,0.7812 0.0120,0.9822 0.2588,0.9941 0.2602,0.7384' },
|
|
{ label: 'Dining Room', slug: 'dnrm-5', points: '0.4295,0.4934 0.3140,0.5582 0.1877,0.6351 0.0993,0.6867 0.1020,0.6879 0.0493,0.4526 0.0223,0.2545 0.0101,0.0480 0.0020,0.0564 0.0014,0.0012 0.2883,0.0012 0.4234,0.0000 0.4322,0.0000' },
|
|
{ label: 'Glassroom', slug: 'gsrm-5', points: '0.9798,0.2293 0.9994,0.2497 0.9987,0.3601 0.9987,0.4958 0.9717,0.6242 0.9697,0.6267 0.9326,0.5822 0.9272,0.6002 0.9191,0.5918 0.9116,0.5822' },
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.7057,0.9976 0.8373,0.7575 0.8590,0.7803 0.9278,0.6002 0.9332,0.5846 0.9731,0.6291 0.9974,0.5042 0.9987,0.9952' },
|
|
],
|
|
'hwdn-5': [
|
|
{ label: 'Foyer', slug: 'foyr-5', points: '0.2782,0.8800 0.2445,0.6267 0.2228,0.3553 0.2201,0.2221 0.1695,0.3433 0.1310,0.4526 0.1742,0.7479 0.2235,0.9640 0.2336,0.9952 0.2620,0.9952' },
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.8191,0.9988 0.6901,0.6279 0.5753,0.3373 0.5490,0.2713 0.5328,0.4766 0.5119,0.7107 0.4923,0.9328 0.5038,0.9988' },
|
|
{ label: 'Dorm', slug: 'dmrm-5', points: '0.4099,0.3193 0.4193,0.0144 0.3356,0.0156 0.3316,0.3157' },
|
|
],
|
|
'dnrm-5': [
|
|
{ label: 'West Wing', slug: 'hwup-5', points: '0.2006,0.0036 0.0014,0.0036 0.0007,0.2761 0.0000,0.3397 0.0115,0.4226 0.0257,0.4994 0.0358,0.5534 0.0513,0.6387 0.0527,0.6399 0.0939,0.6062 0.1310,0.5798 0.1486,0.5666 0.1533,0.5366 0.1722,0.4874 0.1918,0.4346 0.2107,0.3733 0.2282,0.3289 0.2289,0.2977 0.2093,0.3073' },
|
|
{ label: 'Kitchen', slug: 'ktch-5', points: '0.6415,0.3505 0.6550,0.1909 0.6692,0.0024 0.8009,0.0012 0.9157,0.0624 0.9164,0.0864 0.9076,0.1753 0.8914,0.3049 0.8569,0.4970 0.8563,0.5006 0.8549,0.5042 0.8535,0.5150' },
|
|
],
|
|
'mrke-5': [
|
|
{ label: 'Foyer', slug: 'foyr-5', points: '0.9981,0.2509 0.9461,0.1825 0.8617,0.7239 0.8806,0.9928 0.9069,0.9976 0.9981,0.7119' },
|
|
{ label: 'Market Alt', slug: 'mrke2-5', points: '0.3559,0.0024 0.3579,0.1068 0.5085,0.1092 0.5098,0.0012' },
|
|
],
|
|
'foyr-5': [
|
|
{ label: 'East Wing', slug: 'bkny-5', points: '0.3093,0.0036 0.4686,0.2881 0.5747,0.2569 0.6077,0.1068 0.6226,0.0012' },
|
|
{ label: 'Market', slug: 'mrke-5', points: '0.1148,0.8247 0.0338,0.2809 0.0358,0.0060 0.0014,0.0060 0.0027,0.9952 0.1546,0.9988' },
|
|
{ label: 'Hallway', slug: 'hwdn-5', points: '0.1681,0.3097 0.0682,0.3589 0.0864,0.5138 0.1749,0.4682' },
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.1762,0.0504 0.0594,0.0924 0.0696,0.3529 0.1702,0.3097' },
|
|
{ label: 'Glassroom', slug: 'gsrm-5', points: '0.7597,0.7599 0.8819,0.4094 0.7286,0.2293 0.6375,0.5846' },
|
|
],
|
|
'gsrm-5': [
|
|
{ label: 'Foyer', slug: 'foyr-5', points: '0.2553,0.5114 0.2357,0.2593 0.2222,0.0636 0.1134,0.1224 0.0358,0.1813 0.0675,0.4406 0.1053,0.6363' },
|
|
{ label: 'Kitchen', slug: 'ktch-5', points: '0.5672,0.3745 0.5780,0.2041 0.5895,0.0084 0.5760,0.0000 0.5281,0.0012 0.5112,0.3205' },
|
|
],
|
|
'codr-5': [
|
|
{ label: 'Confessional', slug: 'cfsl-5', points: '0.5517,0.6387 0.3545,0.9928 0.0020,0.9964 0.0007,0.0024 0.5436,0.0036' },
|
|
{ label: 'West Wing', slug: 'hwup-5', points: '0.7435,0.2653 0.8346,0.3109 0.9224,0.3433 0.9353,0.2053 0.8535,0.0012 0.7752,0.0000 0.7455,0.0060' },
|
|
],
|
|
'bkny-5': [
|
|
{ label: 'West Wing', slug: 'hwup-5', points: '0.3322,0.9988 0.3721,0.7695 0.6071,0.8067 0.6091,0.8992 0.5996,0.9184 0.6044,0.9976' },
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.6618,0.3049 0.6523,0.1104 0.6138,0.0588 0.6057,0.1333 0.5990,0.3709 0.5936,0.5726 0.6003,0.6987 0.6064,0.7863 0.6111,0.8944 0.6368,0.6375' },
|
|
{ label: 'Market', slug: 'mrke-5', points: '0.2829,0.4910 0.0716,0.4778 0.0736,0.4802 0.1283,0.7407 0.1513,0.8319 0.2087,0.8487 0.2141,0.8271 0.2046,0.8019 0.2188,0.7515 0.2100,0.7215 0.2255,0.6819 0.2195,0.6435' },
|
|
{ label: 'Foyer', slug: 'foyr-5', points: '0.1519,0.8283 0.1148,0.9448 0.0419,0.7347 0.0020,0.8475 0.0007,0.9976 0.2019,0.9988 0.2114,0.9796 0.1992,0.9520 0.2120,0.9076 0.2033,0.8752 0.2080,0.8535' },
|
|
{ label: 'Computer Lab', slug: 'bbcl-5', points: '0.4274,0.0120 0.4524,0.0060 0.4565,0.2377 0.4612,0.3782 0.4416,0.4850 0.4396,0.4874 0.4383,0.4910' },
|
|
{ label: 'Arena', slug: 'bare-5', points: '0.5639,0.3770 0.5740,0.0024 0.5733,0.0084 0.4686,0.0012 0.4720,0.3721'},
|
|
],
|
|
'hwup-5': [
|
|
{ label: 'Corridor', slug: 'codr-5', points: '0.5227,0.3409 0.5206,0.0300 0.5774,0.0372 0.5726,0.3469' },
|
|
{ label: 'East Wing', slug: 'bkny-5', points: '0.6159,0.7851 0.4079,0.7863 0.3869,0.9976 0.6334,0.9964' },
|
|
{ label: 'Jungle Room', slug: 'br4j-5', points: '0.5861,0.5078 0.5922,0.0384 0.6145,0.0540 0.6064,0.4070 0.5936,0.5954' },
|
|
{ label: 'Dining Room', slug: 'dnrm-5', points: '0.5011,0.3397 0.5004,0.2017 0.4551,0.2041 0.4558,0.2353 0.4443,0.2881 0.4450,0.3397' },
|
|
{ label: 'Job Board', slug: 'jobb-5', points: '0.6301,0.1089 0.6819,0.1484 0.7863,0.2728 0.7694,0.4918 0.7513,0.6594 0.7290,0.7970 0.7109,0.9059 0.6725,0.6702 0.6550,0.5876 0.6341,0.5074 0.6173,0.4428 0.6220,0.3686 0.6267,0.2657 0.6274,0.1137'},
|
|
],
|
|
'dmrm2-5': [
|
|
{ label: 'Dorm', slug: 'dmrm-5', points: '0.5254,0.0024 0.5254,0.0684 0.6213,0.0684 0.6213,0.0036' },
|
|
],
|
|
'brrr2-5': [
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.8272,0.4418 0.8421,0.3001 0.8448,0.2905 0.8542,0.2881 0.8556,0.2725 0.8481,0.2665 0.8427,0.2581 0.8515,0.0024 0.6348,0.0012 0.6314,0.1705 0.6388,0.2173 0.6483,0.2341 0.6483,0.3325 0.6753,0.4346' },
|
|
{ label: 'Kitchen', slug: 'ktch-5', points: '0.3079,0.0048 0.3167,0.1741 0.3282,0.3397 0.3741,0.3397 0.3896,0.3037 0.3768,0.3025 0.3640,0.0348 0.3626,0.0000' },
|
|
{ label: 'Hallway', slug: 'hwdn-5', points: '0.4734,0.2965 0.6186,0.3001 0.6186,0.2473 0.5639,0.2461 0.5314,0.2497 0.4767,0.2509 0.4734,0.2497' },
|
|
{ label: 'Foyer', slug: 'foyr-5', points: '0.6206,0.0240 0.5612,0.0204 0.5618,0.2449 0.6172,0.2461' },
|
|
],
|
|
'jckz-5': [
|
|
{ label: 'Dorm', slug: 'dmrm-5', points: '0.0844,0.0096 0.1067,0.2041 0.1351,0.3842 0.1452,0.4370 0.1074,0.4790 0.0797,0.5102 0.0439,0.3517 0.0081,0.1729 0.0007,0.1357 0.0000,0.0948' },
|
|
],
|
|
'brpz-5': [
|
|
{ label: 'Bar', slug: 'brrr-5', points: '0.9987,0.9160 0.0014,0.9160 0.0014,0.9988 0.5375,0.9988 0.9947,0.9988 0.9987,0.9976' },
|
|
],
|
|
'mrke2-5': [
|
|
{ label: 'Foyer', slug: 'foyr-5', points: '0.2512,0.1345 0.4356,0.0720 0.4369,0.4826 0.3640,0.5378 0.3181,0.5738 0.2971,0.5870 0.2816,0.6026' },
|
|
{ label: 'Market', slug: 'mrke-5', points: '0.5868,0.0000 0.5814,0.1357 0.7509,0.1609 0.7597,0.0024' },
|
|
],
|
|
'bbcl-5': [
|
|
{ label: 'East Wing', slug: 'bkny-5', points: '0.4626,0.0132 0.3815,0.0132 0.3815,0.3169 0.3876,0.4274 0.3863,0.4358 0.3890,0.4394 0.4599,0.3914' },
|
|
],
|
|
'cfsl-5': [
|
|
{ label: 'Corridor', slug: 'codr-5', points: '0.1364,0.9976 0.1364,0.0012 0.0020,0.0024 0.0014,0.9976' },
|
|
],
|
|
'br4j-5': [
|
|
{ label: 'West Wing', slug: 'hwup-5', points: '0.4869,0.3830 0.4923,0.0144 0.4227,0.0228 0.4214,0.1813 0.4220,0.4298' },
|
|
],
|
|
'bare-5': [
|
|
{ label: 'East Wing', slug: 'bkny-5', points: '0.5368,0.2641 0.5497,0.0024 0.4943,0.0024 0.4875,0.2953'},
|
|
],
|
|
'jobb-5': [
|
|
{ label: 'East Wing', slug: 'bkny-5', points: '0.8785,0.0024 0.8785,0.9952 0.8785,0.9952 0.9967,0.9940 0.9981,0.0036'},
|
|
{ label: 'West Wing', slug: 'hwup-5', points: '0.1276,0.9976 0.1276,0.0000 0.1276,0.0000 0.0014,0.0012 0.0014,0.9976'},
|
|
],
|
|
};
|
|
|
|
const ROOM_NAMES = {
|
|
"dmrm-5": "Dorm", "dirc-5": "Director Mode", "cfsl-5": "Confessional",
|
|
"bkny-5": "East Wing", "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",
|
|
"hwup-5": "West Wing",
|
|
"br4j-5": "Jungle Room",
|
|
"bbcl-5": "Computer Lab",
|
|
"cameraman2-5": "Cameraman", "site": "Site-wide", "bare-5": "Arena", "jobb-5": "Job Board",
|
|
};
|
|
|
|
const DEFAULT_IDX = 1; // Director Mode
|
|
const hlsInstances = {};
|
|
let featuredIdx = DEFAULT_IDX;
|
|
let ytSourceActive = false;
|
|
let ytReadyCount = 0;
|
|
// 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,
|
|
});
|
|
const isYt = ytSourceActive && slug !== '__epyc__';
|
|
if (isYt) {
|
|
hls.config.startPosition = -1;
|
|
hls.config.liveSyncDurationCount = 2;
|
|
}
|
|
const hlsSource = isYt
|
|
? 'http://localhost:3000/yt-stream/' + slug
|
|
: 'http://localhost:3000/cam/' + slug + '/index.m3u8';
|
|
hls.loadSource(hlsSource);
|
|
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;
|
|
cell.dataset.slug = slug; // keep slug in sync for viewer count sorting
|
|
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 getVideoRect(wrap) {
|
|
// Calculate actual rendered video rect accounting for object-fit:contain pillarboxing
|
|
const video = wrap.querySelector('video');
|
|
if (!video || !video.videoWidth) return null;
|
|
const cW = wrap.clientWidth, cH = wrap.clientHeight;
|
|
const vRatio = video.videoWidth / video.videoHeight;
|
|
const cRatio = cW / cH;
|
|
let vW, vH, vX, vY;
|
|
if (vRatio > cRatio) {
|
|
// letterboxed top/bottom
|
|
vW = cW;
|
|
vH = cW / vRatio;
|
|
vX = 0;
|
|
vY = (cH - vH) / 2;
|
|
} else {
|
|
// pillarboxed left/right
|
|
vH = cH;
|
|
vW = cH * vRatio;
|
|
vX = (cW - vW) / 2;
|
|
vY = 0;
|
|
}
|
|
return { x: vX, y: vY, w: vW, h: vH };
|
|
}
|
|
|
|
function renderNavOverlay(slug) {
|
|
const wrap = document.getElementById('camFeaturedWrap');
|
|
if (!wrap) return;
|
|
const existing = wrap.querySelector('.cam-nav-overlay');
|
|
if (existing) { if (existing._ro) existing._ro.disconnect(); existing.remove(); }
|
|
const existingTip = wrap.querySelector('.cam-nav-tooltip');
|
|
if (existingTip) existingTip.remove();
|
|
|
|
const navs = CAM_NAV[slug];
|
|
if (!navs || !navs.length) return;
|
|
|
|
const tip = document.createElement('div');
|
|
tip.className = 'cam-nav-tooltip';
|
|
wrap.appendChild(tip);
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'cam-nav-overlay';
|
|
|
|
const svgNS = 'http://www.w3.org/2000/svg';
|
|
const svg = document.createElementNS(svgNS, 'svg');
|
|
// Use pixel coords matching the container — we'll transform points ourselves
|
|
const cW = wrap.clientWidth, cH = wrap.clientHeight;
|
|
svg.setAttribute('viewBox', `0 0 ${cW} ${cH}`);
|
|
svg.setAttribute('preserveAspectRatio', 'none');
|
|
svg.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;';
|
|
|
|
function buildPolygons() {
|
|
// Clear existing polygons
|
|
while (svg.firstChild) svg.removeChild(svg.firstChild);
|
|
|
|
const vRect = getVideoRect(wrap);
|
|
if (!vRect) return;
|
|
|
|
navs.forEach(({ label, slug: targetSlug, points }) => {
|
|
const poly = document.createElementNS(svgNS, 'polygon');
|
|
// Transform normalized 0-1 points to pixel coords within the actual video rect
|
|
const pts = points.split(' ').map(p => {
|
|
const [nx, ny] = p.split(',').map(Number);
|
|
return `${vRect.x + nx * vRect.w},${vRect.y + ny * vRect.h}`;
|
|
}).join(' ');
|
|
poly.setAttribute('points', pts);
|
|
|
|
poly.addEventListener('mouseenter', () => {
|
|
tip.textContent = '→ ' + (ROOM_NAMES[targetSlug] || label);
|
|
tip.style.display = 'block';
|
|
});
|
|
poly.addEventListener('mousemove', e => {
|
|
const rect = wrap.getBoundingClientRect();
|
|
let x = e.clientX - rect.left + 12;
|
|
let y = e.clientY - rect.top + 12;
|
|
if (x + 160 > cW) x = e.clientX - rect.left - 160;
|
|
tip.style.left = x + 'px';
|
|
tip.style.top = y + 'px';
|
|
});
|
|
poly.addEventListener('mouseleave', () => {
|
|
tip.style.display = 'none';
|
|
});
|
|
poly.addEventListener('click', () => {
|
|
switchToRoom(targetSlug);
|
|
});
|
|
|
|
svg.appendChild(poly);
|
|
});
|
|
}
|
|
|
|
div.appendChild(svg);
|
|
wrap.appendChild(div);
|
|
|
|
function rebuild() {
|
|
svg.setAttribute('viewBox', `0 0 ${wrap.clientWidth} ${wrap.clientHeight}`);
|
|
buildPolygons();
|
|
}
|
|
|
|
// Build now if video already has dimensions, otherwise wait for it
|
|
const video = wrap.querySelector('video');
|
|
if (video && video.videoWidth) {
|
|
rebuild();
|
|
} else if (video) {
|
|
video.addEventListener('loadedmetadata', rebuild, { once: true });
|
|
video.addEventListener('canplay', rebuild, { once: true });
|
|
}
|
|
|
|
// Rebuild on container resize — pillarboxing changes when panels collapse
|
|
const ro = new ResizeObserver(rebuild);
|
|
ro.observe(wrap);
|
|
div._ro = ro;
|
|
}
|
|
|
|
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 = bufferAudioCtx && bufferAudioCtx._gain ? 1.0 : parseFloat(s.value); if (bufferAudioCtx && bufferAudioCtx._gain) bufferAudioCtx._gain.gain.value = 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 for new camera — reset audio context too (new video element)
|
|
stopBuffer();
|
|
if (bufferAudioCtx) { try { bufferAudioCtx.close(); } catch(e) {} bufferAudioCtx = null; }
|
|
if (bufferCanvasInterval) { clearInterval(bufferCanvasInterval); bufferCanvasInterval = null; }
|
|
if (bufferCanvas) { bufferCanvas.remove(); bufferCanvas = 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);
|
|
}
|
|
// Render immediately, then re-render once video dimensions are known
|
|
renderNavOverlay(slug);
|
|
if (window.renderFloorButtons) window.renderFloorButtons(window.currentFloor || 'down');
|
|
const featVid = wrap && wrap.querySelector('video');
|
|
if (featVid) {
|
|
featVid.addEventListener('loadedmetadata', () => renderNavOverlay(slug), { once: true });
|
|
}
|
|
}
|
|
|
|
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 = [];
|
|
let bufferInitChunk = null;
|
|
let bufferAudioCtx = null; // persists across restarts — can't re-create on same element
|
|
let bufferCanvasInterval = null;
|
|
let bufferCanvas = null;
|
|
const BUFFER_SECS = 65;
|
|
|
|
function stopBuffer() {
|
|
if (bufferRecorder && bufferRecorder.state !== 'inactive') {
|
|
try { bufferRecorder.stop(); } catch(e) {}
|
|
}
|
|
bufferRecorder = null;
|
|
bufferChunks = [];
|
|
bufferInitChunk = null;
|
|
// canvas and audioCtx are reused — only cleared on camera switch
|
|
}
|
|
|
|
function getOrCreateCanvasStream() {
|
|
const wrap = document.getElementById('camFeaturedWrap');
|
|
if (!wrap) return null;
|
|
const featVideo = wrap.querySelector('video');
|
|
if (!featVideo) return null;
|
|
|
|
// Always use a 1280x720 canvas for the buffer — fixes background tab throttling
|
|
// and ensures consistent 720p output regardless of source resolution
|
|
if (!bufferCanvas || !bufferCanvas.isConnected) {
|
|
if (bufferCanvas) bufferCanvas.remove();
|
|
bufferCanvas = document.createElement('canvas');
|
|
bufferCanvas.width = 1280;
|
|
bufferCanvas.height = 720;
|
|
bufferCanvas.style.cssText = 'position:fixed;top:-9999px;';
|
|
document.body.appendChild(bufferCanvas);
|
|
}
|
|
const ctx = bufferCanvas.getContext('2d');
|
|
if (bufferCanvasInterval) clearInterval(bufferCanvasInterval);
|
|
// Cancel any previous rVFC loop by invalidating it via a shared token
|
|
if (window._bufferDrawToken) window._bufferDrawToken.cancelled = true;
|
|
const drawToken = { cancelled: false };
|
|
window._bufferDrawToken = drawToken;
|
|
|
|
function drawFrame() {
|
|
if (drawToken.cancelled || !bufferCanvas || !bufferCanvas.isConnected) return;
|
|
if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 1280, 720);
|
|
if (typeof featVideo.requestVideoFrameCallback === 'function') {
|
|
featVideo.requestVideoFrameCallback(drawFrame);
|
|
}
|
|
}
|
|
if (typeof featVideo.requestVideoFrameCallback === 'function') {
|
|
featVideo.requestVideoFrameCallback(drawFrame);
|
|
} else {
|
|
bufferCanvasInterval = setInterval(() => {
|
|
if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 1280, 720);
|
|
}, 1000 / 30);
|
|
}
|
|
|
|
const stream = bufferCanvas.captureStream(30);
|
|
|
|
// Audio: create AudioContext once per video element
|
|
if (!bufferAudioCtx) {
|
|
try {
|
|
bufferAudioCtx = new AudioContext();
|
|
const src = bufferAudioCtx.createMediaElementSource(featVideo);
|
|
const dest = bufferAudioCtx.createMediaStreamDestination();
|
|
// recordGain: always 1.0 — buffer is always recorded at full volume
|
|
const recordGain = bufferAudioCtx.createGain();
|
|
recordGain.gain.value = 1.0;
|
|
// speakerGain: controlled by volume slider
|
|
const speakerGain = bufferAudioCtx.createGain();
|
|
const slider = document.getElementById('featuredVolume');
|
|
speakerGain.gain.value = slider ? parseFloat(slider.value) : 1.0;
|
|
src.connect(recordGain);
|
|
recordGain.connect(dest); // to buffer — always full
|
|
src.connect(speakerGain);
|
|
speakerGain.connect(bufferAudioCtx.destination); // to speakers — follows slider
|
|
bufferAudioCtx._stream = dest.stream;
|
|
bufferAudioCtx._gain = speakerGain; // slider updates this
|
|
} catch(e) {
|
|
console.warn('[Buffer] Audio setup failed:', e);
|
|
bufferAudioCtx = null;
|
|
}
|
|
}
|
|
|
|
// Add audio tracks to stream if not already present
|
|
if (bufferAudioCtx && bufferAudioCtx._stream) {
|
|
const existingAudio = stream.getAudioTracks();
|
|
if (existingAudio.length === 0) {
|
|
bufferAudioCtx._stream.getAudioTracks().forEach(t => stream.addTrack(t));
|
|
}
|
|
}
|
|
|
|
return stream;
|
|
}
|
|
|
|
function startBuffer() {
|
|
stopBuffer(); // clears recorder + chunks, keeps canvas + audioCtx
|
|
|
|
const stream = getOrCreateCanvasStream();
|
|
if (!stream) return;
|
|
|
|
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
|
|
? 'video/webm;codecs=vp9,opus'
|
|
: MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
|
? 'video/webm;codecs=vp9'
|
|
: 'video/webm';
|
|
|
|
try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 1800000 }); }
|
|
catch(e) { console.warn('[Buffer] MediaRecorder creation failed:', e); return; }
|
|
|
|
let isFirstChunk = true;
|
|
bufferRecorder.ondataavailable = e => {
|
|
if (!e.data || e.data.size === 0) return;
|
|
const now = Date.now();
|
|
if (isFirstChunk) {
|
|
isFirstChunk = false;
|
|
bufferInitChunk = e.data; // init segment
|
|
return;
|
|
}
|
|
bufferChunks.push({ data: e.data, ts: now });
|
|
const cutoff = now - BUFFER_SECS * 1000;
|
|
bufferChunks = bufferChunks.filter(c => c.ts >= cutoff);
|
|
};
|
|
|
|
bufferRecorder._getBlob = () => {
|
|
const chunks = bufferInitChunk
|
|
? [bufferInitChunk, ...bufferChunks.map(c => c.data)]
|
|
: bufferChunks.map(c => c.data);
|
|
return new Blob(chunks, { type: mimeType });
|
|
};
|
|
|
|
bufferRecorder.onerror = () => {
|
|
console.warn('[Buffer] Recorder error, restarting...');
|
|
setTimeout(startBuffer, 500);
|
|
};
|
|
|
|
bufferRecorder.start(250);
|
|
console.log('[Buffer] Started. mimeType:', mimeType);
|
|
|
|
// Keep AudioContext running in background — prevents browser from throttling
|
|
if (bufferAudioCtx && bufferAudioCtx.state === 'suspended') {
|
|
bufferAudioCtx.resume();
|
|
}
|
|
}
|
|
|
|
// Acquire a Web Lock to prevent browser from throttling background tab
|
|
// This keeps timers, video playback and canvas captures running at full speed
|
|
if (navigator.locks) {
|
|
navigator.locks.request('ft_buffer_keepalive', { mode: 'shared' }, () => {
|
|
return new Promise(() => {}); // never resolve — holds lock for page lifetime
|
|
});
|
|
}
|
|
|
|
// When tab becomes visible again, resume video and audio
|
|
document.addEventListener('visibilitychange', () => {
|
|
const clipBtn = document.getElementById('clipBtn');
|
|
if (document.visibilityState === 'hidden') {
|
|
// Warn user buffer quality will degrade
|
|
if (clipBtn) { clipBtn.style.borderColor = 'var(--accent3)'; clipBtn.style.color = 'var(--accent3)'; clipBtn.title = 'Buffer quality reduced — tab is not active'; }
|
|
} else {
|
|
const wrap = document.getElementById('camFeaturedWrap');
|
|
const featVideo = wrap && wrap.querySelector('video');
|
|
if (featVideo && featVideo.paused) featVideo.play().catch(() => {});
|
|
if (bufferAudioCtx && bufferAudioCtx.state === 'suspended') bufferAudioCtx.resume();
|
|
// Reset clip button
|
|
if (clipBtn) { clipBtn.style.borderColor = ''; clipBtn.style.color = ''; clipBtn.title = ''; }
|
|
// Restart buffer fresh when returning — old frames are degraded
|
|
// Keep bufferAudioCtx alive — createMediaElementSource can only be called once per element
|
|
setTimeout(() => { stopBuffer(); setTimeout(startBuffer, 300); }, 100);
|
|
}
|
|
});
|
|
|
|
// Health check — restart only if truly dead
|
|
setInterval(() => {
|
|
if (bufferRecorder && bufferRecorder.state === 'inactive') {
|
|
console.warn('[Buffer] Detected inactive recorder, restarting...');
|
|
setTimeout(startBuffer, 500);
|
|
}
|
|
}, 5000);
|
|
|
|
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 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()" title="Saves full buffer — audio always included">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 * 1800000 / 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);
|
|
if (!video.paused) stopPlayback();
|
|
updateTrimUI(true);
|
|
});
|
|
|
|
outSlider.addEventListener('input', () => {
|
|
if (parseFloat(outSlider.value) <= parseFloat(inSlider.value) + 1)
|
|
outSlider.value = (parseFloat(inSlider.value) + 1).toFixed(1);
|
|
if (!video.paused) stopPlayback();
|
|
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();
|
|
}
|
|
}, 50);
|
|
}, { once: true });
|
|
}
|
|
|
|
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
|
|
|
|
// Set up audio routing for preview — createMediaElementSource can only be called once
|
|
try {
|
|
const clipAudioCtx = new AudioContext();
|
|
const clipSrc = clipAudioCtx.createMediaElementSource(video);
|
|
const clipAudioDest = clipAudioCtx.createMediaStreamDestination();
|
|
clipSrc.connect(clipAudioDest);
|
|
clipSrc.connect(clipAudioCtx.destination); // preview plays audio at full volume
|
|
video._audioStream = clipAudioDest.stream;
|
|
} catch(e) {
|
|
console.warn('Clip editor audio setup failed:', e);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async function downloadClipFull() {
|
|
const video = document.getElementById('clipEditorVideo');
|
|
if (!video) return;
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const baseName = video._camName + '_clip_' + ts;
|
|
|
|
// Download with audio
|
|
const a = document.createElement('a');
|
|
a.href = video._blobUrl; a.download = baseName + '.webm'; a.click();
|
|
|
|
// Auto-strip audio copy
|
|
try {
|
|
const blob = await fetch(video._blobUrl).then(r => r.blob());
|
|
const res = await fetch('http://localhost:3000/strip-audio', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'video/webm' },
|
|
body: blob,
|
|
});
|
|
if (res.ok) {
|
|
const noAudio = await res.blob();
|
|
const url2 = URL.createObjectURL(noAudio);
|
|
const a2 = document.createElement('a');
|
|
a2.href = url2; a2.download = baseName + '_noaudio.webm'; a2.click();
|
|
URL.revokeObjectURL(url2);
|
|
}
|
|
} catch(e) {
|
|
console.warn('Auto strip-audio failed:', e);
|
|
}
|
|
|
|
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 camName = video._camName;
|
|
const blobUrl = video._blobUrl;
|
|
const includeAudio = true; // always export with audio; no-audio copy handled by server
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
let drawInterval = null;
|
|
|
|
// Use a hidden video element for export — fresh audio context, independent of preview
|
|
const ev = document.createElement('video');
|
|
ev.src = blobUrl;
|
|
ev.muted = false; // independent of main volume — export always at full volume
|
|
ev.style.cssText = 'position:fixed;top:-9999px;width:1px;height:1px;';
|
|
document.body.appendChild(ev);
|
|
|
|
const exportMimeType = includeAudio
|
|
? (['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm'].find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm')
|
|
: (MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm');
|
|
|
|
function startRecording() {
|
|
// Export at 720p regardless of source resolution
|
|
canvas.width = 1280;
|
|
canvas.height = 720;
|
|
|
|
const stream = canvas.captureStream(30);
|
|
|
|
if (includeAudio) {
|
|
try {
|
|
const audioCtx = new AudioContext();
|
|
const src = audioCtx.createMediaElementSource(ev);
|
|
const dest = audioCtx.createMediaStreamDestination();
|
|
src.connect(dest); // route to recorder only — not speakers
|
|
dest.stream.getAudioTracks().forEach(t => stream.addTrack(t));
|
|
} catch(e) { console.warn('Export audio setup failed:', e); }
|
|
}
|
|
|
|
const recorder = new MediaRecorder(stream, { mimeType: exportMimeType, videoBitsPerSecond: 1800000 });
|
|
const chunks = [];
|
|
|
|
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
|
recorder.onstop = async () => {
|
|
ev.remove();
|
|
const trimmed = new Blob(chunks, { type: exportMimeType });
|
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const baseName = camName + '_trimmed_' + ts;
|
|
|
|
// Download with audio
|
|
const url = URL.createObjectURL(trimmed);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = baseName + '.webm'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
// Auto-strip audio and download second copy via server FFmpeg
|
|
try {
|
|
const res = await fetch('http://localhost:3000/strip-audio', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'video/webm' },
|
|
body: trimmed,
|
|
});
|
|
if (res.ok) {
|
|
const noAudio = await res.blob();
|
|
const url2 = URL.createObjectURL(noAudio);
|
|
const a2 = document.createElement('a');
|
|
a2.href = url2; a2.download = baseName + '_noaudio.webm'; a2.click();
|
|
URL.revokeObjectURL(url2);
|
|
}
|
|
} catch(e) {
|
|
console.warn('Auto strip-audio failed:', e);
|
|
}
|
|
|
|
closeClipEditor();
|
|
};
|
|
|
|
exportLabel.textContent = 'Exporting...';
|
|
recorder.start(100);
|
|
|
|
drawInterval = setInterval(() => {
|
|
if (ev.readyState >= 2) ctx.drawImage(ev, 0, 0, canvas.width, canvas.height);
|
|
const remaining = outTime - ev.currentTime;
|
|
exportLabel.textContent = 'Exporting... ' + Math.max(0, remaining).toFixed(1) + 's';
|
|
if (ev.currentTime >= outTime - 0.05) {
|
|
clearInterval(drawInterval);
|
|
recorder.stop();
|
|
}
|
|
}, 1000 / 30);
|
|
|
|
setTimeout(() => {
|
|
if (recorder.state === 'recording') { clearInterval(drawInterval); recorder.stop(); }
|
|
}, (outTime - inTime + 3) * 1000);
|
|
}
|
|
|
|
ev.addEventListener('loadedmetadata', () => {
|
|
ev.currentTime = inTime;
|
|
ev.addEventListener('seeked', function onSeeked() {
|
|
ev.removeEventListener('seeked', onSeeked);
|
|
if (ev.readyState >= 2) ctx.drawImage(ev, 0, 0, canvas.width || ev.videoWidth || 960, canvas.height || ev.videoHeight || 540);
|
|
startRecording();
|
|
ev.play().catch(() => {});
|
|
}, { once: true });
|
|
}, { once: true });
|
|
ev.load();
|
|
}
|
|
|
|
// ── Record (forward, manual stop) ──────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
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 === 'cameraman2-5');
|
|
CAMERAS.forEach(([name, slug], i) => {
|
|
if (i === DEFAULT_IDX) 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 === 'cameraman2-5');
|
|
CAMERAS.forEach(([name, slug], i) => {
|
|
if (i === DEFAULT_IDX) 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 });
|
|
const ytSlugsSet = new Set(['dirc-5','foyr-5','gsrm-5','mrke-5','mrke2-5','hwdn-5',
|
|
'dmrm-5','dmrm2-5','dmcl-5','jckz-5','brrr-5','brrr2-5','brpz-5','ktch-5',
|
|
'dnrm-5','hwup-5','bbcl-5','br4j-5','bkny-5','codr-5','cfsl-5','jobb-5','bare-5']);
|
|
if (ytSourceActive && !ytSlugsSet.has(slug)) { hls.destroy(); resolve(); return; }
|
|
const thumbUrl = ytSourceActive
|
|
? 'http://localhost:3000/yt-stream/' + slug
|
|
: 'http://localhost:3000/cam/' + slug + '/index.m3u8';
|
|
hls.loadSource(thumbUrl);
|
|
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 === 'cameraman2-5');
|
|
const tasks = CAMERAS.map(([name, slug], i) => {
|
|
if (i === DEFAULT_IDX) 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;
|
|
featVideo.crossOrigin = 'anonymous';
|
|
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 = bufferAudioCtx && bufferAudioCtx._gain ? 1.0 : parseFloat(s.value); if (bufferAudioCtx && bufferAudioCtx._gain) bufferAudioCtx._gain.gain.value = parseFloat(s.value); } }, { once: true });
|
|
|
|
// Build thumb grid — skip Director Mode and Cameraman
|
|
const cammanIdx = CAMERAS.findIndex(([,s]) => s === "cameraman2-5");
|
|
CAMERAS.forEach(([name, slug], i) => {
|
|
if (i === DEFAULT_IDX) return;
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cam-cell';
|
|
cell.id = 'cam-' + i;
|
|
cell.dataset.slug = slug;
|
|
cell.dataset.idx = 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) return;
|
|
const v = parseFloat(val);
|
|
if (bufferAudioCtx && bufferAudioCtx._gain) {
|
|
// Route volume through GainNode — video element stays at volume 1.0
|
|
// so the buffer always captures full volume regardless of slider position
|
|
video.volume = 1.0;
|
|
bufferAudioCtx._gain.gain.value = v;
|
|
} else {
|
|
// AudioContext not set up yet — fall back to video volume
|
|
video.volume = v;
|
|
}
|
|
}
|
|
|
|
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 Promise.all([fetchStocks(), fetchContestants()]);
|
|
}
|
|
|
|
async function fetchContestants() {
|
|
try {
|
|
const r = await fetch(BASE + '/v1/contestants', { headers: headers() });
|
|
if (!r.ok) throw new Error(r.status);
|
|
const data = await r.json();
|
|
renderContestants(data.contestants || []);
|
|
// Refresh chart so it picks up the new colors
|
|
if (stocksChart) { stocksChart.destroy(); stocksChart = null; fetchStocks(); }
|
|
} catch(e) {
|
|
console.error('Contestants error:', e);
|
|
}
|
|
}
|
|
|
|
// Map ticker -> color/photo/endorsements from API
|
|
const contestantTickerColors = {};
|
|
|
|
function renderContestants(contestants) {
|
|
// Update ticker maps
|
|
window._contestantPhotos = {};
|
|
window._contestantEndorsements = {};
|
|
window._contestantJobs = {};
|
|
window._contestantEliminated = {};
|
|
contestants.forEach(c => {
|
|
if (c.tickerSymbol) {
|
|
if (c.color) contestantTickerColors[c.tickerSymbol] = c.color;
|
|
if (c.photo) window._contestantPhotos[c.tickerSymbol] = c.photo;
|
|
if (c.endorsements !== undefined) window._contestantEndorsements[c.tickerSymbol] = c.endorsements;
|
|
if (c.job) window._contestantJobs[c.tickerSymbol] = c.job;
|
|
if (c.eliminatedAt) window._contestantEliminated[c.tickerSymbol] = true;
|
|
}
|
|
});
|
|
}
|
|
|
|
function startPolling() {
|
|
if (!getToken()) return;
|
|
fetchPoll();
|
|
fetchTTSHistory();
|
|
fetchFeatureToggles();
|
|
fetchContestants();
|
|
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);
|
|
toggleYtSource();
|
|
if (getToken()) {
|
|
setApiStatus('live');
|
|
scheduleTokenRefresh();
|
|
startPolling();
|
|
} else {
|
|
setApiStatus('none');
|
|
}
|
|
initCameras();
|
|
// Apply saved thumbnail interval from dropdown
|
|
const iv = document.getElementById('intervalSelect');
|
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
|
// Recalc on resize — use lambda so reference resolves at call time not definition time
|
|
window.addEventListener('resize', () => window.recalcGridRows && window.recalcGridRows());
|
|
window.addEventListener('load', () => window.recalcGridRows && window.recalcGridRows());
|
|
// Poll recalc for 3s after load to catch any layout settling
|
|
let _recalcCount = 0;
|
|
const _recalcInit = setInterval(() => {
|
|
if (window.recalcGridRows) window.recalcGridRows();
|
|
if (++_recalcCount >= 6) clearInterval(_recalcInit);
|
|
}, 500);
|
|
});
|
|
|
|
</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);
|
|
inlineChatSetStatus(msg.status === 'connected' ? 'connected' : 'disconnected');
|
|
return;
|
|
}
|
|
|
|
if (msg._ft === 'event') {
|
|
console.log(`[FT-WS EVENT] "${msg.event}"`, msg.data); //if (msg.event !== 'chat:message')
|
|
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;
|
|
autoDiscoverCameras(msg.data);
|
|
updateViewerCounts(msg.data);
|
|
const totalEl = document.getElementById('inlineChatViewers');
|
|
if (totalEl && msg.data.total !== undefined) {
|
|
totalEl.textContent = msg.data.total.toLocaleString() + ' in chat';
|
|
}
|
|
}
|
|
|
|
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' || msg.event === 'tts:insert') {
|
|
// 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' || msg.event === 'sfx:update') {
|
|
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.event === 'chat:message') {
|
|
const data = msg.data;
|
|
const messages = Array.isArray(data) ? data : [data];
|
|
messages.forEach(m => {
|
|
if (isRealChatMessage(m)) {
|
|
addMessage(m);
|
|
inlineChatAddMessage(m);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
if (msg._ft === 'yt_status') {
|
|
ytReadyCount = msg.ready || 0;
|
|
updateYtBtn();
|
|
return;
|
|
}
|
|
|
|
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 recalculation ───────────────────────────────────
|
|
window.recalcGridRows = function recalcGridRows() {
|
|
const grid = document.getElementById('cameraGrid');
|
|
if (!grid) return;
|
|
const gridW = grid.clientWidth;
|
|
if (!gridW) {
|
|
requestAnimationFrame(recalcGridRows);
|
|
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);
|
|
|
|
// Count actual grid cells (excludes director + cameraman which aren't in grid)
|
|
const cellCount = grid.querySelectorAll('.cam-cell').length;
|
|
const totalRows = Math.ceil(cellCount / cols);
|
|
|
|
// All rows same height
|
|
grid.style.gridTemplateRows = `repeat(${totalRows}, ${thumbH}px)`;
|
|
|
|
// Cap visible height at 2 rows — set both height and maxHeight for flex containers
|
|
const visibleRows = 2;
|
|
const capH = visibleRows * thumbH + (visibleRows - 1) * gap + pad * 2;
|
|
grid.style.height = capH + 'px';
|
|
grid.style.maxHeight = capH + 'px';
|
|
};
|
|
// ── End grid row recalculation ────────────────────────────────
|
|
|
|
// ── Chat message helpers (shared by inline panel and popout forwarding) ────
|
|
function isRealChatMessage(msg) {
|
|
if (msg.user && msg.user.id === 'tts') return false;
|
|
if (msg.metadata && msg.metadata.type === 'item') return false;
|
|
if (!msg.message || typeof msg.message !== 'string') return false;
|
|
return true;
|
|
}
|
|
|
|
// addMessage forwards to the chat popout window if open
|
|
function addMessage(msg) {
|
|
if (window._chatPopout && !window._chatPopout.closed) {
|
|
try { window._chatPopout.postMessage({ _ft: 'chat', msg }, '*'); } catch(e) {}
|
|
}
|
|
}
|
|
// ── End chat helpers ─────────────────────────────────────────
|
|
|
|
// ── Inline chat ──────────────────────────────────────────────
|
|
let inlineChatCount = 0;
|
|
let inlineChatAutoScroll = true;
|
|
let chatCollapsed = false;
|
|
|
|
const inlineFeed = document.getElementById('inlineChatFeed');
|
|
const inlineChatScrollBtn = document.getElementById('inlineChatScrollBtn');
|
|
|
|
inlineFeed.addEventListener('scroll', () => {
|
|
const atBottom = inlineFeed.scrollHeight - inlineFeed.scrollTop - inlineFeed.clientHeight < 60;
|
|
inlineChatAutoScroll = atBottom;
|
|
inlineChatScrollBtn.style.display = atBottom ? 'none' : 'block';
|
|
});
|
|
|
|
window.inlineChatScrollToBottom = function inlineChatScrollToBottom() {
|
|
inlineFeed.scrollTop = inlineFeed.scrollHeight;
|
|
inlineChatAutoScroll = true;
|
|
inlineChatScrollBtn.style.display = 'none';
|
|
};
|
|
|
|
function inlineChatSetStatus(state) {
|
|
const dot = document.getElementById('inlineChatDot');
|
|
const label = document.getElementById('inlineChatStatus');
|
|
if (!dot || !label) return;
|
|
if (state === 'connected') { dot.className = 'dot live'; label.textContent = 'LIVE'; label.style.color = 'var(--green)'; }
|
|
else if (state === 'disconnected') { dot.className = 'dot error'; label.textContent = 'OFFLINE'; label.style.color = 'var(--accent2)'; }
|
|
else { dot.className = 'dot'; label.textContent = 'CONNECTING'; label.style.color = 'var(--muted)'; }
|
|
}
|
|
|
|
function inlineChatAddMessage(msg) {
|
|
const empty = inlineFeed.querySelector('.empty');
|
|
if (empty) empty.remove();
|
|
|
|
const user = msg.user || {};
|
|
const meta = msg.metadata || {};
|
|
const isAdmin = meta.isAdmin || false;
|
|
const isMod = meta.isMod || false;
|
|
const isFish = meta.isFish || false;
|
|
const isGM = meta.isGrandMarshall || false;
|
|
|
|
const cls = ['msg', isAdmin?'admin':'', isMod?'mod':'', isFish?'fish':'', isGM?'grand-marshall':''].filter(Boolean).join(' ');
|
|
|
|
const nameStyle = user.customUsernameColor ? `style="color:${user.customUsernameColor}"` : '';
|
|
const endorsement = user.endorsement
|
|
? `<span class="msg-endorsement" style="color:${user.endorsementColor||'#888'}">${user.endorsement}</span>` : '';
|
|
const badges = [
|
|
isAdmin ? '<span class="badge admin">ADMIN</span>' : '',
|
|
isMod ? '<span class="badge mod">MOD</span>' : '',
|
|
isFish ? '<span class="badge fish">FISH</span>' : '',
|
|
isGM ? '<span class="badge gm">GM</span>' : '',
|
|
].filter(Boolean).join('');
|
|
const ts = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}) : '';
|
|
|
|
const div = document.createElement('div');
|
|
div.className = cls;
|
|
div.innerHTML = `
|
|
<div class="msg-header">
|
|
<span class="msg-user" ${nameStyle}>${user.displayName||'unknown'}</span>
|
|
${user.clan?`<span style="font-size:9px;color:var(--muted)">[${user.clan}]</span>`:''}
|
|
${endorsement}${badges}
|
|
${ts?`<span class="msg-time">${ts}</span>`:''}
|
|
</div>
|
|
<div class="msg-text">${msg.message.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}</div>`;
|
|
|
|
inlineFeed.appendChild(div);
|
|
// Keep max 500 messages to avoid memory growth
|
|
while (inlineFeed.children.length > 500) inlineFeed.removeChild(inlineFeed.firstChild);
|
|
inlineChatCount++;
|
|
const countEl = document.getElementById('inlineChatCount');
|
|
if (countEl) countEl.textContent = inlineChatCount.toLocaleString() + ' messages';
|
|
if (inlineChatAutoScroll) inlineFeed.scrollTop = inlineFeed.scrollHeight;
|
|
}
|
|
|
|
// ── YouTube source toggle ────────────────────────────────────
|
|
fetch('http://localhost:3000/yt-status').then(r => r.json()).then(d => {
|
|
ytReadyCount = d.ready || 0;
|
|
updateYtBtn();
|
|
}).catch(() => {});
|
|
|
|
function updateYtBtn() {
|
|
const btn = document.getElementById('ytToggleBtn');
|
|
if (!btn) return;
|
|
if (ytSourceActive) {
|
|
btn.style.borderColor = '#e74c3c';
|
|
btn.style.color = '#e74c3c';
|
|
btn.textContent = '▶ YT LIVE';
|
|
} else if (ytReadyCount > 0) {
|
|
btn.style.borderColor = 'var(--green)';
|
|
btn.style.color = 'var(--green)';
|
|
btn.textContent = '▶ YT (' + ytReadyCount + ')';
|
|
} else {
|
|
btn.style.borderColor = 'var(--muted)';
|
|
btn.style.color = 'var(--muted)';
|
|
btn.textContent = '▶ YT SOURCE';
|
|
}
|
|
}
|
|
|
|
window.toggleYtSource = function() {
|
|
ytSourceActive = !ytSourceActive;
|
|
updateYtBtn();
|
|
|
|
// Switch featured cam — makeHls now reads ytSourceActive
|
|
const wrap = document.getElementById('camFeaturedWrap');
|
|
const featVideo = wrap && wrap.querySelector('video');
|
|
const featSlug = CAMERAS[featuredIdx] && CAMERAS[featuredIdx][1];
|
|
if (featVideo && featSlug) {
|
|
if (hlsInstances['featured']) hlsInstances['featured'].destroy();
|
|
hlsInstances['featured'] = makeHls(featSlug, featVideo, false);
|
|
}
|
|
|
|
// Dim grid cells that have no YT source
|
|
const NO_YT = new Set(['cameraman2-5','br3g-5']);
|
|
const ytSlugs = new Set(['dirc-5','foyr-5','gsrm-5','mrke-5','mrke2-5','hwdn-5',
|
|
'dmrm-5','dmrm2-5','dmcl-5','jckz-5','brrr-5','brrr2-5','brpz-5','ktch-5',
|
|
'dnrm-5','hwup-5','bbcl-5','br4j-5','bkny-5','codr-5','cfsl-5','bare-5','jobb-5']);
|
|
const grid = document.getElementById('cameraGrid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.cam-cell').forEach(cell => {
|
|
const slug = cell.dataset.slug;
|
|
if (ytSourceActive && slug && !ytSlugs.has(slug)) {
|
|
cell.style.opacity = '0.25';
|
|
cell.style.pointerEvents = 'none';
|
|
} else {
|
|
cell.style.opacity = '';
|
|
cell.style.pointerEvents = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Thumbnails are canvas-based — just refresh them from new source
|
|
refreshAllThumbnails();
|
|
};
|
|
// ── End YouTube source toggle ─────────────────────────────────
|
|
|
|
// ── Floor plan ───────────────────────────────────────────────
|
|
const FLOOR_ROOMS = {
|
|
down: [
|
|
{ label: 'Dining\nRoom', slug: 'dnrm-5', style: 'bottom:0.75%;left:0%;height:50%;width:22%' },
|
|
{ label: 'Kitchen', slug: 'ktch-5', style: 'bottom:0.75%;left:22%;height:60%;width:22%' },
|
|
{ label: 'Bar', slug: 'brrr-5', style: 'bottom:0.75%;left:42.5%;height:60%;width:22%' },
|
|
{ label: 'Dorm', slug: 'dmrm-5', style: 'bottom:0.75%;left:75%;height:67%;width:22%' },
|
|
{ label: 'Glass\nroom', slug: 'gsrm-5', style: 'top:0.75%;left:27.5%;height:35%;width:15%' },
|
|
{ label: 'Foyer', slug: 'foyr-5', style: 'top:0%;left:42%;height:33%;width:20%' },
|
|
{ label: 'Market', slug: 'mrke-5', style: 'top:0.75%;left:62.5%;height:30%;width:15%' },
|
|
{ label: 'Jacuzzi', slug: 'jckz-5', style: 'top:0.75%;right:1%;height:30%;width:12%' },
|
|
{ label: 'Hallway', slug: 'hwdn-5', style: 'top:30%;left:42%;height:10%;width:33%' },
|
|
{ label: 'Closet', slug: 'dmcl-5', style: 'bottom:0.75%;left:65%;height:25%;width:10%' },
|
|
],
|
|
up: [
|
|
{ label: 'Corridor', slug: 'codr-5', style: 'top:0%;left:0%;height:55%;width:8%' },
|
|
{ label: 'Confessional', slug: 'cfsl-5', style: 'top:0%;left:0%;height:17%;width:17%' },
|
|
{ label: 'West Wing', slug: 'hwup-5', style: 'top:42%;left:15%;height:12%;width:25%' },
|
|
{ label: 'East Wing', slug: 'bkny-5', style: 'top:42%;left:42%;height:12%;width:25%' },
|
|
{ label: 'Jungle\nRoom', slug: 'br4j-5', style: 'top:0%;left:17%;height:42%;width:22%' },
|
|
{ label: 'Computer\nLab', slug: 'bbcl-5', style: 'top:0%;right:22%;height:42%;width:20%' },
|
|
],
|
|
};
|
|
|
|
window.currentFloor = 'down';
|
|
let currentFloor = 'down';
|
|
|
|
window.renderFloorButtons = function renderFloorButtons(floor) {
|
|
const wrap = document.getElementById('floorPlanWrap');
|
|
if (!wrap) return;
|
|
wrap.querySelectorAll('.floor-plan-btn').forEach(b => b.remove());
|
|
const rooms = FLOOR_ROOMS[floor] || [];
|
|
const currentSlug = CAMERAS[featuredIdx] ? CAMERAS[featuredIdx][1] : null;
|
|
rooms.forEach(({ label, slug, style }) => {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'floor-plan-btn' + (slug === currentSlug ? ' active-room' : '');
|
|
btn.style.cssText = style;
|
|
btn.innerHTML = label.replace('\n', '<br>');
|
|
btn.title = ROOM_NAMES[slug] || label;
|
|
btn.onclick = () => switchToRoom(slug);
|
|
wrap.appendChild(btn);
|
|
});
|
|
};
|
|
|
|
window.setFloor = function(floor) {
|
|
currentFloor = floor;
|
|
window.currentFloor = floor;
|
|
document.getElementById('floorTabDown').classList.toggle('active', floor === 'down');
|
|
document.getElementById('floorTabUp').classList.toggle('active', floor === 'up');
|
|
document.getElementById('floorPlanImg').src = floor === 'down'
|
|
? 'https://cdn.fishtank.live/images/map/s5/lower.png'
|
|
: 'https://cdn.fishtank.live/images/map/s5/upper.png';
|
|
renderFloorButtons(floor);
|
|
};
|
|
|
|
setTimeout(() => renderFloorButtons('down'), 500);
|
|
// ── End floor plan ────────────────────────────────────────────
|
|
|
|
window.toggleChat = function() {
|
|
chatCollapsed = !chatCollapsed;
|
|
const main = document.querySelector('.main');
|
|
const btn = document.getElementById('chatCollapseBtn');
|
|
const fpPanel = document.getElementById('floorPlanPanel');
|
|
if (chatCollapsed) {
|
|
main.classList.add('chat-collapsed');
|
|
if (fpPanel) fpPanel.style.display = 'none';
|
|
btn.textContent = '💬 CHAT';
|
|
btn.title = 'Show chat panel';
|
|
} else {
|
|
main.classList.remove('chat-collapsed');
|
|
if (fpPanel) fpPanel.style.display = '';
|
|
btn.textContent = '💬 CHAT';
|
|
btn.title = 'Hide chat panel';
|
|
inlineFeed.scrollTop = inlineFeed.scrollHeight;
|
|
}
|
|
setTimeout(() => window.recalcGridRows && window.recalcGridRows(), 50);
|
|
};
|
|
// ── End inline chat ───────────────────────────────────────────
|
|
|
|
// ── 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';
|
|
}
|
|
};
|
|
|
|
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(); }
|
|
}
|
|
};
|
|
// ── End stocks panel collapse ─────────────────────────────────
|
|
|
|
// ── Camera auto-discovery ────────────────────────────────────
|
|
const KNOWN_CAMERA_SLUGS = new Set(CAMERAS.map(([, s]) => s));
|
|
|
|
function slugToLabel(slug) {
|
|
// "br4j-5" -> "BR4J" — strip season suffix, uppercase, max 4 chars
|
|
return slug.replace(/-\d+$/, '').toUpperCase().slice(0, 4);
|
|
}
|
|
|
|
function autoDiscoverCameras(presenceData) {
|
|
// presence keys are camera slugs (plus "total")
|
|
const knownSpecial = new Set(['total']);
|
|
let added = false;
|
|
|
|
Object.keys(presenceData).forEach(slug => {
|
|
if (knownSpecial.has(slug)) return;
|
|
if (KNOWN_CAMERA_SLUGS.has(slug)) return;
|
|
|
|
// New camera found — add before Cameraman
|
|
console.log('[CAM] Auto-discovered new camera:', slug);
|
|
KNOWN_CAMERA_SLUGS.add(slug);
|
|
const label = slugToLabel(slug);
|
|
CAMERAS.push([label, slug]);
|
|
added = true;
|
|
});
|
|
|
|
recalcGridRows();
|
|
if (added) {
|
|
// Add new cells to the grid without destroying existing streams
|
|
const grid = document.getElementById('cameraGrid');
|
|
const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman2-5');
|
|
CAMERAS.forEach(([name, slug], i) => {
|
|
if (i === DEFAULT_IDX) return;
|
|
if (document.getElementById('cam-' + i)) return; // already exists
|
|
const cell = document.createElement('div');
|
|
cell.className = 'cam-cell';
|
|
cell.id = 'cam-' + i;
|
|
cell.dataset.slug = slug;
|
|
cell.dataset.idx = 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);
|
|
// Kick off a thumbnail capture for the new cell
|
|
captureThumb(slug, 'canvas-' + i);
|
|
});
|
|
recalcGridRows();
|
|
}
|
|
}
|
|
// ── End camera auto-discovery ─────────────────────────────────
|
|
|
|
// ── Viewer counts ────────────────────────────────────────────
|
|
window.updateViewerCounts = function(data) {
|
|
const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman2-5') : -1;
|
|
// Use data-slug from each cell — it may differ from CAMERAS[i] if director has swapped in
|
|
const grid = document.getElementById('cameraGrid');
|
|
if (grid) {
|
|
grid.querySelectorAll('.cam-cell').forEach(cell => {
|
|
const slug = cell.dataset.slug;
|
|
if (slug) setViewerBadge(cell, data[slug]);
|
|
});
|
|
}
|
|
|
|
// Sort grid cells by viewer count descending
|
|
if (grid && typeof CAMERAS !== 'undefined') {
|
|
const cells = Array.from(grid.querySelectorAll('.cam-cell'));
|
|
cells.sort((a, b) => {
|
|
const aSlug = a.dataset.slug;
|
|
const bSlug = b.dataset.slug;
|
|
return (data[bSlug] || 0) - (data[aSlug] || 0);
|
|
});
|
|
cells.forEach(cell => grid.appendChild(cell));
|
|
}
|
|
|
|
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) 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 === 'cameraman2-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, 15000);
|
|
// Play notification sound
|
|
try { new Audio('https://cdn.fishtank.live/sounds/xp.mp3').play(); } catch(e) {}
|
|
|
|
// Add to log
|
|
const feed = document.getElementById('notifLogFeed');
|
|
if (feed) {
|
|
const empty = feed.querySelector('div[style*="No notifications"]');
|
|
if (empty) empty.remove();
|
|
const ts = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
const item = document.createElement('div');
|
|
item.className = 'notif-log-item';
|
|
item.innerHTML = `<div class="notif-log-item-msg">${message}</div>` +
|
|
(subtitle ? `<div class="notif-log-item-sub">${subtitle}</div>` : '') +
|
|
`<div class="notif-log-item-time">${ts}</div>`;
|
|
feed.insertBefore(item, feed.firstChild);
|
|
while (feed.children.length > 50) feed.removeChild(feed.lastChild);
|
|
}
|
|
|
|
// Unread badge
|
|
if (!notifLogOpen) {
|
|
notifLogUnread++;
|
|
const badge = document.getElementById('notifLogBadge');
|
|
if (badge) { badge.textContent = notifLogUnread; badge.style.display = 'block'; }
|
|
}
|
|
};
|
|
|
|
let notifLogUnread = 0;
|
|
let notifLogOpen = false;
|
|
|
|
window.toggleNotifLog = function() {
|
|
notifLogOpen = !notifLogOpen;
|
|
const log = document.getElementById('notifLog');
|
|
if (log) log.classList.toggle('open', notifLogOpen);
|
|
if (notifLogOpen) {
|
|
notifLogUnread = 0;
|
|
const badge = document.getElementById('notifLogBadge');
|
|
if (badge) badge.style.display = 'none';
|
|
}
|
|
};
|
|
|
|
window.clearNotifLog = function() {
|
|
const feed = document.getElementById('notifLogFeed');
|
|
if (feed) feed.innerHTML = `<div style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);text-align:center;padding:20px;">No notifications yet</div>`;
|
|
notifLogUnread = 0;
|
|
const badge = document.getElementById('notifLogBadge');
|
|
if (badge) badge.style.display = 'none';
|
|
};
|
|
|
|
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>
|