337 lines
16 KiB
HTML
337 lines
16 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{color-scheme:dark;--bg:#0b0e14;--bg2:#10141c;--card:#151a24;--fg:#e6e9f0;--muted:#8791a5;--line:#232a38;--accent:#4f9dff;--accent2:#8b5cf6;--ok:#34d399;--bad:#f87171;--r:14px}
|
||
[hidden]{display:none!important}
|
||
*{box-sizing:border-box}
|
||
html,body{margin:0;min-height:100%;color:var(--fg);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
||
body{background:radial-gradient(1200px 600px at 10% -10%,#16213a 0%,transparent 60%),radial-gradient(900px 500px at 100% 0%,#1d1535 0%,transparent 55%),var(--bg);background-attachment:fixed}
|
||
a{color:var(--accent);text-decoration:none}
|
||
header{display:flex;align-items:center;gap:12px;padding:14px 22px;background:rgba(16,20,28,.72);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:5}
|
||
header h1{font-size:18px;margin:0;font-weight:700;letter-spacing:.2px;display:flex;align-items:center;gap:10px}
|
||
header h1 a{color:var(--fg)}
|
||
header h1 svg{width:24px;height:24px;color:var(--accent)}
|
||
header .sp{flex:1}
|
||
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:999px;background:var(--card);border:1px solid var(--line);color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums}
|
||
.pill b{color:var(--ok);font-weight:600}
|
||
#banner{display:none;background:linear-gradient(90deg,#b91c1c,#ef4444);color:#fff;padding:9px 16px;text-align:center;font-weight:500}
|
||
main{padding:22px;max-width:1800px;margin:0 auto}
|
||
.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px}
|
||
.tile{margin:0;background:var(--card);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;position:relative;box-shadow:0 8px 24px rgba(0,0,0,.35);transition:transform .15s,border-color .15s,box-shadow .15s}
|
||
.tile:hover{border-color:#34405a;box-shadow:0 12px 32px rgba(0,0,0,.5)}
|
||
.strip .tile:hover{transform:translateY(-2px)}
|
||
.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;flex-direction:column;gap:8px;align-items:center;justify-content:center;background:rgba(5,7,11,.8);color:var(--muted);font-weight:500}
|
||
.overlay svg{width:34px;height:34px;color:var(--bad)}
|
||
.tile.off .overlay{display:flex}
|
||
.toolbar{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin:0 0 20px;padding:12px 14px;background:var(--card);border:1px solid var(--line);border-radius:var(--r)}
|
||
.cap{flex-wrap:wrap;display:flex;align-items:center;gap:10px;padding:10px 14px;background:linear-gradient(180deg,var(--card),var(--bg2))}
|
||
.cap .name{font-weight:600;color:var(--fg)}
|
||
.cap .name:hover{color:var(--accent)}
|
||
.dot{width:10px;height:10px;border-radius:50%;background:var(--muted);flex:none}
|
||
.tile.on .dot{background:var(--ok);box-shadow:0 0 0 0 rgba(52,211,153,.6);animation:pulse 2s infinite}
|
||
.tile.off .dot{background:var(--bad)}
|
||
@keyframes pulse{70%{box-shadow:0 0 0 7px rgba(52,211,153,0)}100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}}
|
||
.state{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}
|
||
.tile.on .state{color:var(--ok)}
|
||
.tile.off .state{color:var(--bad)}
|
||
.tile.stopped .dot{background:var(--muted);animation:none;box-shadow:none}
|
||
.tile.stopped .state{color:var(--muted)}
|
||
.tile.stopped .overlay{display:flex}
|
||
.tile.stopped .overlay svg{color:var(--muted)}
|
||
.cap .sp{flex:1}
|
||
.actions{display:flex;flex-wrap:wrap;gap:8px}
|
||
.btn{display:inline-flex;align-items:center;gap:6px;border:0;border-radius:9px;padding:7px 13px;font:inherit;font-size:13px;font-weight:600;color:#fff;cursor:pointer;background:linear-gradient(135deg,var(--accent),var(--accent2));box-shadow:0 2px 10px rgba(79,157,255,.25);transition:transform .1s,filter .15s,box-shadow .15s}
|
||
.btn svg{width:15px;height:15px}
|
||
.btn:hover{filter:brightness(1.15);box-shadow:0 4px 16px rgba(139,92,246,.4)}
|
||
.btn:active{transform:scale(.95)}
|
||
.btn:disabled{opacity:.7;cursor:default}
|
||
.btn.danger{background:linear-gradient(135deg,#ef4444,#b91c1c);box-shadow:0 2px 10px rgba(239,68,68,.25)}
|
||
.btn.danger:hover{box-shadow:0 4px 16px rgba(239,68,68,.45)}
|
||
.btn.ok{background:linear-gradient(135deg,#22c55e,#15803d);box-shadow:0 2px 10px rgba(34,197,94,.25)}
|
||
.btn.ok:hover{box-shadow:0 4px 16px rgba(34,197,94,.45)}
|
||
.btn.alt{background:#222a3a;box-shadow:none;color:var(--fg);border:1px solid var(--line)}
|
||
.btn.alt:hover{border-color:var(--accent);color:var(--accent)}
|
||
.grid .cap{cursor:grab}
|
||
.grid .cap .btn{cursor:pointer}
|
||
.tile.dragging{opacity:.4}
|
||
.tile.dragover{border-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(220px,1fr));gap:16px;margin-top:20px}
|
||
.strip .cap{padding:8px 12px;font-size:13px}
|
||
.strip .btn{padding:5px 9px}
|
||
.tile:fullscreen{border:0;border-radius:0;display:flex;flex-direction:column;background:#000}
|
||
.tile:fullscreen .view{flex:1;aspect-ratio:auto}
|
||
@media (max-width:700px){.grid{grid-template-columns:1fr}}
|
||
@media (max-width:600px){header{padding:12px 14px}main{padding:12px}.btn span{display:none}.btn{padding:7px 10px}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 7l-7 5 7 5V7z"/><rect x="1" y="5" width="15" height="14" rx="2"/></svg><a id="home">Webcams</a></h1>
|
||
<span class="sp"></span>
|
||
<span class="pill" id="count"></span>
|
||
<span class="pill" id="clock"></span>
|
||
</header>
|
||
<div id="banner">Relay nicht erreichbar – neuer Versuch …</div>
|
||
<main>
|
||
<section class="toolbar" id="toolbar">
|
||
<button class="btn ok" id="allStart"><span>Alle starten</span></button>
|
||
<button class="btn danger" id="allStop"><span>Alle stoppen</span></button>
|
||
<button class="btn alt" id="allReload"><span>Alle neu laden</span></button>
|
||
<button class="btn alt" id="setupAll" hidden><span>Setup alle</span></button>
|
||
</section>
|
||
<div id="app"></div>
|
||
</main>
|
||
<script>
|
||
const ROOT = __ROOT__, CURRENT = __CURRENT__;
|
||
const app = document.getElementById("app");
|
||
document.getElementById("home").href = ROOT;
|
||
const banner = document.getElementById("banner");
|
||
const tiles = {};
|
||
let setupNames = [];
|
||
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 ICONS = {
|
||
cam: '<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/>',
|
||
cog: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||
play: '<path d="M6 4l14 8-14 8z"/>',
|
||
stop: '<rect x="5" y="5" width="14" height="14" rx="2"/>',
|
||
reload: '<path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.5 9a9 9 0 0 1 14.9-3.4L23 10M1 14l4.6 4.4A9 9 0 0 0 20.5 15"/>',
|
||
full: '<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>',
|
||
off: '<path d="M1 1l22 22M21 21H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3-3h6l2 3h4a2 2 0 0 1 2 2v9.34m-7.72-2.06a4 4 0 1 1-5.56-5.56"/>'
|
||
};
|
||
const icon = k => { const s = document.createElementNS("http://www.w3.org/2000/svg", "svg"); s.setAttribute("viewBox", "0 0 24 24"); s.setAttribute("fill", "none"); s.setAttribute("stroke", "currentColor"); s.setAttribute("stroke-width", "2"); s.setAttribute("stroke-linecap", "round"); s.setAttribute("stroke-linejoin", "round"); s.innerHTML = ICONS[k]; return s; };
|
||
const btn = (label, ic, fn, cls) => { const b = el("button", "btn" + (cls ? " " + cls : "")); b.title = label; b.append(icon(ic), el("span", "", label)); b.onclick = fn; return b; };
|
||
const setupAllBtn = document.getElementById("setupAll");
|
||
setupAllBtn.prepend(icon("cog"));
|
||
[["allStart", "play", () => liveTiles().forEach(startTile)],
|
||
["allStop", "stop", () => liveTiles().forEach(stopTile)],
|
||
["allReload", "reload", () => liveTiles().forEach(reloadTile)]].forEach(([id, ic, fn]) => {
|
||
const b = document.getElementById(id);
|
||
b.prepend(icon(ic));
|
||
b.onclick = fn;
|
||
});
|
||
setupAllBtn.onclick = async () => {
|
||
setupAllBtn.disabled = true;
|
||
const label = setupAllBtn.querySelector("span");
|
||
const res = await Promise.all(setupNames.map(runSetup));
|
||
label.textContent = res.every(Boolean) ? "Gesendet ✓" : "Fehler ✗";
|
||
setTimeout(() => { label.textContent = "Setup alle"; setupAllBtn.disabled = false; }, 2500);
|
||
};
|
||
|
||
// mode: "live" = MJPEG-Stream, "thumb" = Einzelbild im Intervall
|
||
function makeTile(name, mode, hasSetup) {
|
||
const t = el("figure", "tile");
|
||
const view = el("div", "view");
|
||
const img = el("img");
|
||
img.alt = name;
|
||
const ov = el("div", "overlay");
|
||
const ovText = el("span", "", "Kamera offline");
|
||
ov.append(icon("off"), ovText);
|
||
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", "cam", () => {
|
||
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", "full", () => document.fullscreenElement ? document.exitFullscreen() : t.requestFullscreen(), "alt");
|
||
if (hasSetup) {
|
||
const setup = btn("Setup", "cog", async () => {
|
||
setup.disabled = true;
|
||
const label = setup.querySelector("span");
|
||
label.textContent = (await runSetup(name)) ? "Gesendet ✓" : "Fehler ✗";
|
||
setTimeout(() => { label.textContent = "Setup"; setup.disabled = false; }, 2500);
|
||
}, "alt");
|
||
actions.append(setup);
|
||
}
|
||
const o = { name, mode, t, img, state, ovText, stopped: false, online: null, timer: null, retry: null };
|
||
if (mode === "live") {
|
||
actions.append(
|
||
btn("Start", "play", () => startTile(o), "ok"),
|
||
btn("Stop", "stop", () => stopTile(o), "danger"),
|
||
btn("Reload", "reload", () => reloadTile(o), "alt"));
|
||
}
|
||
actions.append(snap, full);
|
||
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
|
||
t.append(view, cap);
|
||
|
||
img.onerror = () => {
|
||
if (!visible || o.stopped || o.online === false) return;
|
||
clearTimeout(o.retry);
|
||
o.retry = setTimeout(() => connect(o), 3000);
|
||
};
|
||
tiles[name] = o;
|
||
return t;
|
||
}
|
||
|
||
async function runSetup(name) {
|
||
try {
|
||
const r = await fetch(ROOT + name + "/setup", { method: "POST" });
|
||
if (!r.ok) throw new Error(r.status);
|
||
// Kamera startet den Stream mit neuer Auflösung; Bild kurz danach neu laden
|
||
setTimeout(() => tiles[name] && connect(tiles[name]), 800);
|
||
return true;
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function connect(o) {
|
||
clearTimeout(o.retry);
|
||
clearInterval(o.timer);
|
||
if (!visible || o.stopped) { 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 render(o) {
|
||
const on = o.online === true;
|
||
o.t.classList.toggle("stopped", o.stopped);
|
||
o.t.classList.toggle("on", on && !o.stopped);
|
||
o.t.classList.toggle("off", !on && !o.stopped);
|
||
o.state.textContent = o.stopped ? "gestoppt" : on ? "live" : "offline";
|
||
if (o.ovText) o.ovText.textContent = o.stopped ? "Stream gestoppt" : "Kamera offline";
|
||
}
|
||
|
||
function setOnline(o, on) {
|
||
const was = o.online;
|
||
o.online = on;
|
||
render(o);
|
||
if (on && was !== true) connect(o);
|
||
}
|
||
|
||
function startTile(o) { o.stopped = false; render(o); connect(o); }
|
||
function stopTile(o) {
|
||
o.stopped = true;
|
||
clearTimeout(o.retry);
|
||
clearInterval(o.timer);
|
||
o.img.removeAttribute("src");
|
||
render(o);
|
||
}
|
||
function reloadTile(o) { stopTile(o); setTimeout(() => startTile(o), 300); }
|
||
const liveTiles = () => Object.values(tiles).filter(o => o.mode === "live");
|
||
|
||
const ORDER_KEY = "webcam-order";
|
||
function savedOrder() {
|
||
try { return JSON.parse(localStorage.getItem(ORDER_KEY)) || []; } catch (e) { return []; }
|
||
}
|
||
function sortBySaved(names) {
|
||
const saved = savedOrder();
|
||
const rank = n => { const i = saved.indexOf(n); return i < 0 ? saved.length : i; };
|
||
return names.map((n, i) => [n, i]).sort((a, b) => rank(a[0]) - rank(b[0]) || a[1] - b[1]).map(x => x[0]);
|
||
}
|
||
// Kacheln per Drag-and-drop an der Beschriftungsleiste umsortieren
|
||
function enableReorder(grid) {
|
||
let dragged = null;
|
||
grid.querySelectorAll(".tile").forEach(t => {
|
||
const cap = t.querySelector(".cap");
|
||
cap.draggable = true;
|
||
cap.addEventListener("dragstart", e => {
|
||
dragged = t;
|
||
t.classList.add("dragging");
|
||
e.dataTransfer.effectAllowed = "move";
|
||
e.dataTransfer.setData("text/plain", t.querySelector(".name").textContent);
|
||
e.dataTransfer.setDragImage(t, 20, 20);
|
||
});
|
||
cap.addEventListener("dragend", () => {
|
||
t.classList.remove("dragging");
|
||
grid.querySelectorAll(".dragover").forEach(x => x.classList.remove("dragover"));
|
||
dragged = null;
|
||
});
|
||
t.addEventListener("dragover", e => {
|
||
if (!dragged || dragged === t) return;
|
||
e.preventDefault();
|
||
t.classList.add("dragover");
|
||
});
|
||
t.addEventListener("dragleave", () => t.classList.remove("dragover"));
|
||
t.addEventListener("drop", e => {
|
||
e.preventDefault();
|
||
t.classList.remove("dragover");
|
||
if (!dragged || dragged === t) return;
|
||
const kids = [...grid.children];
|
||
grid.insertBefore(dragged, kids.indexOf(dragged) < kids.indexOf(t) ? t.nextSibling : t);
|
||
try {
|
||
localStorage.setItem(ORDER_KEY, JSON.stringify([...grid.children].map(c => c.querySelector(".name").textContent)));
|
||
} catch (err) {}
|
||
});
|
||
});
|
||
}
|
||
|
||
function build(cams) {
|
||
const names = cams.map(c => c.name);
|
||
const setupOf = n => cams.find(c => c.name === n).setup;
|
||
order = names;
|
||
app.replaceChildren();
|
||
if (CURRENT && names.includes(CURRENT)) {
|
||
app.className = "focus";
|
||
const big = makeTile(CURRENT, "live", setupOf(CURRENT));
|
||
big.classList.add("big");
|
||
app.append(big);
|
||
const strip = el("div", "strip");
|
||
names.filter(n => n !== CURRENT).forEach(n => strip.append(makeTile(n, "thumb", false)));
|
||
if (strip.children.length) app.append(strip);
|
||
} else {
|
||
const grid = el("div", "grid");
|
||
sortBySaved(names).forEach(n => grid.append(makeTile(n, "live", setupOf(n))));
|
||
app.append(grid);
|
||
enableReorder(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(data.cameras);
|
||
data.cameras.forEach(c => setOnline(tiles[c.name], c.online));
|
||
const n = data.cameras.filter(c => c.online).length;
|
||
setupNames = data.cameras.filter(c => c.setup).map(c => c.name);
|
||
setupAllBtn.hidden = !setupNames.length;
|
||
document.getElementById("count").innerHTML = "<b>" + n + "</b> / " + data.cameras.length + " 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>
|