LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich

feat: add silent boot script to enhance boot experience

- Introduced a new script `silent-boot.sh` to manage console output during boot.
- Redirect console messages to tty3, keeping tty1 clean.
- Modify Plymouth behavior to retain the splash screen until mpv starts playback.
- Clear tty1 text buffer to prevent old messages from appearing after playback.
- Implemented systemd service configurations to support these changes.
- Added instructions for enabling and reverting the silent boot feature.
R Rüdiger Küpper <rpr@9it.de> committete am 21.08.2026 03:47
461e563c8eb742b028a1b2f25f99c24ff4f324c6
11 geänderte Datei(en) +518 −74
Kontextzeilen: 0 1 2 3 10
geändert README.md
+10 −1 Datei ansehen
@@ -146,4 +146,11 @@ steht im Protokoll und unten im Webinterface.
146 146
| **Playliste** | Reihenfolge per Ziehen am Griff `⠿` oder mit ↑ ↓, Löschen per 🗑 |
147 +
| **Haken** | Ohne Haken bleibt die Datei liegen, wird auf der Wand aber übersprungen |
147 148
148 -
Reihenfolge und Einstellungen bleiben über einen Neustart hinweg erhalten.
149 +
Reihenfolge, Haken und Einstellungen bleiben über einen Neustart hinweg
150 +
erhalten (`order.json`, `disabled.json`, `settings.json` im Datenverzeichnis).
151 +
152 +
Ausgeblendete Einträge stehen weiter im Webinterface, sind grau hinterlegt und
153 +
mit „ausgeblendet" gekennzeichnet — sie landen nur nicht in der `playlist.m3u`,
154 +
die mpv abspielt. Die Änderung greift am nächsten Dateiübergang, ein laufendes
155 +
Video wird also nicht unterbrochen.
149 156
@@ -328,2 +335,3 @@ scripts/console-mode.sh Desktop abschalten, direkt in die Anzeige booten
328 335
scripts/set-splash.sh     eigenes Logo als Startbildschirm setzen
336 +
scripts/silent-boot.sh    Textkonsole beim Starten unterdruecken
329 337
scripts/bisect-mpv.sh     misst, welche mpv-Option einen Haenger ausloest
@@ -357,2 +365,3 @@ Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
357 365
| `/api/delete` | POST | `{"name":"a.jpg"}` |
366 +
| `/api/enabled` | POST | `{"name":"a.jpg","enabled":false}` — aus der Wiedergabe nehmen |
358 367
| `/api/settings` | POST | `{"image_duration":5,"mute":false}` |
geändert config/tvwall.conf
+13 −0 Datei ansehen
@@ -102,2 +102,15 @@ MPV_EXTRA_ARGS=""
102 102
103 +
# --- Startlogo --------------------------------------------------------------
104 +
105 +
# Bild, das beim Start zuerst gezeigt wird, bevor die Playliste beginnt.
106 +
# Leer = aus. Wird von scripts/set-splash.sh automatisch eingetragen.
107 +
#
108 +
# Warum ueber den Player und nicht ueber den System-Startbildschirm: Viele
109 +
# Fernseher wachen erst auf, wenn mpv den Bildmodus setzt - bis dahin ist der
110 +
# Startbildschirm des Systems gar nicht zu sehen.
111 +
BOOT_LOGO=""
112 +
113 +
# Wie lange das Startlogo stehen bleibt (Sekunden).
114 +
BOOT_LOGO_SECONDS="8"
115 +
103 116
# --- Pfade ------------------------------------------------------------------
hinzugefügt ehc-tvwall.png
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

