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
@@ -78,21 +78,21 @@ sudo systemctl restart tvwall
78 78
| `CAPTIVE_PORTAL` | `yes` = Handys öffnen die Seite von selbst |
79 79
| `HOSTNAME` | Gerätename, auch als `http://<name>.local` erreichbar |
80 80
| `WEB_PORT` | Port des Webinterface (Standard 80) |
81 81
| `WEB_PASSWORD` | gesetzt = Webinterface fragt nach einem Passwort |
82 82
| `MAX_UPLOAD_MB` | Obergrenze je Upload-Vorgang |
83 83
| `IMAGE_DURATION` | Anzeigedauer für Bilder in Sekunden |
84 84
| `MUTE` | `yes` = Videos ohne Ton |
85 85
| `SCAN_INTERVAL` | Takt, in dem der Ordner neu eingelesen wird |
86 86
| `DISPLAY_MODE` | `auto` (empfohlen), `drm` oder `desktop` |
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) |
90 90
| `STALL_TIMEOUT` | Sekunden ohne Fortschritt, bevor ein Video übersprungen wird |
91 91
| `MPV_EXTRA_ARGS` | zusätzliche mpv-Optionen, z. B. `--video-rotate=90` |
92 92
| `STATE_DIR` | Datenverzeichnis, Medien liegen in `<STATE_DIR>/media` |
93 93
94 94
### Mehrere Geräte
95 95
96 96
Für jeden Pi nur zwei Zeilen anpassen:
97 97
98 98
```bash
@@ -234,27 +234,46 @@ sudo /opt/tvwall/scripts/setup-ap.sh
234 234
235 235
Häufigste Ursache: Der Pi ist noch mit einem anderen WLAN verbunden. Eine
236 236
Schnittstelle kann nicht gleichzeitig Client und Access Point sein —
237 237
andere WLAN-Verbindungen mit `nmcli connection modify <name> connection.autoconnect no`
238 238
abschalten. Zweithäufigste Ursache: fehlender Ländercode (`AP_COUNTRY`).
239 239
240 240
**Webinterface nicht erreichbar.**
241 241
Zuerst `http://192.168.4.1` probieren — Namen wie `tvwall.local` funktionieren
242 242
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`
252 271
läuft ein Video dort ein bis zwei Sekunden und friert dann ein — genau so lange,
253 272
wie der bereits gefüllte Puffer reicht. Zum Gegenprüfen hilft der direkte
254 273
Vergleich, weil mpv von sich aus `--hwdec=no` verwendet:
255 274
256 275
```bash
257 276
sudo systemctl stop tvwall
258 277
mpv --hwdec=no        --fullscreen /var/lib/tvwall/media/<datei>   # läuft
259 278
mpv --hwdec=auto-safe --fullscreen /var/lib/tvwall/media/<datei>   # friert ein
260 279
sudo systemctl start tvwall
@@ -298,35 +317,40 @@ Ausgabegerät prüfen (`mpv --audio-device=help`) und passend in
298 317
cmd/tvwall/main.go        Start, Signalbehandlung, periodischer Ordner-Durchlauf
299 318
internal/config/          tvwall.conf lesen, Laufzeit-Einstellungen speichern
300 319
internal/playlist/        Ordner einlesen, Reihenfolge merken, playlist.m3u schreiben
301 320
internal/mpv/             JSON-IPC-Verbindung zu mpv
302 321
internal/player/          mpv starten, überwachen, Dateien einzeln weiterschalten
303 322
internal/web/             HTTP-Schnittstelle und Oberfläche (eingebettet)
304 323
systemd/                  Vorlagen für den Dienst (drm / desktop)
305 324
internal/player/display.go  Wayland-/X11-/DRM-Erkennung
306 325
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
311 331
```
312 332
313 333
Ein paar Entscheidungen, die vielleicht nicht offensichtlich sind:
314 334
315 335
* **mpv spielt Bilder und Videos aus einer gemeinsamen Playliste.** Kein
316 336
  Umschalten zwischen zwei Programmen, keine Lücke am Übergang.
317 337
* **Die Playliste wird zwischen zwei Dateien getauscht**, nicht mittendrin: Ein
318 338
  Upload unterbricht kein laufendes Video. Wer nicht warten will, drückt
319 339
  „Neu laden“.
320 340
* **Die Oberfläche kommt ohne externe Bibliotheken aus** und ist ins Programm
321 341
  eingebettet. Am Access Point gibt es kein Internet — alles, was von einem CDN
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
324 348
  Empfang umbenannt. So gerät keine halbe Datei in die Wiedergabe.
325 349
326 350
### Web-Schnittstelle
327 351
328 352
| Weg | Methode | Zweck |
329 353
|---|---|---|
330 354
| `/api/state` | GET | Playliste, Zustand, Einstellungen |
331 355
| `/api/upload` | POST | Dateien hochladen (multipart) |
332 356
| `/api/order` | POST | `{"order":["a.jpg","b.mp4"]}` |
geändert config/tvwall.conf
+9 −7 Datei ansehen
@@ -69,35 +69,37 @@ SCAN_INTERVAL="10"
69 69
#   drm     = direkt auf den Bildschirm, ohne Desktop (Raspberry Pi OS Lite)
70 70
#   desktop = in die laufende Desktop-Sitzung (labwc/wayfire/X11)
71 71
DISPLAY_MODE="auto"
72 72
73 73
# Benutzer, unter dem der Dienst laeuft.
74 74
# Wichtig: Laeuft ein Desktop, muss das der Benutzer sein, dem die
75 75
# Desktop-Sitzung gehoert (meist "pi") - nur der kommt an den Bildschirm.
76 76
# Bei DISPLAY_MODE="auto" erkennt install.sh das und traegt es hier ein.
77 77
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.
80 89
#   no        = alles in Software dekodieren (Standard, mpvs eigene Vorgabe)
81 90
#   auto-safe = Hardware benutzen, wenn moeglich
82 91
# Der Raspberry Pi 5 hat KEINEN Hardware-Dekoder fuer H.264 - dort bleibt die
83 92
# Wiedergabe mit "auto-safe" nach ein bis zwei Sekunden stehen. Auf dem Pi 4
84 93
# kann "auto-safe" dagegen Rechenzeit sparen.
85 94
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,
95 97
# damit eine einzelne Datei nicht die ganze Wiedergabe anhaelt. 0 = aus.
96 98
STALL_TIMEOUT="30"
97 99
98 100
# Zusaetzliche Optionen fuer mpv, z. B. --audio-device=... oder --video-rotate=90
99 101
MPV_EXTRA_ARGS=""
100 102
101 103
# --- Pfade ------------------------------------------------------------------
102 104
103 105
# Hier liegen Medien (Unterordner "media"), Playliste und Einstellungen.
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
@@ -30,20 +30,21 @@ type Config struct {
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 36
	DisplayMode   string // "auto", "drm" oder "desktop"
37 37
	RunUser       string
38 38
	MpvExtraArgs  string
39 39
	MpvHwdec      string
40 +
	MpvGpuAPI     string
40 41
	FreshForVideo bool // mpv vor jedem Video neu starten
41 42
	StallTimeout  int  // Sekunden ohne Fortschritt, bevor ein Video uebersprungen wird
42 43
43 44
	StateDir   string
44 45
	SocketPath string
45 46
}
46 47
47 48
// Default liefert die Konfiguration mit allen Standardwerten.
48 49
func Default() *Config {
49 50
	return &Config{
@@ -53,21 +54,21 @@ func Default() *Config {
53 54
		APIP:          "192.168.4.1",
54 55
		APChannel:     "7",
55 56
		Hostname:      "tvwall-01",
56 57
		WebPort:       80,
57 58
		MaxUploadMB:   2048,
58 59
		ImageDuration: 5,
59 60
		Audio:         true,
60 61
		ScanInterval:  10,
61 62
		DisplayMode:   "auto",
62 63
		MpvHwdec:      "no",
63 -
		FreshForVideo: true,
64 +
		MpvGpuAPI:     "opengl",
64 65
		StallTimeout:  30,
65 66
		RunUser:       "tvwall",
66 67
		StateDir:      "/var/lib/tvwall",
67 68
		SocketPath:    "/run/tvwall/mpv.sock",
68 69
	}
69 70
}
70 71
71 72
// Abgeleitete Pfade.
72 73
func (c *Config) MediaDir() string     { return filepath.Join(c.StateDir, "media") }
73 74
func (c *Config) ThumbDir() string     { return filepath.Join(c.StateDir, "thumbs") }
@@ -141,22 +142,24 @@ func Load(path string) (*Config, error) {
141 142
			}
142 143
		case "DISPLAY_MODE":
143 144
			switch val {
144 145
			case "auto", "drm", "desktop":
145 146
				c.DisplayMode = val
146 147
			}
147 148
		case "RUN_USER":
148 149
			c.RunUser = val
149 150
		case "MPV_EXTRA_ARGS":
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":
154 157
			if val != "" {
155 158
				c.MpvHwdec = val
156 159
			}
157 160
		case "STALL_TIMEOUT":
158 161
			if n, err := strconv.Atoi(val); err == nil && n >= 0 {
159 162
				c.StallTimeout = n
160 163
			}
161 164
		case "MPV_SOCKET":
162 165
			if val != "" {
geändert internal/player/player.go
+117 −245 Datei ansehen
@@ -1,223 +1,196 @@
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
11 8
12 9
import (
13 10
	"context"
14 11
	"encoding/json"
15 -
	"errors"
16 12
	"fmt"
17 13
	"log"
18 14
	"os"
19 15
	"os/exec"
20 16
	"path/filepath"
21 17
	"strconv"
22 18
	"strings"
23 19
	"sync"
24 20
	"time"
25 21
26 22
	"ehc/tvwall/internal/config"
27 23
	"ehc/tvwall/internal/mpv"
28 24
	"ehc/tvwall/internal/playlist"
29 25
)
30 26
31 27
// IDs fuer die beobachteten mpv-Eigenschaften.
32 28
const (
33 29
	propPath = iota + 1
34 30
	propPause
31 +
	propPlaylistPos
35 32
	propIdle
36 33
	propTimePos
37 34
)
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.
50 39
type Status struct {
51 40
	Running       bool   `json:"running"`
52 41
	Idle          bool   `json:"idle"`
53 42
	Paused        bool   `json:"paused"`
54 43
	Current       string `json:"current"`
55 44
	Position      int    `json:"position"`
56 45
	Display       string `json:"display"`
57 46
	PendingReload bool   `json:"pending_reload"`
58 47
	LastError     string `json:"last_error"`
59 48
}
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 {
63 52
	cfg      *config.Config
64 53
	pl       *playlist.Manager
65 54
	settings *config.Store
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
76 62
	lastTimePos    float64
77 63
	lastProgressAt time.Time
78 64
79 -
	loadedCount int // Dateien, die dieser mpv-Prozess schon geladen hat
80 -
81 -
	advance   chan int
82 65
	reloadNow chan struct{}
83 66
	restart   chan struct{}
84 67
}
85 68
86 69
// New erzeugt den Player.
87 70
func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Player {
88 71
	return &Player{
89 72
		cfg:       cfg,
90 73
		pl:        pl,
91 74
		settings:  settings,
92 -
		advance:   make(chan int, 4),
93 75
		reloadNow: make(chan struct{}, 1),
94 76
		restart:   make(chan struct{}, 1),
95 77
	}
96 78
}
97 79
98 80
// Status liefert den aktuellen Zustand.
99 81
func (p *Player) Status() Status {
100 82
	p.mu.RLock()
101 83
	defer p.mu.RUnlock()
102 -
	return p.status
84 +
	s := p.status
85 +
	s.PendingReload = p.pending
86 +
	return s
103 87
}
104 88
105 89
// Run laeuft bis ctx abgebrochen wird und startet mpv bei Bedarf neu.
106 90
func (p *Player) Run(ctx context.Context) {
107 91
	settingsChanged := p.settings.Subscribe()
108 92
	backoff := time.Second
109 93
	for ctx.Err() == nil {
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)
113 100
		if ctx.Err() != nil {
114 101
			return
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 {
125 104
			backoff = time.Second
126 105
		} else if backoff < 15*time.Second {
127 106
			backoff *= 2
128 107
		}
129 108
		log.Printf("player: neustart in %s", backoff)
130 109
		select {
131 110
		case <-ctx.Done():
132 111
			return
133 112
		case <-time.After(backoff):
134 113
		}
135 114
	}
136 115
}
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 {
141 118
	disp, err := resolveDisplay(p.cfg.DisplayMode)
142 119
	if err != nil {
143 120
		return err
144 121
	}
145 122
	disp.logDisplay()
146 123
147 124
	sock := p.cfg.MpvSocket()
148 125
	if err := os.MkdirAll(filepath.Dir(sock), 0o775); err != nil {
149 126
		return fmt.Errorf("socket-verzeichnis: %w", err)
150 127
	}
151 128
	_ = os.Remove(sock)
152 129
153 130
	args := p.mpvArgs(disp)
154 131
	log.Printf("player: starte mpv %s", strings.Join(args, " "))
155 132
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
160 137
	cmd.Stdout = os.Stdout
161 138
	cmd.Stderr = os.Stderr
162 139
	cmd.Env = append(os.Environ(), disp.env...)
163 140
	if err := cmd.Start(); err != nil {
164 141
		return fmt.Errorf("mpv starten: %w (ist mpv installiert?)", err)
165 142
	}
166 143
167 144
	var waitErr error
168 145
	exited := make(chan struct{})
169 146
	go func() {
170 147
		waitErr = cmd.Wait()
171 148
		close(exited)
172 149
	}()
173 -
174 150
	defer func() {
175 151
		if cmd.Process != nil {
176 152
			_ = cmd.Process.Kill()
177 153
		}
178 154
		<-exited
179 155
		_ = os.Remove(sock)
180 156
	}()
181 157
182 158
	client, err := mpv.Dial(sock, 15*time.Second)
183 159
	if err != nil {
184 160
		return err
185 161
	}
186 162
	defer client.Close()
187 163
188 164
	p.mu.Lock()
189 165
	p.client = client
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()
195 170
	defer func() {
196 171
		p.mu.Lock()
197 172
		p.client = nil
198 173
		p.mu.Unlock()
199 174
	}()
200 175
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",
204 179
	} {
205 180
		if err := client.ObserveProperty(id, name); err != nil {
206 181
			log.Printf("player: observe %s: %v", name, err)
207 182
		}
208 183
	}
209 184
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("")
215 188
216 189
	ticker := time.NewTicker(tickInterval)
217 190
	defer ticker.Stop()
218 191
219 192
	for {
220 193
		select {
221 194
		case <-ctx.Done():
222 195
			return nil
223 196
@@ -226,262 +199,163 @@ func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) e
226 199
227 200
		case <-client.Done():
228 201
			select {
229 202
			case <-exited:
230 203
				return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
231 204
			case <-time.After(time.Second):
232 205
				return fmt.Errorf("ipc-verbindung verloren")
233 206
			}
234 207
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:
264 222
			return fmt.Errorf("neustart angefordert")
265 223
266 224
		case <-settingsChanged:
267 225
			p.applySettings(client)
268 226
269 227
		case <-ticker.C:
270 -
			if err := p.tick(client); err != nil {
271 -
				return err
272 -
			}
228 +
			p.checkStalled(client)
273 229
274 230
		case ev, ok := <-client.Events():
275 231
			if !ok {
276 232
				return fmt.Errorf("ipc-verbindung beendet")
277 233
			}
278 -
			if err := p.handleEvent(client, ev); err != nil {
279 -
				return err
280 -
			}
234 +
			p.handleEvent(client, ev)
281 235
		}
282 236
	}
283 237
}
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 {
287 241
	case "property-change":
288 242
		p.mu.Lock()
289 243
		switch ev.ID {
290 244
		case propPath:
291 245
			var s string
292 246
			if json.Unmarshal(ev.Data, &s) == nil && s != "" {
293 247
				p.status.Current = filepath.Base(s)
294 -
				// Eine Datei laeuft wieder - die Fehlerkette ist unterbrochen.
295 -
				p.errStreak = 0
296 248
			} else {
297 249
				p.status.Current = ""
298 250
			}
299 251
			p.lastTimePos = -1
300 252
			p.lastProgressAt = time.Now()
301 253
		case propPause:
302 254
			var b bool
303 255
			if json.Unmarshal(ev.Data, &b) == nil {
304 256
				p.status.Paused = b
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:
307 264
			var b bool
308 265
			if json.Unmarshal(ev.Data, &b) == nil {
309 266
				p.status.Idle = b
310 267
			}
311 268
		case propTimePos:
312 269
			var pos float64
313 270
			if json.Unmarshal(ev.Data, &pos) == nil && pos != p.lastTimePos {
314 271
				p.lastTimePos = pos
315 272
				p.lastProgressAt = time.Now()
316 273
			}
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 {
355 307
			log.Printf("player: stop: %v", err)
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
	}
422 320
	if err := client.SetProperty("pause", false); err != nil {
423 321
		log.Printf("player: fortsetzen: %v", err)
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
	}
463 342
464 343
	log.Printf("player: %q laeuft seit %s nicht weiter - wird uebersprungen",
465 344
		current, since.Round(time.Second))
466 345
	p.setError(fmt.Sprintf("%s bleibt haengen und wurde uebersprungen", current))
467 346
	p.mu.Lock()
468 347
	p.lastProgressAt = time.Now()
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
}
479 353
480 354
// applySettings uebernimmt Anzeigedauer und Stummschaltung ohne mpv-Neustart.
481 355
func (p *Player) applySettings(client *mpv.Client) {
482 356
	s := p.settings.Get()
483 357
	if err := client.SetProperty("image-display-duration", s.ImageDuration); err != nil {
484 358
		log.Printf("player: anzeigedauer setzen: %v", err)
485 359
	}
486 360
	if p.cfg.Audio {
487 361
		if err := client.SetProperty("mute", s.Mute); err != nil {
@@ -491,44 +365,49 @@ func (p *Player) applySettings(client *mpv.Client) {
491 365
}
492 366
493 367
func (p *Player) mpvArgs(disp display) []string {
494 368
	s := p.settings.Get()
495 369
	args := []string{
496 370
		"--input-ipc-server=" + p.cfg.MpvSocket(),
497 371
		"--fullscreen",
498 372
		"--idle=yes",
499 373
		"--force-window=yes",
500 374
		"--keep-open=no",
375 +
		"--loop-playlist=inf",
501 376
		"--image-display-duration=" + strconv.FormatFloat(s.ImageDuration, 'f', -1, 64),
502 377
		"--hwdec=" + p.cfg.MpvHwdec,
503 378
		"--no-osc",
504 379
		"--osd-level=0",
505 380
		"--cursor-autohide=always",
506 381
		"--no-input-default-bindings",
507 382
		"--input-vo-keyboard=no",
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
	}
514 397
	if p.cfg.Audio {
515 398
		args = append(args, "--mute="+yesNo(s.Mute))
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")
520 401
	}
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
	}
526 405
	args = append(args, splitArgs(p.cfg.MpvExtraArgs)...)
527 406
	return args
528 407
}
529 408
530 409
func yesNo(b bool) string {
531 410
	if b {
532 411
		return "yes"
533 412
	}
534 413
	return "no"
@@ -561,83 +440,76 @@ func splitArgs(s string) []string {
561 440
	if cur.Len() > 0 {
562 441
		out = append(out, cur.String())
563 442
	}
564 443
	return out
565 444
}
566 445
567 446
// ---------------------------------------------------------------------------
568 447
// Steuerung aus dem Webinterface
569 448
// ---------------------------------------------------------------------------
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
}
588 459
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
595 476
// TogglePause haelt die Wiedergabe an oder setzt sie fort.
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 {
613 487
	case p.reloadNow <- struct{}{}:
614 488
	default:
615 489
	}
616 490
	return nil
617 491
}
618 492
619 493
// Restart startet den mpv-Prozess komplett neu.
620 494
func (p *Player) Restart() error {
621 495
	select {
622 496
	case p.restart <- struct{}{}:
623 497
	default:
624 498
	}
625 499
	return nil
626 500
}
627 501
628 502
func (p *Player) setRunning(v bool) {
629 503
	p.mu.Lock()
630 504
	p.status.Running = v
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 = ""
635 507
	}
636 508
	p.mu.Unlock()
637 509
}
638 510
639 511
func (p *Player) setError(msg string) {
640 512
	p.mu.Lock()
641 513
	p.status.LastError = msg
642 514
	p.mu.Unlock()
643 515
}
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
@@ -156,20 +156,32 @@ else
156 156
  bad "nicht erreichbar auf Port $PORT (HTTP $CODE)"
157 157
fi
158 158
159 159
hdr "Access Point"
160 160
if command -v nmcli >/dev/null; then
161 161
  nmcli -t -f NAME,DEVICE,STATE connection show --active 2>/dev/null | sed 's/^/      /'
162 162
  nmcli -t -f NAME connection show --active 2>/dev/null | grep -qx tvwall-ap \
163 163
    && ok "tvwall-ap aktiv" || warn "tvwall-ap nicht aktiv"
164 164
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"
167 179
journalctl -u tvwall -n 40 --no-pager 2>/dev/null | sed 's/^/  /'
168 180
169 181
hdr "Direkter mpv-Test"
170 182
FIRST="$(find "$STATE_DIR/media" -maxdepth 1 -type f 2>/dev/null | head -1)"
171 183
if [[ -n "$FIRST" ]]; then
172 184
  note "So laesst sich mpv unabhaengig vom Dienst pruefen:"
173 185
  note "  sudo systemctl stop tvwall"
174 186
  if [[ -n "$COMPOSITOR" ]]; then
175 187
    note "  sudo -u $RUN_USER XDG_RUNTIME_DIR=/run/user/\$(id -u $RUN_USER) mpv --fullscreen '$FIRST'"
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