Some checks failed
gobuild / build (push) Failing after 6s
Implement mpv JSON IPC backend for non-blocking playback, streamed metadata (media-title/icy-title), and runtime controls (pause, mute, volume, next/prev). Extend Player interface and wire a two-stage TUI that switches to a dedicated playback view with keyboard shortcuts and a styled hint bar. Fallback to legacy player when mpv is unavailable.
321 lines
8.2 KiB
Go
321 lines
8.2 KiB
Go
package player
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Player abstracts execution of a media player for a stream URL.
|
|
// Implementations may use exec (legacy) or IPC (preferred for mpv).
|
|
type Player interface {
|
|
// Play starts playback of the given URL. For long-running players this blocks
|
|
// until the player exits (or returns immediately for background/IPC impls).
|
|
Play(url string, extraArgs ...string) error
|
|
Stop() error // best effort
|
|
|
|
// Metadata returns the latest stream/song title observed (e.g. from icy or media-title).
|
|
// Returns "" if unknown or not supported by the impl.
|
|
Metadata() string
|
|
|
|
// Control methods. Best-effort; return nil if unsupported.
|
|
Pause() error
|
|
Resume() error
|
|
Mute() error
|
|
Unmute() error
|
|
Next() error // e.g. playlist-next or station skip; may be no-op for single stream
|
|
Prev() error
|
|
|
|
// Volume controls (relative, best effort; mpv uses "add volume ±5" etc.)
|
|
VolumeUp() error
|
|
VolumeDown() error
|
|
}
|
|
|
|
// IsInstalled reports whether the named executable is on PATH.
|
|
// Fixed to avoid shell injection (no "/bin/sh -c 'command -v ' + name").
|
|
func IsInstalled(name string) bool {
|
|
if name == "" {
|
|
return false
|
|
}
|
|
// Direct lookpath is best; fall back to exec the name with a harmless arg.
|
|
if _, err := exec.LookPath(name); err == nil {
|
|
return true
|
|
}
|
|
// Some players may be scripts without direct lookpath in certain envs; try exec --version like.
|
|
cmd := exec.Command(name, "--version")
|
|
_ = cmd.Run() // ignore output/err; if it started at all, likely present
|
|
// More reliable: use LookPath on common variants? For now LookPath is primary.
|
|
return false
|
|
}
|
|
|
|
// NewLegacy returns a simple exec-based player (stdio inheritance for interactive controls).
|
|
// This is the cleaned version of the old subExecute pattern.
|
|
func NewLegacy(program string, baseArgs ...string) Player {
|
|
return &legacyPlayer{program: program, baseArgs: baseArgs}
|
|
}
|
|
|
|
type legacyPlayer struct {
|
|
program string
|
|
baseArgs []string
|
|
cmd *exec.Cmd
|
|
}
|
|
|
|
func (p *legacyPlayer) Play(url string, extra ...string) error {
|
|
args := append([]string{}, p.baseArgs...)
|
|
args = append(args, extra...)
|
|
args = append(args, url)
|
|
|
|
p.cmd = exec.Command(p.program, args...)
|
|
p.cmd.Stdin = os.Stdin
|
|
p.cmd.Stdout = os.Stdout
|
|
p.cmd.Stderr = os.Stderr
|
|
|
|
if err := p.cmd.Run(); err != nil {
|
|
// For interactive players the err is often just "exit status N" from 'q'.
|
|
// Surface it but don't treat as fatal for the app.
|
|
fmt.Printf("(player exited: %v)\n", err)
|
|
}
|
|
return nil // we intentionally do not try CombinedOutput after Run
|
|
}
|
|
|
|
func (p *legacyPlayer) Stop() error {
|
|
if p.cmd != nil && p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *legacyPlayer) Metadata() string { return "" }
|
|
func (p *legacyPlayer) Pause() error { return nil }
|
|
func (p *legacyPlayer) Resume() error { return nil }
|
|
func (p *legacyPlayer) Mute() error { return nil }
|
|
func (p *legacyPlayer) Unmute() error { return nil }
|
|
func (p *legacyPlayer) Next() error { return nil }
|
|
func (p *legacyPlayer) Prev() error { return nil }
|
|
func (p *legacyPlayer) VolumeUp() error { return nil }
|
|
func (p *legacyPlayer) VolumeDown() error { return nil }
|
|
|
|
// isShellMeta reports if name looks like it contains shell metachars (defense in depth).
|
|
func isShellMeta(name string) bool {
|
|
return strings.ContainsAny(name, ";|&`$(){}[]<>\n\r\t ")
|
|
}
|
|
|
|
// isInstalledLegacy is the old body (kept only for reference / windows where.exe).
|
|
// New code should use the top-level IsInstalled.
|
|
func isInstalledLegacy(name string) bool {
|
|
if runtime.GOOS == "windows" {
|
|
cmd := exec.Command("where.exe", name)
|
|
return cmd.Run() == nil
|
|
}
|
|
if isShellMeta(name) {
|
|
return false // refuse obviously bad names
|
|
}
|
|
cmd := exec.Command("/bin/sh", "-c", "command -v "+name) // still used on some old paths; prefer LookPath above
|
|
return cmd.Run() == nil
|
|
}
|
|
|
|
// NewMpv returns an mpv-based player using JSON IPC over a unix socket for
|
|
// non-blocking background playback, control (pause/mute/etc), and streamed
|
|
// metadata observation (media-title / metadata). Falls back gracefully to
|
|
// audio-only if IPC socket cannot be connected (still starts the mpv process).
|
|
func NewMpv(program string, baseArgs ...string) Player {
|
|
return &mpvPlayer{
|
|
program: program,
|
|
baseArgs: baseArgs,
|
|
}
|
|
}
|
|
|
|
type mpvPlayer struct {
|
|
program string
|
|
baseArgs []string
|
|
|
|
socket string
|
|
cmd *exec.Cmd
|
|
conn net.Conn
|
|
|
|
mu sync.Mutex
|
|
title string
|
|
paused bool
|
|
muted bool
|
|
}
|
|
|
|
func (p *mpvPlayer) Play(url string, extra ...string) error {
|
|
// unique socket per run
|
|
p.socket = filepath.Join(os.TempDir(), fmt.Sprintf("gostations-mpv-%d-%d.sock", os.Getpid(), time.Now().UnixNano()))
|
|
_ = os.Remove(p.socket)
|
|
|
|
args := append([]string{}, p.baseArgs...)
|
|
args = append(args, extra...)
|
|
args = append(args,
|
|
"--no-terminal",
|
|
"--really-quiet",
|
|
"--vo=null",
|
|
"--input-ipc-server="+p.socket,
|
|
"--idle=no",
|
|
url,
|
|
)
|
|
|
|
p.cmd = exec.Command(p.program, args...)
|
|
// detached: no stdio attach so TUI remains responsive
|
|
if err := p.cmd.Start(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// give mpv a moment to create the socket
|
|
time.Sleep(150 * time.Millisecond)
|
|
for i := 0; i < 30; i++ {
|
|
if _, err := os.Stat(p.socket); err == nil {
|
|
break
|
|
}
|
|
time.Sleep(30 * time.Millisecond)
|
|
}
|
|
|
|
conn, err := net.DialTimeout("unix", p.socket, 500*time.Millisecond)
|
|
if err != nil {
|
|
// audio may still be playing; controls/metadata unavailable
|
|
return nil
|
|
}
|
|
p.conn = conn
|
|
|
|
// observe properties we care about
|
|
p.send(map[string]any{"command": []any{"observe_property", 1, "media-title"}})
|
|
p.send(map[string]any{"command": []any{"observe_property", 2, "metadata"}})
|
|
p.send(map[string]any{"command": []any{"observe_property", 3, "pause"}})
|
|
p.send(map[string]any{"command": []any{"observe_property", 4, "mute"}})
|
|
|
|
go p.readLoop()
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) send(cmd map[string]any) {
|
|
if p.conn == nil {
|
|
return
|
|
}
|
|
b, _ := json.Marshal(cmd)
|
|
_, _ = p.conn.Write(append(b, '\n'))
|
|
}
|
|
|
|
func (p *mpvPlayer) readLoop() {
|
|
sc := bufio.NewScanner(p.conn)
|
|
for sc.Scan() {
|
|
var msg map[string]any
|
|
if json.Unmarshal(sc.Bytes(), &msg) != nil {
|
|
continue
|
|
}
|
|
if msg["event"] != "property-change" {
|
|
continue
|
|
}
|
|
id, _ := msg["id"].(float64)
|
|
data := msg["data"]
|
|
p.mu.Lock()
|
|
switch id {
|
|
case 1:
|
|
if s, ok := data.(string); ok {
|
|
p.title = s
|
|
}
|
|
case 2:
|
|
if m, ok := data.(map[string]any); ok {
|
|
if t, ok := m["title"].(string); ok && t != "" {
|
|
p.title = t
|
|
} else if t, ok := m["icy-title"].(string); ok && t != "" {
|
|
p.title = t
|
|
}
|
|
}
|
|
case 3:
|
|
if b, ok := data.(bool); ok {
|
|
p.paused = b
|
|
}
|
|
case 4:
|
|
if b, ok := data.(bool); ok {
|
|
p.muted = b
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
func (p *mpvPlayer) Metadata() string {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.title
|
|
}
|
|
|
|
func (p *mpvPlayer) Pause() error {
|
|
p.mu.Lock()
|
|
p.paused = true
|
|
p.mu.Unlock()
|
|
p.send(map[string]any{"command": []any{"set_property", "pause", true}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) Resume() error {
|
|
p.mu.Lock()
|
|
p.paused = false
|
|
p.mu.Unlock()
|
|
p.send(map[string]any{"command": []any{"set_property", "pause", false}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) Mute() error {
|
|
p.mu.Lock()
|
|
p.muted = true
|
|
p.mu.Unlock()
|
|
p.send(map[string]any{"command": []any{"set_property", "mute", true}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) Unmute() error {
|
|
p.mu.Lock()
|
|
p.muted = false
|
|
p.mu.Unlock()
|
|
p.send(map[string]any{"command": []any{"set_property", "mute", false}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) Next() error {
|
|
p.send(map[string]any{"command": []any{"playlist-next"}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) Prev() error {
|
|
p.send(map[string]any{"command": []any{"playlist-prev"}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) VolumeUp() error {
|
|
p.send(map[string]any{"command": []any{"add", "volume", 5}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) VolumeDown() error {
|
|
p.send(map[string]any{"command": []any{"add", "volume", -5}})
|
|
return nil
|
|
}
|
|
|
|
func (p *mpvPlayer) Stop() error {
|
|
if p.conn != nil {
|
|
p.send(map[string]any{"command": []any{"quit"}})
|
|
_ = p.conn.Close()
|
|
p.conn = nil
|
|
}
|
|
if p.cmd != nil && p.cmd.Process != nil {
|
|
_ = p.cmd.Process.Kill()
|
|
p.cmd = nil
|
|
}
|
|
_ = os.Remove(p.socket)
|
|
p.mu.Lock()
|
|
p.title = ""
|
|
p.paused = false
|
|
p.muted = false
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|