internal/web/assets/app.js
410 Zeilen · 13.6 KB · JavaScript
| 1 | /* TV-Wall Webinterface. Reines Vanilla-JS ohne externe Bibliotheken, damit die |
| 2 | Oberflaeche auch ohne Internetverbindung (Pi als Access Point) funktioniert. */ |
| 3 | 'use strict'; |
| 4 | |
| 5 | const $ = (id) => document.getElementById(id); |
| 6 | |
| 7 | const el = { |
| 8 | deviceLine: $('deviceLine'), |
| 9 | nowText: $('nowText'), |
| 10 | statusDot: $('statusDot'), |
| 11 | pauseBtn: $('pauseBtn'), |
| 12 | list: $('list'), |
| 13 | empty: $('empty'), |
| 14 | counter: $('counter'), |
| 15 | drop: $('drop'), |
| 16 | fileInput: $('fileInput'), |
| 17 | dropHint: $('dropHint'), |
| 18 | progressWrap: $('progressWrap'), |
| 19 | progressFill: $('progressFill'), |
| 20 | progressLabel: $('progressLabel'), |
| 21 | durationInput: $('durationInput'), |
| 22 | muteInput: $('muteInput'), |
| 23 | saveSettings: $('saveSettings'), |
| 24 | diskLine: $('diskLine'), |
| 25 | toasts: $('toasts'), |
| 26 | }; |
| 27 | |
| 28 | let state = null; |
| 29 | let renderedVersion = -1; |
| 30 | let busy = false; // laeuft gerade ein Upload? |
| 31 | let dragging = false; // wird gerade sortiert? |
| 32 | let settingsTouched = false; |
| 33 | |
| 34 | /* ------------------------------------------------------------------ Helfer */ |
| 35 | |
| 36 | function toast(msg, kind) { |
| 37 | const node = document.createElement('div'); |
| 38 | node.className = 'toast' + (kind ? ' ' + kind : ''); |
| 39 | node.textContent = msg; |
| 40 | el.toasts.appendChild(node); |
| 41 | setTimeout(() => node.remove(), kind === 'err' ? 6000 : 3200); |
| 42 | } |
| 43 | |
| 44 | function formatSize(bytes) { |
| 45 | if (!bytes) return '0 B'; |
| 46 | const units = ['B', 'KB', 'MB', 'GB', 'TB']; |
| 47 | let i = 0, n = bytes; |
| 48 | while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } |
| 49 | return (n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)) + ' ' + units[i]; |
| 50 | } |
| 51 | |
| 52 | async function api(path, body) { |
| 53 | const opts = body === undefined |
| 54 | ? {} |
| 55 | : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; |
| 56 | const res = await fetch(path, opts); |
| 57 | let data = null; |
| 58 | try { data = await res.json(); } catch (e) { /* leere Antwort ist ok */ } |
| 59 | if (!res.ok) throw new Error((data && data.error) || ('HTTP ' + res.status)); |
| 60 | return data; |
| 61 | } |
| 62 | |
| 63 | /* -------------------------------------------------------------- Darstellung */ |
| 64 | |
| 65 | async function refresh() { |
| 66 | if (busy || dragging) return; |
| 67 | try { |
| 68 | state = await api('/api/state'); |
| 69 | } catch (e) { |
| 70 | el.statusDot.className = 'dot err'; |
| 71 | el.nowText.textContent = 'keine Verbindung'; |
| 72 | return; |
| 73 | } |
| 74 | renderStatus(); |
| 75 | if (state.version !== renderedVersion) { |
| 76 | renderList(); |
| 77 | renderedVersion = state.version; |
| 78 | } else { |
| 79 | markPlaying(); |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | function renderStatus() { |
| 84 | const d = state.device, s = state.status; |
| 85 | el.deviceLine.textContent = d.ssid + ' · ' + d.hostname + '.local'; |
| 86 | |
| 87 | if (!s.running) { |
| 88 | el.statusDot.className = 'dot err'; |
| 89 | el.nowText.textContent = s.last_error ? 'Player-Fehler' : 'Player startet …'; |
| 90 | } else if (s.current) { |
| 91 | el.statusDot.className = 'dot on'; |
| 92 | el.nowText.textContent = (s.paused ? 'Pause: ' : 'Läuft: ') + s.current; |
| 93 | } else { |
| 94 | el.statusDot.className = 'dot'; |
| 95 | el.nowText.textContent = state.entries.length ? 'wird geladen …' : 'Playliste leer'; |
| 96 | } |
| 97 | el.pauseBtn.textContent = s.paused ? '▶ Weiter' : '⏸ Pause'; |
| 98 | |
| 99 | if (!state.entries.length) { |
| 100 | el.counter.textContent = ''; |
| 101 | } else if (d.active === state.entries.length) { |
| 102 | el.counter.textContent = `${state.entries.length} Einträge · ${d.images} Bilder · ${d.videos} Videos`; |
| 103 | } else { |
| 104 | el.counter.textContent = `${d.active} von ${state.entries.length} aktiv · ${d.images} Bilder · ${d.videos} Videos`; |
| 105 | } |
| 106 | |
| 107 | if (!settingsTouched) { |
| 108 | el.durationInput.value = state.settings.image_duration; |
| 109 | el.muteInput.checked = state.settings.mute; |
| 110 | } |
| 111 | |
| 112 | el.dropHint.textContent = 'Bilder und Videos · max. ' + d.max_upload_mb + ' MB pro Vorgang'; |
| 113 | const parts = []; |
| 114 | if (state.disk.total) parts.push(formatSize(state.disk.free) + ' von ' + formatSize(state.disk.total) + ' frei'); |
| 115 | if (s.display) parts.push('Bildausgabe: ' + s.display); |
| 116 | el.diskLine.textContent = parts.join(' · '); |
| 117 | } |
| 118 | |
| 119 | function renderList() { |
| 120 | el.list.textContent = ''; |
| 121 | el.empty.hidden = state.entries.length > 0; |
| 122 | |
| 123 | for (const entry of state.entries) { |
| 124 | const li = document.createElement('li'); |
| 125 | li.className = 'item'; |
| 126 | li.dataset.name = entry.name; |
| 127 | |
| 128 | const grip = document.createElement('div'); |
| 129 | grip.className = 'grip'; |
| 130 | grip.textContent = '⠿'; |
| 131 | grip.title = 'Zum Sortieren ziehen'; |
| 132 | grip.addEventListener('pointerdown', (ev) => startDrag(ev, li)); |
| 133 | |
| 134 | const toggle = document.createElement('label'); |
| 135 | toggle.className = 'toggle'; |
| 136 | toggle.title = entry.enabled ? 'Wird auf der Wand gezeigt' : 'Wird uebersprungen'; |
| 137 | const box = document.createElement('input'); |
| 138 | box.type = 'checkbox'; |
| 139 | box.checked = entry.enabled; |
| 140 | box.setAttribute('aria-label', 'Auf der Wand zeigen'); |
| 141 | box.addEventListener('change', () => setEnabled(entry.name, box.checked, li)); |
| 142 | toggle.appendChild(box); |
| 143 | |
| 144 | const img = document.createElement('img'); |
| 145 | img.className = 'thumb'; |
| 146 | img.loading = 'lazy'; |
| 147 | img.alt = ''; |
| 148 | img.src = '/thumb/' + encodeURIComponent(entry.name); |
| 149 | |
| 150 | const meta = document.createElement('div'); |
| 151 | meta.className = 'meta'; |
| 152 | const name = document.createElement('div'); |
| 153 | name.className = 'name'; |
| 154 | name.textContent = entry.name; |
| 155 | name.title = entry.name; |
| 156 | const tags = document.createElement('div'); |
| 157 | tags.className = 'tags'; |
| 158 | const badge = document.createElement('span'); |
| 159 | badge.className = 'badge' + (entry.kind === 'video' ? ' video' : ''); |
| 160 | badge.textContent = entry.kind === 'video' ? 'Video' : 'Bild'; |
| 161 | const size = document.createElement('span'); |
| 162 | size.textContent = formatSize(entry.size); |
| 163 | tags.append(badge, size); |
| 164 | if (!entry.enabled) { |
| 165 | const off = document.createElement('span'); |
| 166 | off.className = 'badge off'; |
| 167 | off.textContent = 'ausgeblendet'; |
| 168 | tags.append(off); |
| 169 | } |
| 170 | meta.append(name, tags); |
| 171 | |
| 172 | const actions = document.createElement('div'); |
| 173 | actions.className = 'actions'; |
| 174 | actions.append( |
| 175 | iconButton('↑', 'Nach oben', () => move(entry.name, -1)), |
| 176 | iconButton('↓', 'Nach unten', () => move(entry.name, 1)), |
| 177 | iconButton('🗑', 'Löschen', () => remove(entry.name), 'del'), |
| 178 | ); |
| 179 | |
| 180 | li.classList.toggle('off', !entry.enabled); |
| 181 | li.append(grip, toggle, img, meta, actions); |
| 182 | el.list.appendChild(li); |
| 183 | } |
| 184 | markPlaying(); |
| 185 | } |
| 186 | |
| 187 | function iconButton(label, title, onClick, extra) { |
| 188 | const b = document.createElement('button'); |
| 189 | b.className = 'icon-btn' + (extra ? ' ' + extra : ''); |
| 190 | b.type = 'button'; |
| 191 | b.textContent = label; |
| 192 | b.title = title; |
| 193 | b.setAttribute('aria-label', title); |
| 194 | b.addEventListener('click', onClick); |
| 195 | return b; |
| 196 | } |
| 197 | |
| 198 | function markPlaying() { |
| 199 | const current = state && state.status ? state.status.current : ''; |
| 200 | for (const li of el.list.children) { |
| 201 | li.classList.toggle('playing', !!current && li.dataset.name === current); |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /* ------------------------------------------------------------- Sortieren */ |
| 206 | |
| 207 | function currentOrder() { |
| 208 | return Array.from(el.list.children).map((li) => li.dataset.name); |
| 209 | } |
| 210 | |
| 211 | async function saveOrder() { |
| 212 | const order = currentOrder(); |
| 213 | try { |
| 214 | await api('/api/order', { order }); |
| 215 | renderedVersion = -1; // beim naechsten Abruf frisch zeichnen |
| 216 | toast('Reihenfolge gespeichert', 'ok'); |
| 217 | } catch (e) { |
| 218 | toast('Reihenfolge speichern fehlgeschlagen: ' + e.message, 'err'); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | function move(name, delta) { |
| 223 | const items = Array.from(el.list.children); |
| 224 | const idx = items.findIndex((li) => li.dataset.name === name); |
| 225 | const target = idx + delta; |
| 226 | if (idx < 0 || target < 0 || target >= items.length) return; |
| 227 | if (delta < 0) el.list.insertBefore(items[idx], items[target]); |
| 228 | else el.list.insertBefore(items[target], items[idx]); |
| 229 | saveOrder(); |
| 230 | } |
| 231 | |
| 232 | // Sortieren per Zeigergeraet - funktioniert mit Maus und Finger gleichermassen. |
| 233 | // |
| 234 | // Bewusst OHNE setPointerCapture und ohne Ereignisse am Griff selbst: Beim |
| 235 | // Umsortieren wird die Zeile im DOM verschoben, dabei verliert der Griff die |
| 236 | // Zeigererfassung und es kommen keine weiteren Ereignisse mehr an. Die |
| 237 | // Ereignisse haengen deshalb am Dokument, das sich nie bewegt. |
| 238 | let dragState = null; |
| 239 | |
| 240 | function startDrag(ev, li) { |
| 241 | if (ev.button !== undefined && ev.button !== 0) return; |
| 242 | ev.preventDefault(); |
| 243 | dragging = true; |
| 244 | dragState = { li: li, id: ev.pointerId }; |
| 245 | li.classList.add('dragging'); |
| 246 | document.addEventListener('pointermove', onDragMove, { passive: false }); |
| 247 | document.addEventListener('pointerup', endDrag); |
| 248 | document.addEventListener('pointercancel', endDrag); |
| 249 | } |
| 250 | |
| 251 | // Zielposition aus der Zeigerhoehe bestimmen, statt das Element unter dem |
| 252 | // Zeiger zu suchen: unabhaengig davon, was gerade darueber liegt. |
| 253 | function onDragMove(ev) { |
| 254 | if (!dragState) return; |
| 255 | if (ev.pointerId !== dragState.id) return; |
| 256 | ev.preventDefault(); |
| 257 | |
| 258 | const y = ev.clientY; |
| 259 | let eingefuegt = false; |
| 260 | for (const item of Array.from(el.list.children)) { |
| 261 | if (item === dragState.li) continue; |
| 262 | const r = item.getBoundingClientRect(); |
| 263 | if (y < r.top + r.height / 2) { |
| 264 | el.list.insertBefore(dragState.li, item); |
| 265 | eingefuegt = true; |
| 266 | break; |
| 267 | } |
| 268 | } |
| 269 | if (!eingefuegt) el.list.appendChild(dragState.li); |
| 270 | } |
| 271 | |
| 272 | function endDrag(ev) { |
| 273 | if (!dragState) return; |
| 274 | if (ev && ev.pointerId !== undefined && ev.pointerId !== dragState.id) return; |
| 275 | document.removeEventListener('pointermove', onDragMove); |
| 276 | document.removeEventListener('pointerup', endDrag); |
| 277 | document.removeEventListener('pointercancel', endDrag); |
| 278 | dragState.li.classList.remove('dragging'); |
| 279 | dragState = null; |
| 280 | dragging = false; |
| 281 | saveOrder(); |
| 282 | } |
| 283 | |
| 284 | async function setEnabled(name, enabled, li) { |
| 285 | li.classList.toggle('off', !enabled); |
| 286 | try { |
| 287 | await api('/api/enabled', { name: name, enabled: enabled }); |
| 288 | renderedVersion = -1; |
| 289 | toast(enabled ? name + ' wird wieder gezeigt' : name + ' wird übersprungen', 'ok'); |
| 290 | } catch (e) { |
| 291 | li.classList.toggle('off', enabled); |
| 292 | toast('Änderung fehlgeschlagen: ' + e.message, 'err'); |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | async function remove(name) { |
| 297 | if (!confirm('"' + name + '" wirklich löschen?')) return; |
| 298 | try { |
| 299 | await api('/api/delete', { name }); |
| 300 | renderedVersion = -1; |
| 301 | await refresh(); |
| 302 | toast('Gelöscht: ' + name, 'ok'); |
| 303 | } catch (e) { |
| 304 | toast('Löschen fehlgeschlagen: ' + e.message, 'err'); |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | /* --------------------------------------------------------------- Hochladen */ |
| 309 | |
| 310 | function upload(files) { |
| 311 | const list = Array.from(files || []); |
| 312 | if (!list.length) { |
| 313 | toast('Es wurde keine Datei ausgewählt', 'err'); |
| 314 | return; |
| 315 | } |
| 316 | |
| 317 | const form = new FormData(); |
| 318 | for (const f of list) form.append('files', f, f.name); |
| 319 | |
| 320 | busy = true; |
| 321 | el.progressWrap.hidden = false; |
| 322 | el.progressFill.style.width = '0%'; |
| 323 | el.progressLabel.textContent = '0 %'; |
| 324 | |
| 325 | const xhr = new XMLHttpRequest(); |
| 326 | xhr.open('POST', '/api/upload'); |
| 327 | xhr.upload.addEventListener('progress', (ev) => { |
| 328 | if (!ev.lengthComputable) return; |
| 329 | const pct = Math.round((ev.loaded / ev.total) * 100); |
| 330 | el.progressFill.style.width = pct + '%'; |
| 331 | el.progressLabel.textContent = pct + ' %'; |
| 332 | }); |
| 333 | xhr.addEventListener('load', () => { |
| 334 | busy = false; |
| 335 | el.progressWrap.hidden = true; |
| 336 | // Erst jetzt zuruecksetzen: iOS gibt die ausgewaehlten Dateien frei, |
| 337 | // sobald das Feld geleert wird - vorher waere der Upload leer. |
| 338 | el.fileInput.value = ''; |
| 339 | let data = null; |
| 340 | try { data = JSON.parse(xhr.responseText); } catch (e) { /* egal */ } |
| 341 | if (xhr.status >= 200 && xhr.status < 300 && data) { |
| 342 | if (data.uploaded.length) toast(data.uploaded.length + ' Datei(en) hinzugefügt', 'ok'); |
| 343 | for (const s of data.skipped || []) toast('Übersprungen: ' + s, 'err'); |
| 344 | } else { |
| 345 | toast('Upload fehlgeschlagen: ' + ((data && data.error) || ('HTTP ' + xhr.status)), 'err'); |
| 346 | } |
| 347 | renderedVersion = -1; |
| 348 | refresh(); |
| 349 | }); |
| 350 | xhr.addEventListener('error', () => { |
| 351 | busy = false; |
| 352 | el.progressWrap.hidden = true; |
| 353 | el.fileInput.value = ''; |
| 354 | toast('Upload fehlgeschlagen (Verbindung)', 'err'); |
| 355 | }); |
| 356 | xhr.send(form); |
| 357 | } |
| 358 | |
| 359 | /* ----------------------------------------------------------- Verdrahtung */ |
| 360 | |
| 361 | // Kein click()-Aufruf auf das Feld: das Label oeffnet die Auswahl bereits |
| 362 | // selbst. Beides zusammen laesst die Fotoauswahl auf dem iPhone ohne Ergebnis |
| 363 | // wieder zugehen. |
| 364 | el.fileInput.addEventListener('change', () => upload(el.fileInput.files)); |
| 365 | |
| 366 | for (const type of ['dragenter', 'dragover']) { |
| 367 | el.drop.addEventListener(type, (ev) => { ev.preventDefault(); el.drop.classList.add('over'); }); |
| 368 | } |
| 369 | for (const type of ['dragleave', 'drop']) { |
| 370 | el.drop.addEventListener(type, (ev) => { ev.preventDefault(); el.drop.classList.remove('over'); }); |
| 371 | } |
| 372 | el.drop.addEventListener('drop', (ev) => { |
| 373 | if (ev.dataTransfer && ev.dataTransfer.files) upload(ev.dataTransfer.files); |
| 374 | }); |
| 375 | // Verhindert, dass der Browser eine daneben abgelegte Datei einfach oeffnet. |
| 376 | window.addEventListener('dragover', (ev) => ev.preventDefault()); |
| 377 | window.addEventListener('drop', (ev) => ev.preventDefault()); |
| 378 | |
| 379 | document.querySelectorAll('[data-action]').forEach((btn) => { |
| 380 | btn.addEventListener('click', async () => { |
| 381 | try { |
| 382 | await api('/api/control', { action: btn.dataset.action }); |
| 383 | setTimeout(refresh, 250); |
| 384 | } catch (e) { |
| 385 | toast('Aktion fehlgeschlagen: ' + e.message, 'err'); |
| 386 | } |
| 387 | }); |
| 388 | }); |
| 389 | |
| 390 | el.durationInput.addEventListener('input', () => { settingsTouched = true; }); |
| 391 | el.muteInput.addEventListener('change', () => { settingsTouched = true; }); |
| 392 | |
| 393 | el.saveSettings.addEventListener('click', async () => { |
| 394 | const value = parseFloat(el.durationInput.value); |
| 395 | if (!(value >= 0.5 && value <= 3600)) { |
| 396 | toast('Anzeigedauer muss zwischen 0,5 und 3600 Sekunden liegen', 'err'); |
| 397 | return; |
| 398 | } |
| 399 | try { |
| 400 | await api('/api/settings', { image_duration: value, mute: el.muteInput.checked }); |
| 401 | settingsTouched = false; |
| 402 | toast('Einstellungen gespeichert', 'ok'); |
| 403 | refresh(); |
| 404 | } catch (e) { |
| 405 | toast('Speichern fehlgeschlagen: ' + e.message, 'err'); |
| 406 | } |
| 407 | }); |
| 408 | |
| 409 | refresh(); |
| 410 | setInterval(refresh, 3000); |