Files
appRobotWebcam/public/viewer.js
2026-06-07 10:42:28 +02:00

235 lines
9.3 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');
}
// ── Snapshot-Modus (stream:false): alle 5 s ein Einzelbild ──────────────────
// Kein Video der Server öffnet das Gerät pro Snapshot kurz (one-shot). Fehler
// (z. B. Gerät gerade durch HD-Grab belegt) werden still übersprungen, das letzte
// gute Bild bleibt stehen.
const SNAPSHOT_INTERVAL_MS = 5000;
async function fetchSnapshot(cam) {
if (!cam.snapshotActive) return;
try {
const r = await fetch(`/api/snapshot/${encodeURIComponent(cam.id)}?t=${Date.now()}`,
{ signal: AbortSignal.timeout(8000) });
if (!r.ok) { setInfo(cam, `Snapshot-Fehler (HTTP ${r.status})`, 'warn'); return; }
const blob = await r.blob();
const url = URL.createObjectURL(blob);
cam.img.src = url;
if (cam.lastBlobUrl) URL.revokeObjectURL(cam.lastBlobUrl);
cam.lastBlobUrl = url;
setInfo(cam, `Einzelbild · ${new Date().toLocaleTimeString()}`, 'ok');
} catch (_e) {
setInfo(cam, 'Snapshot-Timeout', 'warn');
}
}
function startSnapshotMode(cam) {
cam.snapshotActive = true;
setInfo(cam, 'Einzelbild lädt…', '');
fetchSnapshot(cam); // sofort das erste Bild
cam.snapTimer = setInterval(() => fetchSnapshot(cam), SNAPSHOT_INTERVAL_MS);
log(cam.id, `Snapshot-Modus (alle ${SNAPSHOT_INTERVAL_MS / 1000} s)`);
}
// ── 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 {
// Snapshot-Modus: grosser Banner + Einzelbild, das alle 5 s aktualisiert wird.
const banner = document.createElement('div');
banner.className = 'single-pic-banner';
banner.textContent = 'Single Picture no Video';
const img = document.createElement('img');
img.className = 'cam-img';
img.alt = labelText;
cam.img = img;
hd.title = 'Hi-Res-Snapshot Download';
box.appendChild(banner);
box.appendChild(img);
startSnapshotMode(cam);
}
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();