Neues Web-Frontend mit Statusanzeige, Vorschau-Leiste und relativen Links

This commit is contained in:
Joachim Hummel committed 2026-10-17 20:52:49 +02:00
1 parent cb21f948ee
commit aded536568
3 files changed
+202 -23

No files matched your search

+14 -2
View File
@@ -77,13 +77,25 @@ journalctl -u webcam-relay -f
| Pfad | Beschreibung | | Pfad | Beschreibung |
|---|---| |---|---|
| `/` | Übersicht aller Kameras | | `/` | Web-Frontend: Übersicht aller Kameras |
| `/<name>/` | Webseite mit Livebild einer Kamera | | `/<name>/` | Web-Frontend: große Ansicht einer Kamera, die anderen als Vorschau-Leiste |
| `/status.json` | Online-Status aller Kameras (wird vom Frontend alle 5 s abgefragt) |
| `/<name>/stream.mjpg` | Livestream (`multipart/x-mixed-replace`) | | `/<name>/stream.mjpg` | Livestream (`multipart/x-mixed-replace`) |
| `/<name>/snapshot.jpg` | Aktuelles Einzelbild | | `/<name>/snapshot.jpg` | Aktuelles Einzelbild |
| `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) | | `/stream.mjpg`, `/snapshot.jpg` | Alias für die erste Kamera (Abwärtskompatibilität) |
| `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar | | `/health` | Status aller Kameras (`200` wenn alle liefern, sonst `503`); ohne Authentifizierung erreichbar |
### Web-Frontend
Das Frontend liegt in `frontend.html` (reines HTML/CSS/JS, keine Abhängigkeiten) und wird vom Relay ausgeliefert. Änderungen an der Datei wirken sofort, ohne Neustart.
- Raster mit Live-Streams aller Kameras, Klick auf den Namen öffnet die große Ansicht
- Statuspunkt und Hinweis „Kamera offline“ pro Kamera, automatisches Neuverbinden
- Buttons für Foto (Download) und Vollbild
- Hell/Dunkel passend zum System
- Streams werden getrennt, solange der Tab im Hintergrund ist
- Alle Links sind relativ, daher funktioniert es auch unter einem Proxy-Prefix wie `/webcam/`
### Funktionsweise ### Funktionsweise
- Pro Kamera läuft ein Hintergrund-Thread, der den Stream liest und die JPEG-Frames anhand der Marker `FFD8`/`FFD9` ausschneidet. - Pro Kamera läuft ein Hintergrund-Thread, der den Stream liest und die JPEG-Frames anhand der Marker `FFD8`/`FFD9` ausschneidet.
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Webcams</title>
<style>
:root{--bg:#f4f5f7;--card:#fff;--fg:#1b1f24;--muted:#6a737d;--line:#dfe2e6;--accent:#2563eb;--ok:#16a34a;--bad:#dc2626}
@media (prefers-color-scheme:dark){:root{--bg:#0f1114;--card:#181b20;--fg:#e8eaed;--muted:#8b949e;--line:#2a2f36;--accent:#6ea8ff;--ok:#3fb950;--bad:#f85149}}
*{box-sizing:border-box}
html,body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.4 system-ui,-apple-system,"Segoe UI",sans-serif}
a{color:var(--accent);text-decoration:none}
header{display:flex;align-items:center;gap:12px;padding:12px 16px;border-bottom:1px solid var(--line);background:var(--card);position:sticky;top:0;z-index:5}
header h1{font-size:17px;margin:0;font-weight:600}
header .sp{flex:1}
#clock{color:var(--muted);font-variant-numeric:tabular-nums}
#banner{display:none;background:var(--bad);color:#fff;padding:8px 16px;text-align:center}
main{padding:16px;max-width:1800px;margin:0 auto}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
.tile{margin:0;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;position:relative}
.view{position:relative;background:#000;aspect-ratio:16/9}
.view img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;display:block}
.overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.65);color:#fff;font-weight:500}
.tile.off .overlay{display:flex}
.cap{display:flex;align-items:center;gap:8px;padding:8px 12px}
.cap .name{font-weight:600}
.dot{width:9px;height:9px;border-radius:50%;background:var(--muted)}
.tile.on .dot{background:var(--ok)}
.tile.off .dot{background:var(--bad)}
.state{color:var(--muted);font-size:13px}
.cap .sp{flex:1}
.btn{border:1px solid var(--line);background:transparent;color:var(--fg);border-radius:6px;padding:4px 9px;font:inherit;font-size:13px;cursor:pointer}
.btn:hover{border-color:var(--accent);color:var(--accent)}
.focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)}
.strip{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;margin-top:16px}
.strip .cap{padding:6px 10px;font-size:13px}
.tile:fullscreen{border:0;border-radius:0;display:flex;flex-direction:column;background:#000}
.tile:fullscreen .view{flex:1;aspect-ratio:auto}
.tile:fullscreen .cap{background:var(--card)}
</style>
</head>
<body>
<header>
<h1><a href="__ROOT__" style="color:inherit">Webcams</a></h1>
<span class="sp"></span>
<span id="clock"></span>
</header>
<div id="banner">Relay nicht erreichbar – neuer Versuch …</div>
<main id="app"></main>
<script>
const ROOT = __ROOT__, CURRENT = __CURRENT__;
const app = document.getElementById("app");
const banner = document.getElementById("banner");
const tiles = {};
let order = [], visible = true;
const el = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text) e.textContent = text;
return e;
};
const btn = (label, fn) => { const b = el("button", "btn", label); b.onclick = fn; return b; };
// mode: "live" = MJPEG-Stream, "thumb" = Einzelbild im Intervall
function makeTile(name, mode) {
const t = el("figure", "tile");
const view = el("div", "view");
const img = el("img");
img.alt = name;
const ov = el("div", "overlay", "Kamera offline");
view.append(img, ov);
const cap = el("figcaption", "cap");
const link = el("a", "name", name);
link.href = ROOT + name + "/";
const state = el("span", "state", "verbinde …");
const actions = el("span", "actions");
const snap = btn("Foto", () => {
const a = el("a");
a.href = ROOT + name + "/snapshot.jpg?t=" + Date.now();
a.download = name + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".jpg";
a.click();
});
const full = btn("Vollbild", () => document.fullscreenElement ? document.exitFullscreen() : t.requestFullscreen());
actions.append(snap, " ", full);
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
t.append(view, cap);
const o = { name, mode, t, img, state, online: null, timer: null, retry: null };
img.onerror = () => {
if (!visible || o.online === false) return;
clearTimeout(o.retry);
o.retry = setTimeout(() => connect(o), 3000);
};
tiles[name] = o;
return t;
}
function connect(o) {
clearTimeout(o.retry);
clearInterval(o.timer);
if (!visible) { o.img.removeAttribute("src"); return; }
const base = ROOT + o.name + "/";
if (o.mode === "live") {
o.img.src = base + "stream.mjpg?t=" + Date.now();
} else {
const load = () => { o.img.src = base + "snapshot.jpg?t=" + Date.now(); };
load();
o.timer = setInterval(load, 3000);
}
}
function setOnline(o, on) {
o.t.classList.toggle("on", on);
o.t.classList.toggle("off", !on);
o.state.textContent = on ? "live" : "offline";
if (on && o.online !== true) connect(o);
o.online = on;
}
function build(names) {
order = names;
app.replaceChildren();
if (CURRENT && names.includes(CURRENT)) {
app.className = "focus";
const big = makeTile(CURRENT, "live");
big.classList.add("big");
app.append(big);
const strip = el("div", "strip");
names.filter(n => n !== CURRENT).forEach(n => strip.append(makeTile(n, "thumb")));
if (strip.children.length) app.append(strip);
} else {
const grid = el("div", "grid");
names.forEach(n => grid.append(makeTile(n, "live")));
app.append(grid);
}
}
async function poll() {
try {
const r = await fetch(ROOT + "status.json", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
const data = await r.json();
banner.style.display = "none";
const names = data.cameras.map(c => c.name);
if (names.join() !== order.join()) build(names);
data.cameras.forEach(c => setOnline(tiles[c.name], c.online));
} catch (e) {
banner.style.display = "block";
}
}
// Verbindungen im Hintergrund-Tab trennen, spart Bandbreite
document.addEventListener("visibilitychange", () => {
visible = !document.hidden;
Object.values(tiles).forEach(o => { if (!visible || o.online) connect(o); });
});
const clock = document.getElementById("clock");
const tick = () => { clock.textContent = new Date().toLocaleString("de-DE"); };
tick(); setInterval(tick, 1000);
poll(); setInterval(poll, 5000);
</script>
</body>
</html>
+22 -21
View File
@@ -3,8 +3,9 @@
per HTTP für beliebig viele Zuschauer bereit (hinter Zoraxy / Reverse Proxy). per HTTP für beliebig viele Zuschauer bereit (hinter Zoraxy / Reverse Proxy).
Endpunkte: Endpunkte:
/ Übersicht aller Kameras / Übersicht aller Kameras (Web-Frontend, frontend.html)
/<name>/ Webseite mit Livebild einer Kamera /<name>/ Webseite mit Livebild einer Kamera
/status.json Status aller Kameras (vom Frontend abgefragt)
/<name>/stream.mjpg Livestream (multipart/x-mixed-replace) /<name>/stream.mjpg Livestream (multipart/x-mixed-replace)
/<name>/snapshot.jpg aktuelles Einzelbild /<name>/snapshot.jpg aktuelles Einzelbild
/stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität) /stream.mjpg Alias für die erste Kamera (Abwärtskompatibilität)
@@ -17,6 +18,7 @@ Konfiguration über die Datei .env (neben relay.py) oder Umgebungsvariablen:
""" """
import base64 import base64
import hmac import hmac
import json
import os import os
import threading import threading
import time import time
@@ -48,22 +50,15 @@ AUTH_USER = os.environ.get("AUTH_USER", "")
AUTH_PASS = os.environ.get("AUTH_PASS", "") AUTH_PASS = os.environ.get("AUTH_PASS", "")
BOUNDARY = "frame" BOUNDARY = "frame"
HEAD = ("<!doctype html><html lang=de><head><meta charset=utf-8>" FRONTEND = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend.html")
"<meta name=viewport content='width=device-width,initial-scale=1'><title>Webcam</title>"
"<style>html,body{margin:0;background:#111;color:#eee;font-family:sans-serif}"
"img{display:block;width:100%}a{color:#8cf}"
".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:8px;padding:8px}"
"h3{margin:4px 0}</style></head><body>")
def single_page(name): def page(root, current):
return (HEAD + f"<img src=stream.mjpg alt='{name}'></body></html>").encode() """Frontend ausliefern; Links sind relativ, damit es auch unter einem Proxy-Prefix läuft."""
with open(FRONTEND, encoding="utf-8") as f:
html = f.read()
def overview_page(): return (html.replace("__ROOT__", json.dumps(root))
cells = "".join(f"<div><h3><a href='{n}/'>{n}</a></h3><img src='{n}/stream.mjpg' alt='{n}'></div>" .replace("__CURRENT__", json.dumps(current))).encode()
for n in CAMERAS)
return (HEAD + f"<div class=grid>{cells}</div></body></html>").encode()
class Hub: class Hub:
@@ -89,6 +84,10 @@ class Hub:
hubs = {name: Hub() for name in CAMERAS} hubs = {name: Hub() for name in CAMERAS}
def is_online(hub):
return bool(hub.last) and time.time() - hub.last < 10
def pull_camera(name, url, hub): def pull_camera(name, url, hub):
"""Liest die Kamera dauerhaft; baut die Verbindung bei Fehlern neu auf.""" """Liest die Kamera dauerhaft; baut die Verbindung bei Fehlern neu auf."""
while True: while True:
@@ -140,16 +139,18 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self): def do_GET(self):
parts = [p for p in self.path.split("?")[0].split("/") if p] parts = [p for p in self.path.split("?")[0].split("/") if p]
if parts == ["health"]: if parts == ["health"]:
now = time.time() lines = [f"{n}: {'ok' if is_online(h) else 'keine Frames'}" for n, h in hubs.items()]
lines = [f"{n}: {'ok' if h.last and now - h.last < 10 else 'keine Frames'}"
for n, h in hubs.items()]
ok = all(l.endswith(": ok") for l in lines) ok = all(l.endswith(": ok") for l in lines)
return self.send_bytes(200 if ok else 503, "text/plain", ("\n".join(lines) + "\n").encode()) return self.send_bytes(200 if ok else 503, "text/plain", ("\n".join(lines) + "\n").encode())
if not self.authorized(): if not self.authorized():
return self.send_bytes(401, "text/plain", b"Login erforderlich", return self.send_bytes(401, "text/plain", b"Login erforderlich",
{"WWW-Authenticate": 'Basic realm="Webcam"'}) {"WWW-Authenticate": 'Basic realm="Webcam"'})
if not parts: if not parts:
return self.send_bytes(200, "text/html; charset=utf-8", overview_page()) return self.send_bytes(200, "text/html; charset=utf-8", page("", ""))
if parts == ["status.json"]:
body = json.dumps({"cameras": [{"name": n, "online": is_online(h)}
for n, h in hubs.items()]}).encode()
return self.send_bytes(200, "application/json", body)
if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]: if parts == ["stream.mjpg"] or parts == ["snapshot.jpg"]:
parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera parts = [next(iter(CAMERAS))] + parts # Alias: erste Kamera
name = parts[0] name = parts[0]
@@ -159,8 +160,8 @@ class Handler(BaseHTTPRequestHandler):
what = parts[1] if len(parts) == 2 else "" what = parts[1] if len(parts) == 2 else ""
if what == "": if what == "":
if not self.path.split("?")[0].endswith("/"): if not self.path.split("?")[0].endswith("/"):
return self.send_bytes(301, "text/plain", b"", {"Location": f"/{name}/"}) return self.send_bytes(301, "text/plain", b"", {"Location": f"{name}/"}) # relativ: Proxy-Prefix bleibt erhalten
return self.send_bytes(200, "text/html; charset=utf-8", single_page(name)) return self.send_bytes(200, "text/html; charset=utf-8", page("../", name))
if what == "snapshot.jpg": if what == "snapshot.jpg":
if hub.frame is None: if hub.frame is None:
return self.send_bytes(503, "text/plain", b"Noch kein Bild") return self.send_bytes(503, "text/plain", b"Noch kein Bild")