// 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() } }