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
@@ -137,22 +137,29 @@ steht im Protokoll und unten im Webinterface.
137 137
## Bedienung
138 138
139 139
### Webinterface
140 140
141 141
| Bereich | Funktion |
142 142
|---|---|
143 143
| **Hochladen** | Dateien ziehen oder antippen; mehrere gleichzeitig möglich |
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
152 159
Dateien lassen sich auch direkt ablegen — per USB-Stick, `scp` oder
153 160
Netzwerkfreigabe:
154 161
155 162
```bash
156 163
scp urlaub.mp4 pi@tvwall-01.local:/var/lib/tvwall/media/
157 164
```
158 165
@@ -319,20 +326,21 @@ internal/config/ tvwall.conf lesen, Laufzeit-Einstellungen speichern
319 326
internal/playlist/        Ordner einlesen, Reihenfolge merken, playlist.m3u schreiben
320 327
internal/mpv/             JSON-IPC-Verbindung zu mpv
321 328
internal/player/          mpv starten, überwachen, Dateien einzeln weiterschalten
322 329
internal/web/             HTTP-Schnittstelle und Oberfläche (eingebettet)
323 330
systemd/                  Vorlagen für den Dienst (drm / desktop)
324 331
internal/player/display.go  Wayland-/X11-/DRM-Erkennung
325 332
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
```
332 340
333 341
Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
334 342
335 343
* **mpv spielt Bilder und Videos aus einer gemeinsamen Playliste.** Kein
336 344
  Umschalten zwischen zwei Programmen, keine Lücke am Übergang.
337 345
* **Die Playliste wird zwischen zwei Dateien getauscht**, nicht mittendrin: Ein
338 346
  Upload unterbricht kein laufendes Video. Wer nicht warten will, drückt
@@ -348,20 +356,21 @@ Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
348 356
  Empfang umbenannt. So gerät keine halbe Datei in die Wiedergabe.
349 357
350 358
### Web-Schnittstelle
351 359
352 360
| Weg | Methode | Zweck |
353 361
|---|---|---|
354 362
| `/api/state` | GET | Playliste, Zustand, Einstellungen |
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) |
361 370
| `/media/<name>` | GET | Originaldatei |
362 371
363 372
### Lokal ausprobieren
364 373
365 374
Ohne Pi, nur die Oberfläche (kein mpv nötig):
366 375
367 376
```bash
geändert config/tvwall.conf
+13 −0 Datei ansehen
@@ -93,17 +93,30 @@ MPV_GPU_API="opengl"
93 93
# kann "auto-safe" dagegen Rechenzeit sparen.
94 94
MPV_HWDEC="no"
95 95
96 96
# Laeuft ein Video so lange nicht weiter (Sekunden), wird es uebersprungen,
97 97
# damit eine einzelne Datei nicht die ganze Wiedergabe anhaelt. 0 = aus.
98 98
STALL_TIMEOUT="30"
99 99
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.
106 119
STATE_DIR="/var/lib/tvwall"
107 120
108 121
# Steuersocket fuer mpv.
109 122
MPV_SOCKET="/run/tvwall/mpv.sock"
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
@@ -26,60 +26,65 @@ type Config struct {
26 26
	WebPort     int
27 27
	WebPassword string
28 28
	MaxUploadMB int64
29 29
30 30
	ImageDuration float64
31 31
	Mute          bool
32 32
	Audio         bool // false = Tonausgabe komplett abschalten
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
46 49
}
47 50
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
72 76
// Abgeleitete Pfade.
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 }
79 84
80 85
// Load liest die Konfigurationsdatei im Shell-Stil (KEY="value").
81 86
// Eine fehlende Datei ist kein Fehler - dann gelten die Standardwerte.
82 87
func Load(path string) (*Config, error) {
83 88
	c := Default()
84 89
	f, err := os.Open(path)
85 90
	if err != nil {
@@ -142,20 +147,26 @@ func Load(path string) (*Config, error) {
142 147
			}
143 148
		case "DISPLAY_MODE":
144 149
			switch val {
145 150
			case "auto", "drm", "desktop":
146 151
				c.DisplayMode = val
147 152
			}
148 153
		case "RUN_USER":
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
155 166
			}
156 167
		case "MPV_HWDEC":
157 168
			if val != "" {
158 169
				c.MpvHwdec = val
159 170
			}
160 171
		case "STALL_TIMEOUT":
161 172
			if n, err := strconv.Atoi(val); err == nil && n >= 0 {
geändert internal/player/player.go
+85 −8 Datei ansehen
@@ -55,20 +55,22 @@ type Player struct {
55 55
56 56
	mu       sync.RWMutex
57 57
	client   *mpv.Client
58 58
	status   Status
59 59
	pending  bool
60 60
	loadedID uint64 // Version der zuletzt an mpv uebergebenen Playliste
61 61
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
}
68 70
69 71
// New erzeugt den Player.
70 72
func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Player {
71 73
	return &Player{
72 74
		cfg:       cfg,
73 75
		pl:        pl,
74 76
		settings:  settings,
@@ -120,21 +122,42 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
120 122
		return err
121 123
	}
122 124
	disp.logDisplay()
123 125
124 126
	sock := p.cfg.MpvSocket()
125 127
	if err := os.MkdirAll(filepath.Dir(sock), 0o775); err != nil {
126 128
		return fmt.Errorf("socket-verzeichnis: %w", err)
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...)
134 157
	// stdin durchreichen: bei --gpu-context=drm braucht mpv das Terminal der
135 158
	// Konsole, um den Bildschirm uebernehmen zu duerfen.
136 159
	cmd.Stdin = os.Stdin
137 160
	cmd.Stdout = os.Stdout
138 161
	cmd.Stderr = os.Stderr
139 162
	cmd.Env = append(os.Environ(), disp.env...)
140 163
	if err := cmd.Start(); err != nil {
@@ -175,22 +198,47 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
175 198
176 199
	for id, name := range map[int]string{
177 200
		propPath: "path", propPause: "pause", propPlaylistPos: "playlist-pos",
178 201
		propIdle: "idle-active", propTimePos: "time-pos",
179 202
	} {
180 203
		if err := client.ObserveProperty(id, name); err != nil {
181 204
			log.Printf("player: observe %s: %v", name, err)
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)
190 238
	defer ticker.Stop()
191 239
192 240
	for {
193 241
		select {
194 242
		case <-ctx.Done():
195 243
			return nil
196 244
@@ -214,20 +262,26 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
214 262
			if pending && idle {
215 263
				p.loadPlaylist(client)
216 264
			}
217 265
218 266
		case <-p.reloadNow:
219 267
			p.loadPlaylist(client)
220 268
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
227 281
		case <-ticker.C:
228 282
			p.checkStalled(client)
229 283
230 284
		case ev, ok := <-client.Events():
231 285
			if !ok {
232 286
				return fmt.Errorf("ipc-verbindung beendet")
233 287
			}
@@ -283,51 +337,61 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
283 337
		// Der beste Moment fuer eine neue Playliste: zwischen zwei Dateien.
284 338
		p.mu.RLock()
285 339
		pending := p.pending
286 340
		p.mu.RUnlock()
287 341
		if pending {
288 342
			p.loadPlaylist(client)
289 343
		}
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
		}
306 370
		if _, err := client.Command("stop"); err != nil {
307 371
			log.Printf("player: stop: %v", err)
308 372
		}
309 373
		p.mu.Lock()
310 374
		p.status.Current = ""
311 375
		p.mu.Unlock()
312 376
		return
313 377
	}
314 378
315 379
	if _, err := client.Command("loadlist", p.pl.PlaylistFile(), "replace"); err != nil {
316 380
		log.Printf("player: playlist laden: %v", err)
317 381
		p.setError("Playliste laden: " + err.Error())
318 382
		return
319 383
	}
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
327 391
// einzelne Datei nicht die ganze Wiedergabe anhaelt.
328 392
func (p *Player) checkStalled(client *mpv.Client) {
329 393
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
330 394
	if timeout <= 0 {
331 395
		return
332 396
	}
333 397
	p.mu.RLock()
@@ -357,30 +421,35 @@ func (p *Player) applySettings(client *mpv.Client) {
357 421
	if err := client.SetProperty("image-display-duration", s.ImageDuration); err != nil {
358 422
		log.Printf("player: anzeigedauer setzen: %v", err)
359 423
	}
360 424
	if p.cfg.Audio {
361 425
		if err := client.SetProperty("mute", s.Mute); err != nil {
362 426
			log.Printf("player: stummschaltung setzen: %v", err)
363 427
		}
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",
372 441
		"--idle=yes",
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",
380 449
		"--cursor-autohide=always",
381 450
		"--no-input-default-bindings",
382 451
		"--input-vo-keyboard=no",
383 452
		"--stop-screensaver=yes",
384 453
		// Kein --no-terminal: sonst verschluckt mpv seine Fehlermeldungen.
385 454
		"--msg-level=all=warn",
386 455
		"--no-input-terminal",
@@ -396,20 +465,28 @@ func (p *Player) mpvArgs(disp display) []string {
396 465
	}
397 466
	if p.cfg.Audio {
398 467
		args = append(args, "--mute="+yesNo(s.Mute))
399 468
	} else {
400 469
		args = append(args, "--no-audio")
401 470
	}
402 471
	if disp.mode == "drm" {
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
409 486
func yesNo(b bool) string {
410 487
	if b {
411 488
		return "yes"
412 489
	}
413 490
	return "no"
414 491
}
415 492
geändert internal/playlist/playlist.go
+74 −2 Datei ansehen
@@ -54,40 +54,45 @@ func Kind(name string) string {
54 54
55 55
// Supported meldet, ob die Datei abgespielt werden kann.
56 56
func Supported(name string) bool { return Kind(name) != "" }
57 57
58 58
// Entry ist ein Eintrag der Playliste.
59 59
type Entry struct {
60 60
	Name  string `json:"name"`
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
73 77
	entries []Entry
74 78
	version uint64
75 79
76 80
	changed chan struct{}
77 81
}
78 82
79 83
// New erzeugt den Manager. Scan() muss danach mindestens einmal laufen.
80 84
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
	}
87 92
}
88 93
89 94
// MediaDir liefert den Medienordner.
90 95
func (m *Manager) MediaDir() string { return m.mediaDir }
91 96
92 97
// PlaylistFile liefert den Pfad der von mpv geladenen m3u-Datei.
93 98
func (m *Manager) PlaylistFile() string { return m.playlistFile }
@@ -122,32 +127,37 @@ func (m *Manager) Version() uint64 {
122 127
// (nach Aenderungsdatum sortiert) am Ende der Playliste.
123 128
func (m *Manager) Scan() (bool, error) {
124 129
	if err := os.MkdirAll(m.mediaDir, 0o775); err != nil {
125 130
		return false, err
126 131
	}
127 132
	dirEntries, err := os.ReadDir(m.mediaDir)
128 133
	if err != nil {
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 {
135 141
		name := de.Name()
136 142
		if de.IsDir() || strings.HasPrefix(name, ".") || !Supported(name) {
137 143
			continue
138 144
		}
139 145
		info, err := de.Info()
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
	}
147 157
148 158
	// Neue Dateien: aeltestes zuerst, damit die Reihenfolge dem Upload folgt.
149 159
	sort.Slice(fresh, func(i, j int) bool {
150 160
		if fresh[i].MTime != fresh[j].MTime {
151 161
			return fresh[i].MTime < fresh[j].MTime
152 162
		}
153 163
		return fresh[i].Name < fresh[j].Name
@@ -195,20 +205,78 @@ func (m *Manager) SetOrder(names []string) error {
195 205
	for _, e := range current {
196 206
		if !seen[e.Name] {
197 207
			ordered = append(ordered, e)
198 208
		}
199 209
	}
200 210
201 211
	_, err := m.commit(ordered)
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)
208 276
	if err != nil {
209 277
		return err
210 278
	}
211 279
	if err := os.Remove(filepath.Join(m.mediaDir, safe)); err != nil && !os.IsNotExist(err) {
212 280
		return err
213 281
	}
214 282
	_, err = m.Scan()
@@ -225,20 +293,23 @@ func (m *Manager) commit(ordered []Entry) (bool, error) {
225 293
	}
226 294
	m.entries = ordered
227 295
	m.version++
228 296
	m.mu.Unlock()
229 297
230 298
	names := make([]string, len(ordered))
231 299
	var m3u strings.Builder
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
	}
238 309
239 310
	data, err := json.MarshalIndent(names, "", "  ")
240 311
	if err != nil {
241 312
		return true, err
242 313
	}
243 314
	if err := config.WriteFileAtomic(m.orderFile, append(data, '\n'), 0o664); err != nil {
244 315
		return true, fmt.Errorf("order.json schreiben: %w", err)
@@ -264,21 +335,22 @@ func (m *Manager) readOrder() []string {
264 335
		return nil
265 336
	}
266 337
	return names
267 338
}
268 339
269 340
func sameEntries(a, b []Entry) bool {
270 341
	if len(a) != len(b) {
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
	}
278 350
	return true
279 351
}
280 352
281 353
// SafeName verhindert Pfad-Ausbrueche (../) und leere Namen.
282 354
func SafeName(name string) (string, error) {
283 355
	name = filepath.Base(strings.TrimSpace(name))
284 356
	if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
geändert internal/web/assets/app.js
+71 −23 Datei ansehen
@@ -89,23 +89,27 @@ function renderStatus() {
89 89
    el.nowText.textContent = s.last_error ? 'Player-Fehler' : 'Player startet …';
90 90
  } else if (s.current) {
91 91
    el.statusDot.className = 'dot on';
92 92
    el.nowText.textContent = (s.paused ? 'Pause: ' : 'Läuft: ') + s.current;
93 93
  } else {
94 94
    el.statusDot.className = 'dot';
95 95
    el.nowText.textContent = state.entries.length ? 'wird geladen …' : 'Playliste leer';
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;
105 109
    el.muteInput.checked = state.settings.mute;
106 110
  }
107 111
108 112
  el.dropHint.textContent = 'Bilder und Videos · max. ' + d.max_upload_mb + ' MB pro Vorgang';
109 113
  const parts = [];
110 114
  if (state.disk.total) parts.push(formatSize(state.disk.free) + ' von ' + formatSize(state.disk.total) + ' frei');
111 115
  if (s.display) parts.push('Bildausgabe: ' + s.display);
@@ -120,51 +124,68 @@ function renderList() {
120 124
    const li = document.createElement('li');
121 125
    li.className = 'item';
122 126
    li.dataset.name = entry.name;
123 127
124 128
    const grip = document.createElement('div');
125 129
    grip.className = 'grip';
126 130
    grip.textContent = '⠿';
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';
133 147
    img.alt = '';
134 148
    img.src = '/thumb/' + encodeURIComponent(entry.name);
135 149
136 150
    const meta = document.createElement('div');
137 151
    meta.className = 'meta';
138 152
    const name = document.createElement('div');
139 153
    name.className = 'name';
140 154
    name.textContent = entry.name;
141 155
    name.title = entry.name;
142 156
    const tags = document.createElement('div');
143 157
    tags.className = 'tags';
144 158
    const badge = document.createElement('span');
145 159
    badge.className = 'badge' + (entry.kind === 'video' ? ' video' : '');
146 160
    badge.textContent = entry.kind === 'video' ? 'Video' : 'Bild';
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');
153 173
    actions.className = 'actions';
154 174
    actions.append(
155 175
      iconButton('↑', 'Nach oben', () => move(entry.name, -1)),
156 176
      iconButton('↓', 'Nach unten', () => move(entry.name, 1)),
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();
164 185
}
165 186
166 187
function iconButton(label, title, onClick, extra) {
167 188
  const b = document.createElement('button');
168 189
  b.className = 'icon-btn' + (extra ? ' ' + extra : '');
169 190
  b.type = 'button';
170 191
  b.textContent = label;
@@ -202,56 +223,83 @@ function move(name, delta) {
202 223
  const items = Array.from(el.list.children);
203 224
  const idx = items.findIndex((li) => li.dataset.name === name);
204 225
  const target = idx + delta;
205 226
  if (idx < 0 || target < 0 || target >= items.length) return;
206 227
  if (delta < 0) el.list.insertBefore(items[idx], items[target]);
207 228
  else el.list.insertBefore(items[target], items[idx]);
208 229
  saveOrder();
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 {
251 299
    await api('/api/delete', { name });
252 300
    renderedVersion = -1;
253 301
    await refresh();
254 302
    toast('Gelöscht: ' + name, 'ok');
255 303
  } catch (e) {
256 304
    toast('Löschen fehlgeschlagen: ' + e.message, 'err');
257 305
  }
geändert internal/web/assets/style.css
+9 −2 Datei ansehen
@@ -122,30 +122,37 @@ input[type="number"] {
122 122
  border: 1px solid var(--line); border-radius: 9px;
123 123
  background: var(--bg-soft); color: var(--fg); font-size: .95rem;
124 124
}
125 125
.switch { display: flex; align-items: center; gap: .5rem; font-size: .82rem; color: var(--fg-dim); cursor: pointer; }
126 126
.switch input { width: 20px; height: 20px; accent-color: var(--accent); }
127 127
128 128
/* ---------- Playliste ---------- */
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);
136 136
}
137 137
.item.playing { border-color: var(--accent); box-shadow: 0 0 0 1px rgba(82,182,255,.25); }
138 138
.item.dragging { opacity: .4; }
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;
145 152
}
146 153
.meta { min-width: 0; }
147 154
.name { font-size: .88rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
148 155
.tags { display: flex; gap: .4rem; align-items: center; margin-top: .2rem; font-size: .72rem; color: var(--fg-dim); }
149 156
.badge { padding: .1rem .4rem; border-radius: 5px; background: #223247; color: #9dc7ee; font-size: .68rem; letter-spacing: .03em; }
150 157
.badge.video { background: #33244a; color: #c6a8f0; }
151 158
.actions { display: flex; gap: .25rem; }
@@ -172,13 +179,13 @@ input[type="number"] {
172 179
  background: #223142; border: 1px solid var(--line);
173 180
  color: var(--fg); font-size: .85rem;
174 181
  box-shadow: 0 8px 24px rgba(0,0,0,.45);
175 182
  animation: pop .2s ease-out;
176 183
}
177 184
.toast.err { background: #3a2028; border-color: #6b2b36; }
178 185
.toast.ok { background: #1c3527; border-color: #2f6b47; }
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
@@ -48,20 +48,21 @@ func New(cfg *config.Config, pl *playlist.Manager, pp *player.Player, settings *
48 48
// Handler liefert den fertig verdrahteten HTTP-Handler.
49 49
func (s *Server) Handler() http.Handler {
50 50
	mux := http.NewServeMux()
51 51
52 52
	mux.HandleFunc("/", s.handleIndex)
53 53
	mux.Handle("/static/", http.StripPrefix("/static/", staticHandler()))
54 54
	mux.HandleFunc("/api/state", s.handleState)
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)
61 62
	mux.HandleFunc("/media/", s.handleMedia)
62 63
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
63 64
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
64 65
		io.WriteString(w, "ok\n")
65 66
	})
66 67
67 68
	return s.withAuth(mux)
@@ -109,49 +110,54 @@ type stateResponse struct {
109 110
	Settings config.Settings  `json:"settings"`
110 111
	Device   deviceInfo       `json:"device"`
111 112
	Disk     diskInfo         `json:"disk"`
112 113
}
113 114
114 115
type deviceInfo struct {
115 116
	SSID      string `json:"ssid"`
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{}
135 140
	}
136 141
	free, total := diskUsage(s.cfg.MediaDir())
137 142
138 143
	writeJSON(w, http.StatusOK, stateResponse{
139 144
		Entries:  entries,
140 145
		Version:  s.pl.Version(),
141 146
		Status:   s.player.Status(),
142 147
		Settings: s.settings.Get(),
143 148
		Device: deviceInfo{
144 149
			SSID:      s.cfg.APSSID,
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
		},
151 157
		Disk: diskInfo{Free: free, Total: total},
152 158
	})
153 159
}
154 160
155 161
type uploadResult struct {
156 162
	Uploaded []string `json:"uploaded"`
157 163
	Skipped  []string `json:"skipped"`
@@ -289,20 +295,40 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
289 295
		httpError(w, http.StatusBadRequest, "ungueltige Anfrage")
290 296
		return
291 297
	}
292 298
	if err := s.pl.Delete(req.Name); err != nil {
293 299
		httpError(w, http.StatusBadRequest, err.Error())
294 300
		return
295 301
	}
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")
302 328
		return
303 329
	}
304 330
	cur := s.settings.Get()
305 331
	var req struct {
306 332
		ImageDuration *float64 `json:"image_duration"`
307 333
		Mute          *bool    `json:"mute"`
308 334
	}
geändert scripts/set-splash.sh
+56 −12 Datei ansehen
@@ -2,35 +2,40 @@
2 2
# ===========================================================================
3 3
#  Ersetzt den Startbildschirm (Plymouth-Thema "pix") durch ein eigenes Logo.
4 4
#
5 5
#  Aufruf:  sudo /opt/tvwall/scripts/set-splash.sh /pfad/zum/logo.png [--dauer N]
6 6
#           sudo /opt/tvwall/scripts/set-splash.sh --zurueck
7 7
#
8 8
#  Das Logo wird auf die Bildschirmgroesse zentriert auf schwarzem Grund
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
19 22
THEME_DIR="/usr/share/plymouth/themes/pix"
20 23
SPLASH="$THEME_DIR/splash.png"
21 24
BACKUP="$THEME_DIR/splash.original.png"
22 25
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' "$*"; }
30 35
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
31 36
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
32 37
33 38
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0 $*"
34 39
[[ -d "$THEME_DIR" ]] || die "Plymouth-Thema 'pix' nicht gefunden ($THEME_DIR)"
35 40
36 41
# --- Aufrufparameter -------------------------------------------------------
@@ -46,20 +51,26 @@ set -- "${ARGS[@]+"${ARGS[@]}"}"
46 51
47 52
# --- zuruecksetzen ---------------------------------------------------------
48 53
if [[ "${1:-}" == "--zurueck" ]]; then
49 54
  [[ -f "$BACKUP" ]] || die "keine Sicherung gefunden ($BACKUP)"
50 55
  cp -f "$BACKUP" "$SPLASH"
51 56
  ok "urspruenglicher Startbildschirm wiederhergestellt"
52 57
  systemctl disable --now tvwall-splash.service >/dev/null 2>&1 || true
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
59 70
fi
60 71
61 72
LOGO="${1:-}"
62 73
[[ -n "$LOGO" && -f "$LOGO" ]] || die "Bilddatei angeben:  sudo $0 /pfad/zum/logo.png"
63 74
command -v ffmpeg >/dev/null || die "ffmpeg fehlt:  sudo apt install ffmpeg"
64 75
65 76
# --- Bildschirmaufloesung ermitteln ---------------------------------------
@@ -87,25 +98,55 @@ ok "Startbild erzeugt ($(stat -c %s "$TMP/splash.png") Bytes)"
87 98
install -m 0644 "$TMP/splash.png" "$SPLASH"
88 99
ok "eingesetzt: $SPLASH"
89 100
90 101
# --- Regenbogen-Quadrat der Firmware abschalten ---------------------------
91 102
if [[ -w "$CONFIG_TXT" ]] && ! grep -q "^disable_splash=1" "$CONFIG_TXT"; then
92 103
  cp -f "$CONFIG_TXT" "$CONFIG_TXT.tvwall-backup"
93 104
  printf '\n# von tvwall set-splash.sh: kein Regenbogen-Quadrat beim Einschalten\ndisable_splash=1\n' >> "$CONFIG_TXT"
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
105 146
# Einschalten erreicht ist, und uebergibt ihn dann an die Wiedergabe.
106 147
# --retain-splash laesst das Bild stehen, bis mpv das erste Mal malt - so
107 148
# gibt es dazwischen keine schwarze Luecke.
108 149
ZIEL="${1:-12}"
109 150
UP="$(cut -d. -f1 /proc/uptime)"
110 151
[ "$UP" -lt "$ZIEL" ] && sleep $(( ZIEL - UP ))
111 152
[ -x /usr/bin/plymouth ] && /usr/bin/plymouth quit --retain-splash
@@ -140,20 +181,23 @@ fi
140 181
# --- Plymouth liest das Bild aus der initramfs ----------------------------
141 182
if command -v update-initramfs >/dev/null; then
142 183
  info "Aktualisiere initramfs (dauert einen Moment)"
143 184
  update-initramfs -u >/dev/null 2>&1 && ok "initramfs aktualisiert" \
144 185
    || warn "initramfs liess sich nicht aktualisieren - meist trotzdem in Ordnung"
145 186
fi
146 187
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