Frontend: Start/Stop/Reload pro Kamera und Toolbar (Alle starten/stoppen/neu laden, Setup alle)

This commit is contained in:
Joachim Hummel committed 2026-10-17 21:15:53 +02:00
1 parent 7c80b3402e
commit 4d14458c46
1 file changed
+67 -14
+67 -14
View File
@@ -6,6 +6,7 @@
<title>Webcams</title> <title>Webcams</title>
<style> <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} :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} *{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} 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} 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}
@@ -28,7 +29,8 @@ main{padding:22px;max-width:1800px;margin:0 auto}
.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{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)} .overlay svg{width:34px;height:34px;color:var(--bad)}
.tile.off .overlay{display:flex} .tile.off .overlay{display:flex}
.cap{display:flex;align-items:center;gap:10px;padding:10px 14px;background:linear-gradient(180deg,var(--card),var(--bg2))} .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{font-weight:600;color:var(--fg)}
.cap .name:hover{color:var(--accent)} .cap .name:hover{color:var(--accent)}
.dot{width:10px;height:10px;border-radius:50%;background:var(--muted);flex:none} .dot{width:10px;height:10px;border-radius:50%;background:var(--muted);flex:none}
@@ -38,13 +40,21 @@ main{padding:22px;max-width:1800px;margin:0 auto}
.state{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)} .state{font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}
.tile.on .state{color:var(--ok)} .tile.on .state{color:var(--ok)}
.tile.off .state{color:var(--bad)} .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} .cap .sp{flex:1}
.actions{display:flex;gap:8px} .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{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 svg{width:15px;height:15px}
.btn:hover{filter:brightness(1.15);box-shadow:0 4px 16px rgba(139,92,246,.4)} .btn:hover{filter:brightness(1.15);box-shadow:0 4px 16px rgba(139,92,246,.4)}
.btn:active{transform:scale(.95)} .btn:active{transform:scale(.95)}
.btn:disabled{opacity:.7;cursor:default} .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{background:#222a3a;box-shadow:none;color:var(--fg);border:1px solid var(--line)}
.btn.alt:hover{border-color:var(--accent);color:var(--accent)} .btn.alt:hover{border-color:var(--accent);color:var(--accent)}
.focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)} .focus .tile.big .view{aspect-ratio:auto;height:min(75vh,56.25vw)}
@@ -61,12 +71,19 @@ main{padding:22px;max-width:1800px;margin:0 auto}
<header> <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> <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="sp"></span>
<button class="btn" id="setupAll" hidden><span>Setup alle</span></button>
<span class="pill" id="count"></span> <span class="pill" id="count"></span>
<span class="pill" id="clock"></span> <span class="pill" id="clock"></span>
</header> </header>
<div id="banner">Relay nicht erreichbar – neuer Versuch …</div> <div id="banner">Relay nicht erreichbar – neuer Versuch …</div>
<main id="app"></main> <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> <script>
const ROOT = __ROOT__, CURRENT = __CURRENT__; const ROOT = __ROOT__, CURRENT = __CURRENT__;
const app = document.getElementById("app"); const app = document.getElementById("app");
@@ -85,6 +102,9 @@ const el = (tag, cls, text) => {
const ICONS = { 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"/>', 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"/>', 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"/>', 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"/>' 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"/>'
}; };
@@ -92,6 +112,13 @@ const icon = k => { const s = document.createElementNS("http://www.w3.org/2000/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 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"); const setupAllBtn = document.getElementById("setupAll");
setupAllBtn.prepend(icon("cog")); 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.onclick = async () => {
setupAllBtn.disabled = true; setupAllBtn.disabled = true;
const label = setupAllBtn.querySelector("span"); const label = setupAllBtn.querySelector("span");
@@ -107,7 +134,8 @@ function makeTile(name, mode, hasSetup) {
const img = el("img"); const img = el("img");
img.alt = name; img.alt = name;
const ov = el("div", "overlay"); const ov = el("div", "overlay");
ov.append(icon("off"), el("span", "", "Kamera offline")); const ovText = el("span", "", "Kamera offline");
ov.append(icon("off"), ovText);
view.append(img, ov); view.append(img, ov);
const cap = el("figcaption", "cap"); const cap = el("figcaption", "cap");
@@ -131,13 +159,19 @@ function makeTile(name, mode, hasSetup) {
}, "alt"); }, "alt");
actions.append(setup); 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); actions.append(snap, full);
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions); cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
t.append(view, cap); t.append(view, cap);
const o = { name, mode, t, img, state, online: null, timer: null, retry: null };
img.onerror = () => { img.onerror = () => {
if (!visible || o.online === false) return; if (!visible || o.stopped || o.online === false) return;
clearTimeout(o.retry); clearTimeout(o.retry);
o.retry = setTimeout(() => connect(o), 3000); o.retry = setTimeout(() => connect(o), 3000);
}; };
@@ -160,7 +194,7 @@ async function runSetup(name) {
function connect(o) { function connect(o) {
clearTimeout(o.retry); clearTimeout(o.retry);
clearInterval(o.timer); clearInterval(o.timer);
if (!visible) { o.img.removeAttribute("src"); return; } if (!visible || o.stopped) { o.img.removeAttribute("src"); return; }
const base = ROOT + o.name + "/"; const base = ROOT + o.name + "/";
if (o.mode === "live") { if (o.mode === "live") {
o.img.src = base + "stream.mjpg?t=" + Date.now(); o.img.src = base + "stream.mjpg?t=" + Date.now();
@@ -171,14 +205,33 @@ function connect(o) {
} }
} }
function setOnline(o, on) { function render(o) {
o.t.classList.toggle("on", on); const on = o.online === true;
o.t.classList.toggle("off", !on); o.t.classList.toggle("stopped", o.stopped);
o.state.textContent = on ? "live" : "offline"; o.t.classList.toggle("on", on && !o.stopped);
if (on && o.online !== true) connect(o); o.t.classList.toggle("off", !on && !o.stopped);
o.online = on; 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");
function build(cams) { function build(cams) {
const names = cams.map(c => c.name); const names = cams.map(c => c.name);
const setupOf = n => cams.find(c => c.name === n).setup; const setupOf = n => cams.find(c => c.name === n).setup;