LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich
Branch: main
ehc-tvwall / internal / web / assets / app.js
Verlauf Rohdaten
R Rüdiger Küpper feat: add silent boot script to enhance boot experience
461e563c vor 11 Tagen
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);