NextNVR/public/app.js

364 lines
16 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) {
// go2rtc snapshot endpoint — refresh every 2 seconds for live preview.
const snapshotURL = `http://${location.hostname}:1984/api/frame.jpeg?src=${cam.id}_sub`;
tile.innerHTML = `
<img src="${snapshotURL}" class="grid-snap" onerror="this.style.display='none'"
style="width:100%;height:100%;object-fit:cover;position:absolute;inset:0" alt="${cam.name}">
<span class="tile-status ${cam.online ? 'online' : 'offline'}"></span>
<span class="tile-label">${cam.name}</span>
`;
// Auto-refresh snapshot.
setInterval(() => {
const img = tile.querySelector('img');
if (img) img.src = snapshotURL + '&t=' + Date.now();
}, 2000);
tile.addEventListener('click', () => openFocus(cam));
} else {
tile.innerHTML = '<span class="tile-placeholder">No Camera</span>';
}
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 = '<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}')">
<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></h3>
<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 = {};
card.querySelectorAll('[data-field]').forEach(el => {
const field = el.dataset.field;
if (el.type === 'checkbox') c[field] = el.checked;
else c[field] = el.value;
});
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];
}