diff --git a/gostations b/gostations index 93e3b33..f59f7f5 100755 Binary files a/gostations and b/gostations differ diff --git a/internal/player/player.go b/internal/player/player.go index 9f6ba27..5bcf6db 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -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 +} diff --git a/internal/player/player_test.go b/internal/player/player_test.go index b450f85..c006a6e 100644 --- a/internal/player/player_test.go +++ b/internal/player/player_test.go @@ -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 +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 6290266..c622b9d 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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()) diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 421da04..a174f1a 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -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'") + } +} +