Files
webcam-server/frontend.html
T

510 lines
24 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.rec{background:linear-gradient(135deg,#ef4444,#b91c1c);color:#fff;border:0}
.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}
#tl{position:fixed;inset:0;z-index:20;background:rgba(5,7,11,.92);display:flex;flex-direction:column;padding:16px;gap:12px}
#tl .bar{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
#tl .bar h2{margin:0;font-size:16px}
#tl select{background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:9px;padding:6px 10px;font:inherit}
#tl .body{flex:1;min-height:0;display:flex;flex-direction:column;gap:12px}
#tl .big{flex:1;min-height:0;background:#000;border-radius:var(--r);display:flex;align-items:center;justify-content:center;position:relative}
#tl .big img{max-width:100%;max-height:100%;object-fit:contain}
#tl .big .when{position:absolute;left:12px;bottom:10px;background:rgba(0,0,0,.6);padding:2px 10px;border-radius:999px;font-size:13px;font-variant-numeric:tabular-nums}
#tl .thumbs{display:flex;gap:8px;overflow-x:auto;padding-bottom:4px}
#tl .thumbs img{height:72px;aspect-ratio:16/9;object-fit:cover;border-radius:6px;border:2px solid transparent;cursor:pointer;flex:none;background:#000}
#tl .thumbs img.mot{border-color:#f59e0b}
#tl .thumbs img.sel{border-color:var(--accent)}
#tl .empty{color:var(--muted);margin: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>
<div id="tl" hidden></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"/>',
down: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>',
motion: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
rec: '<circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3" fill="currentColor"/>',
film: '<rect x="2" y="3" width="20" height="18" rx="2"/><path d="M7 3v18M17 3v18M2 8h5M2 16h5M17 8h5M17 16h5"/>',
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, rec: null, mot: null, tl: { enabled: false, interval: 300 }, 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"));
}
const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt");
const mot = btn("Bewegung aus", "motion", () => setTimelapse(name, { motion: !tiles[name].tl.motion }), "alt");
const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt");
o.rec = rec;
o.mot = mot;
actions.append(snap, rec, mot, gallery, 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;
}
// Timelapse-Galerie: Tag wählen, Bilder durchklicken oder abspielen
const tl = document.getElementById("tl");
let tlPlay = null;
function closeTimelapse() { clearInterval(tlPlay); tl.hidden = true; tl.replaceChildren(); }
document.addEventListener("keydown", e => { if (e.key === "Escape" && !tl.hidden) closeTimelapse(); });
async function openTimelapse(name) {
const bar = el("div", "bar");
const close = btn("Schließen", "stop", closeTimelapse, "alt");
const daySel = el("select");
const play = btn("Abspielen", "play", () => {}, "ok");
const ivSel = el("select");
ivSel.title = "Aufnahme-Intervall";
TL_INTERVALS.forEach(v => ivSel.append(new Option("alle " + fmtInterval(v), v)));
ivSel.value = tiles[name].tl.interval;
ivSel.onchange = () => setTimelapse(name, { interval: +ivSel.value });
const sensSel = el("select");
sensSel.title = "Empfindlichkeit der Bewegungserkennung";
[["low", "Bewegung: niedrig"], ["mid", "Bewegung: mittel"], ["high", "Bewegung: hoch"]].forEach(([v, t]) => sensSel.append(new Option(t, v)));
sensSel.value = tiles[name].tl.sensitivity;
sensSel.onchange = () => setTimelapse(name, { sensitivity: sensSel.value });
sensSel.hidden = !tiles[name].motionOk;
const kindSel = el("select");
kindSel.title = "Anzeige filtern";
[["all", "Alle Bilder"], ["lapse", "Nur Zeitraffer"], ["motion", "Nur Bewegung"]].forEach(([v, t]) => kindSel.append(new Option(t, v)));
const fpsSel = el("select");
fpsSel.title = "Bilder pro Sekunde im Video";
[5, 10, 25].forEach(v => fpsSel.append(new Option(v + " fps", v)));
fpsSel.value = 10;
const video = btn("Video", "down", () => {}, "alt");
bar.append(el("h2", "", "Timelapse " + name), daySel, kindSel, play, ivSel, sensSel, fpsSel, video, el("span", "sp"), close);
bar.querySelector(".sp").style.flex = "1";
const body = el("div", "body");
tl.replaceChildren(bar, body);
tl.hidden = false;
let data;
try {
const r = await fetch(ROOT + name + "/timelapse.json", { cache: "no-store" });
if (!r.ok) throw new Error(r.status);
data = await r.json();
} catch (e) {
body.append(el("div", "empty", "Timelapse konnte nicht geladen werden"));
return;
}
const days = Object.keys(data.days).filter(d => data.days[d].length);
if (!days.length) {
body.append(el("div", "empty", "Noch keine Bilder – Timelapse oder Bewegungserkennung in der Kachel starten"));
play.hidden = daySel.hidden = true;
return;
}
days.forEach(d => daySel.append(new Option(d + " (" + data.days[d].length + ")", d)));
const big = el("div", "big"), bigImg = el("img"), when = el("span", "when");
big.append(bigImg, when);
const thumbs = el("div", "thumbs");
body.append(big, thumbs);
let files = [], idx = 0;
const url = (d, f) => ROOT + name + "/timelapse/" + d + "/" + f;
const show = i => {
idx = (i + files.length) % files.length;
const d = daySel.value, f = files[idx];
bigImg.src = url(d, f);
when.textContent = d + " " + f.slice(0, 2) + ":" + f.slice(2, 4) + ":" + f.slice(4, 6) + (f.includes("_m") ? " · Bewegung" : "");
thumbs.querySelectorAll("img").forEach((im, k) => im.classList.toggle("sel", k === idx));
const sel = thumbs.children[idx];
if (sel) sel.scrollIntoView({ block: "nearest", inline: "center" });
};
const stop = () => { clearInterval(tlPlay); play.querySelector("span").textContent = "Abspielen"; };
const loadDay = () => {
stop();
files = data.days[daySel.value].filter(f => kindSel.value === "all" || (kindSel.value === "motion") === f.includes("_m"));
if (!files.length) {
thumbs.replaceChildren();
bigImg.removeAttribute("src");
when.textContent = "Keine Bilder dieser Art an diesem Tag";
return;
}
thumbs.replaceChildren(...files.map((f, i) => {
const im = el("img", f.includes("_m") ? "mot" : "");
im.loading = "lazy";
im.src = url(daySel.value, f);
im.onclick = () => { stop(); show(i); };
return im;
}));
show(files.length - 1);
};
daySel.onchange = loadDay;
kindSel.onchange = loadDay;
video.onclick = async () => {
const label = video.querySelector("span");
video.disabled = true;
label.textContent = "erstelle …";
try {
const r = await fetch(url(daySel.value, "video") + "?fps=" + fpsSel.value + (kindSel.value === "motion" ? "&kind=motion" : ""));
if (!r.ok) throw new Error(r.status);
const a = el("a");
a.href = URL.createObjectURL(await r.blob());
a.download = (r.headers.get("Content-Disposition") || "").match(/filename="(.+)"/)?.[1] || name + "-" + daySel.value + ".mp4";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
label.textContent = "Video";
} catch (e) {
label.textContent = "Fehler ✗";
setTimeout(() => { label.textContent = "Video"; }, 2500);
}
video.disabled = false;
};
play.onclick = () => {
if (!files.length) return;
if (tlPlay) { const was = play.querySelector("span").textContent === "Pause"; stop(); if (was) return; }
play.querySelector("span").textContent = "Pause";
if (idx === files.length - 1) show(0);
tlPlay = setInterval(() => { if (idx === files.length - 1) stop(); else show(idx + 1); }, 200);
};
loadDay();
}
const fmtInterval = s => s >= 3600 ? s / 3600 + " h" : s >= 60 ? s / 60 + " min" : s + " s";
const TL_INTERVALS = [10, 30, 60, 300, 900, 1800, 3600];
function renderTimelapse(o) {
const on = o.tl.enabled;
o.rec.classList.toggle("rec", on);
o.rec.classList.toggle("alt", !on);
o.rec.title = on ? "Timelapse läuft (alle " + fmtInterval(o.tl.interval) + ") – klicken zum Stoppen" : "Timelapse starten";
o.rec.querySelector("span").textContent = on ? "Timelapse " + fmtInterval(o.tl.interval) : "Timelapse aus";
o.mot.hidden = !o.motionOk;
o.mot.classList.toggle("rec", !!o.tl.motion);
o.mot.classList.toggle("alt", !o.tl.motion);
o.mot.title = o.tl.motion ? "Bewegungserkennung läuft – klicken zum Stoppen" : "Bewegungserkennung starten";
o.mot.querySelector("span").textContent = o.tl.motion ? "Bewegung an" : "Bewegung aus";
}
async function setTimelapse(name, change) {
try {
const r = await fetch(ROOT + name + "/timelapse", { method: "POST", body: JSON.stringify(change) });
if (!r.ok) throw new Error(r.status);
tiles[name].tl = await r.json();
renderTimelapse(tiles[name]);
return true;
} catch (e) {
return false;
}
}
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);
tiles[c.name].tl = c.timelapse;
tiles[c.name].motionOk = c.motion_ok;
renderTimelapse(tiles[c.name]);
});
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>