refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
package ui
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-05 21:30:50 +00:00
|
|
|
"context"
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
"fmt"
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
"io"
|
|
|
|
|
"log"
|
2026-06-05 22:18:30 +00:00
|
|
|
"math"
|
2026-06-05 20:46:04 +00:00
|
|
|
"strings"
|
2026-06-05 22:13:01 +00:00
|
|
|
"time"
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
|
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
"github.com/charmbracelet/bubbles/list"
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
|
|
2026-06-05 22:13:01 +00:00
|
|
|
"github.com/gmgauthier/gostations/internal/config"
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
"github.com/gmgauthier/gostations/internal/data"
|
2026-06-05 22:13:01 +00:00
|
|
|
playerpkg "github.com/gmgauthier/gostations/internal/player"
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
"github.com/gmgauthier/gostations/internal/radio"
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-05 22:13:01 +00:00
|
|
|
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)
|
|
|
|
|
)
|
|
|
|
|
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
// item wraps a station for the bubbles list.
|
|
|
|
|
type item struct {
|
2026-06-05 20:36:49 +00:00
|
|
|
station radio.Station
|
|
|
|
|
isFavorite bool
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 20:36:49 +00:00
|
|
|
func (i item) Title() string { return i.station.Name }
|
|
|
|
|
func (i item) Description() string {
|
|
|
|
|
return fmt.Sprintf("%s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 50))
|
|
|
|
|
}
|
|
|
|
|
func (i item) FilterValue() string {
|
|
|
|
|
return i.station.Name + " " + i.station.Tags + " " + i.station.Codec + " " + i.station.Url
|
|
|
|
|
}
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
|
|
|
|
|
func truncate(s string, n int) string {
|
|
|
|
|
if len(s) <= n {
|
|
|
|
|
return s
|
|
|
|
|
}
|
|
|
|
|
return s[:n] + "…"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// listDelegate for nice rendering.
|
|
|
|
|
type listDelegate struct{}
|
|
|
|
|
|
|
|
|
|
func (d listDelegate) Height() int { return 2 }
|
|
|
|
|
func (d listDelegate) Spacing() int { return 1 }
|
|
|
|
|
func (d listDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
|
|
|
|
|
func (d listDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
|
|
|
|
|
i, ok := listItem.(item)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-06-05 20:36:49 +00:00
|
|
|
title := i.station.Name
|
|
|
|
|
if i.isFavorite {
|
|
|
|
|
title = "★ " + title
|
|
|
|
|
}
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
if m.Index() == index {
|
|
|
|
|
title = lipgloss.NewStyle().Bold(true).Render("▶ " + title)
|
|
|
|
|
} else {
|
|
|
|
|
title = " " + title
|
|
|
|
|
}
|
2026-06-05 20:36:49 +00:00
|
|
|
desc := fmt.Sprintf(" %s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 45))
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
fmt.Fprintf(w, "%s\n%s", title, desc)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:13:01 +00:00
|
|
|
// App is the root model. Supports list selection + playback view (winamp-ish).
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
type App struct {
|
2026-06-05 21:30:50 +00:00
|
|
|
list list.Model
|
|
|
|
|
quitting bool
|
|
|
|
|
currentSearchTerm string
|
|
|
|
|
favs *data.Favorites
|
2026-06-05 22:13:01 +00:00
|
|
|
width int
|
|
|
|
|
height int
|
|
|
|
|
|
|
|
|
|
// player and playback state (two-stage UI)
|
|
|
|
|
player playerpkg.Player
|
2026-06-05 22:18:30 +00:00
|
|
|
playing bool
|
|
|
|
|
playingItem item
|
|
|
|
|
nowPlaying string // streamed metadata title
|
|
|
|
|
paused bool
|
|
|
|
|
muted bool
|
|
|
|
|
currentVolume int
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewApp(initial []radio.Station) *App {
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
favs, err := data.NewFavorites()
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf("warning: could not load favorites: %v", err)
|
|
|
|
|
}
|
|
|
|
|
favSet := map[string]bool{}
|
|
|
|
|
if favs != nil {
|
|
|
|
|
for _, s := range favs.List() {
|
|
|
|
|
favSet[s.Url] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
items := make([]list.Item, len(initial))
|
|
|
|
|
for i, s := range initial {
|
2026-06-05 20:36:49 +00:00
|
|
|
isFav := favSet[s.Url]
|
|
|
|
|
items[i] = item{station: s, isFavorite: isFav}
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:30:50 +00:00
|
|
|
// Heuristic: if every provided initial station is a favorite, this was a "favorites" initial load
|
|
|
|
|
isFavoritesInitial := len(initial) > 0
|
|
|
|
|
if isFavoritesInitial {
|
|
|
|
|
for _, s := range initial {
|
|
|
|
|
if !favSet[s.Url] {
|
|
|
|
|
isFavoritesInitial = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
l := list.New(items, listDelegate{}, 60, 20)
|
2026-06-05 21:30:50 +00:00
|
|
|
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
|
|
|
|
if isFavoritesInitial {
|
|
|
|
|
title = "GoStations - Your Favorites"
|
|
|
|
|
}
|
|
|
|
|
l.Title = title
|
2026-06-05 22:13:01 +00:00
|
|
|
l.SetShowStatusBar(false)
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
l.SetFilteringEnabled(true)
|
|
|
|
|
l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63"))
|
|
|
|
|
|
2026-06-05 20:46:04 +00:00
|
|
|
// Use a custom substring/AND filter instead of the default fuzzy (which matches
|
|
|
|
|
// any combination of individual letters, e.g. "WFMT" matching stations with W or F or M or T).
|
|
|
|
|
// This makes "/WFMT" find stations containing the substring "wfmt" (case-insensitive),
|
|
|
|
|
// and multi-word searches require all words to appear somewhere in the target.
|
|
|
|
|
l.Filter = func(term string, targets []string) []list.Rank {
|
|
|
|
|
term = strings.TrimSpace(term)
|
|
|
|
|
if term == "" {
|
|
|
|
|
ranks := make([]list.Rank, len(targets))
|
|
|
|
|
for i := range targets {
|
|
|
|
|
ranks[i] = list.Rank{Index: i}
|
|
|
|
|
}
|
|
|
|
|
return ranks
|
|
|
|
|
}
|
|
|
|
|
words := strings.Fields(strings.ToLower(term))
|
|
|
|
|
var ranks []list.Rank
|
|
|
|
|
for i, t := range targets {
|
|
|
|
|
tl := strings.ToLower(t)
|
|
|
|
|
matches := true
|
|
|
|
|
for _, w := range words {
|
|
|
|
|
if !strings.Contains(tl, w) {
|
|
|
|
|
matches = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if matches {
|
|
|
|
|
ranks = append(ranks, list.Rank{Index: i})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ranks
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:13:01 +00:00
|
|
|
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...)
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) Init() tea.Cmd {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
|
|
switch msg := msg.(type) {
|
|
|
|
|
case tea.KeyMsg:
|
2026-06-05 22:13:01 +00:00
|
|
|
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
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
switch msg.String() {
|
|
|
|
|
case "q", "ctrl+c":
|
|
|
|
|
a.quitting = true
|
|
|
|
|
return a, tea.Quit
|
|
|
|
|
case "enter":
|
2026-06-05 21:30:50 +00:00
|
|
|
if a.list.FilterState() == list.Filtering || a.list.FilterState() == list.FilterApplied {
|
|
|
|
|
// User pressed enter while in filter: treat the filter term as a new lookup/search
|
|
|
|
|
term := strings.TrimSpace(a.list.FilterValue())
|
|
|
|
|
if term != "" {
|
|
|
|
|
a.currentSearchTerm = term
|
|
|
|
|
a.list.Title = fmt.Sprintf(`Searching for "%s"...`, term)
|
|
|
|
|
return a, searchCmd(term)
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
if i, ok := a.list.SelectedItem().(item); ok {
|
2026-06-05 22:13:01 +00:00
|
|
|
// Transition to playback view (two-stage TUI).
|
|
|
|
|
a.playing = true
|
|
|
|
|
a.playingItem = i
|
|
|
|
|
a.nowPlaying = i.station.Name
|
|
|
|
|
a.paused = false
|
|
|
|
|
a.muted = false
|
2026-06-05 22:18:30 +00:00
|
|
|
a.currentVolume = 70 // default, will be updated by poll/observe
|
2026-06-05 22:13:01 +00:00
|
|
|
if a.player != nil {
|
2026-06-05 22:18:30 +00:00
|
|
|
if v := a.player.Volume(); v > 0 {
|
|
|
|
|
a.currentVolume = v
|
|
|
|
|
}
|
2026-06-05 22:13:01 +00:00
|
|
|
// 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) }()
|
|
|
|
|
}
|
2026-06-05 22:18:30 +00:00
|
|
|
// start polling for streamed metadata and volume (for the vertical bar)
|
|
|
|
|
return a, tea.Batch(
|
|
|
|
|
metadataPollCmd(a.player),
|
|
|
|
|
volumePollCmd(a.player),
|
|
|
|
|
)
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
}
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
return a, nil
|
2026-06-05 21:30:50 +00:00
|
|
|
case "f":
|
|
|
|
|
// Toggle favorite on the currently selected item
|
|
|
|
|
if sel, ok := a.list.SelectedItem().(item); ok {
|
|
|
|
|
masterIdx := a.list.GlobalIndex()
|
|
|
|
|
if a.favs == nil {
|
|
|
|
|
var ferr error
|
|
|
|
|
a.favs, ferr = data.NewFavorites()
|
|
|
|
|
if ferr != nil {
|
|
|
|
|
statusCmd := a.list.NewStatusMessage("Favorites unavailable: " + ferr.Error())
|
|
|
|
|
return a, statusCmd
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
url := sel.station.Url
|
|
|
|
|
wasFav := sel.isFavorite
|
|
|
|
|
if wasFav {
|
|
|
|
|
a.favs.Remove(url)
|
|
|
|
|
} else {
|
|
|
|
|
a.favs.Add(sel.station)
|
|
|
|
|
}
|
|
|
|
|
if saveErr := a.favs.Save(); saveErr != nil {
|
|
|
|
|
statusCmd := a.list.NewStatusMessage("Failed to save favorites: " + saveErr.Error())
|
|
|
|
|
return a, statusCmd
|
|
|
|
|
}
|
|
|
|
|
sel.isFavorite = !wasFav
|
|
|
|
|
setCmd := a.list.SetItem(masterIdx, sel)
|
|
|
|
|
status := "Removed from favorites"
|
|
|
|
|
if sel.isFavorite {
|
|
|
|
|
status = "Added to favorites ★"
|
|
|
|
|
}
|
|
|
|
|
statusCmd := a.list.NewStatusMessage(status)
|
|
|
|
|
return a, tea.Batch(setCmd, statusCmd)
|
|
|
|
|
}
|
|
|
|
|
return a, nil
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
}
|
2026-06-05 20:36:49 +00:00
|
|
|
|
|
|
|
|
// Auto-start filter on first alphanumeric character (better UX than requiring / first)
|
|
|
|
|
s := msg.String()
|
|
|
|
|
if len(s) == 1 {
|
|
|
|
|
r := rune(s[0])
|
|
|
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
|
|
|
if a.list.FilterState() != list.Filtering {
|
2026-06-05 20:46:04 +00:00
|
|
|
// Simulate pressing the filter key to activate, then feed the char
|
2026-06-05 20:36:49 +00:00
|
|
|
slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}
|
2026-06-05 20:46:04 +00:00
|
|
|
var c1 tea.Cmd
|
|
|
|
|
a.list, c1 = a.list.Update(slash)
|
|
|
|
|
var c2 tea.Cmd
|
|
|
|
|
a.list, c2 = a.list.Update(msg)
|
|
|
|
|
return a, tea.Batch(c1, c2)
|
2026-06-05 20:36:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
case tea.WindowSizeMsg:
|
2026-06-05 22:13:01 +00:00
|
|
|
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
|
2026-06-05 22:18:30 +00:00
|
|
|
case volumeMsg:
|
|
|
|
|
if a.playing {
|
|
|
|
|
a.currentVolume = msg.volume
|
|
|
|
|
}
|
|
|
|
|
if a.playing && a.player != nil {
|
|
|
|
|
return a, volumePollCmd(a.player)
|
|
|
|
|
}
|
|
|
|
|
return a, nil
|
2026-06-05 21:30:50 +00:00
|
|
|
case searchResultsMsg:
|
|
|
|
|
if msg.err != nil {
|
|
|
|
|
a.list.Title = fmt.Sprintf("Search error: %v (press / to search again)", msg.err)
|
|
|
|
|
return a, nil
|
|
|
|
|
}
|
|
|
|
|
// Rebuild items (preserve favorites)
|
|
|
|
|
var favs *data.Favorites
|
|
|
|
|
var ferr error
|
|
|
|
|
if a.favs != nil {
|
|
|
|
|
favs = a.favs
|
|
|
|
|
// reload to get latest persisted state (in case of external edits)
|
|
|
|
|
favs, ferr = data.NewFavorites()
|
|
|
|
|
if ferr != nil {
|
|
|
|
|
favs = a.favs // fallback to in-memory
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
favs, ferr = data.NewFavorites()
|
|
|
|
|
}
|
|
|
|
|
a.favs = favs
|
|
|
|
|
favSet := map[string]bool{}
|
|
|
|
|
if favs != nil {
|
|
|
|
|
for _, fs := range favs.List() {
|
|
|
|
|
favSet[fs.Url] = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
newItems := make([]list.Item, len(msg.stations))
|
|
|
|
|
for i, s := range msg.stations {
|
|
|
|
|
newItems[i] = item{station: s, isFavorite: favSet[s.Url]}
|
|
|
|
|
}
|
|
|
|
|
setCmd := a.list.SetItems(newItems)
|
|
|
|
|
title := "GoStations - Radio Browser (new TUI • ★ = favorite)"
|
|
|
|
|
if a.currentSearchTerm != "" {
|
|
|
|
|
title = fmt.Sprintf("GoStations - Results for %q (%d)", a.currentSearchTerm, len(newItems))
|
|
|
|
|
}
|
|
|
|
|
a.list.Title = title
|
|
|
|
|
a.list.ResetFilter()
|
|
|
|
|
return a, setCmd
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
}
|
|
|
|
|
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
var cmd tea.Cmd
|
|
|
|
|
a.list, cmd = a.list.Update(msg)
|
|
|
|
|
return a, cmd
|
|
|
|
|
}
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
|
|
|
|
|
func (a *App) View() string {
|
|
|
|
|
if a.quitting {
|
|
|
|
|
return "Thanks for using GoStations!\n"
|
|
|
|
|
}
|
2026-06-05 22:13:01 +00:00
|
|
|
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)
|
|
|
|
|
|
2026-06-05 22:18:30 +00:00
|
|
|
dispW := min(boxW-10, 50) // leave room for vertical vol bar (~2) + separator
|
2026-06-05 22:13:01 +00:00
|
|
|
display := lipgloss.NewStyle().
|
|
|
|
|
Background(lipgloss.Color("235")).
|
|
|
|
|
Foreground(lipgloss.Color("46")). // classic green lcd
|
|
|
|
|
Width(dispW).
|
|
|
|
|
Height(5).
|
|
|
|
|
Padding(1, 1).
|
|
|
|
|
Align(lipgloss.Left)
|
|
|
|
|
|
2026-06-05 22:18:30 +00:00
|
|
|
// build the metadata content
|
|
|
|
|
metaLines := []string{
|
2026-06-05 22:13:01 +00:00
|
|
|
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
|
|
|
|
|
"",
|
|
|
|
|
truncate(a.playingItem.station.Name, dispW-2),
|
|
|
|
|
}
|
|
|
|
|
if a.nowPlaying != "" {
|
2026-06-05 22:18:30 +00:00
|
|
|
metaLines = append(metaLines, truncate(a.nowPlaying, dispW-2))
|
2026-06-05 22:13:01 +00:00
|
|
|
} else {
|
2026-06-05 22:18:30 +00:00
|
|
|
metaLines = append(metaLines, "(waiting for stream metadata...)")
|
2026-06-05 22:13:01 +00:00
|
|
|
}
|
2026-06-05 22:18:30 +00:00
|
|
|
metaLines = append(metaLines, truncate(a.playingItem.station.Url, dispW-4))
|
|
|
|
|
|
|
|
|
|
metadata := display.Render(strings.Join(metaLines, "\n"))
|
2026-06-05 22:13:01 +00:00
|
|
|
|
2026-06-05 22:18:30 +00:00
|
|
|
// vertical volume bar to the right of the metadata display
|
|
|
|
|
volBar := renderVolumeBar(a.currentVolume, 5, 2)
|
|
|
|
|
|
|
|
|
|
// place side-by-side (top aligned). Add a small separator space.
|
|
|
|
|
viewer := lipgloss.JoinHorizontal(lipgloss.Top, metadata, " ", volBar)
|
2026-06-05 22:13:01 +00:00
|
|
|
|
|
|
|
|
// 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"),
|
|
|
|
|
"",
|
2026-06-05 22:18:30 +00:00
|
|
|
viewer,
|
2026-06-05 22:13:01 +00:00
|
|
|
"",
|
|
|
|
|
btnRow,
|
|
|
|
|
help,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return box.Render(inner)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func min(a, b int) int {
|
|
|
|
|
if a < b {
|
|
|
|
|
return a
|
|
|
|
|
}
|
|
|
|
|
return b
|
2026-06-05 21:30:50 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:18:30 +00:00
|
|
|
// renderVolumeBar draws a vertical volume indicator bar.
|
|
|
|
|
// height matches the metadata display (e.g. 5).
|
|
|
|
|
// background is dark gray ("236"), filled indicator uses the green ("46") from the lcd display.
|
|
|
|
|
func renderVolumeBar(vol int, height, width int) string {
|
|
|
|
|
if height <= 0 {
|
|
|
|
|
height = 5
|
|
|
|
|
}
|
|
|
|
|
if width <= 0 {
|
|
|
|
|
width = 2
|
|
|
|
|
}
|
|
|
|
|
if vol < 0 {
|
|
|
|
|
vol = 0
|
|
|
|
|
}
|
|
|
|
|
if vol > 100 {
|
|
|
|
|
vol = 100
|
|
|
|
|
}
|
|
|
|
|
filled := int(math.Round(float64(vol) * float64(height) / 100.0))
|
|
|
|
|
|
|
|
|
|
darkGray := lipgloss.Color("236")
|
|
|
|
|
green := lipgloss.Color("46")
|
|
|
|
|
|
|
|
|
|
var lines []string
|
|
|
|
|
for i := 0; i < height; i++ {
|
|
|
|
|
// i=0 is top (high volume), fill from bottom up
|
|
|
|
|
isFilled := i >= (height - filled)
|
|
|
|
|
style := lipgloss.NewStyle().
|
|
|
|
|
Width(width).
|
|
|
|
|
Background(darkGray)
|
|
|
|
|
if isFilled {
|
|
|
|
|
style = style.Background(green)
|
|
|
|
|
}
|
|
|
|
|
// use block char for the indicator
|
|
|
|
|
seg := "█"
|
|
|
|
|
if !isFilled {
|
|
|
|
|
seg = " "
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, style.Render(strings.Repeat(seg, width)))
|
|
|
|
|
}
|
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 21:30:50 +00:00
|
|
|
// searchCmd performs an async station search (used for in-TUI lookups via the filter box).
|
|
|
|
|
func searchCmd(name string) tea.Cmd {
|
|
|
|
|
return func() tea.Msg {
|
|
|
|
|
stations, err := radio.Search(context.Background(), name, "", "", "", false)
|
|
|
|
|
return searchResultsMsg{stations: stations, err: err}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// searchResultsMsg is sent when a background search completes.
|
|
|
|
|
type searchResultsMsg struct {
|
|
|
|
|
stations []radio.Station
|
|
|
|
|
err error
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:13:01 +00:00
|
|
|
// metadataMsg carries an update to the now-playing stream title from the player.
|
|
|
|
|
type metadataMsg struct {
|
|
|
|
|
title string
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:18:30 +00:00
|
|
|
// volumeMsg carries volume level update (0-100).
|
|
|
|
|
type volumeMsg struct {
|
|
|
|
|
volume int
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:13:01 +00:00
|
|
|
// 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
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 22:18:30 +00:00
|
|
|
// volumePollCmd polls the player's Volume() for the vertical bar.
|
|
|
|
|
func volumePollCmd(p playerpkg.Player) tea.Cmd {
|
|
|
|
|
if p == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
return tea.Tick(600*time.Millisecond, func(t time.Time) tea.Msg {
|
|
|
|
|
v := p.Volume()
|
|
|
|
|
if v < 0 {
|
|
|
|
|
v = 0
|
|
|
|
|
}
|
|
|
|
|
if v > 100 {
|
|
|
|
|
v = 100
|
|
|
|
|
}
|
|
|
|
|
return volumeMsg{volume: v}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
// Run starts the TUI (alt screen).
|
refactor: reorganize into internal packages, fix critical panics and error handling
Move browser, config, player, and radio logic into internal/{config,radio,player,ui,version}.
Add cached API host resolution, context-aware HTTP client, and nil/length guards to eliminate
RandomIP, nslookup, reverseLookup, and GetStations panics. Replace subExecute with clean
legacyPlayer using Run-only execution. Fix inverted -short test guards, unformatted config
error messages, and "Erorr" typo. Remove obsolete vendor/GOPATH logic from build scripts.
Update callers in stations.go and radiomenu.go to new paths; retain shims for transition.
2026-06-05 20:10:54 +00:00
|
|
|
func Run(initial []radio.Station) error {
|
|
|
|
|
p := tea.NewProgram(NewApp(initial), tea.WithAltScreen())
|
|
|
|
|
_, err := p.Run()
|
|
|
|
|
return err
|
|
|
|
|
}
|
feat(cli): default to bubbles TUI, add find/play subcmds + JSON favorites
- implement internal/ui with bubbles list + ★ fav markers, filter, enter stub
- add data/favorites (JSON roundtrip, Add/Remove, XDG), config tests
- wire subcommands: `find -j`, `play [url|search]`
- gate legacy wmenu behind --legacy; keep old flags for seeding
- fix inverted -short guards, format string, go.mod deps
- add unit tests for radio prune, player IsInstalled, ui keys, etc.
2026-06-05 20:23:11 +00:00
|
|
|
|
|
|
|
|
// Short is a small helper (duplicated from old for TUI list desc; can be shared later).
|
|
|
|
|
func Short(s string, i int) string {
|
|
|
|
|
runes := []rune(s)
|
|
|
|
|
if len(runes) > i {
|
|
|
|
|
return string(runes[:i])
|
|
|
|
|
}
|
|
|
|
|
return s
|
|
|
|
|
}
|