167 lines
6.3 KiB
HTML
167 lines
6.3 KiB
HTML
<!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>
|