geändert internal/config/config.go
+36 −25 Datei ansehen
@@ -35,9 +35,12 @@ type Config struct {
35 35
36 -
	DisplayMode   string // "auto", "drm" oder "desktop"
37 -
	RunUser       string
38 -
	MpvExtraArgs  string
39 -
	MpvHwdec      string
40 -
	MpvGpuAPI     string
41 -
	FreshForVideo bool // mpv vor jedem Video neu starten
42 -
	StallTimeout  int  // Sekunden ohne Fortschritt, bevor ein Video uebersprungen wird
36 +
	DisplayMode  string // "auto", "drm" oder "desktop"
37 +
	RunUser      string
38 +
	MpvExtraArgs string
39 +
	MpvHwdec     string
40 +
	MpvGpuAPI    string
41 +
42 +
	BootLogo        string  // Bild, das beim Start zuerst gezeigt wird
43 +
	BootLogoSeconds float64 // wie lange
44 +
	FreshForVideo   bool    // mpv vor jedem Video neu starten
45 +
	StallTimeout    int     // Sekunden ohne Fortschritt, bevor ein Video uebersprungen wird
43 46
@@ -50,20 +53,21 @@ func Default() *Config {
50 53
	return &Config{
51 -
		APSSID:        "TVWall-01",
52 -
		APPassword:    "tvwall1234",
53 -
		APCountry:     "DE",
54 -
		APIP:          "192.168.4.1",
55 -
		APChannel:     "7",
56 -
		Hostname:      "tvwall-01",
57 -
		WebPort:       80,
58 -
		MaxUploadMB:   2048,
59 -
		ImageDuration: 5,
60 -
		Audio:         true,
61 -
		ScanInterval:  10,
62 -
		DisplayMode:   "auto",
63 -
		MpvHwdec:      "no",
64 -
		MpvGpuAPI:     "opengl",
65 -
		StallTimeout:  30,
66 -
		RunUser:       "tvwall",
67 -
		StateDir:      "/var/lib/tvwall",
68 -
		SocketPath:    "/run/tvwall/mpv.sock",
54 +
		APSSID:          "TVWall-01",
55 +
		APPassword:      "tvwall1234",
56 +
		APCountry:       "DE",
57 +
		APIP:            "192.168.4.1",
58 +
		APChannel:       "7",
59 +
		Hostname:        "tvwall-01",
60 +
		WebPort:         80,
61 +
		MaxUploadMB:     2048,
62 +
		ImageDuration:   5,
63 +
		Audio:           true,
64 +
		ScanInterval:    10,
65 +
		DisplayMode:     "auto",
66 +
		MpvHwdec:        "no",
67 +
		MpvGpuAPI:       "opengl",
68 +
		BootLogoSeconds: 8,
69 +
		StallTimeout:    30,
70 +
		RunUser:         "tvwall",
71 +
		StateDir:        "/var/lib/tvwall",
72 +
		SocketPath:      "/run/tvwall/mpv.sock",
69 73
	}
@@ -75,2 +79,3 @@ func (c *Config) ThumbDir() string { return filepath.Join(c.StateDir, "thumb
75 79
func (c *Config) OrderFile() string    { return filepath.Join(c.StateDir, "order.json") }
80 +
func (c *Config) DisabledFile() string { return filepath.Join(c.StateDir, "disabled.json") }
76 81
func (c *Config) PlaylistFile() string { return filepath.Join(c.StateDir, "playlist.m3u") }
@@ -151,2 +156,8 @@ func Load(path string) (*Config, error) {
151 156
			c.MpvExtraArgs = val
157 +
		case "BOOT_LOGO":
158 +
			c.BootLogo = val
159 +
		case "BOOT_LOGO_SECONDS":
160 +
			if f, err := strconv.ParseFloat(val, 64); err == nil && f >= 0 {
161 +
				c.BootLogoSeconds = f
162 +
			}
152 163
		case "MPV_GPU_API":
geändert internal/player/player.go
+85 −8 Datei ansehen
@@ -64,2 +64,4 @@ type Player struct {
64 64
65 +
	bootLogoDone bool // Startlogo wurde in dieser Dienstlaufzeit schon gezeigt
66 +
65 67
	reloadNow chan struct{}
@@ -129,3 +131,24 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
129 131
130 -
	args := p.mpvArgs(disp)
132 +
	// Die Playliste wird mpv direkt auf der Kommandozeile mitgegeben. Sonst
133 +
	// muesste erst die IPC-Verbindung stehen, bevor ueberhaupt etwas zu sehen
134 +
	// ist - das kostet beim Start rund eine Sekunde, in der die Textkonsole
135 +
	// sichtbar wird.
136 +
	entries, version := p.pl.EntriesWithVersion()
137 +
138 +
	// Startlogo: beim ersten mpv-Start dieser Dienstlaufzeit bekommt mpv das
139 +
	// Logo als erste Datei. So ist es das allererste Bild auf dem Schirm.
140 +
	startLogo := ""
141 +
	p.mu.RLock()
142 +
	logoGezeigt := p.bootLogoDone
143 +
	p.mu.RUnlock()
144 +
	if !logoGezeigt && p.cfg.BootLogo != "" && p.cfg.BootLogoSeconds > 0 {
145 +
		if _, err := os.Stat(p.cfg.BootLogo); err == nil {
146 +
			startLogo = p.cfg.BootLogo
147 +
		} else {
148 +
			log.Printf("player: startlogo %q nicht lesbar: %v", p.cfg.BootLogo, err)
149 +
		}
150 +
	}
151 +
152 +
	aktive := p.pl.ActiveCount()
153 +
	args := p.mpvArgs(disp, aktive, startLogo)
131 154
	log.Printf("player: starte mpv %s", strings.Join(args, " "))
@@ -184,4 +207,29 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
184 207
185 -
	p.applySettings(client)
186 -
	p.loadPlaylist(client)
208 +
	var logoTimer *time.Timer
209 +
	switch {
210 +
	case startLogo != "":
211 +
		// mpv zeigt das Logo bereits - nur den Zeitpunkt merken, an dem auf
212 +
		// die Playliste umgeschaltet wird. applySettings kaeme der
213 +
		// Anzeigedauer des Logos in die Quere und folgt deshalb spaeter.
214 +
		p.mu.Lock()
215 +
		p.bootLogoDone = true
216 +
		p.status.Current = filepath.Base(startLogo)
217 +
		p.mu.Unlock()
218 +
		logoTimer = time.NewTimer(time.Duration(p.cfg.BootLogoSeconds * float64(time.Second)))
219 +
		log.Printf("player: startlogo %s fuer %.0f s", filepath.Base(startLogo), p.cfg.BootLogoSeconds)
220 +
221 +
	case aktive > 0:
222 +
		// Playliste lief schon beim Start von mpv mit - nur den Stand merken.
223 +
		p.applySettings(client)
224 +
		p.mu.Lock()
225 +
		p.loadedID = version
226 +
		p.pending = false
227 +
		p.mu.Unlock()
228 +
		log.Printf("player: playliste beim start uebergeben (%d von %d eintraegen aktiv)",
229 +
			aktive, len(entries))
230 +
231 +
	default:
232 +
		p.applySettings(client)
233 +
		p.loadPlaylist(client)
234 +
	}
187 235
	p.setError("")
@@ -223,2 +271,8 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
223 271
272 +
		case <-timerC(logoTimer):
273 +
			logoTimer.Stop()
274 +
			logoTimer = nil
275 +
			p.applySettings(client) // Anzeigedauer wieder auf den Normalwert
276 +
			p.loadPlaylist(client)
277 +
224 278
		case <-settingsChanged:
@@ -292,5 +346,15 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
292 346
347 +
// timerC liefert den Kanal eines Timers - oder nil, wenn keiner laeuft.
348 +
// Ein nil-Kanal blockiert im select-Zweig fuer immer, genau wie gewuenscht.
349 +
func timerC(t *time.Timer) <-chan time.Time {
350 +
	if t == nil {
351 +
		return nil
352 +
	}
353 +
	return t.C
354 +
}
355 +
293 356
// loadPlaylist uebergibt mpv die aktuelle m3u-Datei.
294 357
func (p *Player) loadPlaylist(client *mpv.Client) {
295 -
	entries, version := p.pl.EntriesWithVersion()
358 +
	_, version := p.pl.EntriesWithVersion()
359 +
	aktive := p.pl.ActiveCount()
296 360
@@ -301,3 +365,3 @@ func (p *Player) loadPlaylist(client *mpv.Client) {
301 365
302 -
	if len(entries) == 0 {
366 +
	if aktive == 0 {
303 367
		if _, err := client.Command("playlist-clear"); err != nil {
@@ -322,3 +386,3 @@ func (p *Player) loadPlaylist(client *mpv.Client) {
322 386
	}
323 -
	log.Printf("player: playliste geladen (%d eintraege)", len(entries))
387 +
	log.Printf("player: playliste geladen (%d eintraege)", aktive)
324 388
}
@@ -366,4 +430,9 @@ func (p *Player) applySettings(client *mpv.Client) {
366 430
367 -
func (p *Player) mpvArgs(disp display) []string {
431 +
func (p *Player) mpvArgs(disp display, aktive int, startLogo string) []string {
368 432
	s := p.settings.Get()
433 +
	anzeigedauer := s.ImageDuration
434 +
	if startLogo != "" {
435 +
		// Etwas laenger als bis zum Umschalten, damit keine Luecke entsteht.
436 +
		anzeigedauer = p.cfg.BootLogoSeconds + 2
437 +
	}
369 438
	args := []string{
@@ -375,3 +444,3 @@ func (p *Player) mpvArgs(disp display) []string {
375 444
		"--loop-playlist=inf",
376 -
		"--image-display-duration=" + strconv.FormatFloat(s.ImageDuration, 'f', -1, 64),
445 +
		"--image-display-duration=" + strconv.FormatFloat(anzeigedauer, 'f', -1, 64),
377 446
		"--hwdec=" + p.cfg.MpvHwdec,
@@ -405,2 +474,10 @@ func (p *Player) mpvArgs(disp display) []string {
405 474
	args = append(args, splitArgs(p.cfg.MpvExtraArgs)...)
475 +
	// Die erste Datei bekommt mpv direkt mit: so erscheint das Bild, sobald
476 +
	// mpv den Bildschirm hat, ohne Umweg ueber die IPC-Verbindung.
477 +
	switch {
478 +
	case startLogo != "":
479 +
		args = append(args, startLogo)
480 +
	case aktive > 0:
481 +
		args = append(args, "--playlist="+p.pl.PlaylistFile())
482 +
	}
406 483
	return args
geändert internal/playlist/playlist.go
+74 −2 Datei ansehen
@@ -63,2 +63,5 @@ type Entry struct {
63 63
	MTime int64  `json:"mtime"`
64 +
	// Enabled=false: Die Datei bleibt liegen und steht im Webinterface, wird
65 +
	// auf der Wand aber nicht gezeigt.
66 +
	Enabled bool `json:"enabled"`
64 67
}
@@ -69,2 +72,3 @@ type Manager struct {
69 72
	orderFile    string
73 +
	disabledFile string
70 74
	playlistFile string
@@ -83,2 +87,3 @@ func New(cfg *config.Config) *Manager {
83 87
		orderFile:    cfg.OrderFile(),
88 +
		disabledFile: cfg.DisabledFile(),
84 89
		playlistFile: cfg.PlaylistFile(),
@@ -131,2 +136,3 @@ func (m *Manager) Scan() (bool, error) {
131 136
137 +
	ausgeblendet := m.readDisabled()
132 138
	found := make(map[string]Entry, len(dirEntries))
@@ -142,3 +148,7 @@ func (m *Manager) Scan() (bool, error) {
142 148
		}
143 -
		e := Entry{Name: name, Kind: Kind(name), Size: info.Size(), MTime: info.ModTime().Unix()}
149 +
		e := Entry{
150 +
			Name: name, Kind: Kind(name),
151 +
			Size: info.Size(), MTime: info.ModTime().Unix(),
152 +
			Enabled: !ausgeblendet[name],
153 +
		}
144 154
		found[name] = e
@@ -204,2 +214,60 @@ func (m *Manager) SetOrder(names []string) error {
204 214
215 +
// SetEnabled blendet einen Eintrag auf der Wand ein oder aus. Die Datei bleibt
216 +
// dabei liegen und im Webinterface sichtbar.
217 +
func (m *Manager) SetEnabled(name string, enabled bool) error {
218 +
	safe, err := SafeName(name)
219 +
	if err != nil {
220 +
		return err
221 +
	}
222 +
	ausgeblendet := m.readDisabled()
223 +
	if enabled {
224 +
		delete(ausgeblendet, safe)
225 +
	} else {
226 +
		ausgeblendet[safe] = true
227 +
	}
228 +
	namen := make([]string, 0, len(ausgeblendet))
229 +
	for n := range ausgeblendet {
230 +
		namen = append(namen, n)
231 +
	}
232 +
	sort.Strings(namen)
233 +
	data, err := json.MarshalIndent(namen, "", "  ")
234 +
	if err != nil {
235 +
		return err
236 +
	}
237 +
	if err := config.WriteFileAtomic(m.disabledFile, append(data, '\n'), 0o664); err != nil {
238 +
		return fmt.Errorf("ausgeblendete eintraege speichern: %w", err)
239 +
	}
240 +
	_, err = m.Scan()
241 +
	return err
242 +
}
243 +
244 +
func (m *Manager) readDisabled() map[string]bool {
245 +
	out := map[string]bool{}
246 +
	data, err := os.ReadFile(m.disabledFile)
247 +
	if err != nil {
248 +
		return out
249 +
	}
250 +
	var namen []string
251 +
	if json.Unmarshal(data, &namen) != nil {
252 +
		return out
253 +
	}
254 +
	for _, n := range namen {
255 +
		out[n] = true
256 +
	}
257 +
	return out
258 +
}
259 +
260 +
// ActiveCount liefert die Anzahl der Eintraege, die tatsaechlich gezeigt werden.
261 +
func (m *Manager) ActiveCount() int {
262 +
	m.mu.RLock()
263 +
	defer m.mu.RUnlock()
264 +
	n := 0
265 +
	for _, e := range m.entries {
266 +
		if e.Enabled {
267 +
			n++
268 +
		}
269 +
	}
270 +
	return n
271 +
}
272 +
205 273
// Delete entfernt eine Datei aus dem Medienordner und der Playliste.
@@ -234,2 +302,5 @@ func (m *Manager) commit(ordered []Entry) (bool, error) {
234 302
		names[i] = e.Name
303 +
		if !e.Enabled {
304 +
			continue // bleibt in der Liste, wird aber nicht abgespielt
305 +
		}
235 306
		m3u.WriteString(filepath.Join(m.mediaDir, e.Name))
@@ -273,3 +344,4 @@ func sameEntries(a, b []Entry) bool {
273 344
	for i := range a {
274 -
		if a[i].Name != b[i].Name || a[i].MTime != b[i].MTime || a[i].Size != b[i].Size {
345 +
		if a[i].Name != b[i].Name || a[i].MTime != b[i].MTime ||
346 +
			a[i].Size != b[i].Size || a[i].Enabled != b[i].Enabled {
275 347
			return false
geändert internal/web/assets/app.js
+71 −23 Datei ansehen
@@ -98,5 +98,9 @@ function renderStatus() {
98 98
99 -
  el.counter.textContent = state.entries.length
100 -
    ? `${state.entries.length} Einträge · ${d.images} Bilder · ${d.videos} Videos`
101 -
    : '';
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 +
  }
102 106
@@ -129,2 +133,12 @@ function renderList() {
129 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 +
130 144
    const img = document.createElement('img');
@@ -149,2 +163,8 @@ function renderList() {
149 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 +
    }
150 170
    meta.append(name, tags);
@@ -159,3 +179,4 @@ function renderList() {
159 179
160 -
    li.append(grip, img, meta, actions);
180 +
    li.classList.toggle('off', !entry.enabled);
181 +
    li.append(grip, toggle, img, meta, actions);
161 182
    el.list.appendChild(li);
@@ -211,3 +232,8 @@ function move(name, delta) {
211 232
// Sortieren per Zeigergeraet - funktioniert mit Maus und Finger gleichermassen.
212 -
let dragItem = null;
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;
213 239
@@ -217,19 +243,28 @@ function startDrag(ev, li) {
217 243
  dragging = true;
218 -
  dragItem = li;
244 +
  dragState = { li: li, id: ev.pointerId };
219 245
  li.classList.add('dragging');
220 -
  ev.target.setPointerCapture(ev.pointerId);
221 -
  ev.target.addEventListener('pointermove', onDragMove);
222 -
  ev.target.addEventListener('pointerup', endDrag);
223 -
  ev.target.addEventListener('pointercancel', endDrag);
246 +
  document.addEventListener('pointermove', onDragMove, { passive: false });
247 +
  document.addEventListener('pointerup', endDrag);
248 +
  document.addEventListener('pointercancel', endDrag);
224 249
}
225 250
251 +
// Zielposition aus der Zeigerhoehe bestimmen, statt das Element unter dem
252 +
// Zeiger zu suchen: unabhaengig davon, was gerade darueber liegt.
226 253
function onDragMove(ev) {
227 -
  if (!dragItem) return;
254 +
  if (!dragState) return;
255 +
  if (ev.pointerId !== dragState.id) return;
228 256
  ev.preventDefault();
229 -
  const over = document.elementFromPoint(ev.clientX, ev.clientY);
230 -
  const target = over && over.closest ? over.closest('.item') : null;
231 -
  if (!target || target === dragItem || target.parentElement !== el.list) return;
232 -
  const rect = target.getBoundingClientRect();
233 -
  const after = ev.clientY > rect.top + rect.height / 2;
234 -
  el.list.insertBefore(dragItem, after ? target.nextSibling : target);
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);
235 270
}
@@ -237,8 +272,9 @@ function onDragMove(ev) {
237 272
function endDrag(ev) {
238 -
  if (!dragItem) return;
239 -
  ev.target.removeEventListener('pointermove', onDragMove);
240 -
  ev.target.removeEventListener('pointerup', endDrag);
241 -
  ev.target.removeEventListener('pointercancel', endDrag);
242 -
  dragItem.classList.remove('dragging');
243 -
  dragItem = null;
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;
244 280
  dragging = false;
@@ -247,2 +283,14 @@ function endDrag(ev) {
247 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 +
248 296
async function remove(name) {
geändert internal/web/assets/style.css
+9 −2 Datei ansehen
@@ -131,3 +131,3 @@ input[type="number"] {
131 131
  display: grid;
132 -
  grid-template-columns: auto 84px 1fr auto;
132 +
  grid-template-columns: auto auto 84px 1fr auto;
133 133
  align-items: center; gap: .7rem;
@@ -141,2 +141,9 @@ input[type="number"] {
141 141
.grip:active { cursor: grabbing; }
142 +
143 +
/* Haken: bestimmt, ob der Eintrag auf der Wand erscheint */
144 +
.toggle { display: grid; place-items: center; padding: .2rem; cursor: pointer; }
145 +
.toggle input { width: 22px; height: 22px; accent-color: var(--accent); cursor: pointer; }
146 +
.item.off { opacity: .55; }
147 +
.item.off .thumb { filter: grayscale(1); }
148 +
.badge.off { background: #3a2a1e; color: #e0b088; }
142 149
.thumb {
@@ -181,3 +188,3 @@ input[type="number"] {
181 188
@media (max-width: 420px) {
182 -
  .item { grid-template-columns: auto 64px 1fr auto; }
189 +
  .item { grid-template-columns: auto auto 64px 1fr auto; }
183 190
  .thumb { width: 64px; height: 44px; }
geändert internal/web/web.go
+27 −1 Datei ansehen
@@ -57,2 +57,3 @@ func (s *Server) Handler() http.Handler {
57 57
	mux.HandleFunc("/api/delete", s.handleDelete)
58 +
	mux.HandleFunc("/api/enabled", s.handleEnabled)
58 59
	mux.HandleFunc("/api/settings", s.handleSettings)
@@ -118,2 +119,3 @@ type deviceInfo struct {
118 119
	Videos    int    `json:"videos"`
120 +
	Active    int    `json:"active"`
119 121
	Thumbs    bool   `json:"thumbs"`
@@ -124,3 +126,3 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
124 126
	entries := s.pl.Entries()
125 -
	var images, videos int
127 +
	var images, videos, active int
126 128
	for _, e := range entries {
@@ -131,2 +133,5 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
131 133
		}
134 +
		if e.Enabled {
135 +
			active++
136 +
		}
132 137
	}
@@ -147,2 +152,3 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
147 152
			Videos:    videos,
153 +
			Active:    active,
148 154
			Thumbs:    s.hasFF,
@@ -298,2 +304,22 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
298 304
305 +
func (s *Server) handleEnabled(w http.ResponseWriter, r *http.Request) {
306 +
	if r.Method != http.MethodPost {
307 +
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
308 +
		return
309 +
	}
310 +
	var req struct {
311 +
		Name    string `json:"name"`
312 +
		Enabled bool   `json:"enabled"`
313 +
	}
314 +
	if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
315 +
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
316 +
		return
317 +
	}
318 +
	if err := s.pl.SetEnabled(req.Name, req.Enabled); err != nil {
319 +
		httpError(w, http.StatusBadRequest, err.Error())
320 +
		return
321 +
	}
322 +
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
323 +
}
324 +
299 325
func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) {
geändert scripts/set-splash.sh
+56 −12 Datei ansehen
@@ -11,6 +11,9 @@
11 11
#
12 -
#  Zusaetzlich wird das Logo bis --dauer Sekunden nach dem Einschalten
13 -
#  stehengelassen (Standard 12). Ohne das ist es nach gut vier Sekunden schon
14 -
#  wieder weg - viele Fernseher brauchen laenger, um sich auf das HDMI-Signal
15 -
#  zu synchronisieren, und zeigen dann gar nichts davon.
12 +
#  Dasselbe Bild wird ausserdem als Startlogo des Players hinterlegt
13 +
#  (BOOT_LOGO). Das ist der zuverlaessige Teil: Viele Fernseher wachen erst
14 +
#  auf, wenn mpv den Bildmodus setzt - der Startbildschirm des Systems ist bis
15 +
#  dahin gar nicht zu sehen, beim Herunterfahren dagegen schon.
16 +
#
17 +
#  --dauer N haelt zusaetzlich den System-Startbildschirm bis N Sekunden nach
18 +
#  dem Einschalten (0 = aus, Standard 0).
16 19
# ===========================================================================
@@ -25,3 +28,5 @@ HOLD_BIN="/usr/local/bin/tvwall-splash-hold"
25 28
HOLD_UNIT="/etc/systemd/system/tvwall-splash.service"
26 -
DAUER=12
29 +
TVWALL_CONF="/etc/tvwall/tvwall.conf"
30 +
BOOT_LOGO="/var/lib/tvwall/boot-logo.png"
31 +
DAUER=0
27 32
@@ -55,2 +60,8 @@ if [[ "${1:-}" == "--zurueck" ]]; then
55 60
  ok "Halten des Startbildes entfernt"
61 +
  if [[ -f "$TVWALL_CONF" ]]; then
62 +
    sed -i 's|^BOOT_LOGO=.*|BOOT_LOGO=""|' "$TVWALL_CONF"
63 +
    systemctl restart tvwall 2>/dev/null || true
64 +
    ok "Startlogo des Players abgeschaltet"
65 +
  fi
66 +
  rm -f "$BOOT_LOGO"
56 67
  update-initramfs -u >/dev/null 2>&1 || true
@@ -96,2 +107,25 @@ fi
96 107
108 +
# --- dasselbe Bild als Startlogo des Players -------------------------------
109 +
# Der zuverlaessige Teil: mpv schaltet den Bildschirm ein, also sieht man
110 +
# dieses Bild garantiert - unabhaengig davon, wann der Fernseher aufwacht.
111 +
if [[ -d "$(dirname "$BOOT_LOGO")" ]]; then
112 +
  install -m 0644 "$TMP/splash.png" "$BOOT_LOGO"
113 +
  RUN_USER="$(sed -n 's/^RUN_USER="\(.*\)"/\1/p' "$TVWALL_CONF" 2>/dev/null | head -1)"
114 +
  [[ -n "${RUN_USER:-}" ]] && chown "$RUN_USER" "$BOOT_LOGO" 2>/dev/null || true
115 +
  ok "Startlogo des Players: $BOOT_LOGO"
116 +
117 +
  if [[ -f "$TVWALL_CONF" ]]; then
118 +
    if grep -q "^BOOT_LOGO=" "$TVWALL_CONF"; then
119 +
      sed -i "s|^BOOT_LOGO=.*|BOOT_LOGO=\"$BOOT_LOGO\"|" "$TVWALL_CONF"
120 +
    else
121 +
      printf '\n# Startlogo, von set-splash.sh eingetragen\nBOOT_LOGO="%s"\nBOOT_LOGO_SECONDS="8"\n' \
122 +
        "$BOOT_LOGO" >> "$TVWALL_CONF"
123 +
    fi
124 +
    ok "BOOT_LOGO in $TVWALL_CONF eingetragen"
125 +
    systemctl restart tvwall 2>/dev/null && ok "Dienst neu gestartet - das Logo ist sofort zu sehen" || true
126 +
  fi
127 +
else
128 +
  warn "$(dirname "$BOOT_LOGO") fehlt - Startlogo des Players nicht gesetzt"
129 +
fi
130 +
97 131
# --- Startbild stehenlassen, bis die Wiedergabe uebernimmt ----------------
@@ -99,4 +133,11 @@ fi
99 133
# sind zu diesem Zeitpunkt noch gar nicht synchronisiert.
100 -
if [[ -x /usr/bin/plymouth ]]; then
101 -
  info "Startbild bis $DAUER s nach dem Einschalten halten"
134 +
if [[ "$DAUER" == "0" ]]; then
135 +
  if [[ -f "$HOLD_UNIT" ]]; then
136 +
    systemctl disable --now tvwall-splash.service >/dev/null 2>&1 || true
137 +
    rm -f "$HOLD_UNIT" "$HOLD_BIN"
138 +
    systemctl daemon-reload
139 +
    ok "Halten des System-Startbildes entfernt (--dauer 0)"
140 +
  fi
141 +
elif [[ -x /usr/bin/plymouth ]]; then
142 +
  info "System-Startbild bis $DAUER s nach dem Einschalten halten"
102 143
  cat > "$HOLD_BIN" <<'HOLD'
@@ -149,5 +190,8 @@ cat <<EOF
149 190
===========================================================================
150 -
 Fertig. Beim naechsten Neustart erscheint dein Logo und bleibt bis
151 -
 $DAUER Sekunden nach dem Einschalten stehen - danach uebernimmt die
152 -
 Wiedergabe.
191 +
 Fertig.
192 +
193 +
   - Der Player zeigt das Logo beim Start 8 Sekunden lang, bevor die
194 +
     Playliste beginnt. Das sieht man sicher, weil mpv den Bildschirm
195 +
     einschaltet.
196 +
   - Beim Herunterfahren zeigt das System denselben Bildschirm.
153 197
@@ -155,4 +199,4 @@ cat <<EOF
155 199
156 -
 Anders lange stehen lassen:  sudo $0 <logo> --dauer 20
157 -
 Zurueck zum Original:        sudo $0 --zurueck
200 +
 Anzeigedauer aendern:  BOOT_LOGO_SECONDS in $TVWALL_CONF
201 +
 Zurueck zum Original:  sudo $0 --zurueck
158 202
===========================================================================
hinzugefügt scripts/silent-boot.sh
+137 −0 Datei ansehen
@@ -0,0 +1,137 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Entfernt den Textbildschirm zwischen Startlogo und Wiedergabe.
4 +
#
5 +
#  Drei Aenderungen:
6 +
#    1. Konsolenmeldungen auf tty3 umleiten - tty1 bleibt sauber
7 +
#    2. Plymouth wird erst beendet, wenn tvwall startet - und zwar von tvwall
8 +
#       selbst, direkt bevor mpv losgeht. Mit --retain-splash bleibt das Logo
9 +
#       dabei stehen, bis mpv das erste Mal malt.
10 +
#    3. tvwall raeumt tty1 nicht mehr auf (das hat das Bild geloescht),
11 +
#       leert aber einmal dessen Textpuffer - sonst taucht Plymouths
12 +
#       Statustext wieder auf, sobald die Wiedergabe endet
13 +
#
14 +
#  Aufruf:  sudo ~/silent-boot.sh            einschalten
15 +
#           sudo ~/silent-boot.sh --zurueck  rueckgaengig
16 +
# ===========================================================================
17 +
set -euo pipefail
18 +
CMDLINE="/boot/firmware/cmdline.txt"; [[ -f "$CMDLINE" ]] || CMDLINE="/boot/cmdline.txt"
19 +
QUIT_DIR="/etc/systemd/system/plymouth-quit.service.d"
20 +
TVWALL_DIR="/etc/systemd/system/tvwall.service.d"
21 +
EXTRA=(loglevel=3 logo.nologo vt.global_cursor_default=0 consoleblank=0)
22 +
23 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
24 +
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
25 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
26 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten"
27 +
28 +
schreibe_cmdline() {
29 +
  local zeile="$1"
30 +
  cp -f "$CMDLINE" "$CMDLINE.tvwall-backup"
31 +
  printf '%s\n' "$zeile" > "$CMDLINE"
32 +
  if [[ "$(grep -c . "$CMDLINE")" != "1" ]] || ! grep -q "root=" "$CMDLINE"; then
33 +
    cp -f "$CMDLINE.tvwall-backup" "$CMDLINE"
34 +
    die "cmdline.txt sah falsch aus - Sicherung zurueckgespielt"
35 +
  fi
36 +
}
37 +
38 +
if [[ "${1:-}" == "--zurueck" ]]; then
39 +
  zeile="$(tr -d '\n' < "$CMDLINE")"
40 +
  zeile="${zeile//console=tty3/console=tty1}"
41 +
  for e in "${EXTRA[@]}"; do zeile="${zeile// $e/}"; done
42 +
  schreibe_cmdline "$zeile"
43 +
  rm -rf "$QUIT_DIR" "$TVWALL_DIR/silent.conf" /usr/local/bin/tvwall-clear-tty
44 +
  systemctl daemon-reload
45 +
  ok "rueckgaengig gemacht - bitte neu starten"
46 +
  exit 0
47 +
fi
48 +
49 +
# --- 1) Konsole von tty1 wegnehmen ----------------------------------------
50 +
info "Konsolenmeldungen auf tty3 umleiten"
51 +
zeile="$(tr -d '\n' < "$CMDLINE")"
52 +
zeile="${zeile//console=tty1/console=tty3}"
53 +
for e in "${EXTRA[@]}"; do
54 +
  zeile="${zeile// $e/}"      # doppelte Eintraege vermeiden
55 +
  zeile="$zeile $e"
56 +
done
57 +
schreibe_cmdline "$zeile"
58 +
ok "console=tty3 ${EXTRA[*]}"
59 +
echo "    ($(grep -c . "$CMDLINE") Zeile, root= vorhanden)"
60 +
61 +
# --- 2) Logo stehen lassen, bis mpv malt ----------------------------------
62 +
info "Plymouth soll das Bild beim Beenden stehen lassen"
63 +
install -d -m 0755 "$QUIT_DIR"
64 +
cat > "$QUIT_DIR/retain-splash.conf" <<'UNIT'
65 +
# Von tvwall silent-boot.sh:
66 +
#  - --retain-splash laesst das Startbild auf dem Schirm stehen, bis mpv das
67 +
#    erste Mal malt. Ohne das gibt Plymouth den Bildschirm frei und die
68 +
#    Textkonsole blitzt auf.
69 +
#  - After=tvwall.service: nicht schon beenden, waehrend die Wiedergabe noch
70 +
#    startet. tvwall beendet Plymouth selbst, direkt bevor mpv losgeht.
71 +
[Unit]
72 +
After=tvwall.service
73 +
74 +
[Service]
75 +
ExecStart=
76 +
ExecStart=-/usr/bin/plymouth quit --retain-splash
77 +
UNIT
78 +
ok "$QUIT_DIR/retain-splash.conf"
79 +
80 +
# --- 3) tvwall soll tty1 nicht mehr loeschen ------------------------------
81 +
info "Textpuffer von tty1 leeren"
82 +
cat > /usr/local/bin/tvwall-clear-tty <<'CLR'
83 +
#!/bin/sh
84 +
# Leert den Textpuffer der virtuellen Konsole tty1.
85 +
# Plymouth legt dort neben der Grafikausgabe eine Textanzeige an und schreibt
86 +
# seine Statuszeilen hinein. Sichtbar wird das erst, wenn die Wiedergabe endet
87 +
# und die Konsole ihren Inhalt neu zeichnet - dann steht dort alter Text.
88 +
# Wird ausgefuehrt, solange Plymouth den Bildschirm noch im Grafikmodus haelt:
89 +
# der Puffer wird geleert, ohne dass davon etwas zu sehen ist.
90 +
printf '' > /dev/tty1 2>/dev/null
91 +
exit 0
92 +
CLR
93 +
chmod 0755 /usr/local/bin/tvwall-clear-tty
94 +
ok "/usr/local/bin/tvwall-clear-tty"
95 +
96 +
info "tvwall raeumt tty1 nicht mehr auf"
97 +
install -d -m 0755 "$TVWALL_DIR"
98 +
cat > "$TVWALL_DIR/silent.conf" <<'UNIT'
99 +
# Von tvwall silent-boot.sh:
100 +
#  - TTYVTDisallocate/setterm haben das stehengelassene Startbild geloescht.
101 +
#    Der Bildschirmschoner der Konsole wird jetzt ueber consoleblank=0
102 +
#    im Bootparameter abgeschaltet.
103 +
#  - ExecStartPre uebergibt den Bildschirm: Plymouth gibt ihn frei (das Bild
104 +
#    bleibt dank --retain-splash stehen), direkt danach uebernimmt mpv.
105 +
#    Das "+" laesst diesen einen Schritt mit vollen Rechten laufen.
106 +
[Service]
107 +
TTYVTDisallocate=no
108 +
NoNewPrivileges=no
109 +
ExecStartPre=
110 +
ExecStartPre=+/usr/local/bin/tvwall-clear-tty
111 +
ExecStartPre=+/bin/sh -c '/usr/bin/plymouth quit --retain-splash 2>/dev/null || true'
112 +
ExecStopPost=+/usr/local/bin/tvwall-clear-tty
113 +
UNIT
114 +
ok "$TVWALL_DIR/silent.conf"
115 +
116 +
systemctl daemon-reload
117 +
ok "systemd neu eingelesen"
118 +
119 +
cat <<EOF
120 +
121 +
===========================================================================
122 +
 Jetzt neu starten:  sudo reboot
123 +
124 +
 Erwartet: Logo erscheint - bleibt stehen - Wiedergabe uebernimmt.
125 +
 Kein Text mehr dazwischen.
126 +
127 +
 Zur Kontrolle danach:
128 +
   systemd-analyze critical-chain tvwall.service | head -5
129 +
   journalctl -b -o short-monotonic | grep -E "plymouth-quit|starte mpv"
130 +
   -> die beiden Zeitpunkte sollten dicht beieinander liegen
131 +
132 +
 Rueckgaengig:  sudo ~/silent-boot.sh --zurueck && sudo reboot
133 +
134 +
 Hinweis: Startmeldungen liegen danach auf tty3 (Strg+Alt+F3) und
135 +
 weiterhin vollstaendig im Journal:  journalctl -b
136 +
===========================================================================
137 +
EOF