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,0 +147 @@ steht im Protokoll und unten im Webinterface.
147 +
| **Haken** | Ohne Haken bleibt die Datei liegen, wird auf der Wand aber übersprungen |
@@ -148 +149,7 @@ steht im Protokoll und unten im Webinterface.
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.
@@ -328,0 +336 @@ scripts/set-splash.sh eigenes Logo als Startbildschirm setzen
336 +
scripts/silent-boot.sh    Textkonsole beim Starten unterdruecken
@@ -357,0 +366 @@ Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
366 +
| `/api/enabled` | POST | `{"name":"a.jpg","enabled":false}` — aus der Wiedergabe nehmen |
geändert config/tvwall.conf
+13 −0 Datei ansehen
@@ -102,0 +103,13 @@ MPV_EXTRA_ARGS=""
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 +
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
@@ -36,7 +36,10 @@ type Config struct {
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
@@ -51,18 +54,19 @@ func Default() *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",
@@ -75,0 +80 @@ func (c *Config) OrderFile() string { return filepath.Join(c.StateDir, "order
80 +
func (c *Config) DisabledFile() string { return filepath.Join(c.StateDir, "disabled.json") }
@@ -151,0 +157,6 @@ func Load(path string) (*Config, error) {
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 +
			}
geändert internal/player/player.go
+85 −8 Datei ansehen
@@ -64,0 +65,2 @@ type Player struct {
65 +
	bootLogoDone bool // Startlogo wurde in dieser Dienstlaufzeit schon gezeigt
66 +
@@ -130 +132,22 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
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)
@@ -185,2 +208,27 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
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 +
	}
@@ -223,0 +272,6 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
272 +
		case <-timerC(logoTimer):
273 +
			logoTimer.Stop()
274 +
			logoTimer = nil
275 +
			p.applySettings(client) // Anzeigedauer wieder auf den Normalwert
276 +
			p.loadPlaylist(client)
277 +
@@ -292,0 +347,9 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
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 +
@@ -295 +358,2 @@ func (p *Player) loadPlaylist(client *mpv.Client) {
295 -
	entries, version := p.pl.EntriesWithVersion()
358 +
	_, version := p.pl.EntriesWithVersion()
359 +
	aktive := p.pl.ActiveCount()
@@ -302 +366 @@ func (p *Player) loadPlaylist(client *mpv.Client) {
302 -
	if len(entries) == 0 {
366 +
	if aktive == 0 {
@@ -323 +387 @@ func (p *Player) loadPlaylist(client *mpv.Client) {
323 -
	log.Printf("player: playliste geladen (%d eintraege)", len(entries))
387 +
	log.Printf("player: playliste geladen (%d eintraege)", aktive)
@@ -367 +431 @@ func (p *Player) applySettings(client *mpv.Client) {
367 -
func (p *Player) mpvArgs(disp display) []string {
431 +
func (p *Player) mpvArgs(disp display, aktive int, startLogo string) []string {
@@ -368,0 +433,5 @@ func (p *Player) mpvArgs(disp display) []string {
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 +
	}
@@ -376 +445 @@ func (p *Player) mpvArgs(disp display) []string {
376 -
		"--image-display-duration=" + strconv.FormatFloat(s.ImageDuration, 'f', -1, 64),
445 +
		"--image-display-duration=" + strconv.FormatFloat(anzeigedauer, 'f', -1, 64),
@@ -405,0 +475,8 @@ func (p *Player) mpvArgs(disp display) []string {
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 +
	}
geändert internal/playlist/playlist.go
+74 −2 Datei ansehen
@@ -63,0 +64,3 @@ type Entry struct {
64 +
	// Enabled=false: Die Datei bleibt liegen und steht im Webinterface, wird
65 +
	// auf der Wand aber nicht gezeigt.
66 +
	Enabled bool `json:"enabled"`
@@ -69,0 +73 @@ type Manager struct {
73 +
	disabledFile string
@@ -83,0 +88 @@ func New(cfg *config.Config) *Manager {
88 +
		disabledFile: cfg.DisabledFile(),
@@ -131,0 +137 @@ func (m *Manager) Scan() (bool, error) {
137 +
	ausgeblendet := m.readDisabled()
@@ -143 +149,5 @@ func (m *Manager) Scan() (bool, error) {
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 +
		}
@@ -204,0 +215,58 @@ func (m *Manager) SetOrder(names []string) error {
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 +
@@ -234,0 +303,3 @@ func (m *Manager) commit(ordered []Entry) (bool, error) {
303 +
		if !e.Enabled {
304 +
			continue // bleibt in der Liste, wird aber nicht abgespielt
305 +
		}
@@ -274 +345,2 @@ func sameEntries(a, b []Entry) bool {
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 {
geändert internal/web/assets/app.js
+71 −23 Datei ansehen
@@ -99,3 +99,7 @@ function renderStatus() {
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 +
  }
@@ -129,0 +134,10 @@ function renderList() {
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 +
@@ -149,0 +164,6 @@ function renderList() {
164 +
    if (!entry.enabled) {
165 +
      const off = document.createElement('span');
166 +
      off.className = 'badge off';
167 +
      off.textContent = 'ausgeblendet';
168 +
      tags.append(off);
169 +
    }
@@ -160 +180,2 @@ function renderList() {
160 -
    li.append(grip, img, meta, actions);
180 +
    li.classList.toggle('off', !entry.enabled);
181 +
    li.append(grip, toggle, img, meta, actions);
@@ -212 +233,6 @@ function move(name, delta) {
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;
@@ -218 +244 @@ function startDrag(ev, li) {
218 -
  dragItem = li;
244 +
  dragState = { li: li, id: ev.pointerId };
@@ -220,4 +246,3 @@ function startDrag(ev, li) {
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);
@@ -225,0 +251,2 @@ function startDrag(ev, li) {
251 +
// Zielposition aus der Zeigerhoehe bestimmen, statt das Element unter dem
252 +
// Zeiger zu suchen: unabhaengig davon, was gerade darueber liegt.
@@ -227 +254,2 @@ function onDragMove(ev) {
227 -
  if (!dragItem) return;
254 +
  if (!dragState) return;
255 +
  if (ev.pointerId !== dragState.id) return;
@@ -229,6 +257,13 @@ function onDragMove(ev) {
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);
@@ -238,6 +273,7 @@ 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;
@@ -247,0 +284,12 @@ function endDrag(ev) {
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 +
geändert internal/web/assets/style.css
+9 −2 Datei ansehen
@@ -132 +132 @@ input[type="number"] {
132 -
  grid-template-columns: auto 84px 1fr auto;
132 +
  grid-template-columns: auto auto 84px 1fr auto;
@@ -141,0 +142,7 @@ input[type="number"] {
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; }
@@ -182 +189 @@ input[type="number"] {
182 -
  .item { grid-template-columns: auto 64px 1fr auto; }
189 +
  .item { grid-template-columns: auto auto 64px 1fr auto; }
geändert internal/web/web.go
+27 −1 Datei ansehen
@@ -57,0 +58 @@ func (s *Server) Handler() http.Handler {
58 +
	mux.HandleFunc("/api/enabled", s.handleEnabled)
@@ -118,0 +120 @@ type deviceInfo struct {
120 +
	Active    int    `json:"active"`
@@ -125 +127 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
125 -
	var images, videos int
127 +
	var images, videos, active int
@@ -131,0 +134,3 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
134 +
		if e.Enabled {
135 +
			active++
136 +
		}
@@ -147,0 +153 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
153 +
			Active:    active,
@@ -298,0 +305,20 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
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 +
geändert scripts/set-splash.sh
+56 −12 Datei ansehen
@@ -12,4 +12,7 @@
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).
@@ -26 +29,3 @@ 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
@@ -55,0 +61,6 @@ if [[ "${1:-}" == "--zurueck" ]]; then
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"
@@ -96,0 +108,23 @@ fi
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 +
@@ -100,2 +134,9 @@ fi
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"
@@ -150,3 +191,6 @@ cat <<EOF
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.
@@ -156,2 +200,2 @@ cat <<EOF
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
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