Files
webcam-server/frontend.html
T

233 lines
12 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}
*{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}
.cap{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)}
.cap .sp{flex:1}
.actions{display:flex;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.alt{background:#222a3a;box-shadow:none;color:var(--fg);border:1px solid var(--line)}
.btn.alt: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(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>
<button class="btn" id="setupAll" hidden><span>Setup alle</span></button>
<span class="pill" id="count"></span>
<span class="pill" 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");
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"/>',
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"));
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");
ov.append(icon("off"), el("span", "", "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", "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);
}
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;
}
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.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(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");
names.forEach(n => grid.append(makeTile(n, "live", setupOf(n))));
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(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>