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
@@ -144,8 +144,15 @@ steht im Protokoll und unten im Webinterface.
144 144
| **Wiedergabe** | Zurück / Pause / Weiter, „Neu laden“ springt sofort in die aktuelle Playliste |
145 145
| **Einstellungen** | Anzeigedauer für Bilder, Ton stummschalten |
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
150 157
### Ohne Webinterface
151 158
@@ -326,6 +333,7 @@ scripts/setup-ap.sh Access Point per nmcli
326 333
scripts/diagnose.sh       Selbsttest bei Problemen
327 334
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
330 338
install.sh                Installation auf dem Pi
331 339
```
@@ -355,6 +363,7 @@ Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
355 363
| `/api/upload` | POST | Dateien hochladen (multipart) |
356 364
| `/api/order` | POST | `{"order":["a.jpg","b.mp4"]}` |
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}` |
359 368
| `/api/control` | POST | `{"action":"next\|prev\|pause\|reload\|restart\|rescan"}` |
360 369
| `/thumb/<name>` | GET | Vorschaubild (per ffmpeg erzeugt, zwischengespeichert) |
geändert config/tvwall.conf
+13 −0 Datei ansehen
@@ -100,6 +100,19 @@ STALL_TIMEOUT="30"
100 100
# Zusaetzliche Optionen fuer mpv, z. B. --audio-device=... oder --video-rotate=90
101 101
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 ------------------------------------------------------------------
104 117
105 118
# Hier liegen Medien (Unterordner "media"), Playliste und Einstellungen.
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
@@ -33,13 +33,16 @@ type Config struct {
33 33
34 34
	ScanInterval int // Sekunden zwischen zwei Durchlaeufen des Medienordners
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
44 47
	StateDir   string
45 48
	SocketPath string
@@ -48,24 +51,25 @@ type Config struct {
48 51
// Default liefert die Konfiguration mit allen Standardwerten.
49 52
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
	}
70 74
}
71 75
@@ -73,6 +77,7 @@ func Default() *Config {
73 77
func (c *Config) MediaDir() string     { return filepath.Join(c.StateDir, "media") }
74 78
func (c *Config) ThumbDir() string     { return filepath.Join(c.StateDir, "thumbs") }
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") }
77 82
func (c *Config) SettingsFile() string { return filepath.Join(c.StateDir, "settings.json") }
78 83
func (c *Config) MpvSocket() string    { return c.SocketPath }
@@ -149,6 +154,12 @@ func Load(path string) (*Config, error) {
149 154
			c.RunUser = val
150 155
		case "MPV_EXTRA_ARGS":
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":
153 164
			if val != "" {
154 165
				c.MpvGpuAPI = val
geändert internal/player/player.go
+85 −8 Datei ansehen
@@ -62,6 +62,8 @@ type Player struct {
62 62
	lastTimePos    float64
63 63
	lastProgressAt time.Time
64 64
65 +
	bootLogoDone bool // Startlogo wurde in dieser Dienstlaufzeit schon gezeigt
66 +
65 67
	reloadNow chan struct{}
66 68
	restart   chan struct{}
67 69
}
@@ -127,7 +129,28 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
127 129
	}
128 130
	_ = os.Remove(sock)
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, " "))
132 155
133 156
	cmd := exec.Command("mpv", args...)
@@ -182,8 +205,33 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
182 205
		}
183 206
	}
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("")
188 236
189 237
	ticker := time.NewTicker(tickInterval)
@@ -221,6 +269,12 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
221 269
		case <-p.restart:
222 270
			return fmt.Errorf("neustart angefordert")
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:
225 279
			p.applySettings(client)
226 280
@@ -290,16 +344,26 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
290 344
	}
291 345
}
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
297 361
	p.mu.Lock()
298 362
	p.pending = false
299 363
	p.loadedID = version
300 364
	p.mu.Unlock()
