194 lines
7.8 KiB
JavaScript
194 lines
7.8 KiB
JavaScript
'use strict';
|
||
|
||
// ── Architektur ───────────────────────────────────────────────────────────────
|
||
// Der Server (Node) besitzt die Kameras und liefert den Live-Stream als
|
||
// MJPEG multipart/x-mixed-replace unter /api/stream/<id>. Der Browser rendert
|
||
// das nativ in einem <img>. KEIN WebRTC, KEIN go2rtc, kein Transcode.
|
||
//
|
||
// HD-Snapshot: GET /api/snapshot/<id>/hires. Der Server-Schalter pausiert dafür
|
||
// den Live-FFmpeg kurz (~1–2 s), greift 1280×960, schaltet zurück. Der <img>-
|
||
// Stream friert in dieser Zeit ein und läuft danach weiter – kein Client-Handling
|
||
// nötig (das war früher die Fehlerquelle).
|
||
|
||
const P = '[WebcamViewer]';
|
||
const log = (c, m) => console.log(`${P}[${c}] ${m}`);
|
||
const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`);
|
||
const logErr = (c, m, e) => console.error(`${P}[${c}] ✗ ${m}`, e ?? '');
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
const cameras = []; // { id, box, img, infoEl, toggleBtn, hdBtn, active, busy }
|
||
|
||
// ── Live-Stream an/aus ────────────────────────────────────────────────────────
|
||
function startStream(cam) {
|
||
cam.active = true;
|
||
// Cache-Buster erzwingt eine frische Verbindung (sonst hängt Reconnect manchmal)
|
||
cam.img.src = `/api/stream/${encodeURIComponent(cam.id)}?t=${Date.now()}`;
|
||
cam.toggleBtn.textContent = '⏸';
|
||
cam.toggleBtn.title = 'Stream ausschalten';
|
||
setInfo(cam, 'verbindet…', '');
|
||
log(cam.id, 'Live an');
|
||
}
|
||
|
||
function stopStream(cam) {
|
||
cam.active = false;
|
||
cam.img.removeAttribute('src'); // schließt die multipart-Verbindung
|
||
cam.toggleBtn.textContent = '▶';
|
||
cam.toggleBtn.title = 'Stream einschalten';
|
||
setInfo(cam, 'aus', '');
|
||
log(cam.id, 'Live aus');
|
||
}
|
||
|
||
// ── HD-Snapshot ───────────────────────────────────────────────────────────────
|
||
async function runHiresGrab(cam) {
|
||
if (cam.busy) return;
|
||
cam.busy = true;
|
||
cam.hdBtn.disabled = true;
|
||
setInfo(cam, cam.stream ? 'HD: erfasse… (Stream friert kurz)' : 'HD: erfasse…', 'warn');
|
||
log(cam.id, '── HD-Grab gestartet ──');
|
||
|
||
let blobUrl = null;
|
||
try {
|
||
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}/hires`, { signal: AbortSignal.timeout(20000) });
|
||
if (!r.ok) {
|
||
const body = await r.json().catch(() => ({}));
|
||
throw new Error(body.error ?? `HTTP ${r.status}`);
|
||
}
|
||
const blob = await r.blob();
|
||
const width = r.headers.get('X-Frame-Width') || '?';
|
||
blobUrl = URL.createObjectURL(blob);
|
||
|
||
const a = document.createElement('a');
|
||
a.href = blobUrl;
|
||
a.download = `${cam.id}_hires_${Date.now()}.jpg`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
|
||
setInfo(cam, `HD gespeichert (${width}px)`, 'ok');
|
||
log(cam.id, `HD-Grab OK – ${blob.size} bytes, ${width}px`);
|
||
} catch (e) {
|
||
logErr(cam.id, 'HD-Grab fehlgeschlagen', e);
|
||
setInfo(cam, `HD Fehler: ${e.message}`, 'crit');
|
||
} finally {
|
||
if (blobUrl) setTimeout(() => URL.revokeObjectURL(blobUrl), 10000);
|
||
cam.busy = false;
|
||
cam.hdBtn.disabled = false;
|
||
log(cam.id, '── HD-Grab beendet ──');
|
||
}
|
||
}
|
||
|
||
// ── HD-Snapshot aller Kameras (parallel) ──────────────────────────────────────
|
||
// cam0/cam1 liegen auf getrennten Geräten → der Schalter grabbt beide parallel
|
||
// gefahrlos (jeder Schalter steuert nur sein eigenes Gerät).
|
||
async function snapshotAllHires() {
|
||
const snapBtn = document.getElementById('snapAllBtn');
|
||
if (snapBtn) snapBtn.disabled = true;
|
||
log('snap', `HD-Grab alle: ${cameras.map((c) => c.id).join(', ')}`);
|
||
try {
|
||
await Promise.allSettled(cameras.map((c) => runHiresGrab(c)));
|
||
} finally {
|
||
if (snapBtn) snapBtn.disabled = false;
|
||
log('snap', '── HD-Grab alle beendet ──');
|
||
}
|
||
}
|
||
|
||
// ── Status-Anzeige ────────────────────────────────────────────────────────────
|
||
function setInfo(cam, text, cls) {
|
||
cam.infoEl.textContent = text;
|
||
cam.infoEl.className = 'cam-info ' + (cls ?? '');
|
||
}
|
||
|
||
// ── Kamera-View aufbauen ──────────────────────────────────────────────────────
|
||
// camMeta = { id, name, position, stream, hires }
|
||
function buildCamera(camMeta, container) {
|
||
const box = document.createElement('div');
|
||
box.className = 'cam-box';
|
||
|
||
const labelText = camMeta.name + (camMeta.position ? ` · ${camMeta.position}` : '');
|
||
const label = document.createElement('div');
|
||
label.className = 'cam-label';
|
||
label.textContent = labelText;
|
||
|
||
const info = document.createElement('div');
|
||
info.className = 'cam-info';
|
||
info.textContent = camMeta.stream ? '…' : 'Nur Snapshot';
|
||
|
||
const hd = document.createElement('button');
|
||
hd.className = 'cam-hdtest';
|
||
hd.textContent = 'HD';
|
||
|
||
const cam = { id: camMeta.id, stream: camMeta.stream, box, infoEl: info, hdBtn: hd, active: false, busy: false };
|
||
|
||
hd.onclick = () => runHiresGrab(cam);
|
||
|
||
if (camMeta.stream) {
|
||
const img = document.createElement('img');
|
||
img.className = 'cam-img';
|
||
img.alt = labelText;
|
||
img.addEventListener('load', () => { if (cam.active && !cam.busy) setInfo(cam, 'MJPEG · live', 'ok'); });
|
||
img.addEventListener('error', () => {
|
||
if (!cam.active) return;
|
||
setInfo(cam, 'Verbindungsfehler – neu…', 'crit');
|
||
setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000);
|
||
});
|
||
|
||
const toggle = document.createElement('button');
|
||
toggle.className = 'cam-toggle';
|
||
toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); };
|
||
|
||
cam.img = img;
|
||
cam.toggleBtn = toggle;
|
||
hd.title = 'Hi-Res-Snapshot (1280×960) – Live friert kurz ein, dann Download';
|
||
|
||
box.appendChild(img);
|
||
box.appendChild(toggle);
|
||
startStream(cam);
|
||
} else {
|
||
const placeholder = document.createElement('div');
|
||
placeholder.className = 'cam-img cam-placeholder';
|
||
placeholder.textContent = 'Kein Live-Stream';
|
||
hd.title = 'Hi-Res-Snapshot (1280×960) – Download';
|
||
box.appendChild(placeholder);
|
||
}
|
||
|
||
box.appendChild(label);
|
||
box.appendChild(info);
|
||
box.appendChild(hd);
|
||
container.appendChild(box);
|
||
|
||
cameras.push(cam);
|
||
}
|
||
|
||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
log('init', 'Starte...');
|
||
const container = document.getElementById('cameras');
|
||
const statusText = document.getElementById('statusText');
|
||
|
||
let camList = [];
|
||
try {
|
||
const r = await fetch('/api/snapshot');
|
||
log('init', `/api/snapshot → HTTP ${r.status}`);
|
||
if (r.ok) camList = (await r.json()).cameras ?? [];
|
||
log('init', `Kameras: ${camList.map((c) => c.id).join(', ') || '(keine)'}`);
|
||
} catch (e) {
|
||
logErr('init', '/api/snapshot Fehler – Fallback', e);
|
||
}
|
||
if (camList.length === 0) {
|
||
warn('init', 'Fallback cam0, cam1');
|
||
camList = [
|
||
{ id: 'cam0', name: 'cam0', position: '', stream: true, hires: true },
|
||
{ id: 'cam1', name: 'cam1', position: '', stream: true, hires: true },
|
||
];
|
||
}
|
||
|
||
const snapBtn = document.getElementById('snapAllBtn');
|
||
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
|
||
|
||
camList.forEach((c) => buildCamera(c, container));
|
||
statusText.textContent = `${camList.length} Kamera${camList.length !== 1 ? 's' : ''} · MJPEG`;
|
||
log('init', 'Fertig');
|
||
}
|
||
|
||
init();
|