Neues Web-Frontend mit Statusanzeige, Vorschau-Leiste und relativen Links
This commit is contained in:
1 parent
cb21f948ee
commit
aded536568
3 files changed
+202
-23
No files matched your search
@@ -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
@@ -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>
|
||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user