mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-05-15 16:02:42 -04:00
added clip trimming, chat popout + minor changes
This commit is contained in:
committed by
GitHub
parent
1c63a3c95f
commit
ffcdd43004
+645
-61
@@ -22,12 +22,19 @@
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'DM Sans', sans-serif;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body::before {
|
||||
@@ -158,9 +165,11 @@
|
||||
grid-template-rows: 320px 1fr;
|
||||
grid-template-areas: "poll stocks camman" "tts cameras cameras";
|
||||
gap: 1px;
|
||||
height: calc(100vh - 73px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--border);
|
||||
transition: grid-template-rows 0.3s ease, grid-template-columns 0.3s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main.stocks-collapsed {
|
||||
@@ -282,24 +291,18 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cameras-panel {
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cam-featured-wrap {
|
||||
flex-shrink: 0;
|
||||
background: #000;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
max-height: 65%;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
/* Flex child: take all space minus the grid's fixed height */
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.cam-featured-wrap .cam-cell {
|
||||
@@ -308,16 +311,25 @@
|
||||
aspect-ratio: unset;
|
||||
}
|
||||
|
||||
.cam-featured-wrap video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
align-content: start;
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
/* Row height set dynamically by JS to match actual panel width */
|
||||
}
|
||||
|
||||
|
||||
|
||||
.camera-grid::-webkit-scrollbar { width: 4px; }
|
||||
.camera-grid::-webkit-scrollbar-track { background: transparent; }
|
||||
.camera-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
@@ -620,6 +632,8 @@
|
||||
font-size: 10px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* SFX */
|
||||
@@ -735,6 +749,127 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Clip editor modal */
|
||||
.clip-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clip-modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: min(860px, 95vw);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.clip-modal-title {
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
font-size: 20px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.clip-modal video {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.clip-trim-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clip-trim-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clip-trim-labels span { color: var(--accent); }
|
||||
|
||||
.clip-range-wrap {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clip-range-track {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.clip-range-fill {
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clip-range-wrap input[type=range] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clip-range-wrap input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--bg);
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.clip-range-wrap input[type=range]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--bg);
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.clip-modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.clip-export-progress {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Camera reconnecting overlay */
|
||||
.cam-reconnecting {
|
||||
position: absolute;
|
||||
@@ -808,6 +943,127 @@
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
/* Clip editor modal */
|
||||
.clip-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clip-modal {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
width: min(860px, 95vw);
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.clip-modal-title {
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
font-size: 20px;
|
||||
letter-spacing: 3px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.clip-modal video {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
background: #000;
|
||||
max-height: 420px;
|
||||
}
|
||||
|
||||
.clip-trim-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.clip-trim-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.clip-trim-labels span { color: var(--accent); }
|
||||
|
||||
.clip-range-wrap {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.clip-range-track {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 4px;
|
||||
background: var(--border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.clip-range-fill {
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clip-range-wrap input[type=range] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
-webkit-appearance: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.clip-range-wrap input[type=range]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--bg);
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.clip-range-wrap input[type=range]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 2px solid var(--bg);
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.clip-modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.clip-export-progress {
|
||||
font-family: 'Share Tech Mono', monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* Camera reconnecting overlay */
|
||||
.cam-reconnecting {
|
||||
position: absolute;
|
||||
@@ -1048,6 +1304,14 @@
|
||||
}
|
||||
initCameras();
|
||||
initCamerman();
|
||||
// Apply saved thumbnail interval from dropdown
|
||||
const iv = document.getElementById('intervalSelect');
|
||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||
// Set initial grid row heights and recalc on resize
|
||||
setTimeout(recalcGridRows, 100);
|
||||
window.addEventListener('resize', recalcGridRows);
|
||||
// Set initial grid row height
|
||||
setTimeout(recalcGridRows, 100);
|
||||
});
|
||||
|
||||
function initCamerman() {
|
||||
@@ -1125,6 +1389,14 @@
|
||||
}
|
||||
initCameras();
|
||||
initCamerman();
|
||||
// Apply saved thumbnail interval from dropdown
|
||||
const iv = document.getElementById('intervalSelect');
|
||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||
// Set initial grid row heights and recalc on resize
|
||||
setTimeout(recalcGridRows, 100);
|
||||
window.addEventListener('resize', recalcGridRows);
|
||||
// Set initial grid row height
|
||||
setTimeout(recalcGridRows, 100);
|
||||
});
|
||||
|
||||
function initCamerman() {
|
||||
@@ -1210,6 +1482,7 @@
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<button class="stocks-collapse-btn" onclick="toggleLeftPanels()" id="leftCollapseBtn">◀ POLL / TTS</button>
|
||||
<button class="stocks-collapse-btn" onclick="toggleStocks()" id="stocksCollapseBtn">▲ STOCKS</button>
|
||||
<button class="stocks-collapse-btn" onclick="window.open('http://localhost:3000/chat','fishtank-chat','width=400,height=700,resizable=yes')" title="Open chat popout">💬 CHAT</button>
|
||||
</div>
|
||||
|
||||
<div id="apiControl" style="display:flex;align-items:center;gap:8px;">
|
||||
@@ -1223,11 +1496,12 @@
|
||||
<button id="logoutBtn" onclick="logout()" style="display:none;background:transparent;border:1px solid var(--border);color:var(--muted);font-family:'Share Tech Mono',monospace;font-size:10px;padding:4px 12px;border-radius:4px;cursor:pointer;letter-spacing:1px;">LOGOUT</button>
|
||||
</div>
|
||||
<div class="status-bar">
|
||||
<select class="interval-select" id="intervalSelect" onchange="changeInterval(this.value)" title="Stocks refresh interval">
|
||||
<option value="30000">30s</option>
|
||||
<option value="60000" selected>60s</option>
|
||||
<option value="120000">2m</option>
|
||||
<option value="300000">5m</option>
|
||||
<select class="interval-select" id="intervalSelect" onchange="changeInterval(this.value)" title="Thumbnail refresh interval">
|
||||
<option value="5000">5s</option>
|
||||
<option value="10000">10s</option>
|
||||
<option value="15000">15s</option>
|
||||
<option value="30000" selected>30s</option>
|
||||
<option value="60000">60s</option>
|
||||
</select>
|
||||
<span id="lastUpdated" style="font-size:13px;color:#e2e8f0;font-family:'Share Tech Mono',monospace;"></span>
|
||||
</div>
|
||||
@@ -1487,14 +1761,25 @@
|
||||
|
||||
function applyPollVote(scores) {
|
||||
// scores = [{value, score}, ...] from poll:vote WS event
|
||||
if (!pollCache) return; // no poll loaded yet, ignore
|
||||
if (pollCache.currentPoll) {
|
||||
pollCache.currentPoll.scores = scores;
|
||||
} else {
|
||||
// No current poll in cache yet — trigger a full fetch
|
||||
if (!pollCache) {
|
||||
fetchPoll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if options have changed — means a new poll started
|
||||
const cachedOptions = (pollCache.currentPoll && pollCache.currentPoll.scores || [])
|
||||
.map(s => s.value).sort().join('|');
|
||||
const incomingOptions = scores.map(s => s.value).sort().join('|');
|
||||
|
||||
if (!pollCache.currentPoll || cachedOptions !== incomingOptions) {
|
||||
// New poll — re-fetch to get the question and metadata
|
||||
console.log('[Poll] Options changed, fetching new poll...');
|
||||
fetchPoll();
|
||||
return;
|
||||
}
|
||||
|
||||
// Same poll — just update scores and re-render
|
||||
pollCache.currentPoll.scores = scores;
|
||||
renderPoll(pollCache);
|
||||
}
|
||||
|
||||
@@ -1550,10 +1835,9 @@
|
||||
<span class="tts-voice">${msg.voice}</span>
|
||||
<span class="tts-room ${hasRoom ? 'linked' : ''}">${ROOM_NAMES[msg.room] || msg.room || ''}</span>
|
||||
<span class="tts-status ${msg.status}">${msg.status.toUpperCase()}</span>
|
||||
<span class="tts-cost">⬡ ${msg.cost}</span>
|
||||
</div>
|
||||
<div class="tts-message">${msg.message}</div>
|
||||
<div class="tts-time">${formatTime(msg.createdAt)} · ${timeAgo(msg.createdAt)}</div>
|
||||
<div class="tts-time">${formatTime(msg.createdAt)} · ${timeAgo(msg.createdAt)}<span class="tts-cost" style="margin-left:auto;">⬡ ${msg.cost}</span></div>
|
||||
</div>`;
|
||||
} else {
|
||||
// SFX item — fields: sound, url, displayName, cost, room, createdAt (unix ms)
|
||||
@@ -1884,17 +2168,39 @@
|
||||
|
||||
function makeHls(slug, video, muted, retryDelay) {
|
||||
retryDelay = retryDelay || 2000;
|
||||
const hls = new Hls({ lowLatencyMode: true, maxBufferLength: 8 });
|
||||
const hls = new Hls({
|
||||
lowLatencyMode: true,
|
||||
maxBufferLength: 8,
|
||||
maxMaxBufferLength: 16,
|
||||
fragLoadingTimeOut: 10000,
|
||||
manifestLoadingTimeOut: 10000,
|
||||
levelLoadingTimeOut: 10000,
|
||||
fragLoadingMaxRetry: 3,
|
||||
manifestLoadingMaxRetry: 3,
|
||||
});
|
||||
hls.loadSource('http://localhost:3000/cam/' + slug + '/index.m3u8');
|
||||
hls.attachMedia(video);
|
||||
|
||||
let reconnectOverlayTimer = null;
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
video.muted = muted;
|
||||
video.play().catch(() => {});
|
||||
});
|
||||
|
||||
// Hide overlay as soon as frames start playing
|
||||
video.addEventListener('playing', () => {
|
||||
if (reconnectOverlayTimer) { clearTimeout(reconnectOverlayTimer); reconnectOverlayTimer = null; }
|
||||
hideReconnecting(video);
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (e, d) => {
|
||||
if (!d.fatal) return;
|
||||
showReconnecting(video);
|
||||
// Only show reconnecting overlay after a short grace period
|
||||
// so brief blips on a healthy stream don't flash it
|
||||
if (!reconnectOverlayTimer) {
|
||||
reconnectOverlayTimer = setTimeout(() => showReconnecting(video), 1500);
|
||||
}
|
||||
hls.destroy();
|
||||
const nextDelay = Math.min(retryDelay * 1.5, 10000);
|
||||
setTimeout(() => {
|
||||
@@ -1940,6 +2246,11 @@
|
||||
const overlay = document.getElementById('featPlayOverlay');
|
||||
if (overlay) overlay.classList.add('hidden');
|
||||
featuredIdx = idx;
|
||||
// Restart buffer so clips target the new camera
|
||||
stopBuffer();
|
||||
bufferChunks = [];
|
||||
bufferInitChunk = null;
|
||||
setTimeout(() => ensureBuffer(), 500);
|
||||
// Refresh overlays and viewer counts for new featured cam
|
||||
if (typeof updateContestantOverlays === 'function' && window._lastContestantData && Object.keys(window._lastContestantData).length) {
|
||||
updateContestantOverlays(window._lastContestantData);
|
||||
@@ -2001,6 +2312,7 @@
|
||||
// ── Rolling 60s buffer (for CLIP) ──────────────────────────
|
||||
let bufferRecorder = null;
|
||||
let bufferChunks = []; // { data, ts }
|
||||
let bufferInitChunk = null; // WebM init segment, kept permanently
|
||||
const BUFFER_SECS = 65; // keep a bit extra
|
||||
|
||||
let mirrorVideo = null;
|
||||
@@ -2064,13 +2376,25 @@
|
||||
try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
|
||||
catch(e) { return; }
|
||||
bufferChunks = [];
|
||||
bufferInitChunk = null; // reset so first chunk of new recorder is captured as init
|
||||
bufferRecorder.ondataavailable = e => {
|
||||
if (!e.data || e.data.size === 0) return;
|
||||
const now = Date.now();
|
||||
if (!bufferInitChunk) {
|
||||
// First chunk is always the WebM init segment — store separately
|
||||
bufferInitChunk = e.data;
|
||||
return;
|
||||
}
|
||||
bufferChunks.push({ data: e.data, ts: now });
|
||||
const cutoff = now - BUFFER_SECS * 1000;
|
||||
bufferChunks = bufferChunks.filter(c => c.ts >= cutoff);
|
||||
};
|
||||
bufferRecorder._getBlob = (mimeType) => {
|
||||
const chunks = bufferInitChunk
|
||||
? [bufferInitChunk, ...bufferChunks.map(c => c.data)]
|
||||
: bufferChunks.map(c => c.data);
|
||||
return new Blob(chunks, { type: mimeType });
|
||||
};
|
||||
bufferRecorder.start(250);
|
||||
}
|
||||
|
||||
@@ -2193,19 +2517,238 @@
|
||||
}
|
||||
|
||||
function saveClip() {
|
||||
ensureBuffer();
|
||||
if (!bufferChunks.length) { alert('No buffer yet — wait a moment after the stream starts.'); return; }
|
||||
const mimeType = bufferRecorder ? bufferRecorder.mimeType : 'video/webm';
|
||||
const blob = new Blob(bufferChunks.map(c => c.data), { type: mimeType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (!bufferChunks.length) { alert('No buffer yet — wait a moment after the stream starts.'); return; }
|
||||
// Calculate real duration from chunk timestamps (WebM header duration is unreliable)
|
||||
const realDuration = (bufferChunks[bufferChunks.length - 1].ts - bufferChunks[0].ts) / 1000;
|
||||
const blob = bufferRecorder._getBlob ? bufferRecorder._getBlob(mimeType)
|
||||
: new Blob(bufferChunks.map(c => c.data), { type: mimeType });
|
||||
const wrap = document.getElementById('camFeaturedWrap');
|
||||
const label = wrap ? wrap.querySelector('.cam-label') : null;
|
||||
const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'clip';
|
||||
openClipEditor(blob, mimeType, camName, realDuration);
|
||||
}
|
||||
|
||||
function openClipEditor(blob, mimeType, camName, realDuration) {
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'clip-modal-backdrop';
|
||||
backdrop.innerHTML = `
|
||||
<div class="clip-modal">
|
||||
<div class="clip-modal-title">✂ EDIT CLIP</div>
|
||||
<video id="clipEditorVideo" src="${blobUrl}" preload="auto" playsinline></video>
|
||||
<div class="clip-trim-row">
|
||||
<div class="clip-trim-labels">
|
||||
<span>IN: <span id="clipInLabel">0.0s</span></span>
|
||||
<span>DURATION: <span id="clipDurLabel">—</span></span>
|
||||
<span>OUT: <span id="clipOutLabel">—</span></span>
|
||||
</div>
|
||||
<div class="clip-range-wrap">
|
||||
<div class="clip-range-track"></div>
|
||||
<div class="clip-range-fill" id="clipRangeFill"></div>
|
||||
<input type="range" id="clipInSlider" min="0" max="100" step="0.1" value="0">
|
||||
<input type="range" id="clipOutSlider" min="0" max="100" step="0.1" value="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="clip-modal-actions">
|
||||
<div class="clip-export-progress" id="clipExportProgress">
|
||||
<div class="refresh-ring active" style="width:12px;height:12px;border-width:2px;"></div>
|
||||
<span id="clipExportLabel">Exporting...</span>
|
||||
</div>
|
||||
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
|
||||
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()">SAVE FULL</button>
|
||||
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
const video = document.getElementById('clipEditorVideo');
|
||||
const inSlider = document.getElementById('clipInSlider');
|
||||
const outSlider= document.getElementById('clipOutSlider');
|
||||
const fill = document.getElementById('clipRangeFill');
|
||||
|
||||
video._blobUrl = blobUrl;
|
||||
video._mimeType = mimeType;
|
||||
video._camName = camName;
|
||||
video._realDur = realDuration || null;
|
||||
|
||||
let playbackWatcher = null;
|
||||
|
||||
function getDur() {
|
||||
if (video._realDur && isFinite(video._realDur)) return video._realDur;
|
||||
if (video.duration && isFinite(video.duration)) return video.duration;
|
||||
return null;
|
||||
}
|
||||
|
||||
function updateTrimUI(seekVideo) {
|
||||
const d = getDur();
|
||||
if (!d) return;
|
||||
const inTime = parseFloat(inSlider.value);
|
||||
const outTime = parseFloat(outSlider.value);
|
||||
document.getElementById('clipInLabel').textContent = inTime.toFixed(1) + 's';
|
||||
document.getElementById('clipOutLabel').textContent = outTime.toFixed(1) + 's';
|
||||
const trimDur = Math.max(0, outTime - inTime);
|
||||
const estMB = (trimDur * 4000000 / 8 / 1048576).toFixed(1);
|
||||
document.getElementById('clipDurLabel').textContent = trimDur.toFixed(1) + 's (~' + estMB + ' MB)';
|
||||
fill.style.left = (inTime / d * 100) + '%';
|
||||
fill.style.width = (trimDur / d * 100) + '%';
|
||||
if (seekVideo && video.paused) video.currentTime = inTime;
|
||||
}
|
||||
|
||||
function initSliders(d) {
|
||||
inSlider.min = '0'; inSlider.max = d.toFixed(1); inSlider.step = '0.1'; inSlider.value = '0';
|
||||
outSlider.min = '0'; outSlider.max = d.toFixed(1); outSlider.step = '0.1'; outSlider.value = d.toFixed(1);
|
||||
updateTrimUI(true);
|
||||
}
|
||||
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
const d = getDur();
|
||||
if (d) initSliders(d);
|
||||
});
|
||||
|
||||
video.addEventListener('error', () => {
|
||||
document.getElementById('clipDurLabel').textContent = 'Load error — use SAVE FULL';
|
||||
});
|
||||
|
||||
inSlider.addEventListener('input', () => {
|
||||
if (parseFloat(inSlider.value) >= parseFloat(outSlider.value) - 1)
|
||||
inSlider.value = (parseFloat(outSlider.value) - 1).toFixed(1);
|
||||
updateTrimUI(true);
|
||||
});
|
||||
|
||||
outSlider.addEventListener('input', () => {
|
||||
if (parseFloat(outSlider.value) <= parseFloat(inSlider.value) + 1)
|
||||
outSlider.value = (parseFloat(inSlider.value) + 1).toFixed(1);
|
||||
updateTrimUI(false);
|
||||
});
|
||||
|
||||
function stopPlayback() {
|
||||
video.pause();
|
||||
if (playbackWatcher) { clearInterval(playbackWatcher); playbackWatcher = null; }
|
||||
}
|
||||
|
||||
function playFromIn() {
|
||||
stopPlayback();
|
||||
const inTime = parseFloat(inSlider.value);
|
||||
const outTime = parseFloat(outSlider.value);
|
||||
video.currentTime = inTime;
|
||||
// Wait for seek to complete before playing
|
||||
video.addEventListener('seeked', function onSeeked() {
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
video.play().catch(() => {});
|
||||
playbackWatcher = setInterval(() => {
|
||||
if (video.currentTime >= outTime - 0.05) {
|
||||
stopPlayback();
|
||||
video.currentTime = parseFloat(inSlider.value);
|
||||
}
|
||||
}, 50);
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
|
||||
video.load();
|
||||
}
|
||||
|
||||
function closeClipEditor() {
|
||||
const backdrop = document.querySelector('.clip-modal-backdrop');
|
||||
if (!backdrop) return;
|
||||
const video = document.getElementById('clipEditorVideo');
|
||||
if (video && video._blobUrl) URL.revokeObjectURL(video._blobUrl);
|
||||
backdrop.remove();
|
||||
// Reset buffer so next clip starts clean
|
||||
stopBuffer();
|
||||
bufferChunks = [];
|
||||
bufferInitChunk = null;
|
||||
setTimeout(() => ensureBuffer(), 500);
|
||||
}
|
||||
|
||||
function downloadClipFull() {
|
||||
const video = document.getElementById('clipEditorVideo');
|
||||
if (!video) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
||||
a.href = video._blobUrl;
|
||||
a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
closeClipEditor();
|
||||
}
|
||||
|
||||
function exportTrimmedClip() {
|
||||
const video = document.getElementById('clipEditorVideo');
|
||||
const inSlider = document.getElementById('clipInSlider');
|
||||
const outSlider = document.getElementById('clipOutSlider');
|
||||
if (!video) return;
|
||||
|
||||
const inTime = parseFloat(inSlider.value);
|
||||
const outTime = parseFloat(outSlider.value);
|
||||
if (!isFinite(inTime) || !isFinite(outTime) || outTime <= inTime) return;
|
||||
|
||||
const progress = document.getElementById('clipExportProgress');
|
||||
const exportLabel = document.getElementById('clipExportLabel');
|
||||
const saveBtn = document.getElementById('clipSaveTrimBtn');
|
||||
const fullBtn = document.getElementById('clipSaveFullBtn');
|
||||
progress.style.display = 'flex';
|
||||
saveBtn.disabled = true;
|
||||
fullBtn.disabled = true;
|
||||
exportLabel.textContent = 'Seeking...';
|
||||
|
||||
video.pause();
|
||||
video.muted = true;
|
||||
|
||||
const mimeType = video._mimeType;
|
||||
const camName = video._camName;
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let drawInterval = null;
|
||||
|
||||
function startRecording() {
|
||||
canvas.width = video.videoWidth || 960;
|
||||
canvas.height = video.videoHeight || 540;
|
||||
|
||||
const stream = canvas.captureStream(30);
|
||||
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 });
|
||||
const chunks = [];
|
||||
|
||||
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
||||
recorder.onstop = () => {
|
||||
const trimmed = new Blob(chunks, { type: mimeType });
|
||||
const url = URL.createObjectURL(trimmed);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = camName + '_trimmed_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
closeClipEditor();
|
||||
};
|
||||
|
||||
exportLabel.textContent = 'Exporting...';
|
||||
recorder.start(100);
|
||||
|
||||
drawInterval = setInterval(() => {
|
||||
if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const remaining = outTime - video.currentTime;
|
||||
exportLabel.textContent = 'Exporting... ' + Math.max(0, remaining).toFixed(1) + 's';
|
||||
if (video.currentTime >= outTime - 0.05) {
|
||||
clearInterval(drawInterval);
|
||||
recorder.stop();
|
||||
}
|
||||
}, 1000 / 30);
|
||||
|
||||
// Safety net
|
||||
setTimeout(() => {
|
||||
if (recorder.state === 'recording') { clearInterval(drawInterval); recorder.stop(); }
|
||||
}, (outTime - inTime + 3) * 1000);
|
||||
}
|
||||
|
||||
// Seek first, only start recording once seek is complete and frame is ready
|
||||
video.currentTime = inTime;
|
||||
video.addEventListener('seeked', function onSeeked() {
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
// Draw one frame to warm up canvas, then start recording and play
|
||||
if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width || video.videoWidth || 960, canvas.height || video.videoHeight || 540);
|
||||
startRecording();
|
||||
video.play().catch(() => {});
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
// ── Record (forward, manual stop) ──────────────────────────
|
||||
@@ -2406,24 +2949,21 @@
|
||||
}
|
||||
|
||||
|
||||
function applyFeatureToggle(feature, enabled) {
|
||||
const dotId = feature === 'tts' ? 'ttsEnabledDot' : feature === 'sfx' ? 'sfxEnabledDot' : null;
|
||||
if (!dotId) return;
|
||||
const dot = document.getElementById(dotId);
|
||||
if (!dot) return;
|
||||
dot.className = 'dot' + (enabled ? ' live' : ' error');
|
||||
dot.title = feature.toUpperCase() + (enabled ? ' enabled' : ' disabled');
|
||||
}
|
||||
|
||||
async function fetchFeatureToggles() {
|
||||
try {
|
||||
const r = await fetch(BASE + '/v1/feature-toggles', { headers: headers() });
|
||||
if (!r.ok) throw new Error(r.status);
|
||||
const data = await r.json();
|
||||
const toggles = data.featureToggles || [];
|
||||
const tts = toggles.find(f => f.feature === 'tts');
|
||||
const sfx = toggles.find(f => f.feature === 'sfx');
|
||||
const ttsDot = document.getElementById('ttsEnabledDot');
|
||||
const sfxDot = document.getElementById('sfxEnabledDot');
|
||||
if (ttsDot && tts) {
|
||||
ttsDot.className = 'dot' + (tts.enabled ? ' live' : ' error');
|
||||
ttsDot.title = tts.enabled ? 'TTS enabled' : 'TTS disabled';
|
||||
}
|
||||
if (sfxDot && sfx) {
|
||||
sfxDot.className = 'dot' + (sfx.enabled ? ' live' : ' error');
|
||||
sfxDot.title = sfx.enabled ? 'SFX enabled' : 'SFX disabled';
|
||||
}
|
||||
(data.featureToggles || []).forEach(f => applyFeatureToggle(f.feature, f.enabled));
|
||||
} catch(e) {
|
||||
console.error('Feature toggles error:', e);
|
||||
}
|
||||
@@ -2441,21 +2981,20 @@
|
||||
document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString('en-US', { timeZone: 'America/New_York', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + ' ET';
|
||||
}
|
||||
|
||||
// Slow tick — stocks + feature toggles only (60s)
|
||||
// Stocks tick — hardcoded 60s
|
||||
let slowIntervalId = null;
|
||||
|
||||
async function slowTick() {
|
||||
if (!getToken()) return;
|
||||
await Promise.all([fetchStocks(), fetchFeatureToggles()]);
|
||||
await fetchStocks();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (!getToken()) return;
|
||||
// Initial fast loads
|
||||
fetchPoll();
|
||||
fetchTTSHistory();
|
||||
fetchFeatureToggles();
|
||||
slowTick();
|
||||
// Slow interval for stocks + toggles
|
||||
if (!slowIntervalId) {
|
||||
slowIntervalId = setInterval(slowTick, 60000);
|
||||
}
|
||||
@@ -2466,12 +3005,11 @@
|
||||
slowIntervalId = null;
|
||||
}
|
||||
|
||||
// intervalSelect now controls stocks refresh rate
|
||||
// intervalSelect controls thumbnail refresh rate
|
||||
function changeInterval(val) {
|
||||
if (slowIntervalId) {
|
||||
clearInterval(slowIntervalId);
|
||||
slowIntervalId = setInterval(slowTick, parseInt(val));
|
||||
}
|
||||
const ms = parseInt(val);
|
||||
if (window._thumbInterval) clearInterval(window._thumbInterval);
|
||||
window._thumbInterval = setInterval(refreshAllThumbnails, ms);
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
@@ -2495,6 +3033,14 @@
|
||||
}
|
||||
initCameras();
|
||||
initCamerman();
|
||||
// Apply saved thumbnail interval from dropdown
|
||||
const iv = document.getElementById('intervalSelect');
|
||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||
// Set initial grid row heights and recalc on resize
|
||||
setTimeout(recalcGridRows, 100);
|
||||
window.addEventListener('resize', recalcGridRows);
|
||||
// Set initial grid row height
|
||||
setTimeout(recalcGridRows, 100);
|
||||
});
|
||||
|
||||
function initCamerman() {
|
||||
@@ -2609,6 +3155,11 @@
|
||||
updateViewerCounts(msg.data);
|
||||
}
|
||||
|
||||
if (msg.event === 'feature-toggles:update') {
|
||||
if (msg.data && msg.data.feature !== undefined) {
|
||||
applyFeatureToggle(msg.data.feature, msg.data.enabled);
|
||||
}
|
||||
}
|
||||
if (msg.event === 'poll:vote') {
|
||||
// data is [{value, score}, ...] — update scores in cached poll
|
||||
if (Array.isArray(msg.data)) applyPollVote(msg.data);
|
||||
@@ -2695,6 +3246,38 @@
|
||||
// ── Notification popup ───────────────────────────────────────
|
||||
let notifTimer = null;
|
||||
|
||||
// ── Grid row height recalculation ────────────────────────────
|
||||
function recalcGridRows() {
|
||||
const grid = document.getElementById('cameraGrid');
|
||||
if (!grid) return;
|
||||
const w = grid.clientWidth;
|
||||
if (!w) return;
|
||||
const cols = 9;
|
||||
const gap = 2;
|
||||
const pad = 2;
|
||||
const cellW = (w - pad * 2 - gap * (cols - 1)) / cols;
|
||||
const cellH = Math.floor(cellW * 9 / 16);
|
||||
grid.style.gridTemplateRows = `repeat(2, ${cellH}px)`;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', recalcGridRows);
|
||||
// ── End grid row height ───────────────────────────────────────
|
||||
|
||||
// ── Grid row recalculation ───────────────────────────────────
|
||||
function recalcGridRows() {
|
||||
const grid = document.getElementById('cameraGrid');
|
||||
if (!grid) return;
|
||||
const gridW = grid.clientWidth;
|
||||
if (!gridW) return;
|
||||
const cols = 9;
|
||||
const gap = 2;
|
||||
const pad = 2;
|
||||
const thumbW = (gridW - pad * 2 - gap * (cols - 1)) / cols;
|
||||
const thumbH = Math.floor(thumbW * 9 / 16);
|
||||
grid.style.gridTemplateRows = `repeat(2, ${thumbH}px)`;
|
||||
}
|
||||
// ── End grid row recalculation ────────────────────────────────
|
||||
|
||||
// ── Stocks panel collapse ────────────────────────────────────
|
||||
let stocksCollapsed = false;
|
||||
|
||||
@@ -2713,6 +3296,7 @@
|
||||
btn.textContent = '◀ POLL / TTS';
|
||||
btn.title = 'Hide poll and TTS panels';
|
||||
}
|
||||
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
|
||||
};
|
||||
|
||||
window.toggleStocks = function toggleStocks() {
|
||||
@@ -2727,9 +3311,9 @@
|
||||
main.classList.remove('stocks-collapsed');
|
||||
btn.textContent = '▲ STOCKS';
|
||||
btn.title = 'Hide stocks panel';
|
||||
// Redraw chart since it was hidden
|
||||
if (stocksChart) { stocksChart.resize(); }
|
||||
}
|
||||
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
|
||||
};
|
||||
// ── End stocks panel collapse ─────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user