470 lines
21 KiB
JavaScript
470 lines
21 KiB
JavaScript
// 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>🔐 Authentication</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">Master (full access)</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>
|
||
<div style="flex:1;min-width:200px">
|
||
<h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Viewer (live wall only, port 8090)</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"><label>Enable viewer <input type="checkbox" ${config?.auth?.viewer?.enabled ? 'checked' : ''} data-auth="viewer-enabled" style="width:auto"></label></div>
|
||
</div>
|
||
</div>
|
||
<div class="row" style="margin-top:8px">
|
||
<label>Require login on port 8080 <input type="checkbox" ${config?.auth?.enabled ? 'checked' : ''} data-auth="auth-enabled" style="width:auto"></label>
|
||
</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" value="${escAttr(c.password)}" data-field="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 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,'<').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];
|
||
}
|