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 @@
CAMERAS
- - - 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' });