NextNVR/public/app.js

474 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = `
<img src="${snapURL}" class="grid-snap" loading="lazy"
onerror="this.parentElement.classList.add('offline')"
style="width:100%;height:100%;object-fit:cover;position:absolute;inset:0" alt="${cam.name}">
<span class="tile-status online"></span>
<span class="tile-label">${cam.name}</span>
`;
tile.addEventListener('click', () => openFocus(cam));
} else {
tile.innerHTML = '<span class="tile-placeholder">No Camera</span>';
}
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 = '<option value="">— Select Camera —</option>';
cameras.forEach(c => {
sel.innerHTML += `<option value="${c.id}">${c.name}</option>`;
});
}
// 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 = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>';
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 = `
<div style="grid-column:1/-1;text-align:center;padding:60px 20px;color:var(--text-muted)">
<p style="font-size:48px;margin-bottom:12px">🎥</p>
<p style="font-size:20px;font-weight:600;color:var(--text)">Welcome to NextNVR</p>
<p style="margin-top:8px;max-width:500px;margin-left:auto;margin-right:auto">
No cameras configured yet. Enter your camera's IP range and credentials to scan your network.
</p>
<div style="margin-top:24px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<label style="color:var(--text-muted);font-size:13px">IP Range:
<input type="text" id="wiz-from" value="192.168.1.200" style="width:130px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
<label style="color:var(--text-muted);font-size:13px">to
<input type="text" id="wiz-to" value="192.168.1.210" style="width:130px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
</div>
<div style="margin-top:12px;display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<label style="color:var(--text-muted);font-size:13px">Username:
<input type="text" id="wiz-user" value="admin" style="width:120px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
<label style="color:var(--text-muted);font-size:13px">Password:
<input type="password" id="wiz-pass" value="" style="width:120px;margin:0 4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:4px">
</label>
</div>
<button class="btn-primary" style="margin-top:20px;font-size:16px;padding:10px 32px" onclick="runWizard()">
🔍 Scan Network
</button>
</div>`;
return;
}
container.innerHTML = `
<div class="camera-card" id="auth-card" style="grid-column:1/-1">
<h3>🔐 Access Setup</h3>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:8px">
<div style="flex:1;min-width:200px">
<h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Port 8080 — Admin</h4>
<label>Username <input type="text" value="${escAttr(config?.auth?.master?.username || '')}" data-auth="master-user"></label>
<label>Password <input type="password" placeholder="new password" data-auth="master-pass"></label>
<div class="row" style="margin-top:8px">
<label>Require login on port 8080 <input type="checkbox" id="auth-enable" ${config?.auth?.enabled ? 'checked' : ''} style="width:auto"></label>
</div>
</div>
<div style="flex:1;min-width:200px">
<h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Port 8090 — Local View</h4>
<label>Username <input type="text" value="${escAttr(config?.auth?.viewer?.username || '')}" data-auth="viewer-user"></label>
<label>Password <input type="password" placeholder="new password" data-auth="viewer-pass"></label>
<div class="row" style="margin-top:8px">
<label>Enable local view access <input type="checkbox" id="viewer-enable" ${config?.auth?.viewer?.enabled ? 'checked' : ''} style="width:auto"></label>
</div>
</div>
</div>
</div>
<div style="grid-column:1/-1;display:flex;gap:8px;margin-bottom:4px">
<button class="btn-primary" onclick="addCamera()">+ Add Camera</button>
<span style="color:var(--text-muted);font-size:13px;align-self:center">${cameras.length} camera(s)</span>
</div>
` + cameras.map((c, i) => `
<div class="camera-card" data-id="${c.id}">
<h3>📷 ${c.name || 'Camera_' + c.ip.split('.').pop()}
<span style="font-size:11px;color:var(--text-muted)">${c.ip}</span>
</h3>
<input type="hidden" value="${escAttr(c.id)}" data-field="id">
<input type="hidden" value="${escAttr(c.ip)}" data-field="ip">
<input type="hidden" value="${c.onvif_port || 80}" data-field="onvif_port">
<label>Name <input type="text" value="${escAttr(c.name)}" data-field="name"></label>
<label>Description <textarea data-field="description" rows="2">${escHtml(c.description)}</textarea></label>
<div class="camera-row">
<label>Recording <input type="checkbox" ${c.record ? 'checked' : ''} data-field="record" style="width:auto"></label>
<button class="adv-toggle" onclick="toggleAdv(this)" style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;margin-left:auto;margin-right:8px">▸ Advanced</button>
<button onclick="removeCamera(${i})" class="btn-remove">✕</button>
</div>
<div class="advanced hidden">
<label>RTSP Main <input type="text" value="${escAttr(c.rtsp_main)}" data-field="rtsp_main"></label>
<label>RTSP Sub <input type="text" value="${escAttr(c.rtsp_sub)}" data-field="rtsp_sub"></label>
<label>Username <input type="text" value="${escAttr(c.username)}" data-field="username"></label>
<label>Password <input type="password" placeholder="${c.password && c.password !== '********' ? '••••••••' : 'set password'}" data-field="password" data-password="${escAttr(c.password === '********' ? '' : c.password)}"></label>
</div>
</div>
`).join('');
}
// ── Settings: Add / Remove cameras ──
function toggleAdv(btn) {
const adv = btn.parentElement.parentElement.querySelector('.advanced');
adv.classList.toggle('hidden');
btn.textContent = adv.classList.contains('hidden') ? '▸ Advanced' : '▾ Advanced';
}
// ── Settings: Add / Remove cameras ──
function addCamera() {
const ip = prompt('Camera IP address:', '192.168.1.');
if (!ip) return;
const id = 'cam_' + ip.split('.').pop();
cameras.push({
id, name: 'Camera_' + ip.split('.').pop(),
ip, username: 'admin', password: '',
onvif_port: 80, rtsp_main: '', rtsp_sub: '',
description: '', enabled: true, record: true
});
renderCameraCards();
renderLiveGrid();
renderPlaybackCameras();
}
function removeCamera(idx) {
if (!confirm('Remove camera ' + (cameras[idx]?.name || '') + '?')) return;
cameras.splice(idx, 1);
renderCameraCards();
renderLiveGrid();
renderPlaybackCameras();
}
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 (skip the auth card).
const cards = document.querySelectorAll('.camera-card[data-id]');
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 if (field === 'password') c[field] = el.dataset.password || el.value;
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.getElementById('auth-enable')?.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.getElementById('viewer-enable')?.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 config and cameras from the server.
await loadConfig();
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,'&amp;').replace(/"/g,'&quot;'); }
function escHtml(s) { return (s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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];
}