gostations/internal/ui/ui.go

923 lines
27 KiB
Go
Raw Normal View History

package ui
import (
"context"
"fmt"
"io"
"log"
"math"
"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
currentVolume int
// Flash state for volume button feedback (when ↑/↓ pressed in playback)
volDownFlash bool
volUpFlash bool
skipBackFlash bool
skipFwdFlash bool
stopFlash 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,
currentVolume: config.LastVolume(),
}
}
// 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"
}
// Note: volume is now passed per-Play via extra args in the enter block
// (see the "enter" case), so we do not inject here. This keeps baseArgs
// stable and lets us use the session's current volume (or latest LastVolume)
// for each new station.
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()
}
config.SetLastVolume(a.currentVolume)
if a.favs != nil && a.playingItem.station.Url != "" {
if a.favs.Contains(a.playingItem.station.Url) {
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
_ = a.favs.Save()
}
}
a.quitting = true
return a, tea.Quit
case "s", "S", "x", "X":
// stop playback and return to list view.
// We set the stop flash first so the button briefly highlights,
// then schedule the actual UI transition after the flash duration
// for visual consistency with the other button flashes.
if a.player != nil {
_ = a.player.Stop()
}
a.stopFlash = true
return a, tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
return stopPlaybackMsg{}
})
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()
a.skipBackFlash = true
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
return clearSkipFlashMsg{back: true}
})
return a, clearCmd
}
return a, nil
case "right", "l", "L":
if a.player != nil {
_ = a.player.Next()
a.skipFwdFlash = true
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
return clearSkipFlashMsg{back: false}
})
return a, clearCmd
}
return a, nil
case "up", "down":
if a.player != nil {
isUp := msg.String() == "up"
if isUp {
_ = a.player.VolumeUp()
a.volUpFlash = true
a.currentVolume += 5
if a.currentVolume > 100 {
a.currentVolume = 100
}
} else {
_ = a.player.VolumeDown()
a.volDownFlash = true
a.currentVolume -= 5
if a.currentVolume < 0 {
a.currentVolume = 0
}
}
// Save immediately on user action so it is persisted even if
// the user stops playback before the next poll.
config.SetLastVolume(a.currentVolume)
// If this is a favorited station, also persist the per-station volume.
if a.favs != nil && a.playingItem.station.Url != "" {
if a.favs.Contains(a.playingItem.station.Url) {
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
_ = a.favs.Save()
}
}
// Schedule a message to clear the flash highlight shortly after.
clearCmd := tea.Tick(140*time.Millisecond, func(t time.Time) tea.Msg {
return clearVolFlashMsg{up: isUp}
})
return a, clearCmd
}
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":
config.SetLastVolume(a.currentVolume)
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
// Priority for volume when starting a station:
// 1. Per-favorite saved volume (if this station is in favorites and has one).
// 2. Live session currentVolume (stickiness across s/x for non-favorites or
// favorites that don't have their own saved volume yet).
// 3. Global last volume from the ini.
desired := config.LastVolume()
if a.favs != nil {
if v := a.favs.GetVolume(i.station.Url); v > 0 {
desired = v
}
}
if a.currentVolume > 0 && desired == config.LastVolume() {
// Only fall back to live session value if we didn't have a specific
// per-favorite preference for this station.
desired = a.currentVolume
}
a.currentVolume = desired
if a.player != nil {
// Pass the desired volume as an extra arg for this specific
// playback. For mpv this ensures the new instance starts at
// the right level (overrides any stale --volume in baseArgs).
extra := []string{fmt.Sprintf("--volume=%d", desired)}
// launch in goroutine so TUI doesn't block even if using legacy player
go func() { _ = a.player.Play(i.station.Url, extra...) }()
}
// start polling for streamed metadata and volume (for the vertical bar)
return a, tea.Batch(
metadataPollCmd(a.player),
volumePollCmd(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 {
toAdd := sel.station
// If we're adding a station we recently played (or are playing),
// capture the current volume so it becomes the per-favorite default.
if a.currentVolume > 0 && toAdd.Url == a.playingItem.station.Url {
toAdd.Volume = a.currentVolume
}
a.favs.Add(toAdd)
}
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 volumeMsg:
if a.playing {
old := a.currentVolume
a.currentVolume = msg.volume
if old != a.currentVolume {
// Persist only on actual change. Prevents spamming the ini
// file on every 600ms poll tick.
config.SetLastVolume(a.currentVolume)
}
// If playing a favorited station, persist its per-station volume too.
if a.favs != nil && a.playingItem.station.Url != "" {
if a.favs.Contains(a.playingItem.station.Url) {
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
_ = a.favs.Save()
}
}
}
if a.playing && a.player != nil {
return a, volumePollCmd(a.player)
}
return a, nil
case clearVolFlashMsg:
if msg.up {
a.volUpFlash = false
} else {
a.volDownFlash = false
}
return a, nil
case clearSkipFlashMsg:
if msg.back {
a.skipBackFlash = false
} else {
a.skipFwdFlash = false
}
return a, nil
case stopPlaybackMsg:
// Save the current volume on explicit stop for both global and (if favorite)
// per-station.
config.SetLastVolume(a.currentVolume)
if a.favs != nil && a.playingItem.station.Url != "" {
if a.favs.Contains(a.playingItem.station.Url) {
a.favs.SetVolume(a.playingItem.station.Url, a.currentVolume)
_ = a.favs.Save()
}
}
// Perform the delayed transition out of playback now that the
// stop button flash has been visible.
a.playing = false
a.nowPlaying = ""
a.paused = false
a.muted = false
a.stopFlash = false
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"
}
hint := a.renderHint()
if a.playing {
// Playback view: render a compact "card" (the bordered player UI).
// It is intentionally *not* expanded to fill the terminal.
// We use the terminal dimensions only to *reposition* (center) the card.
card := a.renderPlayback()
hintH := lipgloss.Height(hint)
availH := a.height - hintH
if availH < 1 {
availH = 1
}
// Center the card both horizontally and vertically in the available space
// above the full-width hint bar. This cleans up the player screen by
// floating the winamp-style panel in the middle of the terminal instead
// of left-aligning or stretching it.
centered := lipgloss.Place(
a.width,
availH,
lipgloss.Center,
lipgloss.Center,
card,
lipgloss.WithWhitespaceChars(" "),
)
return centered + hint
}
// List view keeps its natural expanding layout (good for browsing results).
// The hint bar is always anchored full-width at the bottom.
return a.list.View() + "\n" + hint + "\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-15, 48) // leave room for bordered "Now Playing" + bordered volume bar (~4 wide) + gap + outer margins
display := lipgloss.NewStyle().
Background(lipgloss.Color("235")).
Foreground(lipgloss.Color("46")). // classic green lcd
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")). // subtle dark gray border for depth
Width(dispW).
Height(5).
Padding(1, 1).
Align(lipgloss.Left)
// build the metadata content
metaLines := []string{
lipgloss.NewStyle().Bold(true).Render("NOW PLAYING"),
"",
truncate(a.playingItem.station.Name, dispW-2),
}
if a.nowPlaying != "" {
metaLines = append(metaLines, truncate(a.nowPlaying, dispW-2))
} else {
metaLines = append(metaLines, "(waiting for stream metadata...)")
}
metaLines = append(metaLines, truncate(a.playingItem.station.Url, dispW-4))
metadata := display.Render(strings.Join(metaLines, "\n"))
// vertical volume bar to the right of the metadata display.
// We render an inner gauge at (bordered metadata height - 2), then wrap it
// with the same subtle border so the two sit at identical height and have matching depth.
barHeight := lipgloss.Height(metadata)
volInnerHeight := barHeight - 2
if volInnerHeight < 1 {
volInnerHeight = 1
}
volInner := renderVolumeBar(a.currentVolume, volInnerHeight, 2)
volBar := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")). // subtle dark gray border for depth / gauge frame
Render(volInner)
// place side-by-side (top aligned). Slightly increased gap between the two bordered elements.
viewer := lipgloss.JoinHorizontal(lipgloss.Top, metadata, " ", volBar)
// Graphical media control symbols using Unicode (from the Miscellaneous
// Technical block and emoji ranges). These render cleanly in modern
// GPU-accelerated terminals like kitty, WezTerm, iTerm2, Ghostty, etc.
playSymbol := "►"
if a.paused {
playSymbol = "❚❚"
}
muteSymbol := "🔊"
if a.muted {
muteSymbol = "🔇"
}
// Build each symbol as a slightly larger "button" by giving it a fixed
// width + center alignment + padding. This makes the symbols feel bigger
// and more substantial without changing the actual glyph size.
symStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("250"))
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")) // LCD green for active state
// Flash style used momentarily when volume up/down keys are pressed.
// Gives a quick "pressed" visual highlight on the corresponding symbol.
// Using color 63 to match the "GoStations" label and the main outer border.
flashStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("63")).
Bold(true)
makeButton := func(symbol string, active bool) string {
st := symStyle
if active {
st = activeStyle
}
return st.Width(4).Align(lipgloss.Center).Padding(0, 1).Render(symbol)
}
// Volume buttons can flash on key press for feedback.
volDownBtn := makeButton("🔉", false)
if a.volDownFlash {
volDownBtn = flashStyle.Width(4).Align(lipgloss.Center).Padding(0, 1).Render("🔉")
}
volUpBtn := makeButton("🔊", false)
if a.volUpFlash {
volUpBtn = flashStyle.Width(4).Align(lipgloss.Center).Padding(0, 1).Render("🔊")
}
// Skip controls use double pointers in the same geometric style as the
// play symbol (►) so they match the visual weight/brightness of the rest
// of the control row (instead of the bolder technical ⏪/⏩).
skipBack := symStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("◀◀")
if a.skipBackFlash {
skipBack = flashStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("◀◀")
}
skipFwd := symStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("►►")
if a.skipFwdFlash {
skipFwd = flashStyle.Width(5).Align(lipgloss.Center).Padding(0, 1).Render("►►")
}
// Stop button flash for s/x (or X) key presses.
stopBtn := makeButton("⬛", false)
if a.stopFlash {
stopBtn = flashStyle.Width(4).Align(lipgloss.Center).Padding(0, 1).Render("⬛")
}
rawBtnRow := lipgloss.JoinHorizontal(lipgloss.Top,
skipBack, " ",
skipFwd, " ",
makeButton(muteSymbol, true), " ",
makeButton(playSymbol, true), " ",
volDownBtn, " ",
volUpBtn, " ",
stopBtn, " ",
)
// Subtle border around the button row to give it a distinct "panel" feel.
// The bordered area is sized to the natural width of the buttons + minor
// padding (not stretched to the full viewer width), then centered.
buttonPanel := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("238")).
Padding(0, 1).
Render(rawBtnRow)
viewerW := lipgloss.Width(viewer)
buttonPanel = lipgloss.NewStyle().
Width(viewerW).
Align(lipgloss.Center).
Render(buttonPanel)
help := lipgloss.NewStyle().Faint(true).Render("←/→:skip | ↑↓:vol | spc/p:pause | m:mute | s/x:stop")
centeredHelp := lipgloss.NewStyle().
Width(viewerW).
Align(lipgloss.Center).
Render(help)
inner := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("63")).Render("GoStations"),
"",
viewer,
"",
buttonPanel,
centeredHelp,
)
return box.Render(inner)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// renderVolumeBar draws the inner vertical volume indicator bar (the gauge itself).
// It is intended to be wrapped by a subtle border in the caller for visual depth.
// The provided height should be the *inner* height (outer bordered height minus 2).
// 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...)
}
// 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
}
// volumeMsg carries volume level update (0-100).
type volumeMsg struct {
volume int
}
// clearVolFlashMsg is used to turn off the temporary "flash" highlight on the
// volume buttons after a short delay (triggered on ↑/↓ key presses).
type clearVolFlashMsg struct {
up bool // true = volume up button, false = volume down button
}
// clearSkipFlashMsg is used to turn off the temporary "flash" highlight on the
// skip buttons after a short delay (triggered on left/right key presses).
type clearSkipFlashMsg struct {
back bool // true = skip back, false = skip forward
}
// stopPlaybackMsg triggers the actual transition out of the playback view
// (after the stop button has had time to flash for visual feedback).
type stopPlaybackMsg struct{}
// 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
})
}
// 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}
})
}
// 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
}