// NextNVR v0.1.0 — Single Page Application // Tabbed UI: Live Wall (4×2 grid + focus), Playback browser, Settings/Onboarding const API = '/api'; // ── App State ── let cameras = []; let config = null; // ── Init ── document.addEventListener('DOMContentLoaded', async () => { setupTabs(); await loadStatus(); await loadConfig(); await loadCameras(); renderLiveGrid(); renderPlaybackCameras(); renderCameraCards(); // Read role from meta tag (set by server). const role = document.querySelector('meta[name="nextnvr-role"]')?.content || ''; applyRole(role); // First-run experience: auto-switch to Settings if no cameras configured. if (cameras.length === 0) { switchTab('settings'); } }); function applyRole(role) { if (role === 'viewer') { // Hide playback and settings tabs. document.querySelectorAll('.tab[data-tab="playback"], .tab[data-tab="settings"]').forEach(t => t.style.display = 'none'); // Hide save button if present. const saveBtn = document.getElementById('btn-save'); if (saveBtn) saveBtn.style.display = 'none'; } } // ── Tabs ── function setupTabs() { document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { switchTab(btn.dataset.tab); }); }); } function switchTab(tabName) { document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); const tabBtn = document.querySelector(`.tab[data-tab="${tabName}"]`); if (tabBtn) tabBtn.classList.add('active'); const panel = document.getElementById('tab-' + tabName); if (panel) panel.classList.add('active'); } // ── Status ── async function loadStatus() { try { const r = await fetch(API + '/status'); const j = await r.json(); const dot = document.getElementById('status-dot'); dot.className = 'status ' + (j.success ? 'online' : 'offline'); } catch(e) { document.getElementById('status-dot').className = 'status offline'; } } async function loadConfig() { try { const r = await fetch(API + '/config'); const j = await r.json(); if (j.success) config = j.data; } catch(e) { config = {}; } } // ── Cameras ── async function loadCameras() { try { const r = await fetch(API + '/cameras'); const j = await r.json(); if (j.success) cameras = j.data; } catch(e) { cameras = []; } } // ── Live Grid ── let gridRefreshTimer = null; function renderLiveGrid() { const grid = document.getElementById('grid'); // Clear previous refresh timer. if (gridRefreshTimer) { clearInterval(gridRefreshTimer); gridRefreshTimer = null; } grid.innerHTML = ''; for (let i = 0; i < 8; i++) { const cam = cameras[i]; const tile = document.createElement('div'); tile.className = 'grid-tile' + (cam && cam.enabled ? '' : ' offline'); tile.dataset.camId = cam ? cam.id : ''; if (cam && cam.enabled && cam.name) { // Load latest.jpg — the snapshot engine updates this every 2 seconds. const snapURL = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`; tile.innerHTML = ` ${cam.name} ${cam.name} `; tile.addEventListener('click', () => openFocus(cam)); } else { tile.innerHTML = 'No Camera'; } grid.appendChild(tile); } // Single global refresh timer — avoids interval leak. gridRefreshTimer = setInterval(() => { grid.querySelectorAll('.grid-tile:not(.offline) img.grid-snap').forEach(img => { const camId = img.closest('.grid-tile').dataset.camId; if (camId) { const cam = cameras.find(c => c.id === camId); if (cam) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`; } }); }, 3000); } // ── Focus Overlay ── function openFocus(cam) { const overlay = document.getElementById('focus-overlay'); overlay.classList.remove('hidden'); document.getElementById('focus-frame').src = `/go2rtc/stream.html?src=${cam.id}_sub`; } document.getElementById('focus-close').addEventListener('click', () => { document.getElementById('focus-overlay').classList.add('hidden'); document.getElementById('focus-frame').src = ''; }); document.addEventListener('keydown', e => { if (document.getElementById('focus-overlay').classList.contains('hidden')) return; if (e.key === 'Escape') document.getElementById('focus-close').click(); }); // ── Playback ── let activePreset = 'today'; function renderPlaybackCameras() { const sel = document.getElementById('pb-camera'); sel.innerHTML = ''; cameras.forEach(c => { sel.innerHTML += ``; }); } // Preset buttons. document.querySelectorAll('.preset').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.preset').forEach(b => b.classList.remove('active')); btn.classList.add('active'); activePreset = btn.dataset.preset; const customRange = document.getElementById('pb-custom-range'); if (activePreset === 'custom') { customRange.classList.remove('hidden'); } else { customRange.classList.add('hidden'); loadClips(); // auto-load on preset change } }); }); document.getElementById('pb-load').addEventListener('click', loadClips); async function loadClips() { const cam = document.getElementById('pb-camera').value; if (!cam) return; let url = API + '/recordings?cam=' + cam + '&preset=' + activePreset; if (activePreset === 'custom') { const from = document.getElementById('pb-from').value; const to = document.getElementById('pb-to').value; if (from) url += '&from=' + from; if (to) url += '&to=' + to; } try { const r = await fetch(url); const j = await r.json(); renderClips(j.data || []); } catch(e) { renderClips([]); } } function renderClips(clips) { const grid = document.getElementById('pb-clips'); grid.innerHTML = ''; if (clips.length === 0) { grid.innerHTML = '

No recordings found for this selection.

