LabCodeHub öffentliche Ansicht
Anmelden
Küpper / ehc-tvwall öffentlich
Branch: main
ehc-tvwall / internal / player / player.go
Verlauf Rohdaten
R Rüdiger Küpper feat: add silent boot script to enhance boot experience
461e563c vor 11 Tagen
internal/player/player.go 592 Zeilen · 14.8 KB · Go
1
// Package player startet und ueberwacht mpv und haelt dessen Playliste aktuell.
2
//
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.
7
package player
8
9
import (
10
	"context"
11
	"encoding/json"
12
	"fmt"
13
	"log"
14
	"os"
15
	"os/exec"
16
	"path/filepath"
17
	"strconv"
18
	"strings"
19
	"sync"
20
	"time"
21
22
	"ehc/tvwall/internal/config"
23
	"ehc/tvwall/internal/mpv"
24
	"ehc/tvwall/internal/playlist"
25
)
26
27
// IDs fuer die beobachteten mpv-Eigenschaften.
28
const (
29
	propPath = iota + 1
30
	propPause
31
	propPlaylistPos
32
	propIdle
33
	propTimePos
34
)
35
36
const tickInterval = 5 * time.Second
37
38
// Status beschreibt den Zustand der Wiedergabe fuer das Webinterface.
39
type Status struct {
40
	Running       bool   `json:"running"`
41
	Idle          bool   `json:"idle"`
42
	Paused        bool   `json:"paused"`
43
	Current       string `json:"current"`
44
	Position      int    `json:"position"`
45
	Display       string `json:"display"`
46
	PendingReload bool   `json:"pending_reload"`
47
	LastError     string `json:"last_error"`
48
}
49
50
// Player haelt genau einen mpv-Prozess am Leben.
51
type Player struct {
52
	cfg      *config.Config
53
	pl       *playlist.Manager
54
	settings *config.Store
55
56
	mu       sync.RWMutex
57
	client   *mpv.Client
58
	status   Status
59
	pending  bool
60
	loadedID uint64 // Version der zuletzt an mpv uebergebenen Playliste
61
62
	lastTimePos    float64
63
	lastProgressAt time.Time
64
65
	bootLogoDone bool // Startlogo wurde in dieser Dienstlaufzeit schon gezeigt
66
67
	reloadNow chan struct{}
68
	restart   chan struct{}
69
}
70
71
// New erzeugt den Player.
72
func New(cfg *config.Config, pl *playlist.Manager, settings *config.Store) *Player {
73
	return &Player{
74
		cfg:       cfg,
75
		pl:        pl,
76
		settings:  settings,
77
		reloadNow: make(chan struct{}, 1),
78
		restart:   make(chan struct{}, 1),
79
	}
80
}
81
82
// Status liefert den aktuellen Zustand.
83
func (p *Player) Status() Status {
84
	p.mu.RLock()
85
	defer p.mu.RUnlock()
86
	s := p.status
87
	s.PendingReload = p.pending
88
	return s
89
}
90
91
// Run laeuft bis ctx abgebrochen wird und startet mpv bei Bedarf neu.
92
func (p *Player) Run(ctx context.Context) {
93
	settingsChanged := p.settings.Subscribe()
94
	backoff := time.Second
95
	for ctx.Err() == nil {
96
		start := time.Now()
97
		if err := p.runOnce(ctx, settingsChanged); err != nil && ctx.Err() == nil {
98
			log.Printf("player: %v", err)
99
			p.setError(err.Error())
100
		}
101
		p.setRunning(false)
102
		if ctx.Err() != nil {
103
			return
104
		}
105
		if time.Since(start) > time.Minute {
106
			backoff = time.Second
107
		} else if backoff < 15*time.Second {
108
			backoff *= 2
109
		}
110
		log.Printf("player: neustart in %s", backoff)
111
		select {
112
		case <-ctx.Done():
113
			return
114
		case <-time.After(backoff):
115
		}
116
	}
117
}
118
119
func (p *Player) runOnce(ctx context.Context, settingsChanged <-chan struct{}) error {
120
	disp, err := resolveDisplay(p.cfg.DisplayMode)
121
	if err != nil {
122
		return err
123
	}
124
	disp.logDisplay()
125
126
	sock := p.cfg.MpvSocket()
127
	if err := os.MkdirAll(filepath.Dir(sock), 0o775); err != nil {
128
		return fmt.Errorf("socket-verzeichnis: %w", err)
129
	}
130
	_ = os.Remove(sock)
131
132
	// Die Playliste wird mpv direkt auf der Kommandozeile mitgegeben. Sonst
133
	// muesste erst die IPC-Verbindung stehen, bevor ueberhaupt etwas zu sehen
134
	// ist - das kostet beim Start rund eine Sekunde, in der die Textkonsole
135
	// sichtbar wird.
136
	entries, version := p.pl.EntriesWithVersion()
137
138
	// Startlogo: beim ersten mpv-Start dieser Dienstlaufzeit bekommt mpv das
139
	// Logo als erste Datei. So ist es das allererste Bild auf dem Schirm.
140
	startLogo := ""
141
	p.mu.RLock()
142
	logoGezeigt := p.bootLogoDone
143
	p.mu.RUnlock()
144
	if !logoGezeigt && p.cfg.BootLogo != "" && p.cfg.BootLogoSeconds > 0 {
145
		if _, err := os.Stat(p.cfg.BootLogo); err == nil {
146
			startLogo = p.cfg.BootLogo
147
		} else {
148
			log.Printf("player: startlogo %q nicht lesbar: %v", p.cfg.BootLogo, err)
149
		}
150
	}
151
152
	aktive := p.pl.ActiveCount()
153
	args := p.mpvArgs(disp, aktive, startLogo)
154
	log.Printf("player: starte mpv %s", strings.Join(args, " "))
155
156
	cmd := exec.Command("mpv", args...)
157
	// stdin durchreichen: bei --gpu-context=drm braucht mpv das Terminal der
158
	// Konsole, um den Bildschirm uebernehmen zu duerfen.
159
	cmd.Stdin = os.Stdin
160
	cmd.Stdout = os.Stdout
161
	cmd.Stderr = os.Stderr
162
	cmd.Env = append(os.Environ(), disp.env...)
163
	if err := cmd.Start(); err != nil {
164
		return fmt.Errorf("mpv starten: %w (ist mpv installiert?)", err)
165
	}
166
167
	var waitErr error
168
	exited := make(chan struct{})
169
	go func() {
170
		waitErr = cmd.Wait()
171
		close(exited)
172
	}()
173
	defer func() {
174
		if cmd.Process != nil {
175
			_ = cmd.Process.Kill()
176
		}
177
		<-exited
178
		_ = os.Remove(sock)
179
	}()
180
181
	client, err := mpv.Dial(sock, 15*time.Second)
182
	if err != nil {
183
		return err
184
	}
185
	defer client.Close()
186
187
	p.mu.Lock()
188
	p.client = client
189
	p.status = Status{Running: true, Display: disp.mode}
190
	p.lastTimePos = -1
191
	p.lastProgressAt = time.Now()
192
	p.mu.Unlock()
193
	defer func() {
194
		p.mu.Lock()
195
		p.client = nil
196
		p.mu.Unlock()
197
	}()
198
199
	for id, name := range map[int]string{
200
		propPath: "path", propPause: "pause", propPlaylistPos: "playlist-pos",
201
		propIdle: "idle-active", propTimePos: "time-pos",
202
	} {
203
		if err := client.ObserveProperty(id, name); err != nil {
204
			log.Printf("player: observe %s: %v", name, err)
205
		}
206
	}
207
208
	var logoTimer *time.Timer
209
	switch {
210
	case startLogo != "":
211
		// mpv zeigt das Logo bereits - nur den Zeitpunkt merken, an dem auf
212
		// die Playliste umgeschaltet wird. applySettings kaeme der
213
		// Anzeigedauer des Logos in die Quere und folgt deshalb spaeter.
214
		p.mu.Lock()
215
		p.bootLogoDone = true
216
		p.status.Current = filepath.Base(startLogo)
217
		p.mu.Unlock()
218
		logoTimer = time.NewTimer(time.Duration(p.cfg.BootLogoSeconds * float64(time.Second)))
219
		log.Printf("player: startlogo %s fuer %.0f s", filepath.Base(startLogo), p.cfg.BootLogoSeconds)
220
221
	case aktive > 0:
222
		// Playliste lief schon beim Start von mpv mit - nur den Stand merken.
223
		p.applySettings(client)
224
		p.mu.Lock()
225
		p.loadedID = version
226
		p.pending = false
227
		p.mu.Unlock()
228
		log.Printf("player: playliste beim start uebergeben (%d von %d eintraegen aktiv)",
229
			aktive, len(entries))
230
231
	default:
232
		p.applySettings(client)
233
		p.loadPlaylist(client)
234
	}
235
	p.setError("")
236
237
	ticker := time.NewTicker(tickInterval)
238
	defer ticker.Stop()
239
240
	for {
241
		select {
242
		case <-ctx.Done():
243
			return nil
244
245
		case <-exited:
246
			return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
247
248
		case <-client.Done():
249
			select {
250
			case <-exited:
251
				return fmt.Errorf("mpv beendet: %v (Meldungen von mpv siehe oben)", waitErr)
252
			case <-time.After(time.Second):
253
				return fmt.Errorf("ipc-verbindung verloren")
254
			}
255
256
		case <-p.pl.Changed():
257
			// Nicht mitten in einer Datei neu laden - erst am Uebergang.
258
			p.mu.Lock()
259
			p.pending = p.pl.Version() != p.loadedID
260
			pending, idle := p.pending, p.status.Idle
261
			p.mu.Unlock()
262
			if pending && idle {
263
				p.loadPlaylist(client)
264
			}
265
266
		case <-p.reloadNow:
267
			p.loadPlaylist(client)
268
269
		case <-p.restart:
270
			return fmt.Errorf("neustart angefordert")
271
272
		case <-timerC(logoTimer):
273
			logoTimer.Stop()
274
			logoTimer = nil
275
			p.applySettings(client) // Anzeigedauer wieder auf den Normalwert
276
			p.loadPlaylist(client)
277
278
		case <-settingsChanged:
279
			p.applySettings(client)
280
281
		case <-ticker.C:
282
			p.checkStalled(client)
283
284
		case ev, ok := <-client.Events():
285
			if !ok {
286
				return fmt.Errorf("ipc-verbindung beendet")
287
			}
288
			p.handleEvent(client, ev)
289
		}
290
	}
291
}
292
293
func (p *Player) handleEvent(client *mpv.Client, ev mpv.Event) {
294
	switch ev.Name {
295
	case "property-change":
296
		p.mu.Lock()
297
		switch ev.ID {
298
		case propPath:
299
			var s string
300
			if json.Unmarshal(ev.Data, &s) == nil && s != "" {
301
				p.status.Current = filepath.Base(s)
302
			} else {
303
				p.status.Current = ""
304
			}
305
			p.lastTimePos = -1
306
			p.lastProgressAt = time.Now()
307
		case propPause:
308
			var b bool
309
			if json.Unmarshal(ev.Data, &b) == nil {
310
				p.status.Paused = b
311
			}
312
		case propPlaylistPos:
313
			var n float64
314
			if json.Unmarshal(ev.Data, &n) == nil {
315
				p.status.Position = int(n)
316
			}
317
		case propIdle:
318
			var b bool
319
			if json.Unmarshal(ev.Data, &b) == nil {
320
				p.status.Idle = b
321
			}
322
		case propTimePos:
323
			var pos float64
324
			if json.Unmarshal(ev.Data, &pos) == nil && pos != p.lastTimePos {
325
				p.lastTimePos = pos
326
				p.lastProgressAt = time.Now()
327
			}
328
		}
329
		idle, pending := p.status.Idle, p.pending
330
		p.mu.Unlock()
331
332
		if ev.ID == propIdle && idle && pending {
333
			p.loadPlaylist(client)
334
		}
335
336
	case "end-file":
337
		// Der beste Moment fuer eine neue Playliste: zwischen zwei Dateien.
338
		p.mu.RLock()
339
		pending := p.pending
340
		p.mu.RUnlock()
341
		if pending {
342
			p.loadPlaylist(client)
343
		}
344
	}
345
}
346
347
// timerC liefert den Kanal eines Timers - oder nil, wenn keiner laeuft.
348
// Ein nil-Kanal blockiert im select-Zweig fuer immer, genau wie gewuenscht.
349
func timerC(t *time.Timer) <-chan time.Time {
350
	if t == nil {
351
		return nil
352
	}
353
	return t.C
354
}
355
356
// loadPlaylist uebergibt mpv die aktuelle m3u-Datei.
357
func (p *Player) loadPlaylist(client *mpv.Client) {
358
	_, version := p.pl.EntriesWithVersion()
359
	aktive := p.pl.ActiveCount()
360
361
	p.mu.Lock()
362
	p.pending = false
363
	p.loadedID = version
364
	p.mu.Unlock()
365
366
	if aktive == 0 {
367
		if _, err := client.Command("playlist-clear"); err != nil {
368
			log.Printf("player: playlist-clear: %v", err)
369
		}
370
		if _, err := client.Command("stop"); err != nil {
371
			log.Printf("player: stop: %v", err)
372
		}
373
		p.mu.Lock()
374
		p.status.Current = ""
375
		p.mu.Unlock()
376
		return
377
	}
378
379
	if _, err := client.Command("loadlist", p.pl.PlaylistFile(), "replace"); err != nil {
380
		log.Printf("player: playlist laden: %v", err)
381
		p.setError("Playliste laden: " + err.Error())
382
		return
383
	}
384
	if err := client.SetProperty("pause", false); err != nil {
385
		log.Printf("player: fortsetzen: %v", err)
386
	}
387
	log.Printf("player: playliste geladen (%d eintraege)", aktive)
388
}
389
390
// checkStalled ueberspringt ein Video, das nicht mehr weiterlaeuft, damit eine
391
// einzelne Datei nicht die ganze Wiedergabe anhaelt.
392
func (p *Player) checkStalled(client *mpv.Client) {
393
	timeout := time.Duration(p.cfg.StallTimeout) * time.Second
394
	if timeout <= 0 {
395
		return
396
	}
397
	p.mu.RLock()
398
	current, paused, idle := p.status.Current, p.status.Paused, p.status.Idle
399
	since := time.Since(p.lastProgressAt)
400
	p.mu.RUnlock()
401
402
	// Bilder haben keinen fortlaufenden Zeitstempel - nur Videos pruefen.
403
	if paused || idle || current == "" || playlist.Kind(current) != "video" || since < timeout {
404
		return
405
	}
406
407
	log.Printf("player: %q laeuft seit %s nicht weiter - wird uebersprungen",
408
		current, since.Round(time.Second))
409
	p.setError(fmt.Sprintf("%s bleibt haengen und wurde uebersprungen", current))
410
	p.mu.Lock()
411
	p.lastProgressAt = time.Now()
412
	p.mu.Unlock()
413
	if _, err := client.Command("playlist-next", "force"); err != nil {
414
		log.Printf("player: weiterschalten: %v", err)
415
	}
416
}
417
418
// applySettings uebernimmt Anzeigedauer und Stummschaltung ohne mpv-Neustart.
419
func (p *Player) applySettings(client *mpv.Client) {
420
	s := p.settings.Get()
421
	if err := client.SetProperty("image-display-duration", s.ImageDuration); err != nil {
422
		log.Printf("player: anzeigedauer setzen: %v", err)
423
	}
424
	if p.cfg.Audio {
425
		if err := client.SetProperty("mute", s.Mute); err != nil {
426
			log.Printf("player: stummschaltung setzen: %v", err)
427
		}
428
	}
429
}
430
431
func (p *Player) mpvArgs(disp display, aktive int, startLogo string) []string {
432
	s := p.settings.Get()
433
	anzeigedauer := s.ImageDuration
434
	if startLogo != "" {
435
		// Etwas laenger als bis zum Umschalten, damit keine Luecke entsteht.
436
		anzeigedauer = p.cfg.BootLogoSeconds + 2
437
	}
438
	args := []string{
439
		"--input-ipc-server=" + p.cfg.MpvSocket(),
440
		"--fullscreen",
441
		"--idle=yes",
442
		"--force-window=yes",
443
		"--keep-open=no",
444
		"--loop-playlist=inf",
445
		"--image-display-duration=" + strconv.FormatFloat(anzeigedauer, 'f', -1, 64),
446
		"--hwdec=" + p.cfg.MpvHwdec,
447
		"--no-osc",
448
		"--osd-level=0",
449
		"--cursor-autohide=always",
450
		"--no-input-default-bindings",
451
		"--input-vo-keyboard=no",
452
		"--stop-screensaver=yes",
453
		// Kein --no-terminal: sonst verschluckt mpv seine Fehlermeldungen.
454
		"--msg-level=all=warn",
455
		"--no-input-terminal",
456
		"--vo=gpu",
457
	}
458
	// Die Vulkan-Anbindung von libplacebo baut bei jedem Bildwechsel die
459
	// Swapchain neu auf und laeuft dabei nach wenigen Wechseln in
460
	// VK_ERROR_OUT_OF_HOST_MEMORY: mpv laeuft weiter, der Bildschirm friert
461
	// ein. Mit OpenGL tritt das nicht auf (auf einem Pi 5 nachgemessen:
462
	// 0 statt 24 Fehlern in 20 Bildwechseln).
463
	if p.cfg.MpvGpuAPI != "" && p.cfg.MpvGpuAPI != "auto" {
464
		args = append(args, "--gpu-api="+p.cfg.MpvGpuAPI)
465
	}
466
	if p.cfg.Audio {
467
		args = append(args, "--mute="+yesNo(s.Mute))
468
	} else {
469
		args = append(args, "--no-audio")
470
	}
471
	if disp.mode == "drm" {
472
		args = append(args, "--gpu-context=drm")
473
	}
474
	args = append(args, splitArgs(p.cfg.MpvExtraArgs)...)
475
	// Die erste Datei bekommt mpv direkt mit: so erscheint das Bild, sobald
476
	// mpv den Bildschirm hat, ohne Umweg ueber die IPC-Verbindung.
477
	switch {
478
	case startLogo != "":
479
		args = append(args, startLogo)
480
	case aktive > 0:
481
		args = append(args, "--playlist="+p.pl.PlaylistFile())
482
	}
483
	return args
484
}
485
486
func yesNo(b bool) string {
487
	if b {
488
		return "yes"
489
	}
490
	return "no"
491
}
492
493
// splitArgs zerlegt eine Optionszeile und respektiert dabei Anfuehrungszeichen.
494
func splitArgs(s string) []string {
495
	var out []string
496
	var cur strings.Builder
497
	var quote rune
498
	for _, r := range s {
499
		switch {
500
		case quote != 0:
501
			if r == quote {
502
				quote = 0
503
			} else {
504
				cur.WriteRune(r)
505
			}
506
		case r == '"' || r == '\'':
507
			quote = r
508
		case r == ' ' || r == '\t':
509
			if cur.Len() > 0 {
510
				out = append(out, cur.String())
511
				cur.Reset()
512
			}
513
		default:
514
			cur.WriteRune(r)
515
		}
516
	}
517
	if cur.Len() > 0 {
518
		out = append(out, cur.String())
519
	}
520
	return out
521
}
522
523
// ---------------------------------------------------------------------------
524
// Steuerung aus dem Webinterface
525
// ---------------------------------------------------------------------------
526
527
func (p *Player) withClient(fn func(*mpv.Client) error) error {
528
	p.mu.RLock()
529
	c := p.client
530
	p.mu.RUnlock()
531
	if c == nil {
532
		return fmt.Errorf("player laeuft gerade nicht")
533
	}
534
	return fn(c)
535
}
536
537
// Next springt zum naechsten Eintrag.
538
func (p *Player) Next() error {
539
	return p.withClient(func(c *mpv.Client) error {
540
		_, err := c.Command("playlist-next", "force")
541
		return err
542
	})
543
}
544
545
// Prev springt zum vorherigen Eintrag.
546
func (p *Player) Prev() error {
547
	return p.withClient(func(c *mpv.Client) error {
548
		_, err := c.Command("playlist-prev", "force")
549
		return err
550
	})
551
}
552
553
// TogglePause haelt die Wiedergabe an oder setzt sie fort.
554
func (p *Player) TogglePause() error {
555
	return p.withClient(func(c *mpv.Client) error {
556
		_, err := c.Command("cycle", "pause")
557
		return err
558
	})
559
}
560
561
// ReloadNow laedt die Playliste sofort neu, auch mitten in einem Video.
562
func (p *Player) ReloadNow() error {
563
	select {
564
	case p.reloadNow <- struct{}{}:
565
	default:
566
	}
567
	return nil
568
}
569
570
// Restart startet den mpv-Prozess komplett neu.
571
func (p *Player) Restart() error {
572
	select {
573
	case p.restart <- struct{}{}:
574
	default:
575
	}
576
	return nil
577
}
578
579
func (p *Player) setRunning(v bool) {
580
	p.mu.Lock()
581
	p.status.Running = v
582
	if !v {
583
		p.status.Current = ""
584
	}
585
	p.mu.Unlock()
586
}
587
588
func (p *Player) setError(msg string) {
589
	p.mu.Lock()
590
	p.status.LastError = msg
591
	p.mu.Unlock()
592
}