diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html
index 4e4e0b0..92b601d 100644
--- a/fishtank-dashboard.html
+++ b/fishtank-dashboard.html
@@ -918,6 +918,86 @@
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;
+ }
+
/* Viewer count badge */
.cam-viewers {
position: absolute;
@@ -1112,6 +1192,86 @@
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;
+ }
+
/* Viewer count badge */
.cam-viewers {
position: absolute;
@@ -1316,13 +1476,13 @@
// Apply saved thumbnail interval from dropdown
const iv = document.getElementById('intervalSelect');
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
- // Recalc on resize
- window.addEventListener('resize', recalcGridRows);
- window.addEventListener('load', recalcGridRows);
+ // 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(() => {
- recalcGridRows();
+ if (window.recalcGridRows) window.recalcGridRows();
if (++_recalcCount >= 6) clearInterval(_recalcInit);
}, 500);
});
@@ -1405,13 +1565,13 @@
// Apply saved thumbnail interval from dropdown
const iv = document.getElementById('intervalSelect');
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
- // Recalc on resize
- window.addEventListener('resize', recalcGridRows);
- window.addEventListener('load', recalcGridRows);
+ // 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(() => {
- recalcGridRows();
+ if (window.recalcGridRows) window.recalcGridRows();
if (++_recalcCount >= 6) clearInterval(_recalcInit);
}, 500);
});
@@ -1585,11 +1745,8 @@
-
-
-
VOL
@@ -1994,7 +2151,7 @@
const datasets = tickers.map(ticker => {
const priceMap = prices[ticker];
const data = allTimestamps.map(ts => priceMap[ts] ?? null);
- const color = TICKER_COLORS[ticker] || '#ffffff';
+ const color = contestantTickerColors[ticker] || TICKER_COLORS[ticker] || '#ffffff';
const lastPrice = data.filter(v => v !== null).at(-1);
return { ticker, data, color, lastPrice };
});
@@ -2002,9 +2159,19 @@
// Sort legend by last price desc
const sorted = [...datasets].sort((a, b) => (b.lastPrice || 0) - (a.lastPrice || 0));
- // Build legend with % change from earliest to latest
+ // 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 = sorted.map(d => {
+ legend.innerHTML = sortedLegend.map(d => {
const validPrices = d.data.filter(v => v !== null);
const earliest = validPrices[0];
const latest = validPrices[validPrices.length - 1];
@@ -2015,9 +2182,31 @@
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 ? `
${job}
` : '',
+ endorsements!== undefined ? `
⬡ ${endorsements.toLocaleString()} endorsements
` : '',
+ eliminated ? `
ELIMINATED
` : '',
+ ].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
+ ? `
+

+ ${tooltipLines ? `
${tooltipLines}
` : ''}
+
`
+ : `
`;
+
return `
-
-
+
+ ${avatarHtml}
${d.ticker}
${d.lastPrice ?? '—'}
${changePct !== null ? `${arrow} ${changePct}` : ''}
@@ -2260,15 +2449,16 @@
label.textContent = name.toUpperCase();
if (hlsInstances['featured']) hlsInstances['featured'].destroy();
hlsInstances['featured'] = makeHls(slug, video, false);
- video.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) video.volume = parseFloat(s.value); }, { once: true });
+ 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 so clips target the new camera
+ // Restart buffer for new camera — reset audio context too (new video element)
stopBuffer();
- bufferChunks = [];
- bufferInitChunk = null;
+ 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) {
@@ -2330,94 +2520,178 @@
// ── Rolling 60s buffer (for CLIP) ──────────────────────────
let bufferRecorder = null;
- let bufferChunks = []; // { data, ts }
- let bufferInitChunk = null; // WebM init segment, kept permanently
- const BUFFER_SECS = 65; // keep a bit extra
-
- let mirrorVideo = null;
- let mirrorCanvas = null;
- let mirrorCanvasInterval = null;
+ 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') bufferRecorder.stop();
+ if (bufferRecorder && bufferRecorder.state !== 'inactive') {
+ try { bufferRecorder.stop(); } catch(e) {}
+ }
bufferRecorder = null;
bufferChunks = [];
- if (mirrorCanvasInterval) { clearInterval(mirrorCanvasInterval); mirrorCanvasInterval = null; }
- if (mirrorCanvas) { mirrorCanvas.remove(); mirrorCanvas = null; }
- if (mirrorVideo) { if (mirrorVideo._hls) mirrorVideo._hls.destroy(); mirrorVideo.remove(); mirrorVideo = null; }
+ bufferInitChunk = null;
+ // canvas and audioCtx are reused — only cleared on camera switch
}
- function getRecordStream() {
+ function getOrCreateCanvasStream() {
const wrap = document.getElementById('camFeaturedWrap');
if (!wrap) return null;
const featVideo = wrap.querySelector('video');
if (!featVideo) return null;
- if (mirrorCanvasInterval) { clearInterval(mirrorCanvasInterval); mirrorCanvasInterval = null; }
- if (mirrorCanvas) { mirrorCanvas.remove(); mirrorCanvas = null; }
+ // Always use a 960x540 canvas for the buffer — fixes background tab throttling
+ // and ensures consistent 540p output regardless of source resolution
+ if (!bufferCanvas || !bufferCanvas.isConnected) {
+ if (bufferCanvas) bufferCanvas.remove();
+ bufferCanvas = document.createElement('canvas');
+ bufferCanvas.width = 960;
+ bufferCanvas.height = 540;
+ bufferCanvas.style.cssText = 'position:fixed;top:-9999px;';
+ document.body.appendChild(bufferCanvas);
+ }
+ const ctx = bufferCanvas.getContext('2d');
+ if (bufferCanvasInterval) clearInterval(bufferCanvasInterval);
- mirrorCanvas = document.createElement('canvas');
- mirrorCanvas.width = 960;
- mirrorCanvas.height = 540;
- mirrorCanvas.style.cssText = 'position:fixed;top:-9999px;';
- document.body.appendChild(mirrorCanvas);
- const ctx = mirrorCanvas.getContext('2d');
-
- mirrorCanvasInterval = setInterval(() => {
- if (featVideo.readyState >= 2) {
- ctx.drawImage(featVideo, 0, 0, 960, 540);
+ function drawFrame() {
+ if (!bufferCanvas || !bufferCanvas.isConnected) return;
+ if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 960, 540);
+ if (typeof featVideo.requestVideoFrameCallback === 'function') {
+ featVideo.requestVideoFrameCallback(drawFrame);
}
- }, 1000 / 30);
-
- const canvasStream = mirrorCanvas.captureStream(30);
-
- // Always capture audio into the buffer — stripping happens at export time
- try {
- const audioCtx = new AudioContext();
- const source = audioCtx.createMediaElementSource(featVideo);
- const dest = audioCtx.createMediaStreamDestination();
- source.connect(dest);
- source.connect(audioCtx.destination);
- dest.stream.getAudioTracks().forEach(t => canvasStream.addTrack(t));
- } catch(e) {
- console.warn('Audio capture failed (stream may not have audio):', e);
+ }
+ if (typeof featVideo.requestVideoFrameCallback === 'function') {
+ featVideo.requestVideoFrameCallback(drawFrame);
+ } else {
+ bufferCanvasInterval = setInterval(() => {
+ if (featVideo.readyState >= 2) ctx.drawImage(featVideo, 0, 0, 960, 540);
+ }, 1000 / 30);
}
- return canvasStream;
+ 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();
- const stream = getRecordStream();
+ stopBuffer(); // clears recorder + chunks, keeps canvas + audioCtx
+
+ const stream = getOrCreateCanvasStream();
if (!stream) return;
- const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';
- try { bufferRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
- catch(e) { return; }
- bufferChunks = [];
- bufferInitChunk = null; // reset so first chunk of new recorder is captured as init
+
+ 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 (!bufferInitChunk) {
- // First chunk is always the WebM init segment — store separately
- bufferInitChunk = e.data;
+ 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 = (mimeType) => {
+
+ 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();
+ }
}
- let sizeLimitEnabled = false;
- const SIZE_LIMIT_MB = 3.9;
+ // 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
+ setTimeout(() => { stopBuffer(); bufferAudioCtx = null; 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');
@@ -2441,92 +2715,7 @@
}, 'image/png');
}
- function toggleSizeLimit() {
- sizeLimitEnabled = !sizeLimitEnabled;
- const btn = document.getElementById('sizeLimitToggle');
- if (sizeLimitEnabled) {
- btn.style.color = 'var(--accent3)';
- btn.style.borderColor = 'var(--accent3)';
- } else {
- btn.style.color = 'var(--muted)';
- btn.style.borderColor = 'var(--muted)';
- }
- }
- // ── Record (forward, manual stop) ──────────────────────────
- let mediaRecorder = null;
- let clipChunks = [];
- let clipTimeout = null;
-
- function startRecord() {
- ensureBuffer();
- const btn = document.getElementById('recordBtn');
- const wrap = document.getElementById('camFeaturedWrap');
- if (!wrap) return;
- const video = wrap.querySelector('video');
- if (!video) return;
-
- if (mediaRecorder && mediaRecorder.state === 'recording') {
- stopRecord();
- return;
- }
-
- const stream = getRecordStream();
- if (!stream) { alert('Could not capture stream'); return; }
-
- clipChunks = [];
- const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';
- try { mediaRecorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); }
- catch(e) { alert('MediaRecorder error: ' + e.message); return; }
-
- mediaRecorder.ondataavailable = e => {
- if (e.data && e.data.size > 0) {
- clipChunks.push(e.data);
- const totalBytes = clipChunks.reduce((s, c) => s + c.size, 0);
- const mb = (totalBytes / 1048576).toFixed(1);
- const sizeEl = document.getElementById('recSize');
- if (sizeEl) {
- sizeEl.textContent = mb + ' MB';
- sizeEl.style.color = parseFloat(mb) > 3.5 ? 'var(--accent2)' : '#e2e8f0';
- }
- if (sizeLimitEnabled && totalBytes >= (SIZE_LIMIT_MB - 0.3) * 1048576) {
- stopRecord();
- }
- }
- };
-
- mediaRecorder.onstop = () => {
- const blob = new Blob(clipChunks, { type: mimeType });
- const url = URL.createObjectURL(blob);
- const wrap = document.getElementById('camFeaturedWrap');
- const label = wrap ? wrap.querySelector('.cam-label') : null;
- const camName = label ? label.textContent.replace(/[^a-z0-9]/gi, '_').toLowerCase() : 'rec';
- const a = document.createElement('a');
- a.href = url;
- a.download = camName + '_rec_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
- a.click();
- URL.revokeObjectURL(url);
- btn.textContent = '⏺ REC';
- btn.style.borderColor = '';
- btn.style.color = '';
- mediaRecorder = null;
- if (clipTimeout) { clearTimeout(clipTimeout); clipTimeout = null; }
- const sizeEl = document.getElementById('recSize');
- if (sizeEl) sizeEl.style.display = 'none';
- };
-
- mediaRecorder.start(250);
- btn.textContent = '⏹ STOP';
- btn.style.borderColor = 'var(--accent2)';
- btn.style.color = 'var(--accent2)';
- const sizeEl = document.getElementById('recSize');
- if (sizeEl) { sizeEl.textContent = '0.0 MB'; sizeEl.style.display = ''; }
- clipTimeout = setTimeout(() => stopRecord(), 3600000);
- }
-
- function stopRecord() {
- if (mediaRecorder && mediaRecorder.state === 'recording') mediaRecorder.stop();
- }
function ensureBuffer(force) {
if (force || !bufferRecorder || bufferRecorder.state === 'inactive') {
@@ -2575,7 +2764,6 @@
Exporting...
-
@@ -2608,7 +2796,7 @@
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);
+ 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) + '%';
@@ -2659,7 +2847,6 @@
playbackWatcher = setInterval(() => {
if (video.currentTime >= outTime - 0.05) {
stopPlayback();
- video.currentTime = parseFloat(inSlider.value);
}
}, 50);
}, { once: true });
@@ -2667,45 +2854,21 @@
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
- // Audio toggle state for this clip session
- let clipIncludeAudio = true;
- video._includeAudio = true;
-
- // Set up audio routing once — createMediaElementSource can only be called once per element
- let clipAudioDest = null;
+ // Set up audio routing for preview — createMediaElementSource can only be called once
try {
const clipAudioCtx = new AudioContext();
const clipSrc = clipAudioCtx.createMediaElementSource(video);
- clipAudioDest = clipAudioCtx.createMediaStreamDestination();
+ const clipAudioDest = clipAudioCtx.createMediaStreamDestination();
clipSrc.connect(clipAudioDest);
- clipSrc.connect(clipAudioCtx.destination); // so preview plays audio
+ clipSrc.connect(clipAudioCtx.destination); // preview plays audio at full volume
video._audioStream = clipAudioDest.stream;
} catch(e) {
console.warn('Clip editor audio setup failed:', e);
}
- window._toggleClipAudio = function() {
- clipIncludeAudio = !clipIncludeAudio;
- video._includeAudio = clipIncludeAudio;
- const btn = document.getElementById('clipAudioBtn');
- if (clipIncludeAudio) {
- btn.textContent = '🔊 AUDIO';
- btn.style.borderColor = 'var(--green)';
- btn.style.color = 'var(--green)';
- } else {
- btn.textContent = '🔇 NO AUDIO';
- btn.style.borderColor = 'var(--muted)';
- btn.style.color = 'var(--muted)';
- }
- };
-
video.load();
}
- function toggleClipAudio() {
- if (window._toggleClipAudio) window._toggleClipAudio();
- }
-
function closeClipEditor() {
const backdrop = document.querySelector('.clip-modal-backdrop');
if (!backdrop) return;
@@ -2719,14 +2882,35 @@
setTimeout(() => ensureBuffer(), 500);
}
- function downloadClipFull() {
+ async function downloadClipFull() {
const video = document.getElementById('clipEditorVideo');
if (!video) return;
- // Note: SAVE FULL always includes audio if buffered — use SAVE TRIMMED to strip audio
+ 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 = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
- a.click();
+ 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();
}
@@ -2754,7 +2938,7 @@
const camName = video._camName;
const blobUrl = video._blobUrl;
- const includeAudio = video._includeAudio;
+ 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;
@@ -2762,7 +2946,7 @@
// Use a hidden video element for export — fresh audio context, independent of preview
const ev = document.createElement('video');
ev.src = blobUrl;
- ev.muted = false;
+ 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);
@@ -2771,8 +2955,9 @@
: (MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm');
function startRecording() {
- canvas.width = ev.videoWidth || 960;
- canvas.height = ev.videoHeight || 540;
+ // Export at 540p regardless of source resolution — maximises duration at given bitrate
+ canvas.width = 960;
+ canvas.height = 540;
const stream = canvas.captureStream(30);
@@ -2786,19 +2971,40 @@
} catch(e) { console.warn('Export audio setup failed:', e); }
}
- const recorder = new MediaRecorder(stream, { mimeType: exportMimeType, videoBitsPerSecond: 4000000 });
+ 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 = () => {
+ 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 = camName + '_trimmed_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
- a.click();
+ 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();
};
@@ -2833,12 +3039,7 @@
}
// ── Record (forward, manual stop) ──────────────────────────
- let recordAudio = false;
- function toggleRecordAudio() {
- // Kept for REC button compatibility — buffer always records audio now
- recordAudio = !recordAudio;
- }
@@ -2992,7 +3193,7 @@
}
});
hlsInstances['featured'] = makeHls(CAMERAS[DEFAULT_IDX][1], featVideo, false);
- featVideo.addEventListener('canplay', () => { const s = document.getElementById('featuredVolume'); if (s) featVideo.volume = parseFloat(s.value); }, { once: true });
+ 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");
@@ -3046,7 +3247,17 @@
const wrap = document.getElementById('camFeaturedWrap');
if (!wrap) return;
const video = wrap.querySelector('video');
- if (video) video.volume = parseFloat(val);
+ 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() {
@@ -3058,7 +3269,40 @@
async function slowTick() {
if (!getToken()) return;
- await fetchStocks();
+ 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() {
@@ -3066,6 +3310,7 @@
fetchPoll();
fetchTTSHistory();
fetchFeatureToggles();
+ fetchContestants();
slowTick();
if (!slowIntervalId) {
slowIntervalId = setInterval(slowTick, 60000);
@@ -3108,13 +3353,13 @@
// Apply saved thumbnail interval from dropdown
const iv = document.getElementById('intervalSelect');
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
- // Recalc on resize
- window.addEventListener('resize', recalcGridRows);
- window.addEventListener('load', recalcGridRows);
+ // 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(() => {
- recalcGridRows();
+ if (window.recalcGridRows) window.recalcGridRows();
if (++_recalcCount >= 6) clearInterval(_recalcInit);
}, 500);
});
@@ -3218,7 +3463,7 @@
}
if (msg._ft === 'event') {
- console.log(`[FT-WS EVENT] "${msg.event}"`, msg.data);
+ if (msg.event !== 'chat:message') console.log(`[FT-WS EVENT] "${msg.event}"`, msg.data);
if (msg.event === 'notification:global') {
const [message, subtitle] = Array.isArray(msg.data) ? msg.data : [msg.data, ''];
if (message) showNotif(message, subtitle || '');
@@ -3324,7 +3569,7 @@
let notifTimer = null;
// ── Grid row recalculation ───────────────────────────────────
- function recalcGridRows() {
+ window.recalcGridRows = function recalcGridRows() {
const grid = document.getElementById('cameraGrid');
if (!grid) return;
const gridW = grid.clientWidth;
@@ -3350,7 +3595,7 @@
const capH = visibleRows * thumbH + (visibleRows - 1) * gap + pad * 2;
grid.style.height = capH + 'px';
grid.style.maxHeight = capH + 'px';
- }
+ };
// ── End grid row recalculation ────────────────────────────────
// ── Stocks panel collapse ────────────────────────────────────
diff --git a/server.js b/server.js
index 880b3ce..07c284c 100644
--- a/server.js
+++ b/server.js
@@ -4,6 +4,8 @@ const fs = require('fs');
const path = require('path');
const url = require('url');
const zlib = require('zlib');
+const os = require('os');
+const { execFile } = require('child_process');
const WebSocket = require('ws');
const PORT = 3000;
@@ -424,6 +426,61 @@ const server = http.createServer((req, res) => {
}); return;
}
+ if (parsed.pathname === '/strip-audio') {
+ if (req.method === 'POST') {
+ // Accept webm upload, strip audio with ffmpeg, return result
+ const chunks = [];
+ req.on('data', c => chunks.push(c));
+ req.on('end', () => {
+ const inputBuf = Buffer.concat(chunks);
+ const tmpIn = path.join(os.tmpdir(), 'ft_strip_in_' + Date.now() + '.webm');
+ const tmpOut = path.join(os.tmpdir(), 'ft_strip_out_' + Date.now() + '.webm');
+ fs.writeFile(tmpIn, inputBuf, err => {
+ if (err) { res.writeHead(500); res.end('Write error'); return; }
+ // -an = no audio, -c:v copy = copy video stream without re-encoding
+ // Find ffmpeg — try PATH first, then common Windows locations
+ const ffmpegCandidates = [
+ 'ffmpeg',
+ 'ffmpeg.exe',
+ 'C:\\ffmpeg\\bin\\ffmpeg.exe',
+ 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',
+ process.env.APPDATA + '\\ffmpeg\\bin\\ffmpeg.exe',
+ ];
+ const ffmpegBin = ffmpegCandidates[0]; // will try with shell:true to use PATH
+ console.log('[STRIP] running ffmpeg:', tmpIn, '->', tmpOut);
+ execFile(ffmpegBin, ['-y', '-i', tmpIn, '-an', '-c:v', 'copy', tmpOut],
+ { shell: true }, (err, stdout, stderr) => {
+ console.log('[STRIP] ffmpeg done. err:', err && err.code, 'stderr:', stderr && stderr.slice(0,300));
+ fs.unlink(tmpIn, () => {});
+ if (err) {
+ fs.unlink(tmpOut, () => {});
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('FFmpeg error: ' + (stderr || err.message || 'unknown'));
+ return;
+ }
+ fs.readFile(tmpOut, (err, data) => {
+ fs.unlink(tmpOut, () => {});
+ if (err) { res.writeHead(500); res.end('Read error'); return; }
+ res.writeHead(200, {
+ 'Content-Type': 'video/webm',
+ 'Content-Disposition': 'attachment; filename="noaudio.webm"',
+ 'Content-Length': data.length,
+ 'access-control-allow-origin': '*',
+ });
+ res.end(data);
+ });
+ });
+ });
+ }); return;
+ }
+ const file = path.join(__dirname, 'strip-audio.html');
+ fs.readFile(file, (err, data) => {
+ if (err) { res.writeHead(404); res.end('Tool not found'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(data);
+ }); return;
+ }
+
// Token registration from dashboard
if (parsed.pathname === '/ws-token' && req.method === 'POST') {
let body = '';
@@ -480,7 +537,6 @@ const server = http.createServer((req, res) => {
const rawText = body.toString('utf8');
// Log first segment line to debug path format
const firstSeg = rawText.split('\n').find(l => l.trim() && !l.startsWith('#'));
- console.log('[CAM] sub-playlist first line:', JSON.stringify(firstSeg));
const basePath = slug + '/' + parts.slice(1, -1).join('/') + '/';
const rewritten = rewriteM3u8(rawText, basePath);
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl', 'access-control-allow-origin': '*', 'cache-control': 'no-cache' });