Files
webcam-server/frontend.html
T

337 lines
16 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.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>