diff --git a/public/index.html b/public/index.html index 5afeb5d..0958082 100644 --- a/public/index.html +++ b/public/index.html @@ -24,18 +24,47 @@ #snapAllBtn:hover:not(:disabled) { background: #3a6a3a; } #snapAllBtn:disabled { opacity: 0.4; cursor: default; } + /* Überlast-Banner */ + #notice { + display: none; align-items: center; gap: 10px; + background: #4a2a2a; color: #fbb; border-bottom: 1px solid #a55; + padding: 8px 16px; font-size: 0.82rem; + } + #notice button { + background: #2a2a2a; color: #fcc; border: 1px solid #a55; + padding: 3px 10px; font-family: monospace; cursor: pointer; border-radius: 3px; + } + #cameras { display: flex; flex-wrap: wrap; gap: 12px; padding: 12px; } - .cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; } + .cam-box { position: relative; background: #000; border: 1px solid #2a2a2a; min-width: 320px; } video-stream { display: block; width: 640px; height: 480px; background: #111; } video-stream video { width: 100%; height: 100%; object-fit: contain; } .cam-label { - position: absolute; top: 5px; left: 8px; + position: absolute; top: 5px; left: 8px; z-index: 2; background: rgba(0,0,0,.65); padding: 2px 7px; border-radius: 3px; font-size: 0.72rem; color: #ccc; } + /* Health-Anzeige unten links: fps · drop% */ + .cam-info { + position: absolute; bottom: 5px; left: 8px; z-index: 2; + background: rgba(0,0,0,.7); padding: 2px 7px; border-radius: 3px; + font-size: 0.7rem; color: #999; + } + .cam-info.ok { color: #6d6; } + .cam-info.warn { color: #fb4; } + .cam-info.crit { color: #f66; } + + /* Ein/Aus-Schalter oben rechts */ + .cam-toggle { + position: absolute; top: 5px; right: 8px; z-index: 2; + background: rgba(0,0,0,.65); color: #ccc; border: 1px solid #444; + width: 26px; height: 22px; font-size: 0.7rem; + cursor: pointer; border-radius: 3px; + } + .cam-toggle:hover { background: rgba(60,60,60,.85); }
@@ -45,6 +74,7 @@ + diff --git a/public/viewer.js b/public/viewer.js index 3dd8541..0207152 100644 --- a/public/viewer.js +++ b/public/viewer.js @@ -3,72 +3,175 @@ // go2rtc Player-Modi – Fallback-Reihenfolge: WebRTC → MSE → MJPEG const MODE = 'webrtc,mse,mjpeg'; +// ── Überwachungs-Parameter ─────────────────────────────────────────────────── +const MONITOR_INTERVAL = 2000; // ms zwischen Health-Checks +const DROP_WARN = 0.10; // >10% verworfene Frames → gelb +const DROP_CRIT = 0.30; // >30% verworfene Frames → rot +const OVERLOAD_TICKS = 3; // so viele kritische Checks in Folge → Auto-Abschaltung + // ── Logging (Browser DevTools → Console → F12) ─────────────────────────────── const P = '[WebcamViewer]'; -const log = (c, m) => console.log(`${P}[${c}] ${m}`); -const warn = (c, m) => console.warn(`${P}[${c}] ⚠ ${m}`); +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 ?? ''); -// ── Snapshot aller Kameras gleichzeitig ────────────────────────────────────── -// Auflösung = was go2rtc im MJPEG-Stream hält (aktuell 640×480 gemäss Config). -// Für höhere Auflösung: in go2rtc.yaml einen separaten Hi-Res-Stream definieren -// und hier auf dessen /api/frame.jpeg?src=cam0_hires zeigen. -function snapshotAll(camIds) { +let GO2RTC_PORT = 1984; +const cameras = []; // { id, box, infoEl, toggleBtn, active, last, badTicks, autoOff } + +// ── Stream starten / stoppen (durch Erzeugen/Entfernen des Elements) ───────── +function startStream(cam) { + if (cam.box.querySelector('video-stream')) return; // läuft schon + const wsUrl = `ws://${location.hostname}:${GO2RTC_PORT}/api/ws?src=${encodeURIComponent(cam.id)}`; + log(cam.id, `Verbinde → ${wsUrl}`); + + const stream = document.createElement('video-stream'); + stream.mode = MODE; + stream.addEventListener('playing', () => log(cam.id, '▶ Bild läuft'), true); + stream.addEventListener('error', (e) => logErr(cam.id, 'Video-Fehler', e), true); + stream.src = wsUrl; + + // Vor das Label einfügen, damit Overlays oben liegen + cam.box.insertBefore(stream, cam.box.firstChild); + cam.active = true; + cam.last = null; + cam.badTicks = 0; + cam.toggleBtn.textContent = '⏸'; + cam.toggleBtn.title = 'Stream ausschalten'; +} + +function stopStream(cam, auto = false) { + const el = cam.box.querySelector('video-stream'); + if (el) el.remove(); // disconnectedCallback schließt WS/PeerConnection + cam.active = false; + cam.autoOff = auto; + cam.toggleBtn.textContent = '▶'; + cam.toggleBtn.title = 'Stream einschalten'; + setInfo(cam, auto ? 'auto-aus (überlastet)' : 'aus', 'crit'); + log(cam.id, auto ? 'AUTO-abgeschaltet (Browser überlastet)' : 'manuell aus'); + if (auto) showNotice(); +} + +// ── Health-Anzeige ─────────────────────────────────────────────────────────── +function setInfo(cam, text, cls) { + cam.infoEl.textContent = text; + cam.infoEl.className = 'cam-info ' + (cls ?? ''); +} + +// ── Monitor-Schleife: misst fps + verworfene Frames pro Kamera ─────────────── +function monitorTick() { + cameras.forEach(cam => { + if (!cam.active) return; + const video = cam.box.querySelector('video'); + if (!video || typeof video.getVideoPlaybackQuality !== 'function') { + setInfo(cam, 'läuft', 'ok'); + return; + } + + const q = video.getVideoPlaybackQuality(); + const now = performance.now(); + const cur = { dropped: q.droppedVideoFrames, total: q.totalVideoFrames, t: now }; + + // Beim ersten Tick (oder nach Neustart = Zähler zurückgesetzt) nur Baseline merken + if (!cam.last || cur.total < cam.last.total) { + cam.last = cur; + setInfo(cam, 'misst…', 'ok'); + return; + } + + const dTotal = cur.total - cam.last.total; + const dDropped = cur.dropped - cam.last.dropped; + const dSec = (cur.t - cam.last.t) / 1000; + cam.last = cur; + + if (dTotal <= 0) { // kein neuer Frame → evtl. Freeze + setInfo(cam, '⏳ keine Frames', 'warn'); + cam.badTicks++; + } else { + const fps = Math.round(dTotal / dSec); + const ratio = dDropped / dTotal; + const pct = Math.round(ratio * 100); + const cls = ratio >= DROP_CRIT ? 'crit' : ratio >= DROP_WARN ? 'warn' : 'ok'; + setInfo(cam, `${fps} fps · ${pct}% drop`, cls); + cam.badTicks = ratio >= DROP_CRIT ? cam.badTicks + 1 : 0; + } + + // Auto-Schutz: anhaltende Überlast → diese Kamera abschalten + if (cam.badTicks >= OVERLOAD_TICKS) { + warn(cam.id, `überlastet (${cam.badTicks}× kritisch) → Auto-Abschaltung`); + stopStream(cam, true); + } + }); +} + +// ── Hinweis-Banner bei Auto-Abschaltung ────────────────────────────────────── +function showNotice() { + const off = cameras.filter(c => c.autoOff && !c.active).map(c => c.id); + const bar = document.getElementById('notice'); + if (off.length === 0) { bar.style.display = 'none'; return; } + bar.innerHTML = `⚠ Browser überlastet – automatisch deaktiviert: ${off.join(', ')} `; + const btn = document.createElement('button'); + btn.textContent = 'Wieder aktivieren'; + btn.onclick = () => { + cameras.filter(c => c.autoOff && !c.active).forEach(c => { c.autoOff = false; startStream(c); }); + showNotice(); + }; + bar.appendChild(btn); + bar.style.display = 'flex'; +} + +// ── Snapshot aller (auch abgeschalteter) Kameras ───────────────────────────── +function snapshotAll() { const ts = Date.now(); - log('snap', `Snapshot alle Kameras: ${camIds.join(', ')}`); - camIds.forEach(id => { + const ids = cameras.map(c => c.id); + log('snap', `Snapshot alle: ${ids.join(', ')}`); + ids.forEach(id => { const a = document.createElement('a'); - a.href = `/api/snapshot/${id}`; + a.href = `/api/snapshot/${id}`; a.download = `${id}_${ts}.jpg`; document.body.appendChild(a); a.click(); - document.body.removeChild(a); + a.remove(); }); } // ── Kamera-View aufbauen ───────────────────────────────────────────────────── -function buildCamera(camId, go2rtcPort, container) { - const wsUrl = `ws://${location.hostname}:${go2rtcPort}/api/ws?src=${encodeURIComponent(camId)}`; - log(camId, `View erstellt mode="${MODE}" ws=${wsUrl}`); - +function buildCamera(camId, container) { const box = document.createElement('div'); box.className = 'cam-box'; - const stream = document.createElement('video-stream'); - stream.mode = MODE; - - stream.addEventListener('play', () => log(camId, '▶ spielt'), true); - stream.addEventListener('playing', () => log(camId, '▶ Bild läuft'), true); - stream.addEventListener('pause', () => warn(camId, 'pausiert'), true); - stream.addEventListener('stalled', () => warn(camId, 'stalled (keine Daten)'), true); - stream.addEventListener('waiting', () => warn(camId, 'waiting (Buffer leer)'), true); - stream.addEventListener('error', (e) => logErr(camId, 'Video-Fehler', e), true); - - log(camId, `Verbinde WebSocket → ${wsUrl}`); - stream.src = wsUrl; - - box.appendChild(stream); - const label = document.createElement('div'); - label.className = 'cam-label'; + label.className = 'cam-label'; label.textContent = camId; - box.appendChild(label); + const info = document.createElement('div'); + info.className = 'cam-info'; + info.textContent = '…'; + + const toggle = document.createElement('button'); + toggle.className = 'cam-toggle'; + + const cam = { id: camId, box, infoEl: info, toggleBtn: toggle, active: false, last: null, badTicks: 0, autoOff: false }; + toggle.onclick = () => { cam.autoOff = false; cam.active ? stopStream(cam) : startStream(cam); showNotice(); }; + + box.appendChild(label); + box.appendChild(info); + box.appendChild(toggle); container.appendChild(box); + + cameras.push(cam); + startStream(cam); } // ── Init ───────────────────────────────────────────────────────────────────── async function init() { log('init', 'Starte...'); - let go2rtcPort = 1984; try { - const r = await fetch('/config.json'); - const d = await r.json(); - go2rtcPort = d.go2rtcPort ?? 1984; - log('init', `go2rtc WS-Port: ${go2rtcPort}`); + const d = await (await fetch('/config.json')).json(); + GO2RTC_PORT = d.go2rtcPort ?? 1984; + log('init', `go2rtc WS-Port: ${GO2RTC_PORT}`); } catch (e) { - warn('init', `Konnte /config.json nicht laden, nehme Port ${go2rtcPort}`); + warn('init', `/config.json nicht ladbar, nehme Port ${GO2RTC_PORT}`); } try { @@ -82,34 +185,25 @@ async function init() { const container = document.getElementById('cameras'); const statusText = document.getElementById('statusText'); - let cams = []; + let camIds = []; try { const r = await fetch('/api/snapshot'); log('init', `/api/snapshot → HTTP ${r.status}`); - if (r.ok) { - const d = await r.json(); - cams = (d.cameras ?? []).map(c => c.id); - log('init', `Kameras: ${cams.join(', ') || '(keine)'}`); - } + 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']; } - if (cams.length === 0) { - warn('init', 'Fallback auf cam0, cam1'); - cams = ['cam0', 'cam1']; - } + const snapBtn = document.getElementById('snapAllBtn'); + if (snapBtn) { snapBtn.onclick = snapshotAll; snapBtn.disabled = false; } - // Globaler Snapshot-Button in der Header-Bar verdrahten - const snapAllBtn = document.getElementById('snapAllBtn'); - if (snapAllBtn) { - snapAllBtn.onclick = () => snapshotAll(cams); - snapAllBtn.disabled = false; - } + camIds.forEach(id => buildCamera(id, container)); + statusText.textContent = `${camIds.length} Kamera${camIds.length !== 1 ? 's' : ''} · WebRTC`; - cams.forEach(id => buildCamera(id, go2rtcPort, container)); - statusText.textContent = `${cams.length} Kamera${cams.length !== 1 ? 's' : ''} · WebRTC`; - log('init', 'Fertig'); + setInterval(monitorTick, MONITOR_INTERVAL); // Health-Überwachung starten + log('init', 'Fertig – Überwachung aktiv'); } init();