Some checks failed
gobuild / build (push) Failing after 7s
Add Volume() to Player interface and mpvPlayer implementation to expose current volume level. Poll volume periodically and render a vertical bar next to the metadata display during playback. Update UI state and tests to support the new volume indicator.
349 lines
8.9 KiB
Go
349 lines
8.9 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
|
|
|
|
// Volume returns current volume level (0-100).
|
|
Volume() int
|
|
}
|
|
|
|
// 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 }
|
|
func (p *legacyPlayer) Volume() int { return 70 }
|
|
|
|
// 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
|
|
vol int
|
|
}
|
|
|
|
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"}})
|
|
p.send(map[string]any{"command": []any{"observe_property", 5, "volume"}})
|
|
// get initial volume
|
|
p.send(map[string]any{"command": []any{"get_property", "volume"}})
|
|
|
|
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" {
|
|
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
|
|
}
|
|
case 5:
|
|
if f, ok := data.(float64); ok {
|
|
p.vol = int(f + 0.5)
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
continue
|
|
}
|
|
// handle get_property response for initial volume
|
|
if data, ok := msg["data"]; ok {
|
|
if f, ok := data.(float64); ok {
|
|
if req, _ := msg["request_id"].(float64); req == 0 { // rough
|
|
p.mu.Lock()
|
|
p.vol = int(f + 0.5)
|
|
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) Volume() int {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.vol
|
|
}
|
|
|
|
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
|
|
}
|