mirror of
https://github.com/fishtank-dashboard/fishtank-dashboard.git
synced 2026-04-30 10:42:02 -04:00
clipping improvements
This commit is contained in:
committed by
GitHub
parent
2852918a3f
commit
46e2c58ba8
@@ -323,11 +323,20 @@
|
|||||||
grid-template-columns: repeat(9, 1fr);
|
grid-template-columns: repeat(9, 1fr);
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
overflow: hidden;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
flex: 0 0 auto;
|
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; }
|
.camera-grid::-webkit-scrollbar { width: 4px; }
|
||||||
@@ -1307,11 +1316,15 @@
|
|||||||
// Apply saved thumbnail interval from dropdown
|
// Apply saved thumbnail interval from dropdown
|
||||||
const iv = document.getElementById('intervalSelect');
|
const iv = document.getElementById('intervalSelect');
|
||||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||||
// Set initial grid row heights and recalc on resize
|
// Recalc on resize
|
||||||
setTimeout(recalcGridRows, 100);
|
|
||||||
window.addEventListener('resize', recalcGridRows);
|
window.addEventListener('resize', recalcGridRows);
|
||||||
// Set initial grid row height
|
window.addEventListener('load', recalcGridRows);
|
||||||
setTimeout(recalcGridRows, 100);
|
// 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() {
|
function initCamerman() {
|
||||||
@@ -1392,11 +1405,15 @@
|
|||||||
// Apply saved thumbnail interval from dropdown
|
// Apply saved thumbnail interval from dropdown
|
||||||
const iv = document.getElementById('intervalSelect');
|
const iv = document.getElementById('intervalSelect');
|
||||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||||
// Set initial grid row heights and recalc on resize
|
// Recalc on resize
|
||||||
setTimeout(recalcGridRows, 100);
|
|
||||||
window.addEventListener('resize', recalcGridRows);
|
window.addEventListener('resize', recalcGridRows);
|
||||||
// Set initial grid row height
|
window.addEventListener('load', recalcGridRows);
|
||||||
setTimeout(recalcGridRows, 100);
|
// 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() {
|
function initCamerman() {
|
||||||
@@ -1573,7 +1590,7 @@
|
|||||||
<button class="range-btn" onclick="takeScreenshot()" style="font-size:10px;">📷 SNAP</button>
|
<button class="range-btn" onclick="takeScreenshot()" style="font-size:10px;">📷 SNAP</button>
|
||||||
<button class="range-btn" id="clipBtn" onclick="saveClip()" style="font-size:10px;">✂ CLIP</button>
|
<button class="range-btn" id="clipBtn" onclick="saveClip()" style="font-size:10px;">✂ CLIP</button>
|
||||||
<button class="range-btn" id="recordBtn" onclick="startRecord()" style="font-size:10px;">⏺ REC</button>
|
<button class="range-btn" id="recordBtn" onclick="startRecord()" style="font-size:10px;">⏺ REC</button>
|
||||||
<button class="range-btn" id="audioToggle" onclick="toggleRecordAudio()" style="font-size:10px;border-color:var(--muted);color:var(--muted);">🔇 MUTED</button>
|
|
||||||
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">VOL</span>
|
<span style="font-family:'Share Tech Mono',monospace;font-size:10px;color:var(--muted);">VOL</span>
|
||||||
<input type="range" id="featuredVolume" min="0" max="1" step="0.01" value="1" style="width:120px;accent-color:var(--accent);cursor:pointer;" oninput="setFeaturedVolume(this.value)">
|
<input type="range" id="featuredVolume" min="0" max="1" step="0.01" value="1" style="width:120px;accent-color:var(--accent);cursor:pointer;" oninput="setFeaturedVolume(this.value)">
|
||||||
<div class="panel-meta" id="camMeta">—</div>
|
<div class="panel-meta" id="camMeta">—</div>
|
||||||
@@ -2130,6 +2147,7 @@
|
|||||||
["Market", "mrke-5"],
|
["Market", "mrke-5"],
|
||||||
["Hallway Down", "hwdn-5"],
|
["Hallway Down", "hwdn-5"],
|
||||||
["Hallway Up", "hwup-5"],
|
["Hallway Up", "hwup-5"],
|
||||||
|
["Jungle Room", "br4j-5"],
|
||||||
["Cameraman", "cameraman-5"],
|
["Cameraman", "cameraman-5"],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -2141,6 +2159,7 @@
|
|||||||
"brrr2-5": "Bar Alternate", "dmrm2-5": "Dorm Alternate", "jckz-5": "Jacuzzi",
|
"brrr2-5": "Bar Alternate", "dmrm2-5": "Dorm Alternate", "jckz-5": "Jacuzzi",
|
||||||
"dnrm-5": "Dining Room", "mrke-5": "Market", "hwdn-5": "Hallway Down",
|
"dnrm-5": "Dining Room", "mrke-5": "Market", "hwdn-5": "Hallway Down",
|
||||||
"hwup-5": "Hallway Up",
|
"hwup-5": "Hallway Up",
|
||||||
|
"br4j-5": "Jungle Room",
|
||||||
"cameraman-5": "Cameraman", "site": "Site-wide",
|
"cameraman-5": "Cameraman", "site": "Site-wide",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2352,17 +2371,16 @@
|
|||||||
|
|
||||||
const canvasStream = mirrorCanvas.captureStream(30);
|
const canvasStream = mirrorCanvas.captureStream(30);
|
||||||
|
|
||||||
if (recordAudio) {
|
// Always capture audio into the buffer — stripping happens at export time
|
||||||
try {
|
try {
|
||||||
const audioCtx = new AudioContext();
|
const audioCtx = new AudioContext();
|
||||||
const source = audioCtx.createMediaElementSource(featVideo);
|
const source = audioCtx.createMediaElementSource(featVideo);
|
||||||
const dest = audioCtx.createMediaStreamDestination();
|
const dest = audioCtx.createMediaStreamDestination();
|
||||||
source.connect(dest);
|
source.connect(dest);
|
||||||
source.connect(audioCtx.destination);
|
source.connect(audioCtx.destination);
|
||||||
dest.stream.getAudioTracks().forEach(t => canvasStream.addTrack(t));
|
dest.stream.getAudioTracks().forEach(t => canvasStream.addTrack(t));
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.warn('Audio capture failed:', e);
|
console.warn('Audio capture failed (stream may not have audio):', e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return canvasStream;
|
return canvasStream;
|
||||||
@@ -2557,7 +2575,8 @@
|
|||||||
<span id="clipExportLabel">Exporting...</span>
|
<span id="clipExportLabel">Exporting...</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
|
<button class="btn stop" onclick="closeClipEditor()">CANCEL</button>
|
||||||
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()">SAVE FULL</button>
|
<button class="btn" id="clipAudioBtn" onclick="toggleClipAudio()" style="border-color:var(--green);color:var(--green);">🔊 AUDIO</button>
|
||||||
|
<button class="btn" id="clipSaveFullBtn" onclick="downloadClipFull()" title="Saves full buffer — audio always included">SAVE FULL</button>
|
||||||
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
|
<button class="btn" id="clipSaveTrimBtn" onclick="exportTrimmedClip()">SAVE TRIMMED</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -2647,9 +2666,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
video.addEventListener('click', () => video.paused ? playFromIn() : stopPlayback());
|
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();
|
video.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleClipAudio() {
|
||||||
|
if (window._toggleClipAudio) window._toggleClipAudio();
|
||||||
|
}
|
||||||
|
|
||||||
function closeClipEditor() {
|
function closeClipEditor() {
|
||||||
const backdrop = document.querySelector('.clip-modal-backdrop');
|
const backdrop = document.querySelector('.clip-modal-backdrop');
|
||||||
if (!backdrop) return;
|
if (!backdrop) return;
|
||||||
@@ -2666,6 +2722,7 @@
|
|||||||
function downloadClipFull() {
|
function downloadClipFull() {
|
||||||
const video = document.getElementById('clipEditorVideo');
|
const video = document.getElementById('clipEditorVideo');
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
// Note: SAVE FULL always includes audio if buffered — use SAVE TRIMMED to strip audio
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = video._blobUrl;
|
a.href = video._blobUrl;
|
||||||
a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
a.download = video._camName + '_clip_' + new Date().toISOString().replace(/[:.]/g, '-') + '.webm';
|
||||||
@@ -2695,23 +2752,47 @@
|
|||||||
video.pause();
|
video.pause();
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
|
|
||||||
const mimeType = video._mimeType;
|
|
||||||
const camName = video._camName;
|
const camName = video._camName;
|
||||||
|
const blobUrl = video._blobUrl;
|
||||||
|
const includeAudio = video._includeAudio;
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
let drawInterval = null;
|
let drawInterval = null;
|
||||||
|
|
||||||
function startRecording() {
|
// Use a hidden video element for export — fresh audio context, independent of preview
|
||||||
canvas.width = video.videoWidth || 960;
|
const ev = document.createElement('video');
|
||||||
canvas.height = video.videoHeight || 540;
|
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 exportMimeType = includeAudio
|
||||||
const recorder = new MediaRecorder(stream, { mimeType, videoBitsPerSecond: 4000000 });
|
? (['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 = [];
|
const chunks = [];
|
||||||
|
|
||||||
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
recorder.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
const trimmed = new Blob(chunks, { type: mimeType });
|
ev.remove();
|
||||||
|
const trimmed = new Blob(chunks, { type: exportMimeType });
|
||||||
const url = URL.createObjectURL(trimmed);
|
const url = URL.createObjectURL(trimmed);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -2725,48 +2806,38 @@
|
|||||||
recorder.start(100);
|
recorder.start(100);
|
||||||
|
|
||||||
drawInterval = setInterval(() => {
|
drawInterval = setInterval(() => {
|
||||||
if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
if (ev.readyState >= 2) ctx.drawImage(ev, 0, 0, canvas.width, canvas.height);
|
||||||
const remaining = outTime - video.currentTime;
|
const remaining = outTime - ev.currentTime;
|
||||||
exportLabel.textContent = 'Exporting... ' + Math.max(0, remaining).toFixed(1) + 's';
|
exportLabel.textContent = 'Exporting... ' + Math.max(0, remaining).toFixed(1) + 's';
|
||||||
if (video.currentTime >= outTime - 0.05) {
|
if (ev.currentTime >= outTime - 0.05) {
|
||||||
clearInterval(drawInterval);
|
clearInterval(drawInterval);
|
||||||
recorder.stop();
|
recorder.stop();
|
||||||
}
|
}
|
||||||
}, 1000 / 30);
|
}, 1000 / 30);
|
||||||
|
|
||||||
// Safety net
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (recorder.state === 'recording') { clearInterval(drawInterval); recorder.stop(); }
|
if (recorder.state === 'recording') { clearInterval(drawInterval); recorder.stop(); }
|
||||||
}, (outTime - inTime + 3) * 1000);
|
}, (outTime - inTime + 3) * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek first, only start recording once seek is complete and frame is ready
|
ev.addEventListener('loadedmetadata', () => {
|
||||||
video.currentTime = inTime;
|
ev.currentTime = inTime;
|
||||||
video.addEventListener('seeked', function onSeeked() {
|
ev.addEventListener('seeked', function onSeeked() {
|
||||||
video.removeEventListener('seeked', onSeeked);
|
ev.removeEventListener('seeked', onSeeked);
|
||||||
// Draw one frame to warm up canvas, then start recording and play
|
if (ev.readyState >= 2) ctx.drawImage(ev, 0, 0, canvas.width || ev.videoWidth || 960, canvas.height || ev.videoHeight || 540);
|
||||||
if (video.readyState >= 2) ctx.drawImage(video, 0, 0, canvas.width || video.videoWidth || 960, canvas.height || video.videoHeight || 540);
|
startRecording();
|
||||||
startRecording();
|
ev.play().catch(() => {});
|
||||||
video.play().catch(() => {});
|
}, { once: true });
|
||||||
}, { once: true });
|
}, { once: true });
|
||||||
|
ev.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Record (forward, manual stop) ──────────────────────────
|
// ── Record (forward, manual stop) ──────────────────────────
|
||||||
let recordAudio = false;
|
let recordAudio = false;
|
||||||
|
|
||||||
function toggleRecordAudio() {
|
function toggleRecordAudio() {
|
||||||
|
// Kept for REC button compatibility — buffer always records audio now
|
||||||
recordAudio = !recordAudio;
|
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();
|
refreshAllThumbnails();
|
||||||
if (window._thumbInterval) clearInterval(window._thumbInterval);
|
if (window._thumbInterval) clearInterval(window._thumbInterval);
|
||||||
window._thumbInterval = setInterval(refreshAllThumbnails, 30000);
|
window._thumbInterval = setInterval(refreshAllThumbnails, 30000);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -3036,11 +3108,15 @@
|
|||||||
// Apply saved thumbnail interval from dropdown
|
// Apply saved thumbnail interval from dropdown
|
||||||
const iv = document.getElementById('intervalSelect');
|
const iv = document.getElementById('intervalSelect');
|
||||||
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
if (iv && parseInt(iv.value) !== 30000) changeInterval(iv.value);
|
||||||
// Set initial grid row heights and recalc on resize
|
// Recalc on resize
|
||||||
setTimeout(recalcGridRows, 100);
|
|
||||||
window.addEventListener('resize', recalcGridRows);
|
window.addEventListener('resize', recalcGridRows);
|
||||||
// Set initial grid row height
|
window.addEventListener('load', recalcGridRows);
|
||||||
setTimeout(recalcGridRows, 100);
|
// 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() {
|
function initCamerman() {
|
||||||
@@ -3152,6 +3228,7 @@
|
|||||||
}
|
}
|
||||||
if (msg.event === 'presence') {
|
if (msg.event === 'presence') {
|
||||||
window._lastPresenceData = msg.data;
|
window._lastPresenceData = msg.data;
|
||||||
|
autoDiscoverCameras(msg.data);
|
||||||
updateViewerCounts(msg.data);
|
updateViewerCounts(msg.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3246,35 +3323,33 @@
|
|||||||
// ── Notification popup ───────────────────────────────────────
|
// ── Notification popup ───────────────────────────────────────
|
||||||
let notifTimer = null;
|
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 ───────────────────────────────────
|
// ── Grid row recalculation ───────────────────────────────────
|
||||||
function recalcGridRows() {
|
function recalcGridRows() {
|
||||||
const grid = document.getElementById('cameraGrid');
|
const grid = document.getElementById('cameraGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
const gridW = grid.clientWidth;
|
const gridW = grid.clientWidth;
|
||||||
if (!gridW) return;
|
if (!gridW) {
|
||||||
|
requestAnimationFrame(recalcGridRows);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cols = 9;
|
const cols = 9;
|
||||||
const gap = 2;
|
const gap = 2;
|
||||||
const pad = 2;
|
const pad = 2;
|
||||||
const thumbW = (gridW - pad * 2 - gap * (cols - 1)) / cols;
|
const thumbW = (gridW - pad * 2 - gap * (cols - 1)) / cols;
|
||||||
const thumbH = Math.floor(thumbW * 9 / 16);
|
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 ────────────────────────────────
|
// ── End grid row recalculation ────────────────────────────────
|
||||||
|
|
||||||
@@ -3296,7 +3371,6 @@
|
|||||||
btn.textContent = '◀ POLL / TTS';
|
btn.textContent = '◀ POLL / TTS';
|
||||||
btn.title = 'Hide poll and TTS panels';
|
btn.title = 'Hide poll and TTS panels';
|
||||||
}
|
}
|
||||||
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.toggleStocks = function toggleStocks() {
|
window.toggleStocks = function toggleStocks() {
|
||||||
@@ -3313,10 +3387,64 @@
|
|||||||
btn.title = 'Hide stocks panel';
|
btn.title = 'Hide stocks panel';
|
||||||
if (stocksChart) { stocksChart.resize(); }
|
if (stocksChart) { stocksChart.resize(); }
|
||||||
}
|
}
|
||||||
[50, 150, 250, 350].forEach(d => setTimeout(recalcGridRows, d));
|
|
||||||
};
|
};
|
||||||
// ── End stocks panel collapse ─────────────────────────────────
|
// ── 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 ────────────────────────────────────────────
|
// ── Viewer counts ────────────────────────────────────────────
|
||||||
window.updateViewerCounts = function(data) {
|
window.updateViewerCounts = function(data) {
|
||||||
const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1;
|
const cammanIdx = typeof CAMERAS !== 'undefined' ? CAMERAS.findIndex(([,s]) => s === 'cameraman-5') : -1;
|
||||||
|
|||||||
Reference in New Issue
Block a user