tsplay/internal/player/mpv.go
28allday 8ee58b905f Initial commit: tuistream-play (tsplay) v0.1
Terminal Jellyfin client, watch-side companion to TUISTREAM.

- LAN auto-discovery (broadcast + unicast subnet sweep)
- mpv-window video playback over JSON IPC (pause/seek/progress/resume)
- headless audio playback with in-terminal now-playing screen
- volume control with remembered level, mute
- audio auto-advance + n/p track skip through the album queue
- Omarchy-themed UI with Nerd Font (nf-md) icons
- cliamp Jellyfin provider auto-config on login
- pacman dependency-installing installer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:36:49 +01:00

252 lines
7.1 KiB
Go

// Package player drives an external mpv process over its JSON IPC socket. mpv
// renders video in its own (hardware-accelerated) window while this TUI acts as
// the remote: it sends transport commands (pause/seek/quit) and observes mpv's
// properties (position, duration, paused, end-of-file) so the terminal can show
// a live progress bar.
//
// The design is deliberately poll-friendly: a background goroutine keeps a
// mutex-guarded State up to date from mpv's event stream, and the TUI reads a
// snapshot via Snapshot() on a timer. That avoids threading a channel through
// BubbleTea's message loop.
package player
import (
"bufio"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/google/uuid"
)
// State is an immutable snapshot of mpv's playback at a moment in time.
type State struct {
TimePos float64 // seconds elapsed
Duration float64 // total seconds (0 until known)
Paused bool
Volume float64 // 0-130 (mpv allows >100 for amplification)
Muted bool
Done bool // mpv reached EOF or the process exited
}
// Player owns one mpv process and its IPC connection.
type Player struct {
cmd *exec.Cmd
conn net.Conn
sockPath string
mu sync.Mutex
state State
}
// findMPV returns the mpv binary path or an error the caller can surface.
func findMPV() (string, error) {
return exec.LookPath("mpv")
}
// Start launches mpv on streamURL with the given window title, seeking to
// startSeconds (0 to start from the beginning). It blocks until the IPC socket
// is connectable, then returns with the property observers running.
//
// When audioOnly is true, mpv runs headless (no video output, no window) so a
// music track doesn't pop a useless black window — tsplay's own now-playing
// screen is the UI. Position/duration still flow over IPC exactly as for video.
//
// startVolume sets mpv's initial volume (mpv's 0-130 scale); pass a negative
// value to leave mpv at its own default.
func Start(streamURL, title string, startSeconds float64, audioOnly bool, startVolume float64) (*Player, error) {
bin, err := findMPV()
if err != nil {
return nil, fmt.Errorf("mpv not found in PATH: %w", err)
}
sock := filepath.Join(os.TempDir(), "tsplay-"+uuid.NewString()+".sock")
args := []string{
"--input-ipc-server=" + sock,
"--idle=no",
"--title=" + title,
"--osd-level=1",
}
if audioOnly {
// No window, no video decode — pure audio in the background.
args = append(args, "--no-video", "--force-window=no", "--no-terminal")
} else {
args = append(args, "--force-window=yes")
}
if startSeconds > 0 {
args = append(args, fmt.Sprintf("--start=%.0f", startSeconds))
}
if startVolume >= 0 {
args = append(args, fmt.Sprintf("--volume=%.0f", startVolume))
}
args = append(args, streamURL)
cmd := exec.Command(bin, args...)
// Detach mpv from our stdio so its logging never corrupts the TUI.
cmd.Stdout = nil
cmd.Stderr = nil
// Suppress MangoHud for our playback window: DISABLE_MANGOHUD turns off the
// Vulkan implicit layer and MANGOHUD=0 the OpenGL path. This is scoped to
// the mpv child only — the user's global MangoHud config is untouched.
cmd.Env = append(os.Environ(), "DISABLE_MANGOHUD=1", "MANGOHUD=0")
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start mpv: %w", err)
}
p := &Player{cmd: cmd, sockPath: sock}
// Wait for mpv to create and accept on the IPC socket (it appears a beat
// after launch). Give it a few seconds before giving up.
conn, err := dialWithRetry(sock, 5*time.Second)
if err != nil {
_ = cmd.Process.Kill()
return nil, fmt.Errorf("connect to mpv ipc: %w", err)
}
p.conn = conn
p.subscribe()
go p.readLoop()
go p.waitProcess()
return p, nil
}
func dialWithRetry(sock string, timeout time.Duration) (net.Conn, error) {
deadline := time.Now().Add(timeout)
var lastErr error
for time.Now().Before(deadline) {
c, err := net.Dial("unix", sock)
if err == nil {
return c, nil
}
lastErr = err
time.Sleep(50 * time.Millisecond)
}
return nil, lastErr
}
// subscribe registers property observers so mpv pushes change events to us.
func (p *Player) subscribe() {
p.send("observe_property", 1, "time-pos")
p.send("observe_property", 2, "duration")
p.send("observe_property", 3, "pause")
p.send("observe_property", 4, "eof-reached")
p.send("observe_property", 5, "volume")
p.send("observe_property", 6, "mute")
}
// send writes one IPC command line. Errors are ignored: a dead socket simply
// means mpv has gone, which waitProcess will report as Done.
func (p *Player) send(command ...any) {
if p.conn == nil {
return
}
payload := map[string]any{"command": command}
b, err := json.Marshal(payload)
if err != nil {
return
}
b = append(b, '\n')
_, _ = p.conn.Write(b)
}
// ipcMessage is the union of command replies and property-change events.
type ipcMessage struct {
Event string `json:"event"`
Name string `json:"name"`
Data json.RawMessage `json:"data"`
}
// readLoop consumes mpv's event stream and keeps State current.
func (p *Player) readLoop() {
sc := bufio.NewScanner(p.conn)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
var m ipcMessage
if err := json.Unmarshal(sc.Bytes(), &m); err != nil {
continue
}
if m.Event != "property-change" {
continue
}
p.mu.Lock()
switch m.Name {
case "time-pos":
var v float64
if json.Unmarshal(m.Data, &v) == nil {
p.state.TimePos = v
}
case "duration":
var v float64
if json.Unmarshal(m.Data, &v) == nil {
p.state.Duration = v
}
case "pause":
var v bool
if json.Unmarshal(m.Data, &v) == nil {
p.state.Paused = v
}
case "eof-reached":
var v bool
if json.Unmarshal(m.Data, &v) == nil && v {
p.state.Done = true
}
case "volume":
var v float64
if json.Unmarshal(m.Data, &v) == nil {
p.state.Volume = v
}
case "mute":
var v bool
if json.Unmarshal(m.Data, &v) == nil {
p.state.Muted = v
}
}
p.mu.Unlock()
}
}
// waitProcess marks the player Done when mpv exits (e.g. the user closes the
// window) and cleans up the socket file.
func (p *Player) waitProcess() {
_ = p.cmd.Wait()
p.mu.Lock()
p.state.Done = true
p.mu.Unlock()
_ = os.Remove(p.sockPath)
}
// Snapshot returns the current playback state.
func (p *Player) Snapshot() State {
p.mu.Lock()
defer p.mu.Unlock()
return p.state
}
// TogglePause flips play/pause.
func (p *Player) TogglePause() { p.send("cycle", "pause") }
// SeekRelative jumps by delta seconds (negative to rewind).
func (p *Player) SeekRelative(delta float64) { p.send("seek", delta, "relative") }
// AdjustVolume changes volume by delta (e.g. +5/-5), clamped by mpv to its
// 0-130 range. mpv echoes the new value back via the volume observer.
func (p *Player) AdjustVolume(delta float64) { p.send("add", "volume", delta) }
// ToggleMute flips mute on/off.
func (p *Player) ToggleMute() { p.send("cycle", "mute") }
// Stop asks mpv to quit and closes the connection. Safe to call more than once.
func (p *Player) Stop() {
p.send("quit")
if p.conn != nil {
_ = p.conn.Close()
}
if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
}
}