Files
appRobotWebcam/public/viewer.js
2026-06-05 06:36:48 +02:00

175 lines
7.2 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.
'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 (~12 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, 'HD: erfasse… (Stream friert kurz)', '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 ──────────────────────────────────────────────────────
function buildCamera(camId, container) {
const box = document.createElement('div');
box.className = 'cam-box';
const img = document.createElement('img');
img.className = 'cam-img';
img.alt = camId;
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');
// Auto-Reconnect nach kurzer Pause (nicht während HD-Grab)
setTimeout(() => { if (cam.active && !cam.busy) startStream(cam); }, 2000);
});
const label = document.createElement('div');
label.className = 'cam-label';
label.textContent = camId;
const info = document.createElement('div');
info.className = 'cam-info';
info.textContent = '…';
const toggle = document.createElement('button');
toggle.className = 'cam-toggle';
const hd = document.createElement('button');
hd.className = 'cam-hdtest';
hd.textContent = 'HD';
hd.title = 'Hi-Res-Snapshot (1280×960) Live friert kurz ein, dann Download';
const cam = { id: camId, box, img, infoEl: info, toggleBtn: toggle, hdBtn: hd, active: false, busy: false };
toggle.onclick = () => { if (!cam.busy) (cam.active ? stopStream(cam) : startStream(cam)); };
hd.onclick = () => runHiresGrab(cam);
box.appendChild(img);
box.appendChild(label);
box.appendChild(info);
box.appendChild(toggle);
box.appendChild(hd);
container.appendChild(box);
cameras.push(cam);
startStream(cam);
}
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
log('init', 'Starte...');
const container = document.getElementById('cameras');
const statusText = document.getElementById('statusText');
let camIds = [];
try {
const r = await fetch('/api/snapshot');
log('init', `/api/snapshot → HTTP ${r.status}`);
if (r.ok) camIds = ((await r.json()).cameras ?? []).map((c) => c.id);
log('init', `Kameras: ${camIds.join(', ') || '(keine)'}`);
} catch (e) {
logErr('init', '/api/snapshot Fehler Fallback', e);
}
if (camIds.length === 0) { warn('init', 'Fallback cam0, cam1'); camIds = ['cam0', 'cam1']; }
const snapBtn = document.getElementById('snapAllBtn');
if (snapBtn) { snapBtn.onclick = snapshotAllHires; snapBtn.disabled = false; }
camIds.forEach((id) => buildCamera(id, container));
statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · MJPEG`;
log('init', 'Fertig');
}
init();