package ui import ( "context" "fmt" "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 isFavorite bool } 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 } 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 } title := i.station.Name if i.isFavorite { title = "★ " + title } if m.Index() == index { title = lipgloss.NewStyle().Bold(true).Render("▶ " + title) } else { title = " " + title } desc := fmt.Sprintf(" %s • %s kbps • %s", i.station.Codec, i.station.Bitrate, truncate(i.station.Url, 45)) fmt.Fprintf(w, "%s\n%s", title, desc) } // 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 { 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 { isFav := favSet[s.Url] items[i] = item{station: s, isFavorite: isFav} } // 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 } } } l := list.New(items, listDelegate{}, 60, 20) title := "GoStations - Radio Browser (new TUI • ★ = favorite)" if isFavoritesInitial { title = "GoStations - Your Favorites" } l.Title = title l.SetShowStatusBar(false) l.SetFilteringEnabled(true) l.Styles.Title = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")) // 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 } 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 { return nil } 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 return a, tea.Quit case "enter": 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) } } if i, ok := a.list.SelectedItem().(item); ok { // 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": // 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 } // 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 { // Simulate pressing the filter key to activate, then feed the char slash := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}} 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) } } } case tea.WindowSizeMsg: 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) 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 } var cmd tea.Cmd a.list, cmd = a.list.Update(msg) return a, cmd } func (a *App) View() string { if a.quitting { return "Thanks for using GoStations!\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). 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 } // 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()) _, err := p.Run() return err } // 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 }