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

fix: [vo/gpu/libplacebo] vk->CreateSwapchainKHR(...): VK_ERROR_OUT_OF_HOST_MEMORY [vo/gpu/libplacebo] Failed (re)creating swapchain!

R Rüdiger Küpper <rpr@9it.de> committete am 21.08.2026 02:33
2f4d870b5ab7c049d585aecd6833463f5366d04d
13 geänderte Datei(en) +416 −523
Kontextzeilen: 0 1 2 3 10
gelöscht EHC-Dienstag.jpeg
+0 −0

Binärdatei – kein Textvergleich möglich.

gelöscht EHC-Montag.jpeg
+0 −0

Binärdatei – kein Textvergleich möglich.

gelöscht EHC-Woche.jpeg
+0 −0

Binärdatei – kein Textvergleich möglich.

geändert README.md
+32 −8 Datei ansehen
@@ -87,3 +87,3 @@ sudo systemctl restart tvwall
87 87
| `RUN_USER` | Benutzer des Dienstes |
88 -
| `FRESH_MPV_FOR_VIDEO` | `yes` startet mpv vor jedem Video neu (umgeht den `partial file`-Abbruch) |
88 +
| `MPV_GPU_API` | `opengl` (Standard) oder `vulkan`/`auto` — siehe Fehlersuche |
89 89
| `MPV_HWDEC` | `no` (Standard, Software) oder `auto-safe` (Hardware, nur Pi 4) |
@@ -243,9 +243,28 @@ nicht auf allen Handys. Läuft der Dienst? `systemctl status tvwall`.
243 243
244 -
**Ein Video bleibt nach ein paar Sekunden stehen.**
245 -
Seit Version 1.0.0 wird jede Datei einzeln an mpv übergeben; der früher
246 -
beobachtete Abbruch mit `partial file` beim Übergang Bild → Video ist damit
247 -
erledigt. Tritt trotzdem etwas auf, misst `scripts/bisect-mpv.sh` jede Variante
248 -
einzeln durch.
244 +
**Das Bild bleibt nach einigen Wechseln stehen, obwohl das Webinterface
245 +
weiterzählt.**
246 +
Das ist der Grund für die Voreinstellung `MPV_GPU_API="opengl"`. Über Vulkan
247 +
baut libplacebo bei jedem Bildwechsel die Swapchain neu auf und läuft nach rund
248 +
neun Wechseln in `VK_ERROR_OUT_OF_HOST_MEMORY`. mpv läuft dann weiter und meldet
249 +
auch weiter Dateiwechsel — nur gemalt wird nichts mehr. Im Journal steht:
250 +
251 +
```
252 +
[vo/gpu/libplacebo] vk->CreateSwapchainKHR(...): VK_ERROR_OUT_OF_HOST_MEMORY
253 +
[vo/gpu/libplacebo] Failed (re)creating swapchain!
254 +
```
255 +
256 +
Nachgemessen auf einem Pi 5 (Wayland/labwc, mpv 0.40), 20 Bildwechsel:
249 257
250 -
Zuerst `MPV_HWDEC="no"` prüfen (seit Version 1.0.0 die Voreinstellung). Der
258 +
| Renderer | Swapchain-Fehler |
259 +
|---|---|
260 +
| `--vo=gpu` (Vulkan) | 24 |
261 +
| `--vo=gpu --gpu-api=opengl` | **0** |
262 +
| `--vo=gpu-next` (Vulkan) | 84 |
263 +
264 +
Dasselbe galt für Videos: Mit Vulkan brach die Wiedergabe nach ein bis zwei
265 +
Sekunden ab, mit OpenGL lief derselbe Clip vollständig durch. `diagnose.sh`
266 +
prüft das mit.
267 +
268 +
**Ein Video bleibt nach ein paar Sekunden stehen.**
269 +
Zuerst `MPV_GPU_API="opengl"` prüfen (siehe oben), dann `MPV_HWDEC="no"`. Der
251 270
Raspberry Pi 5 hat *keinen* Hardware-Dekoder für H.264 mehr; mit `auto-safe`
@@ -307,4 +326,5 @@ scripts/setup-ap.sh Access Point per nmcli
307 326
scripts/diagnose.sh       Selbsttest bei Problemen
327 +
scripts/console-mode.sh   Desktop abschalten, direkt in die Anzeige booten
328 +
scripts/set-splash.sh     eigenes Logo als Startbildschirm setzen
308 329
scripts/bisect-mpv.sh     misst, welche mpv-Option einen Haenger ausloest
309 -
scripts/probe-transition.py  prueft den Uebergang Bild -> Video im selben mpv
310 330
install.sh                Installation auf dem Pi
@@ -322,2 +342,6 @@ Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
322 342
  nachgeladen werden müsste, wäre dort tot.
343 +
* **mpv rendert über OpenGL, nicht über Vulkan.** Auf dem Raspberry Pi 5 baut
344 +
  die Vulkan-Anbindung von libplacebo bei jedem Bildwechsel die Swapchain neu
345 +
  auf und läuft dabei nach rund neun Wechseln aus dem Speicher — der Bildschirm
346 +
  friert ein, während intern alles weiterläuft. Siehe Fehlersuche.
323 347
* **Uploads landen erst in einer temporären Datei** und werden nach vollem
geändert config/tvwall.conf
+9 −7 Datei ansehen
@@ -78,2 +78,11 @@ RUN_USER="tvwall"
78 78
79 +
# Grafik-Anbindung von mpv.
80 +
#   opengl = Standard und dringend empfohlen
81 +
#   vulkan = schneller, aber auf dem Raspberry Pi 5 fehlerhaft: libplacebo baut
82 +
#            bei jedem Bildwechsel die Swapchain neu auf und laeuft nach rund
83 +
#            neun Wechseln in VK_ERROR_OUT_OF_HOST_MEMORY - mpv laeuft dann
84 +
#            weiter, der Bildschirm friert ein.
85 +
#   auto   = mpv entscheidet (waehlt auf dem Pi 5 Vulkan - nicht empfohlen)
86 +
MPV_GPU_API="opengl"
87 +
79 88
# Hardwarebeschleunigte Videodekodierung.
@@ -86,9 +95,2 @@ MPV_HWDEC="no"
86 95
87 -
# mpv vor jedem Video frisch starten.
88 -
# Auf manchen Systemen bricht ein Video ab, wenn mpv vorher schon ein Bild
89 -
# angezeigt hat ("partial file" im Protokoll). Ein frischer Prozess je Video
90 -
# umgeht das; der Preis ist ein kurzer schwarzer Moment davor.
91 -
# Mit scripts/probe-transition.py laesst sich pruefen, ob "no" reicht.
92 -
FRESH_MPV_FOR_VIDEO="yes"
93 -
94 96
# Laeuft ein Video so lange nicht weiter (Sekunden), wird es uebersprungen,
hinzugefügt ehc-logo-v2..png
+0 −0 Datei ansehen

Binärdatei – kein Textvergleich möglich.

