// 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}
`;
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.