NextNVR/public/app.js

278 lines
11 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();
});
// ── Tabs ──
function setupTabs() {
document.querySelectorAll('.tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + btn.dataset.tab).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) {
tile.innerHTML = `
<span class="tile-status ${cam.online ? 'online' : 'offline'}"></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);
}
}
// ── 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';
// TODO: M3 — connect to go2rtc WebRTC stream
const vid = document.getElementById('focus-video');
vid.src = '';
vid.poster = ''; // placeholder until go2rtc is wired up
}
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 ──
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>`;
});
}
document.getElementById('pb-load').addEventListener('click', async () => {
const cam = document.getElementById('pb-camera').value;
const date = document.getElementById('pb-date').value;
if (!cam || !date) return;
try {
const r = await fetch(API + '/recordings?cam=' + cam + '&date=' + date);
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)">No recordings found.</p>'; return; }
grid.innerHTML = clips.map(c => `
<div class="clip-card" onclick="playClip('${c.path}')">
<div>🎬 ${c.name}</div>
<div class="clip-time">${c.time}</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:40px;color:var(--text-muted)">
<p style="font-size:18px">🎥 Welcome to NextNVR</p>
<p style="margin-top:8px">No cameras configured yet. Scan your network to get started.</p>
<button class="btn-primary" style="margin-top:16px" onclick="document.getElementById('btn-scan').click()">
🔍 Scan Network (ONVIF)
</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 ──
document.getElementById('btn-scan').addEventListener('click', async () => {
const btn = document.getElementById('btn-scan');
const status = document.getElementById('settings-status');
btn.disabled = true;
btn.textContent = '⏳ Scanning...';
status.textContent = 'Probing 192.168.1.201209...';
try {
const r = await fetch(API + '/scan', { method: 'POST' });
const j = await r.json();
if (j.success) {
status.textContent = `Found ${j.data.length} devices. Fill in names and save.`;
// Pre-fill camera cards with discovered data.
cameras = j.data.map((d, i) => ({
id: 'cam_' + d.ip.split('.').pop(),
name: d.found ? d.manufacturer + ' ' + d.model : 'Camera ' + (201 + i),
ip: d.ip,
username: 'admin', password: '',
onvif_port: 80,
rtsp_main: d.rtsp_main || '',
rtsp_sub: d.rtsp_sub || '',
description: '',
enabled: true, record: true,
online: d.found
}));
renderCameraCards();
renderLiveGrid();
renderPlaybackCameras();
}
} catch(e) {
status.textContent = 'Scan failed: ' + e.message;
}
btn.disabled = false;
btn.textContent = '🔍 Scan Network (ONVIF)';
});
// ── 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) {
status.textContent = '✅ Configuration saved! Restart may be required.';
status.style.color = 'var(--green)';
} 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];
}