geändert internal/config/config.go
+6 −3 Datei ansehen
@@ -39,2 +39,3 @@ type Config struct {
39 39
	MpvHwdec      string
40 +
	MpvGpuAPI     string
40 41
	FreshForVideo bool // mpv vor jedem Video neu starten
@@ -62,3 +63,3 @@ func Default() *Config {
62 63
		MpvHwdec:      "no",
63 -
		FreshForVideo: true,
64 +
		MpvGpuAPI:     "opengl",
64 65
		StallTimeout:  30,
@@ -150,4 +151,6 @@ func Load(path string) (*Config, error) {
150 151
			c.MpvExtraArgs = val
151 -
		case "FRESH_MPV_FOR_VIDEO":
152 -
			c.FreshForVideo = isTrue(val)
152 +
		case "MPV_GPU_API":
153 +
			if val != "" {
154 +
				c.MpvGpuAPI = val
155 +
			}
153 156
		case "MPV_HWDEC":
geändert internal/player/player.go
+117 −245 Datei ansehen
@@ -1,10 +1,7 @@
1 -
// Package player startet und ueberwacht mpv und steuert die Wiedergabe.
1 +
// Package player startet und ueberwacht mpv und haelt dessen Playliste aktuell.
2 2
//
3 -
// mpv bekommt bewusst KEINE Playliste, sondern immer nur die eine Datei, die
4 -
// gerade laufen soll (loadfile ... replace). Die Reihenfolge fuehrt dieses
5 -
// Programm selbst. Grund: eine gemischte Playliste aus Bildern und Videos
6 -
// bringt den Demuxer durcheinander - eine Videodatei, die allein einwandfrei
7 -
// laeuft, bricht als zweiter Eintrag nach kurzer Zeit mit "partial file" ab.
8 -
// Als jeweils einzige geladene Datei tritt das nicht auf; nebenbei haben wir
9 -
// so die volle Kontrolle ueber Reihenfolge, Weiterschalten und Fehlerfaelle.
3 +
// mpv bekommt die komplette Playliste (Bilder und Videos gemischt) und schaltet
4 +
// selbst weiter; dieses Programm laedt sie nur nach, wenn sich der Medienordner
5 +
// geaendert hat - und zwar zwischen zwei Dateien, damit kein laufendes Video
6 +
// unterbrochen wird.
10 7
package player
@@ -14,3 +11,2 @@ import (
14 11
	"encoding/json"
15 -
	"errors"
16 12
	"fmt"
@@ -34,2 +30,3 @@ const (
34 30
	propPause
31 +
	propPlaylistPos
35 32
	propIdle
@@ -38,12 +35,4 @@ const (
38 35
39 -
// Takt der Ueberwachung: haengende Dateien und leerer Bildschirm.
40 36
const tickInterval = 5 * time.Second
41 37
42 -
// Wartezeit, bevor nach lauter fehlerhaften Dateien erneut versucht wird.
43 -
const retryDelay = 15 * time.Second
44 -
45 -
// errFreshMpv ist kein Fehler, sondern der Wunsch nach einem frischen
46 -
// mpv-Prozess fuer die naechste Datei. Siehe FRESH_MPV_FOR_VIDEO.
47 -
var errFreshMpv = errors.New("frischer mpv-prozess fuer das naechste video")
48 -
49 38
// Status beschreibt den Zustand der Wiedergabe fuer das Webinterface.
@@ -60,3 +49,3 @@ type Status struct {
60 49
61 -
// Player haelt genau einen mpv-Prozess am Leben und schaltet die Dateien weiter.
50 +
// Player haelt genau einen mpv-Prozess am Leben.
62 51
type Player struct {
@@ -66,10 +55,7 @@ type Player struct {
66 55
67 -
	mu     sync.RWMutex
68 -
	client *mpv.Client
69 -
	status Status
70 -
71 -
	playing     string    // Datei, die wir mpv zuletzt uebergeben haben
72 -
	index       int       // deren Platz in der Playliste
73 -
	errStreak   int       // Dateien in Folge, die sich nicht abspielen liessen
74 -
	lastAttempt time.Time // wann zuletzt eine Datei geladen wurde
56 +
	mu       sync.RWMutex
57 +
	client   *mpv.Client
58 +
	status   Status
59 +
	pending  bool
60 +
	loadedID uint64 // Version der zuletzt an mpv uebergebenen Playliste
75 61
@@ -78,5 +64,2 @@ type Player struct {
78 64
79 -
	loadedCount int // Dateien, die dieser mpv-Prozess schon geladen hat
80 -
81 -
	advance   chan int
82 65
	reloadNow chan struct{}
@@ -91,3 +74,2 @@ func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Play
91 74
		settings:  settings,
92 -
		advance:   make(chan int, 4),
93 75
		reloadNow: make(chan struct{}, 1),
@@ -101,3 +83,5 @@ func (p *Player) Status() Status {
101 83
	defer p.mu.RUnlock()
102 -
	return p.status
84 +
	s := p.status
85 +
	s.PendingReload = p.pending
86 +
	return s
103 87
}
@@ -110,3 +94,6 @@ func (p *Player) Run(ctx context.Context) {
110 94
		start := time.Now()
111 -
		err := p.runOnce(ctx, settingsChanged)
95 +
		if err := p.runOnce(ctx, settingsChanged); err != nil && ctx.Err() == nil {
96 +
			log.Printf("player: %v", err)
97 +
			p.setError(err.Error())
98 +
		}
112 99
		p.setRunning(false)
@@ -115,10 +102,2 @@ func (p *Player) Run(ctx context.Context) {
115 102
		}
116 -
		if errors.Is(err, errFreshMpv) {
117 -
			// Geplanter, kurzer Neustart - kein Grund zu warten.
118 -
			continue
119 -
		}
120 -
		if err != nil {
121 -
			log.Printf("player: %v", err)
122 -
			p.setError(err.Error())
123 -
		}
124 103
		if time.Since(start) > time.Minute {
@@ -137,4 +116,2 @@ func (p *Player) Run(ctx context.Context) {
137 116
138 -
// runOnce startet mpv, verbindet sich mit dem IPC-Socket und bleibt, bis mpv
139 -
// endet oder ctx abgebrochen wird.
140 117
func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) error {
@@ -156,4 +133,4 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
156 133
	cmd := exec.Command("mpv", args...)
157 -
	// stdin durchreichen: bei --vo=gpu --gpu-context=drm braucht mpv das
158 -
	// Terminal der Konsole (tty1), um den Bildschirm uebernehmen zu duerfen.
134 +
	// stdin durchreichen: bei --gpu-context=drm braucht mpv das Terminal der
135 +
	// Konsole, um den Bildschirm uebernehmen zu duerfen.
159 136
	cmd.Stdin = os.Stdin
@@ -172,3 +149,2 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
172 149
	}()
173 -
174 150
	defer func() {
@@ -190,5 +166,4 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
190 166
	p.status = Status{Running: true, Display: disp.mode}
191 -
	p.index = 0
192 -
	p.errStreak = 0
193 -
	p.loadedCount = 0
167 +
	p.lastTimePos = -1
168 +
	p.lastProgressAt = time.Now()
194 169
	p.mu.Unlock()
@@ -201,3 +176,3 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
201 176
	for id, name := range map[int]string{
202 -
		propPath: "path", propPause: "pause",
177 +
		propPath: "path", propPause: "pause", propPlaylistPos: "playlist-pos",
203 178
		propIdle: "idle-active", propTimePos: "time-pos",
@@ -210,5 +185,3 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
210 185
	p.applySettings(client)
211 -
	if err := p.step(client, 0); err != nil { // erste Datei laden
212 -
		return err
213 -
	}
186 +
	p.loadPlaylist(client)
214 187
	p.setError("")
@@ -235,29 +208,14 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
235 208
		case <-p.pl.Changed():
236 -
			// Aenderungen greifen von selbst beim naechsten Dateiwechsel, weil
237 -
			// dort die Playliste neu gelesen wird. Nur wenn gerade nichts
238 -
			// laeuft, muss die Wiedergabe angestossen werden.
239 -
			p.mu.RLock()
240 -
			idleNow := p.playing == ""
241 -
			p.mu.RUnlock()
242 -
			if idleNow {
243 -
				if err := p.step(client, 0); err != nil {
244 -
					return err
245 -
				}
246 -
			}
247 -
248 -
		case delta := <-p.advance:
249 -
			if err := p.step(client, delta); err != nil {
250 -
				return err
251 -
			}
252 -
253 -
		case <-p.reloadNow:
209 +
			// Nicht mitten in einer Datei neu laden - erst am Uebergang.
254 210
			p.mu.Lock()
255 -
			p.playing = ""
256 -
			p.index = 0
257 -
			p.errStreak = 0
211 +
			p.pending = p.pl.Version() != p.loadedID
212 +
			pending, idle := p.pending, p.status.Idle
258 213
			p.mu.Unlock()
259 -
			if err := p.step(client, 0); err != nil {
260 -
				return err
214 +
			if pending && idle {
215 +
				p.loadPlaylist(client)
261 216
			}
262 217
218 +
		case <-p.reloadNow:
219 +
			p.loadPlaylist(client)
220 +
263 221
		case <-p.restart:
@@ -269,5 +227,3 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
269 227
		case <-ticker.C:
270 -
			if err := p.tick(client); err != nil {
271 -
				return err
272 -
			}
228 +
			p.checkStalled(client)
273 229
@@ -277,5 +233,3 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
277 233
			}
278 -
			if err := p.handleEvent(client, ev); err != nil {
279 -
				return err
280 -
			}
234 +
			p.handleEvent(client, ev)
281 235
		}
@@ -284,3 +238,3 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
284 238
285 -
func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) error {
239 +
func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
286 240
	switch ev.Name {
@@ -293,4 +247,2 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) error {
293 247
				p.status.Current = filepath.Base(s)
294 -
				// Eine Datei laeuft wieder - die Fehlerkette ist unterbrochen.
295 -
				p.errStreak = 0
296 248
			} else {
@@ -305,2 +257,7 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) error {
305 257
			}
258 +
		case propPlaylistPos:
259 +
			var n float64
260 +
			if json.Unmarshal(ev.Data, &n) == nil {
261 +
				p.status.Position = int(n)
262 +
			}
306 263
		case propIdle:
@@ -317,38 +274,33 @@ func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) error {
317 274
		}
275 +
		idle, pending := p.status.Idle, p.pending
318 276
		p.mu.Unlock()
319 277
320 -
	case "end-file":
321 -
		// "stop" kommt von unserem eigenen loadfile - das darf nicht
322 -
		// weiterschalten, sonst wird jede zweite Datei uebersprungen.
323 -
		var e struct {
324 -
			Reason    string `json:"reason"`
325 -
			FileError string `json:"file_error"`
278 +
		if ev.ID == propIdle && idle && pending {
279 +
			p.loadPlaylist(client)
326 280
		}
327 -
		_ = json.Unmarshal(ev.Raw, &e)
328 -
		switch e.Reason {
329 -
		case "eof":
330 -
			return p.step(client, 1)
331 -
		case "error":
332 -
			p.mu.Lock()
333 -
			bad := p.playing
334 -
			p.errStreak++
335 -
			p.mu.Unlock()
336 -
			log.Printf("player: %q liess sich nicht abspielen (%s) - wird uebersprungen",
337 -
				bad, strings.TrimSpace(e.FileError))
338 -
			p.setError(fmt.Sprintf("%s liess sich nicht abspielen", bad))
339 -
			return p.step(client, 1)
281 +
282 +
	case "end-file":
283 +
		// Der beste Moment fuer eine neue Playliste: zwischen zwei Dateien.
284 +
		p.mu.RLock()
285 +
		pending := p.pending
286 +
		p.mu.RUnlock()
287 +
		if pending {
288 +
			p.loadPlaylist(client)
340 289
		}
341 290
	}
342 -
	return nil
343 291
}
344 292
345 -
// step schaltet um delta Eintraege weiter und laedt die Datei in mpv.
346 -
// delta 0 laedt den aktuellen Eintrag (bzw. steigt neu ein).
347 -
func (p *Player) step(client *mpv.Client, delta int) error {
348 -
	entries, _ := p.pl.EntriesWithVersion()
293 +
// loadPlaylist uebergibt mpv die aktuelle m3u-Datei.
294 +
func (p *Player) loadPlaylist(client *mpv.Client) {
295 +
	entries, version := p.pl.EntriesWithVersion()
296 +
297 +
	p.mu.Lock()
298 +
	p.pending = false
299 +
	p.loadedID = version
300 +
	p.mu.Unlock()
301 +
349 302
	if len(entries) == 0 {
350 -
		p.mu.Lock()
351 -
		p.playing, p.index = "", 0
352 -
		p.status.Current = ""
353 -
		p.mu.Unlock()
303 +
		if _, err := client.Command("playlist-clear"); err != nil {
304 +
			log.Printf("player: playlist-clear: %v", err)
305 +
		}
354 306
		if _, err := client.Command("stop"); err != nil {
@@ -356,66 +308,12 @@ func (p *Player) step(client *mpv.Client, delta int) error {
356 308
		}
357 -
		return nil
358 -
	}
359 -
360 -
	p.mu.RLock()
361 -
	playing, index, errStreak := p.playing, p.index, p.errStreak
362 -
	loaded := p.loadedCount
363 -
	p.mu.RUnlock()
364 -
365 -
	// Lieber gar nichts tun als in einer Schleife lauter kaputte Dateien
366 -
	// durchzuhecheln - der naechste Takt versucht es in Ruhe erneut.
367 -
	if errStreak >= len(entries) {
368 -
		log.Printf("player: keine der %d Dateien liess sich abspielen - warte %s",
369 -
			len(entries), retryDelay)
370 -
		p.setError("keine abspielbare Datei in der Playliste")
371 309
		p.mu.Lock()
372 -
		p.playing = ""
373 -
		p.lastAttempt = time.Now()
374 -
		p.mu.Unlock()
375 -
		return nil
376 -
	}
377 -
378 -
	// Position anhand des Dateinamens bestimmen: so bleibt die Reihenfolge
379 -
	// auch dann sinnvoll, wenn zwischendurch etwas hinzugekommen oder
380 -
	// weggefallen ist.
381 -
	base := index
382 -
	if playing != "" {
383 -
		if i := indexOf(entries, playing); i >= 0 {
384 -
			base = i
385 -
		}
386 -
	}
387 -
	next := ((base+delta)%len(entries) + len(entries)) % len(entries)
388 -
389 -
	entry := entries[next]
390 -
	path := filepath.Join(p.pl.MediaDir(), entry.Name)
391 -
392 -
	// Ein Video in einen mpv-Prozess zu laden, der vorher schon etwas anderes
393 -
	// angezeigt hat, endet auf manchen Systemen im Abbruch des Demuxers
394 -
	// ("partial file"). Deshalb bekommt jedes Video einen frischen Prozess.
395 -
	if p.cfg.FreshForVideo && entry.Kind == "video" && loaded > 0 {
396 -
		p.mu.Lock()
397 -
		p.playing = entry.Name // damit der neue Prozess genau hier einsteigt
398 -
		p.index = next
310 +
		p.status.Current = ""
399 311
		p.mu.Unlock()
400 -
		log.Printf("player: starte mpv neu fuer %q", entry.Name)
401 -
		return errFreshMpv
312 +
		return
402 313
	}
403 314
404 -
	p.mu.Lock()
405 -
	p.playing = entry.Name
406 -
	p.index = next
407 -
	p.lastAttempt = time.Now()
408 -
	p.lastTimePos = -1
409 -
	p.lastProgressAt = time.Now()
410 -
	p.status.Position = next
411 -
	p.loadedCount++
412 -
	p.mu.Unlock()
413 -
414 -
	if _, err := client.Command("loadfile", path, "replace"); err != nil {
415 -
		log.Printf("player: %q laden: %v", entry.Name, err)
416 -
		p.setError("Laden von " + entry.Name + ": " + err.Error())
417 -
		p.mu.Lock()
418 -
		p.errStreak++
419 -
		p.mu.Unlock()
420 -
		return nil
315 +
	if _, err := client.Command("loadlist", p.pl.PlaylistFile(), "replace"); err != nil {
316 +
		log.Printf("player: playlist laden: %v", err)
317 +
		p.setError("Playliste laden: " + err.Error())
318 +
		return
421 319
	}
@@ -424,39 +322,20 @@ func (p *Player) step(client *mpv.Client, delta int) error {
424 322
	}
425 -
	return nil
323 +
	log.Printf("player: playliste geladen (%d eintraege)", len(entries))
426 324
}
427 325
428 -
func indexOf(entries []playlist.Entry, name string) int {
429 -
	for i, e := range entries {
430 -
		if e.Name == name {
431 -
			return i
432 -
		}
326 +
// checkStalled ueberspringt ein Video, das nicht mehr weiterlaeuft, damit eine
327 +
// einzelne Datei nicht die ganze Wiedergabe anhaelt.
328 +
func (p *Player) checkStalled(client *mpv.Client) {
329 +
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
330 +
	if timeout <= 0 {
331 +
		return
433 332
	}
434 -
	return -1
435 -
}
436 -
437 -
// tick prueft regelmaessig, ob die Wiedergabe noch laeuft.
438 -
func (p *Player) tick(client *mpv.Client) error {
439 333
	p.mu.RLock()
440 -
	playing, paused := p.playing, p.status.Paused
441 -
	current := p.status.Current
334 +
	current, paused, idle := p.status.Current, p.status.Paused, p.status.Idle
442 335
	since := time.Since(p.lastProgressAt)
443 -
	sinceAttempt := time.Since(p.lastAttempt)
444 336
	p.mu.RUnlock()
445 337
446 -
	// Nichts geladen (leere Playliste oder lauter kaputte Dateien):
447 -
	// nach einer Weile erneut versuchen.
448 -
	if playing == "" {
449 -
		if len(p.pl.Entries()) > 0 && sinceAttempt > retryDelay {
450 -
			p.mu.Lock()
451 -
			p.errStreak = 0
452 -
			p.mu.Unlock()
453 -
			return p.step(client, 0)
454 -
		}
455 -
		return nil
456 -
	}
457 -
458 -
	// Haengendes Video: ueberspringen, damit nicht die ganze Wand steht.
459 -
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
460 -
	if timeout <= 0 || paused || current == "" || playlist.Kind(current) != "video" || since < timeout {
461 -
		return nil
338 +
	// Bilder haben keinen fortlaufenden Zeitstempel - nur Videos pruefen.
339 +
	if paused || idle || current == "" || playlist.Kind(current) != "video" || since < timeout {
340 +
		return
462 341
	}
@@ -469,10 +348,5 @@ func (p *Player) tick(client *mpv.Client) error {
469 348
	p.mu.Unlock()
470 -
471 -
	// Reagiert mpv nicht mehr, hilft nur noch ein Neustart des Prozesses.
472 -
	if _, err := client.Command("playlist-remove", "current"); err != nil {
473 -
		// playlist-remove ist nur ein billiger Lebenstest; scheitert er,
474 -
		// steht mpv wirklich.
475 -
		return fmt.Errorf("mpv reagiert nicht mehr: %w", err)
349 +
	if _, err := client.Command("playlist-next", "force"); err != nil {
350 +
		log.Printf("player: weiterschalten: %v", err)
476 351
	}
477 -
	return p.step(client, 1)
478 352
}
@@ -500,2 +374,3 @@ func (p *Player) mpvArgs(disp display) []string {
500 374
		"--keep-open=no",
375 +
		"--loop-playlist=inf",
501 376
		"--image-display-duration=" + strconv.FormatFloat(s.ImageDuration, 'f', -1, 64),
@@ -508,6 +383,14 @@ func (p *Player) mpvArgs(disp display) []string {
508 383
		"--stop-screensaver=yes",
509 -
		// Kein --no-terminal: sonst verschluckt mpv seine eigenen
510 -
		// Fehlermeldungen, und im Journal steht nur, dass es beendet wurde.
384 +
		// Kein --no-terminal: sonst verschluckt mpv seine Fehlermeldungen.
511 385
		"--msg-level=all=warn",
512 386
		"--no-input-terminal",
387 +
		"--vo=gpu",
388 +
	}
389 +
	// Die Vulkan-Anbindung von libplacebo baut bei jedem Bildwechsel die
390 +
	// Swapchain neu auf und laeuft dabei nach wenigen Wechseln in
391 +
	// VK_ERROR_OUT_OF_HOST_MEMORY: mpv laeuft weiter, der Bildschirm friert
392 +
	// ein. Mit OpenGL tritt das nicht auf (auf einem Pi 5 nachgemessen:
393 +
	// 0 statt 24 Fehlern in 20 Bildwechseln).
394 +
	if p.cfg.MpvGpuAPI != "" && p.cfg.MpvGpuAPI != "auto" {
395 +
		args = append(args, "--gpu-api="+p.cfg.MpvGpuAPI)
513 396
	}
@@ -516,4 +399,2 @@ func (p *Player) mpvArgs(disp display) []string {
516 399
	} else {
517 -
		// Ohne Tonausgabe kann auch kein blockierendes Audiogeraet die
518 -
		// Wiedergabe anhalten - fuer eine reine Anzeigetafel der sichere Weg.
519 400
		args = append(args, "--no-audio")
@@ -521,5 +402,3 @@ func (p *Player) mpvArgs(disp display) []string {
521 402
	if disp.mode == "drm" {
522 -
		args = append(args, "--vo=gpu", "--gpu-context=drm")
523 -
	} else {
524 -
		args = append(args, "--vo=gpu")
403 +
		args = append(args, "--gpu-context=drm")
525 404
	}
@@ -570,18 +449,10 @@ func splitArgs(s string) []string {
570 449
571 -
func (p *Player) running() bool {
450 +
func (p *Player) withClient(fn func(*mpv.Client) error) error {
572 451
	p.mu.RLock()
573 -
	defer p.mu.RUnlock()
574 -
	return p.client != nil
575 -
}
576 -
577 -
func (p *Player) send(ch chan int, delta int) error {
578 -
	if !p.running() {
452 +
	c := p.client
453 +
	p.mu.RUnlock()
454 +
	if c == nil {
579 455
		return fmt.Errorf("player laeuft gerade nicht")
580 456
	}
581 -
	select {
582 -
	case ch <- delta:
583 -
		return nil
584 -
	default:
585 -
		return fmt.Errorf("player ist beschaeftigt")
586 -
	}
457 +
	return fn(c)
587 458
}
@@ -589,6 +460,16 @@ func (p *Player) send(ch chan int, delta int) error {
589 460
// Next springt zum naechsten Eintrag.
590 -
func (p *Player) Next() error { return p.send(p.advance, 1) }
461 +
func (p *Player) Next() error {
462 +
	return p.withClient(func(c *mpv.Client) error {
463 +
		_, err := c.Command("playlist-next", "force")
464 +
		return err
465 +
	})
466 +
}
591 467
592 468
// Prev springt zum vorherigen Eintrag.
593 -
func (p *Player) Prev() error { return p.send(p.advance, -1) }
469 +
func (p *Player) Prev() error {
470 +
	return p.withClient(func(c *mpv.Client) error {
471 +
		_, err := c.Command("playlist-prev", "force")
472 +
		return err
473 +
	})
474 +
}
594 475
@@ -596,17 +477,10 @@ func (p *Player) Prev() error { return p.send(p.advance, -1) }
596 477
func (p *Player) TogglePause() error {
597 -
	p.mu.RLock()
598 -
	c := p.client
599 -
	p.mu.RUnlock()
600 -
	if c == nil {
601 -
		return fmt.Errorf("player laeuft gerade nicht")
602 -
	}
603 -
	_, err := c.Command("cycle", "pause")
604 -
	return err
478 +
	return p.withClient(func(c *mpv.Client) error {
479 +
		_, err := c.Command("cycle", "pause")
480 +
		return err
481 +
	})
605 482
}
606 483
607 -
// ReloadNow beginnt die Playliste sofort von vorn.
484 +
// ReloadNow laedt die Playliste sofort neu, auch mitten in einem Video.
608 485
func (p *Player) ReloadNow() error {
609 -
	if !p.running() {
610 -
		return fmt.Errorf("player laeuft gerade nicht")
611 -
	}
612 486
	select {
@@ -631,4 +505,2 @@ func (p *Player) setRunning(v bool) {
631 505
	if !v {
632 -
		// p.playing bleibt stehen: Daran erkennt der naechste mpv-Prozess,
633 -
		// wo er wieder einsteigen soll.
634 506
		p.status.Current = ""
gelöscht internal/player/segment.go
+0 −90
@@ -1,90 +0,0 @@
1 -
package player
2 -
3 -
import (
4 -
	"path/filepath"
5 -
	"strings"
6 -
7 -
	"ehc/tvwall/internal/config"
8 -
	"ehc/tvwall/internal/playlist"
9 -
)
10 -
11 -
// segment ist ein Stueck Playliste, das ein mpv-Prozess am Stueck abspielt:
12 -
// entweder eine Folge aufeinanderfolgender Bilder oder genau ein Video.
13 -
//
14 -
// Diese Aufteilung loest zwei beobachtete Fehler auf einmal:
15 -
//
16 -
//   - Ein Video, das in einen mpv-Prozess geladen wird, der vorher schon etwas
17 -
//     angezeigt hat, bricht mit "partial file" ab. Jedes Video bekommt deshalb
18 -
//     einen eigenen, frischen Prozess.
19 -
//   - Wird dagegen jedes einzelne Bild per loadfile nachgeladen, bleibt die
20 -
//     Bildausgabe nach etwa neun Wechseln stehen (mpv meldet weiter Wechsel,
21 -
//     der Bildschirm friert ein). Bilderfolgen laufen deshalb als Playliste
22 -
//     innerhalb eines Prozesses - mpv schaltet selbst weiter.
23 -
type segment struct {
24 -
	kind  string // "images" oder "video"
25 -
	names []string
26 -
}
27 -
28 -
// buildSegments fasst aufeinanderfolgende Bilder zusammen; jedes Video wird
29 -
// ein eigener Abschnitt.
30 -
func buildSegments(entries []playlist.Entry) []segment {
31 -
	var segs []segment
32 -
	for _, e := range entries {
33 -
		if e.Kind == "video" {
34 -
			segs = append(segs, segment{kind: "video", names: []string{e.Name}})
35 -
			continue
36 -
		}
37 -
		if n := len(segs); n > 0 && segs[n-1].kind == "images" {
38 -
			segs[n-1].names = append(segs[n-1].names, e.Name)
39 -
			continue
40 -
		}
41 -
		segs = append(segs, segment{kind: "images", names: []string{e.Name}})
42 -
	}
43 -
	return segs
44 -
}
45 -
46 -
func (s segment) equal(o segment) bool {
47 -
	if s.kind != o.kind || len(s.names) != len(o.names) {
48 -
		return false
49 -
	}
50 -
	for i := range s.names {
51 -
		if s.names[i] != o.names[i] {
52 -
			return false
53 -
		}
54 -
	}
55 -
	return true
56 -
}
57 -
58 -
func (s segment) label() string {
59 -
	if s.kind == "video" && len(s.names) == 1 {
60 -
		return "Video " + s.names[0]
61 -
	}
62 -
	return strings.Join(s.names, ", ")
63 -
}
64 -
65 -
// writePlaylist schreibt die Dateien des Abschnitts als m3u fuer mpv.
66 -
func (s segment) writePlaylist(cfg *config.Config, mediaDir string) (string, error) {
67 -
	var b strings.Builder
68 -
	b.WriteString("#EXTM3U\n")
69 -
	for _, n := range s.names {
70 -
		b.WriteString(filepath.Join(mediaDir, n))
71 -
		b.WriteByte('\n')
72 -
	}
73 -
	path := filepath.Join(cfg.StateDir, "segment.m3u")
74 -
	if err := config.WriteFileAtomic(path, []byte(b.String()), 0o664); err != nil {
75 -
		return "", err
76 -
	}
77 -
	return path, nil
78 -
}
79 -
80 -
func sameSegments(a, b []segment) bool {
81 -
	if len(a) != len(b) {
82 -
		return false
83 -
	}
84 -
	for i := range a {
85 -
		if !a[i].equal(b[i]) {
86 -
			return false
87 -
		}
88 -
	}
89 -
	return true
90 -
}
hinzugefügt scripts/console-mode.sh
+81 −0 Datei ansehen
@@ -0,0 +1,81 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Stellt den Pi auf reinen Anzeigebetrieb um: kein Desktop mehr, Start direkt
4 +
#  in die Konsole, tvwall malt ueber DRM/KMS unmittelbar auf den Bildschirm.
5 +
#
6 +
#  Aufruf:  sudo /opt/tvwall/scripts/console-mode.sh [--zurueck]
7 +
#
8 +
#  --zurueck stellt den Desktop-Betrieb wieder her.
9 +
# ===========================================================================
10 +
set -euo pipefail
11 +
12 +
CONF="/etc/tvwall/tvwall.conf"
13 +
INSTALLER="/opt/tvwall/install.sh"
14 +
15 +
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
16 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
17 +
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
18 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
19 +
20 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0"
21 +
22 +
if [[ "${1:-}" == "--zurueck" ]]; then
23 +
  info "Stelle den Desktop-Betrieb wieder her"
24 +
  systemctl set-default graphical.target
25 +
  systemctl enable lightdm >/dev/null 2>&1 || true
26 +
  systemctl enable getty@tty1.service >/dev/null 2>&1 || true
27 +
  sed -i 's|^DISPLAY_MODE=.*|DISPLAY_MODE="auto"|' "$CONF"
28 +
  [[ -x "$INSTALLER" ]] && "$INSTALLER" --no-ap --yes >/dev/null
29 +
  ok "Desktop wieder aktiv - bitte neu starten:  sudo reboot"
30 +
  exit 0
31 +
fi
32 +
33 +
info "Boot-Ziel auf Konsole umstellen"
34 +
systemctl set-default multi-user.target >/dev/null
35 +
ok "Standard-Ziel: $(systemctl get-default)"
36 +
37 +
info "Desktop-Anmeldung abschalten"
38 +
for dm in lightdm gdm3 sddm; do
39 +
  if systemctl list-unit-files "$dm.service" >/dev/null 2>&1 &&
40 +
     systemctl is-enabled "$dm" >/dev/null 2>&1; then
41 +
    systemctl disable "$dm" >/dev/null 2>&1 || true
42 +
    systemctl stop "$dm" || true
43 +
    ok "$dm abgeschaltet"
44 +
  fi
45 +
done
46 +
sleep 1
47 +
[[ "$(pgrep -c 'labwc|wayfire|Xorg' 2>/dev/null || echo 0)" == "0" ]] \
48 +
  && ok "keine Desktop-Oberflaeche mehr aktiv" \
49 +
  || warn "es laeuft noch eine Oberflaeche - nach dem Neustart ist sie weg"
50 +
51 +
info "Bildausgabe auf DRM festlegen"
52 +
sed -i 's|^DISPLAY_MODE=.*|DISPLAY_MODE="drm"|' "$CONF"
53 +
ok 'DISPLAY_MODE="drm"'
54 +
55 +
# Der Benutzer des Dienstes braucht Zugriff auf Grafik und Konsole.
56 +
RUN_USER="$(sed -n 's/^RUN_USER="\(.*\)"/\1/p' "$CONF" | head -1)"
57 +
RUN_USER="${RUN_USER:-tvwall}"
58 +
for grp in video render input tty; do
59 +
  getent group "$grp" >/dev/null && usermod -aG "$grp" "$RUN_USER" 2>/dev/null || true
60 +
done
61 +
ok "Benutzer $RUN_USER hat Zugriff auf Grafik und Konsole"
62 +
63 +
if [[ -x "$INSTALLER" ]]; then
64 +
  info "Dienst auf die Konsolen-Variante umstellen"
65 +
  "$INSTALLER" --no-ap --yes >/dev/null
66 +
  ok "systemd-Dienst neu eingerichtet"
67 +
else
68 +
  warn "$INSTALLER fehlt - bitte install.sh aus dem Paket erneut ausfuehren"
69 +
fi
70 +
71 +
cat <<EOF
72 +
73 +
===========================================================================
74 +
 Fertig. Nach dem Neustart faehrt der Pi ohne Desktop hoch und zeigt
75 +
 sofort die Playliste.
76 +
77 +
   sudo reboot
78 +
79 +
 Zurueck zum Desktop:  sudo $0 --zurueck
80 +
===========================================================================
81 +
EOF
geändert scripts/diagnose.sh
+12 −0 Datei ansehen
@@ -165,2 +165,14 @@ fi
165 165
166 +
hdr "Bildausgabe"
167 +
SWAP="$(journalctl -u tvwall --since "-30 min" --no-pager 2>/dev/null | grep -ci swapchain)"
168 +
if [[ "${SWAP:-0}" -gt 0 ]]; then
169 +
  bad "$SWAP Swapchain-Fehler in den letzten 30 Minuten"
170 +
  note "Die Vulkan-Anbindung von mpv laeuft auf dem Pi 5 nach wenigen"
171 +
  note "Bildwechseln aus dem Speicher: der Bildschirm friert ein, waehrend"
172 +
  note "das Webinterface weiterzaehlt."
173 +
  note "-> In $CONF setzen:  MPV_GPU_API=\"opengl\"  und Dienst neu starten"
174 +
else
175 +
  ok "keine Swapchain-Fehler (MPV_GPU_API=${MPV_GPU_API:-opengl})"
176 +
fi
177 +
166 178
hdr "Letzte Protokollzeilen"
gelöscht scripts/probe-transition.py
+0 −170
@@ -1,170 +0,0 @@
1 -
#!/usr/bin/env python3
2 -
"""
3 -
Prueft, ob ein Video in einem mpv-Prozess laeuft, der vorher ein Bild
4 -
angezeigt hat - und welche mpv-Option das gegebenenfalls behebt.
5 -
6 -
Genau dieser Uebergang ist der Grund, warum tvwall vor jedem Video einen
7 -
frischen mpv-Prozess startet (FRESH_MPV_FOR_VIDEO). Findet dieses Skript eine
8 -
Option, die den Uebergang heil macht, kann der Neustart entfallen.
9 -
10 -
Aufruf (Dienst vorher anhalten):
11 -
12 -
    sudo systemctl stop tvwall
13 -
    sudo -u rk XDG_RUNTIME_DIR=/run/user/1000 \\
14 -
         python3 probe-transition.py <bild> <video>
15 -
    sudo systemctl start tvwall
16 -
"""
17 -
import json
18 -
import os
19 -
import socket
20 -
import subprocess
21 -
import sys
22 -
import tempfile
23 -
import time
24 -
25 -
BASE = [
26 -
    "--fullscreen", "--idle=yes", "--force-window=yes", "--keep-open=no",
27 -
    "--image-display-duration=2", "--hwdec=no", "--no-osc", "--osd-level=0",
28 -
    "--cursor-autohide=always", "--no-input-default-bindings",
29 -
    "--input-vo-keyboard=no", "--no-input-terminal", "--msg-level=all=error",
30 -
    "--vo=gpu",
31 -
]
32 -
33 -
VARIANTEN = [
34 -
    ("Grundzustand (wie der Dienst)",        []),
35 -
    ("--demuxer-thread=no",                  ["--demuxer-thread=no"]),
36 -
    ("--cache=no",                           ["--cache=no"]),
37 -
    ("--cache=yes",                          ["--cache=yes"]),
38 -
    ("ohne Zusatzdateien-Suche",             ["--audio-file-auto=no", "--sub-auto=no",
39 -
                                              "--cover-art-auto=no"]),
40 -
    ("--vd-lavc-dr=no",                      ["--vd-lavc-dr=no"]),
41 -
    ("--demuxer-lavf-probe-info=nostreams",  ["--demuxer-lavf-probe-info=nostreams"]),
42 -
    ("--gapless-audio=no",                   ["--gapless-audio=no"]),
43 -
    ("--force-window=no",                    ["--force-window=no"]),
44 -
    ("--no-audio",                           ["--no-audio"]),
45 -
]
46 -
47 -
48 -
class Mpv:
49 -
    """Minimaler Client fuer den JSON-IPC-Socket von mpv."""
50 -
51 -
    def __init__(self, path):
52 -
        self.sock = socket.socket(socket.AF_UNIX)
53 -
        for _ in range(80):
54 -
            try:
55 -
                self.sock.connect(path)
56 -
                break
57 -
            except OSError:
58 -
                time.sleep(0.25)
59 -
        else:
60 -
            raise RuntimeError("mpv-Socket nicht erreichbar")
61 -
        self.buf = b""
62 -
        self.rid = 0
63 -
64 -
    def command(self, *args):
65 -
        self.rid += 1
66 -
        rid = self.rid
67 -
        self.sock.sendall(
68 -
            (json.dumps({"command": list(args), "request_id": rid}) + "\n").encode())
69 -
        deadline = time.time() + 5
70 -
        while time.time() < deadline:
71 -
            self.sock.settimeout(max(0.1, deadline - time.time()))
72 -
            try:
73 -
                chunk = self.sock.recv(65536)
74 -
            except (socket.timeout, OSError):
75 -
                break
76 -
            if not chunk:
77 -
                break
78 -
            self.buf += chunk
79 -
            while b"\n" in self.buf:
80 -
                line, self.buf = self.buf.split(b"\n", 1)
81 -
                try:
82 -
                    msg = json.loads(line)
83 -
                except ValueError:
84 -
                    continue
85 -
                if msg.get("request_id") == rid:
86 -
                    return msg.get("data")
87 -
        return None
88 -
89 -
    def close(self):
90 -
        try:
91 -
            self.sock.close()
92 -
        except OSError:
93 -
            pass
94 -
95 -
96 -
def probe(extra, image, video):
97 -
    """Bild laden, dann Video - und schauen, ob das Video wirklich laeuft."""
98 -
    tmp = tempfile.mkdtemp()
99 -
    sock = os.path.join(tmp, "s")
100 -
    proc = subprocess.Popen(
101 -
        ["mpv", "--input-ipc-server=" + sock] + BASE + extra,
102 -
        stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
103 -
    try:
104 -
        m = Mpv(sock)
105 -
        m.command("loadfile", image, "replace")
106 -
        time.sleep(2.5)
107 -
        m.command("loadfile", video, "replace")
108 -
        time.sleep(2.0)
109 -
        erst = m.command("get_property", "time-pos")
110 -
        time.sleep(3.0)
111 -
        spaeter = m.command("get_property", "time-pos")
112 -
        m.close()
113 -
114 -
        if not isinstance(erst, (int, float)) or not isinstance(spaeter, (int, float)):
115 -
            return False, "Video wurde gar nicht erst geladen"
116 -
        if spaeter - erst < 1.0:
117 -
            return False, "steht bei %.1f s" % spaeter
118 -
        return True, "laeuft bis %.1f s" % spaeter
119 -
    finally:
120 -
        proc.kill()
121 -
        try:
122 -
            err = proc.stderr.read().decode(errors="replace").strip()
123 -
        except Exception:
124 -
            err = ""
125 -
        proc.wait()
126 -
        if err:
127 -
            probe.last_error = err.splitlines()[-3:]
128 -
        else:
129 -
            probe.last_error = []
130 -
131 -
132 -
def main():
133 -
    if len(sys.argv) != 3:
134 -
        print(__doc__)
135 -
        sys.exit(1)
136 -
    image, video = sys.argv[1], sys.argv[2]
137 -
    for f in (image, video):
138 -
        if not os.path.isfile(f):
139 -
            print("Datei fehlt:", f)
140 -
            sys.exit(1)
141 -
142 -
    print("\nBild : %s\nVideo: %s\n" % (image, video))
143 -
    print("Uebergang Bild -> Video im selben mpv-Prozess:\n")
144 -
145 -
    gut = []
146 -
    for name, extra in VARIANTEN:
147 -
        ok, info = probe(extra, image, video)
148 -
        print("  %-38s %s  (%s)" % (name, "LAEUFT" if ok else "HAENGT", info))
149 -
        for zeile in getattr(probe, "last_error", []):
150 -
            print("      %s" % zeile)
151 -
        if ok:
152 -
            gut.append(name)
153 -
154 -
    print()
155 -
    if not gut:
156 -
        print("Keine Option hilft. Der Neustart vor jedem Video")
157 -
        print("(FRESH_MPV_FOR_VIDEO=\"yes\") bleibt die richtige Loesung.")
158 -
    elif gut == [n for n, _ in VARIANTEN]:
159 -
        print("Alle Varianten laufen - der Uebergang ist auf diesem System heil.")
160 -
        print("Dann kann FRESH_MPV_FOR_VIDEO=\"no\" gesetzt werden.")
161 -
    else:
162 -
        print("Diese Optionen beheben den Uebergang:")
163 -
        for n in gut:
164 -
            print("   ", n)
165 -
        print("\nTrag die passende in MPV_EXTRA_ARGS ein und setze")
166 -
        print("FRESH_MPV_FOR_VIDEO=\"no\" - dann entfaellt der Neustart.")
167 -
168 -
169 -
if __name__ == "__main__":
170 -
    main()
hinzugefügt scripts/set-splash.sh
+159 −0 Datei ansehen
@@ -0,0 +1,159 @@
1 +
#!/usr/bin/env bash
2 +
# ===========================================================================
3 +
#  Ersetzt den Startbildschirm (Plymouth-Thema "pix") durch ein eigenes Logo.
4 +
#
5 +
#  Aufruf:  sudo /opt/tvwall/scripts/set-splash.sh /pfad/zum/logo.png [--dauer N]
6 +
#           sudo /opt/tvwall/scripts/set-splash.sh --zurueck
7 +
#
8 +
#  Das Logo wird auf die Bildschirmgroesse zentriert auf schwarzem Grund
9 +
#  gesetzt - derselbe Hintergrund, den auch der Player verwendet, damit der
10 +
#  Uebergang vom Start in die Wiedergabe nicht springt.
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.
16 +
# ===========================================================================
17 +
set -euo pipefail
18 +
19 +
THEME_DIR="/usr/share/plymouth/themes/pix"
20 +
SPLASH="$THEME_DIR/splash.png"
21 +
BACKUP="$THEME_DIR/splash.original.png"
22 +
CONFIG_TXT="/boot/firmware/config.txt"
23 +
[[ -f "$CONFIG_TXT" ]] || CONFIG_TXT="/boot/config.txt"
24 +
HOLD_BIN="/usr/local/bin/tvwall-splash-hold"
25 +
HOLD_UNIT="/etc/systemd/system/tvwall-splash.service"
26 +
DAUER=12
27 +
28 +
info() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
29 +
ok()   { printf '\033[1;32m  ✓\033[0m %s\n' "$*"; }
30 +
warn() { printf '\033[1;33m  !\033[0m %s\n' "$*"; }
31 +
die()  { printf '\033[1;31mFehler:\033[0m %s\n' "$*" >&2; exit 1; }
32 +
33 +
[[ $EUID -eq 0 ]] || die "bitte mit sudo starten:  sudo $0 $*"
34 +
[[ -d "$THEME_DIR" ]] || die "Plymouth-Thema 'pix' nicht gefunden ($THEME_DIR)"
35 +
36 +
# --- Aufrufparameter -------------------------------------------------------
37 +
ARGS=()
38 +
while [[ $# -gt 0 ]]; do
39 +
  case "$1" in
40 +
    --dauer)   DAUER="${2:-12}"; shift 2 ;;
41 +
    --zurueck) ARGS+=("--zurueck"); shift ;;
42 +
    *)         ARGS+=("$1"); shift ;;
43 +
  esac
44 +
done
45 +
set -- "${ARGS[@]+"${ARGS[@]}"}"
46 +
47 +
# --- zuruecksetzen ---------------------------------------------------------
48 +
if [[ "${1:-}" == "--zurueck" ]]; then
49 +
  [[ -f "$BACKUP" ]] || die "keine Sicherung gefunden ($BACKUP)"
50 +
  cp -f "$BACKUP" "$SPLASH"
51 +
  ok "urspruenglicher Startbildschirm wiederhergestellt"
52 +
  systemctl disable --now tvwall-splash.service >/dev/null 2>&1 || true
53 +
  rm -f "$HOLD_UNIT" "$HOLD_BIN"
54 +
  systemctl daemon-reload
55 +
  ok "Halten des Startbildes entfernt"
56 +
  update-initramfs -u >/dev/null 2>&1 || true
57 +
  ok "fertig - beim naechsten Neustart wieder das Original"
58 +
  exit 0
59 +
fi
60 +
61 +
LOGO="${1:-}"
62 +
[[ -n "$LOGO" && -f "$LOGO" ]] || die "Bilddatei angeben:  sudo $0 /pfad/zum/logo.png"
63 +
command -v ffmpeg >/dev/null || die "ffmpeg fehlt:  sudo apt install ffmpeg"
64 +
65 +
# --- Bildschirmaufloesung ermitteln ---------------------------------------
66 +
MODE="$(head -1 /sys/class/drm/*/modes 2>/dev/null | grep -m1 -E '^[0-9]+x[0-9]+' || true)"
67 +
W="${MODE%x*}"; H="${MODE#*x}"
68 +
if [[ ! "$W" =~ ^[0-9]+$ || ! "$H" =~ ^[0-9]+$ ]]; then
69 +
  W=1920; H=1080
70 +
  warn "Aufloesung nicht erkannt - nehme ${W}x${H}"
71 +
else
72 +
  ok "Bildschirm: ${W}x${H}"
73 +
fi
74 +
75 +
# --- Startbild bauen -------------------------------------------------------
76 +
info "Baue Startbild aus $(basename "$LOGO")"
77 +
LOGO_H=$(( H * 55 / 100 ))          # Logo auf 55 % der Bildhoehe
78 +
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
79 +
ffmpeg -v error -y -f lavfi -i "color=c=black:s=${W}x${H}" -i "$LOGO" \
80 +
  -filter_complex "[1:v]scale=-1:${LOGO_H}:force_original_aspect_ratio=decrease[logo];\
81 +
[0:v][logo]overlay=(W-w)/2:(H-h)/2:format=auto" \
82 +
  -frames:v 1 "$TMP/splash.png" || die "ffmpeg konnte das Bild nicht erzeugen"
83 +
ok "Startbild erzeugt ($(stat -c %s "$TMP/splash.png") Bytes)"
84 +
85 +
# --- einsetzen -------------------------------------------------------------
86 +
[[ -f "$BACKUP" ]] || { cp -f "$SPLASH" "$BACKUP"; ok "Original gesichert: $BACKUP"; }
87 +
install -m 0644 "$TMP/splash.png" "$SPLASH"
88 +
ok "eingesetzt: $SPLASH"
89 +
90 +
# --- Regenbogen-Quadrat der Firmware abschalten ---------------------------
91 +
if [[ -w "$CONFIG_TXT" ]] && ! grep -q "^disable_splash=1" "$CONFIG_TXT"; then
92 +
  cp -f "$CONFIG_TXT" "$CONFIG_TXT.tvwall-backup"
93 +
  printf '\n# von tvwall set-splash.sh: kein Regenbogen-Quadrat beim Einschalten\ndisable_splash=1\n' >> "$CONFIG_TXT"
94 +
  ok "disable_splash=1 in $(basename "$CONFIG_TXT") eingetragen"
95 +
fi
96 +
97 +
# --- Startbild stehenlassen, bis die Wiedergabe uebernimmt ----------------
98 +
# Ohne das zeigt Plymouth das Logo nur etwa vier Sekunden; viele Fernseher
99 +
# 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"
102 +
  cat > "$HOLD_BIN" <<'HOLD'
103 +
#!/bin/sh
104 +
# Haelt den Plymouth-Startbildschirm, bis die angegebene Zeit seit dem
105 +
# Einschalten erreicht ist, und uebergibt ihn dann an die Wiedergabe.
106 +
# --retain-splash laesst das Bild stehen, bis mpv das erste Mal malt - so
107 +
# gibt es dazwischen keine schwarze Luecke.
108 +
ZIEL="${1:-12}"
109 +
UP="$(cut -d. -f1 /proc/uptime)"
110 +
[ "$UP" -lt "$ZIEL" ] && sleep $(( ZIEL - UP ))
111 +
[ -x /usr/bin/plymouth ] && /usr/bin/plymouth quit --retain-splash
112 +
exit 0
113 +
HOLD
114 +
  chmod 0755 "$HOLD_BIN"
115 +
116 +
  cat > "$HOLD_UNIT" <<UNIT
117 +
# Von tvwall set-splash.sh erzeugt.
118 +
[Unit]
119 +
Description=Startbild halten, bis die Wiedergabe uebernimmt
120 +
After=plymouth-start.service
121 +
Before=tvwall.service plymouth-quit.service plymouth-quit-wait.service
122 +
ConditionPathExists=/usr/bin/plymouth
123 +
124 +
[Service]
125 +
Type=oneshot
126 +
ExecStart=$HOLD_BIN $DAUER
127 +
TimeoutStartSec=60
128 +
RemainAfterExit=yes
129 +
130 +
[Install]
131 +
WantedBy=multi-user.target
132 +
UNIT
133 +
  systemctl daemon-reload
134 +
  systemctl enable tvwall-splash.service >/dev/null 2>&1
135 +
  ok "tvwall-splash.service eingerichtet"
136 +
else
137 +
  warn "/usr/bin/plymouth fehlt - Startbild laesst sich nicht halten"
138 +
fi
139 +
140 +
# --- Plymouth liest das Bild aus der initramfs ----------------------------
141 +
if command -v update-initramfs >/dev/null; then
142 +
  info "Aktualisiere initramfs (dauert einen Moment)"
143 +
  update-initramfs -u >/dev/null 2>&1 && ok "initramfs aktualisiert" \
144 +
    || warn "initramfs liess sich nicht aktualisieren - meist trotzdem in Ordnung"
145 +
fi
146 +
147 +
cat <<EOF
148 +
149 +
===========================================================================
150 +
 Fertig. Beim naechsten Neustart erscheint dein Logo und bleibt bis
151 +
 $DAUER Sekunden nach dem Einschalten stehen - danach uebernimmt die
152 +
 Wiedergabe.
153 +
154 +
   sudo reboot
155 +
156 +
 Anders lange stehen lassen:  sudo $0 <logo> --dauer 20
157 +
 Zurueck zum Original:        sudo $0 --zurueck
158 +
===========================================================================
159 +
EOF