From 46e2c58ba851f60075f0a041045090905ff210bb Mon Sep 17 00:00:00 2001 From: fishtank-dashboard Date: Wed, 18 Mar 2026 14:41:25 -0700 Subject: [PATCH] clipping improvements --- fishtank-dashboard.html | 284 +++++++++++++++++++++++++++++----------- 1 file changed, 206 insertions(+), 78 deletions(-) diff --git a/fishtank-dashboard.html b/fishtank-dashboard.html index da39556..1150120 100644 --- a/fishtank-dashboard.html +++ b/fishtank-dashboard.html @@ -323,11 +323,20 @@ grid-template-columns: repeat(9, 1fr); gap: 2px; padding: 2px; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; flex: 0 0 auto; - /* Row height set dynamically by JS to match actual panel width */ + /* Default 2-row cap using vw — JS overrides with exact px once rendered */ + --thumb-h: calc((100vw - 322px) / 9 * 9 / 16); + grid-template-rows: repeat(3, var(--thumb-h)); + height: calc(var(--thumb-h) * 2 + 6px); + max-height: calc(var(--thumb-h) * 2 + 6px); } + .camera-grid::-webkit-scrollbar { width: 4px; } + .camera-grid::-webkit-scrollbar-track { background: transparent; } + .camera-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + .camera-grid::-webkit-scrollbar { width: 4px; } @@ -1307,11 +1316,15 @@ // 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); + // Recalc on resize window.addEventListener('resize', recalcGridRows); - // Set initial grid row height - setTimeout(recalcGridRows, 100); + window.addEventListener('load', recalcGridRows); + // Poll recalc for 3s after load to catch any layout settling + let _recalcCount = 0; + const _recalcInit = setInterval(() => { + recalcGridRows(); + if (++_recalcCount >= 6) clearInterval(_recalcInit); + }, 500); }); function initCamerman() { @@ -1392,11 +1405,15 @@ // 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); + // Recalc on resize window.addEventListener('resize', recalcGridRows); - // Set initial grid row height - setTimeout(recalcGridRows, 100); + window.addEventListener('load', recalcGridRows); + // Poll recalc for 3s after load to catch any layout settling + let _recalcCount = 0; + const _recalcInit = setInterval(() => { + recalcGridRows(); + if (++_recalcCount >= 6) clearInterval(_recalcInit); + }, 500); }); function initCamerman() { @@ -1573,7 +1590,7 @@ - + VOL
@@ -2130,6 +2147,7 @@ ["Market", "mrke-5"], ["Hallway Down", "hwdn-5"], ["Hallway Up", "hwup-5"], + ["Jungle Room", "br4j-5"], ["Cameraman", "cameraman-5"], ]; @@ -2141,6 +2159,7 @@ "brrr2-5": "Bar Alternate", "dmrm2-5": "Dorm Alternate", "jckz-5": "Jacuzzi", "dnrm-5": "Dining Room", "mrke-5": "Market", "hwdn-5": "Hallway Down", "hwup-5": "Hallway Up", + "br4j-5": "Jungle Room", "cameraman-5": "Cameraman", "site": "Site-wide", }; @@ -2352,17 +2371,16 @@ const canvasStream = mirrorCanvas.captureStream(30); - if (recordAudio) { - try { - const audioCtx = new AudioContext(); - const source = audioCtx.createMediaElementSource(featVideo); - const dest = audioCtx.createMediaStreamDestination(); - source.connect(dest); - source.connect(audioCtx.destination); - dest.stream.getAudioTracks().forEach(t => canvasStream.addTrack(t)); - } catch(e) { - console.warn('Audio capture failed:', e); - } + // 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); } return canvasStream; @@ -2557,7 +2575,8 @@ Exporting... - + + `; @@ -2647,9 +2666,46 @@ } 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; + try { + const clipAudioCtx = new AudioContext(); + const clipSrc = clipAudioCtx.createMediaElementSource(video); + clipAudioDest = clipAudioCtx.createMediaStreamDestination(); + clipSrc.connect(clipAudioDest); + clipSrc.connect(clipAudioCtx.destination); // so preview plays audio + 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; @@ -2666,6 +2722,7 @@ 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 a = document.createElement('a'); a.href = video._blobUrl; a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm'; @@ -2695,23 +2752,47 @@ video.pause(); video.muted = true; - const mimeType = video._mimeType; const camName = video._camName; + const blobUrl = video._blobUrl; + const includeAudio = video._includeAudio; 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; + // 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.style.cssText = 'position:fixed;top:-9999px;width:1px;height:1px;'; + document.body.appendChild(ev); - const stream = canvas.captureStream(30); - const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 }); + const exportMimeType = includeAudio + ? (['video/webm;codecs=vp9,opus','video/webm;codecs=vp8,opus','video/webm'].find(m => MediaRecorder.isTypeSupported(m)) || 'video/webm') + : (MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm'); + + function startRecording() { + canvas.width = ev.videoWidth || 960; + canvas.height = ev.videoHeight || 540; + + const stream = canvas.captureStream(30); + + if (includeAudio) { + try { + const audioCtx = new AudioContext(); + const src = audioCtx.createMediaElementSource(ev); + const dest = audioCtx.createMediaStreamDestination(); + src.connect(dest); // route to recorder only — not speakers + dest.stream.getAudioTracks().forEach(t => stream.addTrack(t)); + } catch(e) { console.warn('Export audio setup failed:', e); } + } + + const recorder = new MediaRecorder(stream, { mimeType: exportMimeType, videoBitsPerSecond: 4000000 }); const chunks = []; recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); }; recorder.onstop = () => { - const trimmed = new Blob(chunks, { type: mimeType }); + ev.remove(); + const trimmed = new Blob(chunks, { type: exportMimeType }); const url = URL.createObjectURL(trimmed); const a = document.createElement('a'); a.href = url; @@ -2725,48 +2806,38 @@ recorder.start(100); drawInterval = setInterval(() => { - if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - const remaining = outTime - video.currentTime; + if (ev.readyState >= 2) ctx.drawImage(ev, 0, 0, canvas.width, canvas.height); + const remaining = outTime - ev.currentTime; exportLabel.textContent = 'Exporting... ' + Math.max(0, remaining).toFixed(1) + 's'; - if (video.currentTime >= outTime - 0.05) { + if (ev.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(() => {}); + ev.addEventListener('loadedmetadata', () => { + ev.currentTime = inTime; + ev.addEventListener('seeked', function onSeeked() { + ev.removeEventListener('seeked', onSeeked); + if (ev.readyState >= 2) ctx.drawImage(ev, 0, 0, canvas.width || ev.videoWidth || 960, canvas.height || ev.videoHeight || 540); + startRecording(); + ev.play().catch(() => {}); + }, { once: true }); }, { once: true }); + ev.load(); } // ── Record (forward, manual stop) ────────────────────────── let recordAudio = false; function toggleRecordAudio() { + // Kept for REC button compatibility — buffer always records audio now recordAudio = !recordAudio; - const btn = document.getElementById('audioToggle'); - if (recordAudio) { - btn.textContent = '🔊 AUDIO'; - btn.style.borderColor = 'var(--green)'; - btn.style.color = 'var(--green)'; - } else { - btn.textContent = '🔇 MUTED'; - btn.style.borderColor = 'var(--muted)'; - btn.style.color = 'var(--muted)'; - } - // Audio setting applies on next clip/record } @@ -2946,6 +3017,7 @@ refreshAllThumbnails(); if (window._thumbInterval) clearInterval(window._thumbInterval); window._thumbInterval = setInterval(refreshAllThumbnails, 30000); + } @@ -3036,11 +3108,15 @@ // 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); + // Recalc on resize window.addEventListener('resize', recalcGridRows); - // Set initial grid row height - setTimeout(recalcGridRows, 100); + window.addEventListener('load', recalcGridRows); + // Poll recalc for 3s after load to catch any layout settling + let _recalcCount = 0; + const _recalcInit = setInterval(() => { + recalcGridRows(); + if (++_recalcCount >= 6) clearInterval(_recalcInit); + }, 500); }); function initCamerman() { @@ -3152,6 +3228,7 @@ } if (msg.event === 'presence') { window._lastPresenceData = msg.data; + autoDiscoverCameras(msg.data); updateViewerCounts(msg.data); } @@ -3246,35 +3323,33 @@ // ── 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; + if (!gridW) { + requestAnimationFrame(recalcGridRows); + return; + } const cols = 9; const gap = 2; const pad = 2; const thumbW = (gridW - pad * 2 - gap * (cols - 1)) / cols; const thumbH = Math.floor(thumbW * 9 / 16); - grid.style.gridTemplateRows = `repeat(2, ${thumbH}px)`; + + // Count actual grid cells (excludes director + cameraman which aren't in grid) + const cellCount = grid.querySelectorAll('.cam-cell').length; + const totalRows = Math.ceil(cellCount / cols); + + // All rows same height + grid.style.gridTemplateRows = `repeat(${totalRows}, ${thumbH}px)`; + + // Cap visible height at 2 rows — set both height and maxHeight for flex containers + const visibleRows = 2; + const capH = visibleRows * thumbH + (visibleRows - 1) * gap + pad * 2; + grid.style.height = capH + 'px'; + grid.style.maxHeight = capH + 'px'; } // ── End grid row recalculation ──────────────────────────────── @@ -3296,7 +3371,6 @@ btn.textContent = '◀ POLL / TTS'; btn.title = 'Hide poll and TTS panels'; } - [50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d)); }; window.toggleStocks = function toggleStocks() { @@ -3313,10 +3387,64 @@ btn.title = 'Hide stocks panel'; if (stocksChart) { stocksChart.resize(); } } - [50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d)); }; // ── End stocks panel collapse ───────────────────────────────── + // ── Camera auto-discovery ──────────────────────────────────── + const KNOWN_CAMERA_SLUGS = new Set(CAMERAS.map(([, s]) => s)); + + function slugToLabel(slug) { + // "br4j-5" -> "BR4J" — strip season suffix, uppercase, max 4 chars + return slug.replace(/-\d+$/, '').toUpperCase().slice(0, 4); + } + + function autoDiscoverCameras(presenceData) { + // presence keys are camera slugs (plus "total") + const knownSpecial = new Set(['total', 'cameraman-5']); + let added = false; + + Object.keys(presenceData).forEach(slug => { + if (knownSpecial.has(slug)) return; + if (KNOWN_CAMERA_SLUGS.has(slug)) return; + + // New camera found — add before Cameraman + console.log('[CAM] Auto-discovered new camera:', slug); + KNOWN_CAMERA_SLUGS.add(slug); + const label = slugToLabel(slug); + const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman-5'); + CAMERAS.splice(cammanIdx, 0, [label, slug]); + added = true; + }); + + recalcGridRows(); + if (added) { + // Add new cells to the grid without destroying existing streams + const grid = document.getElementById('cameraGrid'); + const cammanIdx = CAMERAS.findIndex(([, s]) => s === 'cameraman-5'); + CAMERAS.forEach(([name, slug], i) => { + if (i === DEFAULT_IDX || i === cammanIdx) return; + if (document.getElementById('cam-' + i)) return; // already exists + const cell = document.createElement('div'); + cell.className = 'cam-cell'; + cell.id = 'cam-' + i; + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'width:100%;height:100%;display:block;object-fit:cover;'; + canvas.id = 'canvas-' + i; + const label = document.createElement('div'); + label.className = 'cam-label'; + label.textContent = name.toUpperCase(); + cell.appendChild(canvas); + cell.appendChild(label); + cell.addEventListener('click', () => setFeatured(i)); + grid.appendChild(cell); + // Kick off a thumbnail capture for the new cell + captureThumb(slug, 'canvas-' + i); + }); + recalcGridRows(); + } + } + // ── End camera auto-discovery ───────────────────────────────── + // ── Viewer counts ──────────────────────────────────────────── window.updateViewerCounts = function(data) { const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1;