// 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 loadCameras(); renderLiveGrid(); renderPlaybackCameras(); renderCameraCards(); }); // ── Tabs ── function setupTabs() { document.querySelectorAll('.tab').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(b => b.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); btn.classList.add('active'); document.getElementById('tab-' + btn.dataset.tab).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'; } } // ── 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 ── function renderLiveGrid() { const grid = document.getElementById('grid'); 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) { tile.innerHTML = ` ${cam.name} `; tile.addEventListener('click', () => openFocus(cam)); } else { tile.innerHTML = 'No Camera'; } grid.appendChild(tile); } } // ── Focus Overlay ── let focusIdx = -1; function openFocus(cam) { focusIdx = cameras.indexOf(cam); const overlay = document.getElementById('focus-overlay'); overlay.classList.remove('hidden'); updateFocus(); renderThumbs(); } function updateFocus() { if (focusIdx < 0 || focusIdx >= cameras.length) return; const cam = cameras[focusIdx]; document.getElementById('focus-title').textContent = cam.name; document.getElementById('focus-time').textContent = new Date().toLocaleTimeString() + ' live'; // TODO: M3 — connect to go2rtc WebRTC stream const vid = document.getElementById('focus-video'); vid.src = ''; vid.poster = ''; // placeholder until go2rtc is wired up } function renderThumbs() { const strip = document.getElementById('focus-thumbs'); strip.innerHTML = ''; cameras.forEach((cam, i) => { const thumb = document.createElement('div'); thumb.className = 'thumb' + (i === focusIdx ? ' active' : ''); thumb.textContent = cam.name; thumb.title = cam.name; thumb.addEventListener('click', () => { focusIdx = i; updateFocus(); renderThumbs(); }); strip.appendChild(thumb); }); } document.getElementById('focus-close').addEventListener('click', () => { document.getElementById('focus-overlay').classList.add('hidden'); }); document.getElementById('focus-prev').addEventListener('click', () => { if (focusIdx > 0) { focusIdx--; updateFocus(); renderThumbs(); } }); document.getElementById('focus-next').addEventListener('click', () => { if (focusIdx < cameras.length - 1) { focusIdx++; updateFocus(); renderThumbs(); } }); document.addEventListener('keydown', e => { if (document.getElementById('focus-overlay').classList.contains('hidden')) return; if (e.key === 'Escape') document.getElementById('focus-overlay').classList.add('hidden'); if (e.key === 'ArrowLeft' && focusIdx > 0) { focusIdx--; updateFocus(); renderThumbs(); } if (e.key === 'ArrowRight' && focusIdx < cameras.length - 1) { focusIdx++; updateFocus(); renderThumbs(); } }); // ── Playback ── function renderPlaybackCameras() { const sel = document.getElementById('pb-camera'); sel.innerHTML = ''; cameras.forEach(c => { sel.innerHTML += ``; }); } document.getElementById('pb-load').addEventListener('click', async () => { const cam = document.getElementById('pb-camera').value; const date = document.getElementById('pb-date').value; if (!cam || !date) return; try { const r = await fetch(API + '/recordings?cam=' + cam + '&date=' + date); const j = await r.json(); renderClips(j.data || []); } catch(e) { renderClips([]); } }); function renderClips(clips) { const grid = document.getElementById('pb-clips'); if (clips.length === 0) { grid.innerHTML = '

No recordings found.

'; return; } grid.innerHTML = clips.map(c => `
🎬 ${c.name}
${c.time}
${formatSize(c.size)}
`).join(''); } 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. Scan your network to get started.

`; return; } container.innerHTML = cameras.map(c => `

📷 ${c.name || 'Unnamed'} ${c.ip}

`).join(''); } // ── Settings: Scan ── document.getElementById('btn-scan').addEventListener('click', async () => { const btn = document.getElementById('btn-scan'); const status = document.getElementById('settings-status'); btn.disabled = true; btn.textContent = '⏳ Scanning...'; status.textContent = 'Probing 192.168.1.201–209...'; try { const r = await fetch(API + '/scan', { method: 'POST' }); const j = await r.json(); if (j.success) { status.textContent = `Found ${j.data.length} devices. Fill in names and save.`; // Pre-fill camera cards with discovered data. cameras = j.data.map((d, i) => ({ id: 'cam_' + d.ip.split('.').pop(), name: d.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i), ip: d.ip, username: 'admin', password: '', onvif_port: 80, rtsp_main: d.rtsp_main || '', rtsp_sub: d.rtsp_sub || '', description: '', enabled: true, record: true, online: d.found })); renderCameraCards(); renderLiveGrid(); renderPlaybackCameras(); } } catch(e) { status.textContent = 'Scan failed: ' + e.message; } btn.disabled = false; btn.textContent = '🔍 Scan Network (ONVIF)'; }); // ── 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 = {}; card.querySelectorAll('[data-field]').forEach(el => { const field = el.dataset.field; if (el.type === 'checkbox') c[field] = el.checked; else c[field] = el.value; }); updatedCameras.push(c); }); try { // Fetch current config, then post updated version. const cr = await fetch(API + '/config'); const cj = await cr.json(); const cfg = cj.data || {}; cfg.cameras = updatedCameras; 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) { status.textContent = '✅ Configuration saved! Restart may be required.'; status.style.color = 'var(--green)'; } 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]; }