'; return; } clips.forEach(c => { const card = document.createElement('div'); card.className = 'clip-card'; card.addEventListener('click', () => playClip(c.path)); if (c.snap) { const thumb = document.createElement('img'); thumb.className = 'clip-thumb'; thumb.src = c.snap; thumb.loading = 'lazy'; thumb.onerror = () => { thumb.style.display = 'none'; }; card.appendChild(thumb); } const timeDiv = document.createElement('div'); timeDiv.textContent = (c.live ? '🔴 ' : '🎬 ') + c.time; card.appendChild(timeDiv); const nameDiv = document.createElement('div'); nameDiv.className = 'clip-time'; nameDiv.textContent = c.name; card.appendChild(nameDiv); const sizeDiv = document.createElement('div'); sizeDiv.className = 'clip-size'; sizeDiv.textContent = formatSize(c.size); card.appendChild(sizeDiv); grid.appendChild(card); }); } function playClip(path) { document.getElementById('pb-player').classList.remove('hidden'); document.getElementById('pb-video').src = '/recordings/' + path; } document.getElementById('pb-close').addEventListener('click', () => { document.getElementById('pb-player').classList.add('hidden'); document.getElementById('pb-video').src = ''; }); // ── Settings / Onboarding ── async function renderCameraCards() { const container = document.getElementById('camera-cards'); if (cameras.length === 0) { container.innerHTML = `

🎥

Welcome to NextNVR

No cameras configured yet. Enter your camera's IP range and credentials to scan your network.

`; return; } container.innerHTML = `

🔐 Authentication

Master (full access)

Viewer (live wall only, port 8090)

` + cameras.map(c => `

📷 ${c.name || 'Unnamed'} ${c.ip} ⚙️ Config

`).join(''); } // ── Settings: Scan ── async function runWizard() { const from = document.getElementById('wiz-from')?.value || '192.168.1.200'; const to = document.getElementById('wiz-to')?.value || '192.168.1.210'; const user = document.getElementById('wiz-user')?.value || 'admin'; const pass = document.getElementById('wiz-pass')?.value || ''; const status = document.getElementById('settings-status'); status.textContent = '⏳ Scanning ' + from + ' to ' + to + '...'; try { const r = await fetch(API + '/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'range', from, to, username: user, password: pass }) }); const j = await r.json(); if (j.success) { const found = j.data.filter(d => d.reachable); status.textContent = 'Found ' + found.length + ' cameras.'; if (found.length > 0) { cameras = found.map(d => ({ id: 'cam_' + d.ip.split('.').pop(), name: (d.manufacturer || d.model || 'Camera_' + d.ip.split('.').pop()).replace(/ /g, '_'), ip: d.ip, username: user, password: pass, onvif_port: d.onvif_port || 80, rtsp_main: d.rtsp_main || '', rtsp_sub: d.rtsp_sub || '', description: '', enabled: true, record: true, online: d.reachable })); renderCameraCards(); renderLiveGrid(); renderPlaybackCameras(); status.textContent = '✅ Found ' + found.length + ' cameras. Name them and click Save.'; status.style.color = 'var(--green)'; } else { status.textContent = '❌ No cameras found in that range.'; status.style.color = 'var(--red)'; } } } catch(e) { status.textContent = '❌ Scan failed: ' + e.message; status.style.color = 'var(--red)'; } } document.getElementById('btn-scan').addEventListener('click', runWizard); // ── Settings: Save ── document.getElementById('btn-save').addEventListener('click', async () => { const status = document.getElementById('settings-status'); // Collect camera data from DOM. const cards = document.querySelectorAll('.camera-card'); const updatedCameras = []; cards.forEach(card => { const c = {}; // Collect hidden + visible fields. card.querySelectorAll('[data-field]').forEach(el => { const field = el.dataset.field; if (el.type === 'checkbox') c[field] = el.checked; else c[field] = el.value; }); // Ensure checkboxes default to true even if unchecked (hidden inputs override). if (c.enabled === undefined) c.enabled = true; if (c.record === undefined) c.record = true; updatedCameras.push(c); }); try { // Fetch current config. const cr = await fetch(API + '/config'); const cj = await cr.json(); const cfg = cj.data || {}; cfg.cameras = updatedCameras; // Include auth settings from the management card. const authEnabled = document.querySelector('[data-auth="auth-enabled"]')?.checked || false; const masterUser = document.querySelector('[data-auth="master-user"]')?.value || ''; const masterPass = document.querySelector('[data-auth="master-pass"]')?.value || ''; const viewerUser = document.querySelector('[data-auth="viewer-user"]')?.value || ''; const viewerPass = document.querySelector('[data-auth="viewer-pass"]')?.value || ''; const viewerEnabled = document.querySelector('[data-auth="viewer-enabled"]')?.checked || false; cfg.auth = cfg.auth || {}; cfg.auth.enabled = authEnabled; cfg.auth.master.username = masterUser; cfg.auth.viewer.username = viewerUser; cfg.auth.viewer.enabled = viewerEnabled || false; if (masterPass) { cfg.auth.master.password = masterPass; cfg.auth.master.enabled = true; } if (viewerPass) { cfg.auth.viewer.password = viewerPass; } const r = await fetch(API + '/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg) }); const j = await r.json(); if (j.success) { // Reload cameras from the server. await loadCameras(); renderLiveGrid(); renderPlaybackCameras(); renderCameraCards(); status.textContent = '✅ Saved! Restarting services...'; status.style.color = 'var(--green)'; // Trigger backend reload of cameras. try { await fetch(API + '/config/reload', { method: 'POST' }); } catch(e) {} setTimeout(() => switchTab('live'), 1500); } else { status.textContent = '❌ Save failed: ' + j.error; status.style.color = 'var(--red)'; } } catch(e) { status.textContent = '❌ Error: ' + e.message; status.style.color = 'var(--red)'; } }); // ── Helpers ── function escAttr(s) { return (s || '').replace(/&/g,'&').replace(/"/g,'"'); } function escHtml(s) { return (s || '').replace(/&/g,'&').replace(//g,'>'); } function formatSize(bytes) { if (!bytes || bytes === 0) return '0 B'; const u = ['B','KB','MB','GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + u[i]; }