NextNVR/public/app.js

383 lines
17 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 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) {
// 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>
`;
setInterval(() => {
const img = tile.querySelector('img');
if (img) img.src = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
}, 3000);
tile.addEventListener('click', () => openFocus(cam));
} else {
tile.innerHTML = '<span class="tile-placeholder">No Camera</span>';
}
grid.appendChild(tile);
}
}
// ── Focus Overlay ──
let focusIdx = -1;
let focusTimer = null;
function openFocus(cam) {
focusIdx = cameras.indexOf(cam);
const overlay = document.getElementById('focus-overlay');
overlay.classList.remove('hidden');
updateFocus();
renderThumbs();
// Refresh snapshot every 500ms for smooth near-live view.
focusTimer = setInterval(updateFocus, 500);
}
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();
// Use the latest.jpg directly — updated every 2s by snapshot engine.
// Fast 500ms refresh gives smooth enough monitoring.
const vid = document.getElementById('focus-video');
vid.style.display = 'block';
vid.poster = `/recordings/${cam.name || cam.id}/latest.jpg?t=${Date.now()}`;
}
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');
if (focusTimer) { clearInterval(focusTimer); focusTimer = null; }
});
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 (focusTimer) { clearInterval(focusTimer); focusTimer = null; }
}
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 = '<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');
if (clips.length === 0) { grid.innerHTML = '<p style="color:var(--text-muted);grid-column:1/-1">No recordings found for this selection.</p>'; return; }
grid.innerHTML = clips.map(c => `
<div class="clip-card" onclick="playClip('${c.path}')">
<img src="${c.snap || ''}" class="clip-thumb" loading="lazy"
onerror="this.style.display='none'" alt="${c.time}">
<div>${c.live ? '🔴 ' : '🎬 '}${c.time}</div>
<div class="clip-time">${c.name}</div>
<div class="clip-size">${formatSize(c.size)}</div>
</div>
`).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 = `
<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 = cameras.map(c => `
<div class="camera-card" data-id="${c.id}">
<h3>📷 ${c.name || 'Unnamed'} <span style="font-size:11px;color:var(--text-muted)">${c.ip}</span>
<a href="http://${c.ip}" target="_blank" style="font-size:11px;color:var(--accent);margin-left:8px;text-decoration:none" title="Open camera web interface">⚙️ Config</a>
</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">${escHtml(c.description)}</textarea></label>
<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 class="row">
<label>Enabled <input type="checkbox" ${c.enabled ? 'checked' : ''} data-field="enabled" style="width:auto"></label>
<label>Record <input type="checkbox" ${c.record ? 'checked' : ''} data-field="record" style="width:auto"></label>
</div>
</div>
`).join('');
}
// ── Settings: Scan ──
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 || 'Camera') + ' ' + (d.model || ''),
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.
const cards = document.querySelectorAll('.camera-card');
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, 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) {
// Reload cameras from the server (in-memory config is now updated).
await loadCameras();
renderLiveGrid();
renderPlaybackCameras();
renderCameraCards();
status.textContent = '✅ Cameras saved! Switching to Live Wall...';
status.style.color = 'var(--green)';
setTimeout(() => switchTab('live'), 800);
} 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];
}