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 }