301 365
302 -
	if len(entries) == 0 {
366 +
	if aktive == 0 {
303 367
		if _, err := client.Command("playlist-clear"); err != nil {
304 368
			log.Printf("player: playlist-clear: %v", err)
305 369
		}
@@ -320,7 +384,7 @@ func (p *Player) loadPlaylist(client *mpv.Client) {
320 384
	if err := client.SetProperty("pause", false); err != nil {
321 385
		log.Printf("player: fortsetzen: %v", err)
322 386
	}
323 -
	log.Printf("player: playliste geladen (%d eintraege)", len(entries))
387 +
	log.Printf("player: playliste geladen (%d eintraege)", aktive)
324 388
}
325 389
326 390
// checkStalled ueberspringt ein Video, das nicht mehr weiterlaeuft, damit eine
@@ -364,8 +428,13 @@ func (p *Player) applySettings(client *mpv.Client) {
364 428
	}
365 429
}
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{
370 439
		"--input-ipc-server=" + p.cfg.MpvSocket(),
371 440
		"--fullscreen",
@@ -373,7 +442,7 @@ func (p *Player) mpvArgs(disp display) []string {
373 442
		"--force-window=yes",
374 443
		"--keep-open=no",
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,
378 447
		"--no-osc",
379 448
		"--osd-level=0",
@@ -403,6 +472,14 @@ func (p *Player) mpvArgs(disp display) []string {
403 472
		args = append(args, "--gpu-context=drm")
404 473
	}
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
407 484
}
408 485
geändert internal/playlist/playlist.go
+74 −2 Datei ansehen
@@ -61,12 +61,16 @@ type Entry struct {
61 61
	Kind  string `json:"kind"`
62 62
	Size  int64  `json:"size"`
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
}
65 68
66 69
// Manager haelt den aktuellen Zustand der Playliste.
67 70
type Manager struct {
68 71
	mediaDir     string
69 72
	orderFile    string
73 +
	disabledFile string
70 74
	playlistFile string
71 75
72 76
	mu      sync.RWMutex
@@ -81,6 +85,7 @@ func New(cfg *config.Config) *Manager {
81 85
	return &Manager{
82 86
		mediaDir:     cfg.MediaDir(),
83 87
		orderFile:    cfg.OrderFile(),
88 +
		disabledFile: cfg.DisabledFile(),
84 89
		playlistFile: cfg.PlaylistFile(),
85 90
		changed:      make(chan struct{}, 1),
86 91
	}
@@ -129,6 +134,7 @@ func (m *Manager) Scan() (bool, error) {
129 134
		return false, err
130 135
	}
131 136
137 +
	ausgeblendet := m.readDisabled()
132 138
	found := make(map[string]Entry, len(dirEntries))
133 139
	var fresh []Entry
134 140
	for _, de := range dirEntries {
@@ -140,7 +146,11 @@ func (m *Manager) Scan() (bool, error) {
140 146
		if err != nil {
141 147
			continue
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
145 155
		fresh = append(fresh, e)
146 156
	}
@@ -202,6 +212,64 @@ func (m *Manager) SetOrder(names []string) error {
202 212
	return err
203 213
}
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.
206 274
func (m *Manager) Delete(name string) error {
207 275
	safe, err := SafeName(name)
@@ -232,6 +300,9 @@ func (m *Manager) commit(ordered []Entry) (bool, error) {
232 300
	m3u.WriteString("#EXTM3U\n")
233 301
	for i, e := range ordered {
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))
236 307
		m3u.WriteByte('\n')
237 308
	}
@@ -271,7 +342,8 @@ func sameEntries(a, b []Entry) bool {
271 342
		return false
272 343
	}
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
276 348
		}
277 349
	}
geändert internal/web/assets/app.js
+71 −23 Datei ansehen
@@ -96,9 +96,13 @@ function renderStatus() {
96 96
  }
97 97
  el.pauseBtn.textContent = s.paused ? '▶ Weiter' : '⏸ Pause';
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
103 107
  if (!settingsTouched) {
104 108
    el.durationInput.value = state.settings.image_duration;
@@ -127,6 +131,16 @@ function renderList() {
127 131
    grip.title = 'Zum Sortieren ziehen';
128 132
    grip.addEventListener('pointerdown', (ev) => startDrag(ev, li));
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');
131 145
    img.className = 'thumb';
132 146
    img.loading = 'lazy';
@@ -147,6 +161,12 @@ function renderList() {
147 161
    const size = document.createElement('span');
148 162
    size.textContent = formatSize(entry.size);
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);
151 171
152 172
    const actions = document.createElement('div');
@@ -157,7 +177,8 @@ function renderList() {
157 177
      iconButton('🗑', 'Löschen', () => remove(entry.name), 'del'),
158 178
    );
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);
162 183
  }
163 184
  markPlaying();
@@ -209,42 +230,69 @@ function move(name, delta) {
209 230
}
210 231
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
214 240
function startDrag(ev, li) {
215 241
  if (ev.button !== undefined && ev.button !== 0) return;
216 242
  ev.preventDefault();
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
}
236 271
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;
245 281
  saveOrder();
246 282
}
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) {
249 297
  if (!confirm('"' + name + '" wirklich löschen?')) return;
250 298
  try {
geändert internal/web/assets/style.css
+9 −2 Datei ansehen
@@ -129,7 +129,7 @@ input[type="number"] {
129 129
.list { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
130 130
.item {
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;
134 134
  padding: .5rem; border: 1px solid var(--line); border-radius: 12px;
135 135
  background: var(--bg-soft);
@@ -139,6 +139,13 @@ input[type="number"] {
139 139
.item.drop-target { border-color: var(--accent); border-style: dashed; }
140 140
.grip { cursor: grab; padding: .4rem .2rem; color: var(--fg-dim); font-size: 1.1rem; line-height: 1; touch-action: none; user-select: none; }
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 {
143 150
  width: 84px; height: 56px; border-radius: 8px; object-fit: cover;
144 151
  background: #0b1220; display: block;
@@ -179,6 +186,6 @@ input[type="number"] {
179 186
@keyframes pop { from { opacity: 0; transform: translateY(8px); } }
180 187
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; }
184 191
}
geändert internal/web/web.go
+27 −1 Datei ansehen
@@ -55,6 +55,7 @@ func (s *Server) Handler() http.Handler {
55 55
	mux.HandleFunc("/api/upload", s.handleUpload)
56 56
	mux.HandleFunc("/api/order", s.handleOrder)
57 57
	mux.HandleFunc("/api/delete", s.handleDelete)
58 +
	mux.HandleFunc("/api/enabled", s.handleEnabled)
58 59
	mux.HandleFunc("/api/settings", s.handleSettings)
59 60
	mux.HandleFunc("/api/control", s.handleControl)
60 61
	mux.HandleFunc("/thumb/", s.handleThumb)
@@ -116,19 +117,23 @@ type deviceInfo struct {
116 117
	Hostname  string `json:"hostname"`
117 118
	Images    int    `json:"images"`
118 119
	Videos    int    `json:"videos"`
120 +
	Active    int    `json:"active"`
119 121
	Thumbs    bool   `json:"thumbs"`
120 122
	MaxUpload int64  `json:"max_upload_mb"`
121 123
}
122 124
123 125
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 {
127 129
		if e.Kind == "video" {
128 130
			videos++
129 131
		} else {
130 132
			images++
131 133
		}
134 +
		if e.Enabled {
135 +
			active++
136 +
		}
132 137
	}
133 138
	if entries == nil {
134 139
		entries = []playlist.Entry{}
@@ -145,6 +150,7 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
145 150
			Hostname:  s.cfg.Hostname,
146 151
			Images:    images,
147 152
			Videos:    videos,
153 +
			Active:    active,
148 154
			Thumbs:    s.hasFF,
149 155
			MaxUpload: s.cfg.MaxUploadMB,
150 156
		},
@@ -296,6 +302,26 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
296 302
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
297 303
}
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) {
300 326
	if r.Method != http.MethodPost {
301 327
		httpError(w, http.StatusMethodNotAllowed, "nur POST")
geändert scripts/set-splash.sh
+56 −12 Datei ansehen
@@ -9,10 +9,13 @@
9 9
#  gesetzt - derselbe Hintergrund, den auch der Player verwendet, damit der
10 10
#  Uebergang vom Start in die Wiedergabe nicht springt.
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
# ===========================================================================
17 20
set -euo pipefail
18 21
@@ -23,7 +26,9 @@ CONFIG_TXT="/boot/firmware/config.txt"
23 26
[[ -f "$CONFIG_TXT" ]] || CONFIG_TXT="/boot/config.txt"
24 27
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
28 33
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
29 34
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
@@ -53,6 +58,12 @@ if [[ "${1:-}" == "--zurueck" ]]; then
53 58
  rm -f "$HOLD_UNIT" "$HOLD_BIN"
54 59
  systemctl daemon-reload
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
57 68
  ok "fertig - beim naechsten Neustart wieder das Original"
58 69
  exit 0
@@ -94,11 +105,41 @@ if [[ -w "$CONFIG_TXT" ]] && ! grep -q "^disable_splash=1" "$CONFIG_TXT"; then
94 105
  ok "disable_splash=1 in $(basename "$CONFIG_TXT") eingetragen"
95 106
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 ----------------
98 132
# Ohne das zeigt Plymouth das Logo nur etwa vier Sekunden; viele Fernseher
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'
103 144
#!/bin/sh
104 145
# Haelt den Plymouth-Startbildschirm, bis die angegebene Zeit seit dem
@@ -147,13 +188,16 @@ fi
147 188
cat <<EOF
148 189
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
154 198
   sudo reboot
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
===========================================================================
159 203
EOF
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