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>
252 lines
7.1 KiB
Go
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()
|
|
}
|
|
}
|