Timelapse pro Kamera (Ein/Aus, Intervall) im Relay, Galerie im Frontend
This commit is contained in:
1 parent
0b2837f0c3
commit
6b2cc45550
5 files changed
+261
-7
No files matched your search
+123
-3
@@ -55,6 +55,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
||||
.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}
|
||||
@@ -67,6 +68,18 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
||||
.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.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>
|
||||
@@ -87,6 +100,7 @@ main{padding:22px;max-width:1800px;margin:0 auto}
|
||||
<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__;
|
||||
@@ -110,6 +124,8 @@ const ICONS = {
|
||||
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"/>',
|
||||
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; };
|
||||
@@ -163,14 +179,16 @@ function makeTile(name, mode, hasSetup) {
|
||||
}, "alt");
|
||||
actions.append(setup);
|
||||
}
|
||||
const o = { name, mode, t, img, state, ovText, stopped: false, online: null, timer: null, retry: null };
|
||||
const o = { name, mode, t, img, state, ovText, rec, 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"));
|
||||
}
|
||||
actions.append(snap, full);
|
||||
const rec = btn("Timelapse aus", "rec", () => setTimelapse(name, { enabled: !tiles[name].tl.enabled }), "alt");
|
||||
const gallery = btn("Galerie", "film", () => openTimelapse(name), "alt");
|
||||
actions.append(snap, rec, gallery, full);
|
||||
cap.append(el("span", "dot"), link, state, el("span", "sp"), actions);
|
||||
t.append(view, cap);
|
||||
|
||||
@@ -183,6 +201,104 @@ function makeTile(name, mode, hasSetup) {
|
||||
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 });
|
||||
bar.append(el("h2", "", "Timelapse " + name), daySel, play, ivSel, 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 mit dem Button 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);
|
||||
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];
|
||||
thumbs.replaceChildren(...files.map((f, i) => {
|
||||
const im = el("img");
|
||||
im.loading = "lazy";
|
||||
im.src = url(daySel.value, f);
|
||||
im.onclick = () => { stop(); show(i); };
|
||||
return im;
|
||||
}));
|
||||
show(files.length - 1);
|
||||
};
|
||||
daySel.onchange = loadDay;
|
||||
play.onclick = () => {
|
||||
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";
|
||||
}
|
||||
|
||||
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" });
|
||||
@@ -311,7 +427,11 @@ async function poll() {
|
||||
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));
|
||||
data.cameras.forEach(c => {
|
||||
setOnline(tiles[c.name], c.online);
|
||||
tiles[c.name].tl = c.timelapse;
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user