feat: add mpv IPC player with playback controls and winamp-style UI
Some checks failed
gobuild / build (push) Failing after 6s
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.
This commit is contained in:
parent
e0f45bdd5e
commit
2b688569c7
BIN
gostations
BIN
gostations
Binary file not shown.
@ -1,11 +1,17 @@
|
||||
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.
|
||||
@ -15,6 +21,22 @@ type Player interface {
|
||||
// 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.
|
||||
@ -71,6 +93,16 @@ func (p *legacyPlayer) Stop() error {
|
||||
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 ")
|
||||
@ -89,3 +121,200 @@ func isInstalledLegacy(name string) bool {
|
||||
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
|
||||
}
|
||||
|
||||
@ -24,3 +24,22 @@ func TestLegacyPlayer_BadCommand(t *testing.T) {
|
||||
}
|
||||
_ = p.Stop()
|
||||
}
|
||||
|
||||
func TestMpvInterfaceAndControls(t *testing.T) {
|
||||
p := NewMpv("echo") // won't really play, but exercises creation + interface
|
||||
_ = p.Play("http://example.com/stream")
|
||||
_ = p.Pause()
|
||||
_ = p.Resume()
|
||||
_ = p.Mute()
|
||||
_ = p.Unmute()
|
||||
_ = p.Next()
|
||||
_ = p.Prev()
|
||||
_ = p.VolumeUp()
|
||||
_ = p.VolumeDown()
|
||||
if p.Metadata() != "" {
|
||||
t.Log("mpv metadata (may be empty for echo stub)")
|
||||
}
|
||||
_ = p.Stop()
|
||||
// must satisfy Player fully
|
||||
var _ Player = p
|
||||
}
|
||||
|
||||
@ -6,15 +6,37 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/list"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/config"
|
||||
"github.com/gmgauthier/gostations/internal/data"
|
||||
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
|
||||
var (
|
||||
// hintKeyStyle gives trigger keys a colorful background + foreground so they pop in the hint row.
|
||||
hintKeyStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("15")).
|
||||
Background(lipgloss.Color("63")).
|
||||
Bold(true)
|
||||
|
||||
// hintTextStyle dims the descriptive labels next to the keys.
|
||||
hintTextStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("245"))
|
||||
|
||||
// hintBarStyle renders the entire hint row as a full-width bar with subtle background.
|
||||
// The key badges (with their own bg) will stand out on top of this bar.
|
||||
hintBarStyle = lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("236")).
|
||||
Foreground(lipgloss.Color("245")).
|
||||
PaddingLeft(2)
|
||||
)
|
||||
|
||||
// item wraps a station for the bubbles list.
|
||||
type item struct {
|
||||
station radio.Station
|
||||
@ -60,12 +82,22 @@ func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list
|
||||
fmt.Fprintf(w, "%s\n%s", title, desc)
|
||||
}
|
||||
|
||||
// App is the root model. Currently focused on selection (playback integration later).
|
||||
// App is the root model. Supports list selection + playback view (winamp-ish).
|
||||
type App struct {
|
||||
list list.Model
|
||||
quitting bool
|
||||
currentSearchTerm string
|
||||
favs *data.Favorites
|
||||
width int
|
||||
height int
|
||||
|
||||
// player and playback state (two-stage UI)
|
||||
player playerpkg.Player
|
||||
playing bool
|
||||
playingItem item
|
||||
nowPlaying string // streamed metadata title
|
||||
paused bool
|
||||
muted bool
|
||||
}
|
||||
|
||||
func NewApp(initial []radio.Station) *App {
|
||||
@ -103,7 +135,7 @@ func NewApp(initial []radio.Station) *App {
|
||||
title = "GoStations - Your Favorites"
|
||||
}
|
||||
l.Title = title
|
||||
l.SetShowStatusBar(true)
|
||||
l.SetShowStatusBar(false)
|
||||
l.SetFilteringEnabled(true)
|
||||
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
||||
|
||||
@ -138,7 +170,32 @@ func NewApp(initial []radio.Station) *App {
|
||||
return ranks
|
||||
}
|
||||
|
||||
return &App{list: l, favs: favs}
|
||||
p := newPlayerForTUI()
|
||||
return &App{
|
||||
list: l,
|
||||
favs: favs,
|
||||
width: 80,
|
||||
height: 24,
|
||||
player: p,
|
||||
}
|
||||
}
|
||||
|
||||
// newPlayerForTUI creates the appropriate Player for interactive TUI use.
|
||||
// Prefers mpv + IPC for background + controls + metadata; falls back to legacy
|
||||
// (which will be non-interactive in TUI context).
|
||||
func newPlayerForTUI() playerpkg.Player {
|
||||
pname := "mpv"
|
||||
if v, err := config.Get("player.command"); err == nil && v != "" {
|
||||
pname = v
|
||||
}
|
||||
var base []string
|
||||
if v, err := config.Get("player.options"); err == nil && v != "" {
|
||||
base = strings.Fields(v) // split e.g. "--no-video --volume=50"
|
||||
}
|
||||
if strings.Contains(pname, "mpv") {
|
||||
return playerpkg.NewMpv(pname, base...)
|
||||
}
|
||||
return playerpkg.NewLegacy(pname, base...)
|
||||
}
|
||||
|
||||
func (a *App) Init() tea.Cmd {
|
||||
@ -148,6 +205,71 @@ func (a *App) Init() tea.Cmd {
|
||||
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
if a.playing {
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
if a.player != nil {
|
||||
_ = a.player.Stop()
|
||||
}
|
||||
a.quitting = true
|
||||
return a, tea.Quit
|
||||
case "s", "S", "x", "X":
|
||||
// stop playback and return to list view
|
||||
if a.player != nil {
|
||||
_ = a.player.Stop()
|
||||
}
|
||||
a.playing = false
|
||||
a.nowPlaying = ""
|
||||
a.paused = false
|
||||
a.muted = false
|
||||
return a, nil
|
||||
case " ", "p", "P":
|
||||
if a.player != nil {
|
||||
if a.paused {
|
||||
_ = a.player.Resume()
|
||||
a.paused = false
|
||||
} else {
|
||||
_ = a.player.Pause()
|
||||
a.paused = true
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
case "m", "M":
|
||||
if a.player != nil {
|
||||
if a.muted {
|
||||
_ = a.player.Unmute()
|
||||
a.muted = false
|
||||
} else {
|
||||
_ = a.player.Mute()
|
||||
a.muted = true
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
case "left", "h", "H":
|
||||
if a.player != nil {
|
||||
_ = a.player.Prev()
|
||||
}
|
||||
return a, nil
|
||||
case "right", "l", "L":
|
||||
if a.player != nil {
|
||||
_ = a.player.Next()
|
||||
}
|
||||
return a, nil
|
||||
case "up", "down":
|
||||
if a.player != nil {
|
||||
if msg.String() == "up" {
|
||||
_ = a.player.VolumeUp()
|
||||
} else {
|
||||
_ = a.player.VolumeDown()
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
default:
|
||||
// swallow other keys in playback (don't leak to list)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
// not in playback: normal list key handling
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
a.quitting = true
|
||||
@ -163,9 +285,19 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
if i, ok := a.list.SelectedItem().(item); ok {
|
||||
// Placeholder: for now just show what would be played.
|
||||
// Later: switch to playback model + use player.Play
|
||||
a.list.Title = "Would play: " + i.station.Name + " (press q)"
|
||||
// Transition to playback view (two-stage TUI).
|
||||
a.playing = true
|
||||
a.playingItem = i
|
||||
a.nowPlaying = i.station.Name
|
||||
a.paused = false
|
||||
a.muted = false
|
||||
if a.player != nil {
|
||||
// launch in goroutine so TUI doesn't block even if using legacy player
|
||||
// (for mpv+IPC this returns immediately anyway)
|
||||
go func() { _ = a.player.Play(i.station.Url) }()
|
||||
}
|
||||
// start polling for streamed metadata (works for both legacy stub + real mpv ipc)
|
||||
return a, metadataPollCmd(a.player)
|
||||
}
|
||||
return a, nil
|
||||
case "f":
|
||||
@ -220,7 +352,19 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
case tea.WindowSizeMsg:
|
||||
a.list.SetSize(msg.Width-4, msg.Height-4)
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
// Reserve space for the hint row we append below the list in View().
|
||||
a.list.SetSize(msg.Width-4, msg.Height-5)
|
||||
case metadataMsg:
|
||||
if msg.title != "" && a.playing {
|
||||
a.nowPlaying = msg.title
|
||||
}
|
||||
// continue polling while in playback
|
||||
if a.playing && a.player != nil {
|
||||
return a, metadataPollCmd(a.player)
|
||||
}
|
||||
return a, nil
|
||||
case searchResultsMsg:
|
||||
if msg.err != nil {
|
||||
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
|
||||
@ -269,7 +413,111 @@ func (a *App) View() string {
|
||||
if a.quitting {
|
||||
return "Thanks for using GoStations!\n"
|
||||
}
|
||||
return "\n" + a.list.View() + "\n\n(type to filter current list • ENTER while filtering = server search for term & refresh list • f = toggle ★ favorite • q quit • --legacy for old UI)\n"
|
||||
if a.playing {
|
||||
// playback view (no list, custom winamp-style + optional adapted hint)
|
||||
return "\n" + a.renderPlayback() + "\n" + a.renderHint() + "\n"
|
||||
}
|
||||
return a.list.View() + "\n" + a.renderHint() + "\n"
|
||||
}
|
||||
|
||||
// renderHint builds the terse, colorful bottom hint row as a full-width bar.
|
||||
// The bar has a subtle background so the whole row looks like a distinct footer.
|
||||
// Trigger keys use a colorful bg/fg badge (on top of the bar bg).
|
||||
func (a *App) renderHint() string {
|
||||
k := func(key string) string {
|
||||
return hintKeyStyle.Render("[" + key + "]")
|
||||
}
|
||||
t := hintTextStyle.Render
|
||||
|
||||
var content string
|
||||
if a.playing {
|
||||
content = k("S") + t(" Stop/List ") + k("SPACE") + t(" Pause ") + k("M") + t(" Mute ") + k("←→") + t(" Skip ") + k("↑↓") + t(" Vol ") + k("Q") + t(" Quit")
|
||||
} else {
|
||||
content = k("/") + t(" Filter (") + k("ENTER") + t(" Search / Play) ") +
|
||||
k("Q") + t(" Quit ") +
|
||||
k("f") + t(" Favorite Toggle")
|
||||
}
|
||||
|
||||
w := a.width
|
||||
if w <= 0 {
|
||||
w = 80
|
||||
}
|
||||
|
||||
// Apply dynamic width + the bar background. PaddingLeft gives margin so text
|
||||
// doesn't hug the left edge (roughly aligns with list content inset).
|
||||
return hintBarStyle.Width(w).Render(content)
|
||||
}
|
||||
|
||||
// renderPlayback draws a classic winamp-ish playback screen with metadata viewer
|
||||
// and a row of control "buttons". Called when a.playing.
|
||||
func (a *App) renderPlayback() string {
|
||||
w := a.width
|
||||
if w < 20 {
|
||||
w = 60
|
||||
}
|
||||
boxW := min(w-4, 70)
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("63")).
|
||||
Padding(1, 2).
|
||||
Width(boxW)
|
||||
|
||||
dispW := min(boxW-6, 58)
|
||||
display := lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("235")).
|
||||
Foreground(lipgloss.Color("46")). // classic green lcd
|
||||
Width(dispW).
|
||||
Height(5).
|
||||
Padding(1, 1).
|
||||
Align(lipgloss.Left)
|
||||
|
||||
// build the "screen" text
|
||||
lines := []string{
|
||||
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
|
||||
"",
|
||||
truncate(a.playingItem.station.Name, dispW-2),
|
||||
}
|
||||
if a.nowPlaying != "" {
|
||||
lines = append(lines, truncate(a.nowPlaying, dispW-2))
|
||||
} else {
|
||||
lines = append(lines, "(waiting for stream metadata...)")
|
||||
}
|
||||
lines = append(lines, truncate(a.playingItem.station.Url, dispW-4))
|
||||
|
||||
screen := display.Render(strings.Join(lines, "\n"))
|
||||
|
||||
// button row (text buttons, stateful)
|
||||
playBtn := "[ > ]"
|
||||
if a.paused {
|
||||
playBtn = "[|| ]"
|
||||
}
|
||||
muteBtn := "[M]"
|
||||
if a.muted {
|
||||
muteBtn = "[M*]"
|
||||
}
|
||||
btnRow := fmt.Sprintf("%s %s %s %s %s %s %s",
|
||||
"[<<]", "[>>]", muteBtn, playBtn, "[VOL-]", "[VOL+]", "[ X ]")
|
||||
|
||||
help := lipgloss.NewStyle().Faint(true).Render("left/right or h/l: skip | ↑↓: volume | space/p: pause | m: mute | s/x: stop & list")
|
||||
|
||||
inner := lipgloss.JoinVertical(lipgloss.Left,
|
||||
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"),
|
||||
"",
|
||||
screen,
|
||||
"",
|
||||
btnRow,
|
||||
help,
|
||||
)
|
||||
|
||||
return box.Render(inner)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// searchCmd performs an async station search (used for in-TUI lookups via the filter box).
|
||||
@ -286,6 +534,25 @@ type searchResultsMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
// metadataMsg carries an update to the now-playing stream title from the player.
|
||||
type metadataMsg struct {
|
||||
title string
|
||||
}
|
||||
|
||||
// metadataPollCmd returns a repeating-ish poll that checks the player's
|
||||
// Metadata() and emits updates. (Simple, works whether player is mpvIPC or stub.)
|
||||
func metadataPollCmd(p playerpkg.Player) tea.Cmd {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return tea.Tick(800*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
if title := p.Metadata(); title != "" {
|
||||
return metadataMsg{title: title}
|
||||
}
|
||||
return nil // no change; next tick will try again
|
||||
})
|
||||
}
|
||||
|
||||
// Run starts the TUI (alt screen).
|
||||
func Run(initial []radio.Station) error {
|
||||
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())
|
||||
|
||||
@ -4,9 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
|
||||
"github.com/gmgauthier/gostations/internal/radio"
|
||||
)
|
||||
@ -26,6 +29,9 @@ func TestApp_BasicKeyHandling(t *testing.T) {
|
||||
// Send window size
|
||||
model, _ = app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
a := model.(*App)
|
||||
if a.width != 80 {
|
||||
t.Errorf("expected app.width=80 after WindowSizeMsg, got %d", a.width)
|
||||
}
|
||||
if a.list.Width() == 0 {
|
||||
t.Log("list size not updated (may be ok in test)")
|
||||
}
|
||||
@ -138,3 +144,109 @@ func TestNewAppTitleForFavorites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHint_Visual(t *testing.T) {
|
||||
// Force color output so lipgloss always emits the bg/fg ANSI codes for the key badges
|
||||
// (in real TUI this happens automatically on a pty).
|
||||
prevProfile := lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
defer lipgloss.SetColorProfile(prevProfile)
|
||||
|
||||
app := NewApp(nil)
|
||||
app.width = 120 // simulate a typical terminal width to exercise full-width bar
|
||||
h := app.renderHint()
|
||||
|
||||
// Basic sanity: all the documented trigger keys are present in output.
|
||||
for _, want := range []string{"[/]", "[ENTER]", "[Q]", "[f]"} {
|
||||
if !strings.Contains(h, want) {
|
||||
t.Errorf("renderHint missing %s in %q", want, h)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(h, "\x1b[") {
|
||||
t.Errorf("expected ANSI color codes from hintKeyStyle (bg+fg), got plain: %q", h)
|
||||
}
|
||||
// Show what a user will see (the escapes will be interpreted by the terminal in real run).
|
||||
visible := strings.ReplaceAll(h, "\x1b", "\\x1b")
|
||||
t.Logf("HINT ROW RENDERED (with forced color profile, width=120): %s", visible)
|
||||
|
||||
// Rough check that the bar filled to (near) requested width (after ANSI codes).
|
||||
// We strip the known key/badge escapes for a simple length heuristic on the plain text + pads.
|
||||
plainish := strings.ReplaceAll(h, "\x1b[1;97;48;5;63m", "")
|
||||
plainish = strings.ReplaceAll(plainish, "\x1b[0m", "")
|
||||
plainish = strings.ReplaceAll(plainish, "\x1b[38;5;245m", "")
|
||||
if len(plainish) < 100 {
|
||||
t.Errorf("expected bar to be nearly full width (len after basic strip ~120), got %d: %q", len(plainish), plainish)
|
||||
}
|
||||
}
|
||||
|
||||
// stubPlayer is a no-op player for unit tests (avoids real mpv exec + socket in tests).
|
||||
type stubPlayer struct{}
|
||||
|
||||
func (stubPlayer) Play(url string, extra ...string) error { return nil }
|
||||
func (stubPlayer) Stop() error { return nil }
|
||||
func (stubPlayer) Metadata() string { return "Fake Song Title [stream]" }
|
||||
func (stubPlayer) Pause() error { return nil }
|
||||
func (stubPlayer) Resume() error { return nil }
|
||||
func (stubPlayer) Mute() error { return nil }
|
||||
func (stubPlayer) Unmute() error { return nil }
|
||||
func (stubPlayer) Next() error { return nil }
|
||||
func (stubPlayer) Prev() error { return nil }
|
||||
func (stubPlayer) VolumeUp() error { return nil }
|
||||
func (stubPlayer) VolumeDown() error { return nil }
|
||||
|
||||
func TestApp_PlaybackView(t *testing.T) {
|
||||
stations := []radio.Station{
|
||||
{Name: "Test Radio", Url: "http://example.com/stream", Codec: "MP3", Bitrate: "128"},
|
||||
}
|
||||
app := NewApp(stations)
|
||||
// override with stub so no real process in test
|
||||
app.player = stubPlayer{}
|
||||
|
||||
// size so render works
|
||||
app.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||||
|
||||
// press enter on first (only) item
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
|
||||
if !app.playing {
|
||||
t.Fatal("expected to be in playing state after enter")
|
||||
}
|
||||
if app.playingItem.station.Name != "Test Radio" {
|
||||
t.Errorf("wrong station: %s", app.playingItem.station.Name)
|
||||
}
|
||||
if app.nowPlaying == "" {
|
||||
t.Error("nowPlaying should be initialized")
|
||||
}
|
||||
|
||||
// poll would have set it
|
||||
app.Update(metadataMsg{title: "Fake Song Title [stream]"})
|
||||
if !strings.Contains(app.nowPlaying, "Fake") {
|
||||
t.Errorf("metadata not applied: %s", app.nowPlaying)
|
||||
}
|
||||
|
||||
// exercise volume keys (no-op on stub, but covers the handler)
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyDown})
|
||||
|
||||
// render while still playing (with metadata)
|
||||
v := app.renderPlayback()
|
||||
if !strings.Contains(v, "Test Radio") || !strings.Contains(v, "NOW PLAYING") {
|
||||
t.Errorf("playback render missing expected content: %s", v)
|
||||
}
|
||||
if !strings.Contains(v, "Fake Song") {
|
||||
t.Logf("note: metadata may not be in this render snapshot")
|
||||
}
|
||||
|
||||
// check playing-mode hint bar includes volume
|
||||
app.playing = true
|
||||
h := app.renderHint()
|
||||
if !strings.Contains(h, "Vol") || !strings.Contains(h, "↑↓") {
|
||||
t.Errorf("playing hint missing volume info: %s", h)
|
||||
}
|
||||
|
||||
// press s to stop
|
||||
app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}})
|
||||
if app.playing {
|
||||
t.Error("expected stopped after 's'")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user