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 | } |