// 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();
// First-run experience: auto-switch to Settings if no cameras configured.
if (cameras.length === 0) {
switchTab('settings');
}
});
// ── 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';
}
}
// ── 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 && cam.enabled) {
// Static snapshot from disk — updated every 2s by the backend.
// Cache-bust with timestamp to force refresh.
const snapURL = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`;
tile.innerHTML = `
${cam.name}
`;
// Auto-refresh every 3 seconds — no flooding, simple reload.
setInterval(() => {
const img = tile.querySelector('img');
if (img && !tile.classList.contains('offline')) {
img.src = `/recordings/snapshots/${cam.id}.jpg?t=${Date.now()}`;
}
}, 3000);
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';
// go2rtc MSE stream for full-quality playback.
const vid = document.getElementById('focus-video');
vid.src = `http://${location.hostname}:1984/api/stream.m3u8?src=${cam.id}_main`;
vid.play().catch(() => {});
}
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 ──
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');
if (clips.length === 0) { grid.innerHTML = '
No recordings found for this selection.
'; return; } grid.innerHTML = clips.map(c => `🎥
Welcome to NextNVR
No cameras configured yet. Enter your camera's IP range and credentials to scan your network.