Files
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

926 lines
27 KiB
Go

package model
import (
"fmt"
"math"
"slices"
"strings"
"time"
"unicode/utf8"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/theme"
"github.com/bjarneo/cliamp/ui"
)
// titleScrollSep is the separator runes for cyclic title scrolling,
// pre-allocated to avoid per-frame conversion.
var titleScrollSep = []rune(" ♫ ")
// Pre-built styles for elements created per-render to avoid repeated allocation.
var (
seekFillStyle = lipgloss.NewStyle().Foreground(ui.ColorSeekBar)
seekDimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim)
volBarStyle = lipgloss.NewStyle().Foreground(ui.ColorVolume)
activeToggle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true)
)
// providerEmptyStateHint, keyed by lowercase provider Name(), returns the
// remediation hint shown under the generic "No playlists in X" message.
var providerEmptyStateHint = map[string]string{
"local playlists": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"local": "Add .toml playlists to ~/.config/cliamp/playlists/.",
"spotify": "Sign in via Spotify, or check SPOTIFY_REFRESH_TOKEN.",
"navidrome": "Verify [navidrome] url/username/password in config.toml.",
"jellyfin": "Verify [jellyfin] url and token in config.toml.",
"emby": "Verify [emby] url and token or username/password in config.toml.",
"plex": "Verify [plex] server URL and token or library filter in config.toml.",
"youtube music": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"ytmusic": "Run `cliamp ytmusic-login` to authorize, then refresh.",
"soundcloud": "Set [soundcloud] user in config.toml to browse a profile.",
"netease cloud music": "Run `cliamp setup` and configure NetEase browser cookies.",
}
// renderProviderEmptyState explains why the playlists pane is empty for the
// current provider and offers a remediation hint. Always pads to budget so the
// pane height stays stable.
func (m Model) renderProviderEmptyState(budget int) string {
name := "this provider"
if m.provider != nil {
name = m.provider.Name()
}
lines := []string{
dimStyle.Render(fmt.Sprintf(" No playlists in %s.", name)),
"",
}
if _, searchable := m.provider.(provider.Searcher); searchable {
lines = append(lines,
dimStyle.Render(" Press ")+helpKeyStyle.Render(" Ctrl+F ")+dimStyle.Render(" to search."))
}
if m.provider != nil {
if hint, ok := providerEmptyStateHint[strings.ToLower(m.provider.Name())]; ok {
lines = append(lines, dimStyle.Render(" "+hint))
}
}
return strings.Join(fitLines(lines, budget), "\n")
}
// providerRowStyle picks the prefix and style for a provider-list row.
// Cursor takes precedence; "currently loaded" gets the active-track style and
// the ▶ prefix so users can see at a glance which playlist is in the queue.
func (m Model) providerRowStyle(p playlist.PlaylistInfo, isCursor bool) (string, lipgloss.Style) {
if isCursor {
return "> ", playlistSelectedStyle
}
if m.isProviderRowActive(p) {
return "▶ ", playlistActiveStyle
}
return " ", playlistItemStyle
}
// isProviderRowActive reports whether the given playlist is the one whose
// tracks are currently loaded into the player.
func (m Model) isProviderRowActive(p playlist.PlaylistInfo) bool {
if m.activeProviderPlaylistID != "" && m.activeProviderPlaylistID == p.ID {
return true
}
if m.loadedPlaylist != "" && m.loadedPlaylist == p.Name {
return true
}
return false
}
// playlistLabel formats a playlist entry, omitting fields the provider didn't
// supply. Track count and total duration are appended when available.
func playlistLabel(prefix string, p playlist.PlaylistInfo) string {
out := prefix + p.Name
parts := make([]string, 0, 2)
if p.TrackCount > 0 {
parts = append(parts, fmt.Sprintf("%d tracks", p.TrackCount))
}
if d := formatPlaylistDuration(p.DurationSecs); d != "" {
parts = append(parts, d)
}
if len(parts) > 0 {
out += " · " + strings.Join(parts, " · ")
}
return out
}
// View renders the full TUI frame.
func (m Model) View() tea.View {
if m.quitting {
return tea.NewView("")
}
screen := m.activeScreen()
if !screen.hidesVisualizer() {
m.refreshVisualizerIfPending()
}
var content string
switch screen {
case screenFullVisualizer:
content = m.renderFullVisualizer()
default:
// Every overlay renders inline in the playlist region (renderMainBody),
// with its header/help supplied by renderPlaylistHeader / renderHelp, so
// the now-playing + visualizer chrome stays live above and the layout
// height never shifts when an overlay opens.
content = strings.Join(m.mainSections(m.renderMainBody(), true), "\n")
}
// Every screen now renders within the main frame, so frame and center
// uniformly.
rendered := m.centerFrame(ui.FrameStyle.Render(content))
view := tea.NewView(rendered)
view.AltScreen = true
view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues())
return view
}
func trimTrailingEmpty(sections []string) []string {
for len(sections) > 0 && sections[len(sections)-1] == "" {
sections = sections[:len(sections)-1]
}
return sections
}
func (m Model) mainSections(playlist string, includeTransient bool) []string {
sections := []string{
// Now playing
m.renderTitle(),
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
// ui.Visualizer
m.renderSpectrum(),
m.renderSeekBar(),
"",
// Controls
m.renderControls(),
m.renderProviderPill(),
"",
// Playlist
m.renderPlaylistHeader(),
}
if playlist != "" {
sections = append(sections, playlist)
}
sections = append(sections,
"",
// Help
m.renderHelp(),
m.renderBottomStatus(),
)
if includeTransient {
if m.err != nil {
sections = append(sections, errorStyle.Render(fmt.Sprintf("ERR: %s", m.err)))
}
sections = append(sections, m.footerMessages()...)
}
return trimTrailingEmpty(sections)
}
func (m Model) footerMessages() []string {
var lines []string
if text := m.save.activityText(); text != "" {
lines = append(lines, statusStyle.Render(text))
}
if m.status.text != "" {
lines = append(lines, statusStyle.Render(m.status.text))
}
for _, l := range m.logLines {
lines = append(lines, dimStyle.Render(l.text))
}
return lines
}
// centerFrame centers a pre-rendered frame in the terminal using plain string
// padding instead of allocating a new lipgloss.Style every render.
func (m Model) centerFrame(frame string) string {
frameW := lipgloss.Width(frame)
frameH := lipgloss.Height(frame)
padLeft := max(0, (m.width-frameW)/2)
padTop := max(0, (m.height-frameH)/2)
if padLeft == 0 {
return strings.Repeat("\n", padTop) + frame
}
// Indent every line by padLeft spaces.
prefix := strings.Repeat(" ", padLeft)
lines := strings.Split(frame, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Repeat("\n", padTop) + strings.Join(lines, "\n")
}
func (m Model) renderTitle() string {
title := titleStyle.Render("C L I A M P")
label := m.focus.label()
if label == "" {
return title
}
indicator := dimStyle.Render("[" + label + "]")
gap := max(ui.PanelWidth-lipgloss.Width(title)-lipgloss.Width(indicator), 1)
return title + strings.Repeat(" ", gap) + indicator
}
func (m Model) renderTrackInfo() string {
track, _ := m.currentPlaybackTrack()
name := track.DisplayName()
if name == "" {
name = "No track loaded"
}
// Show live ICY stream title instead of static track name for radio streams.
if m.streamTitle != "" && track.Stream {
name = m.streamTitle
}
// Append album to the title line to save vertical space.
// The album is truncated (never scrolled) so artist/song stays readable.
album := track.Album
if m.streamTitle != "" && track.Stream {
album = ""
}
maxW := ui.PanelWidth - 4
if maxW < 1 {
return trackStyle.Render("♫ " + name)
}
nameRunes := []rune(name)
if album != "" {
sep := " · "
sepLen := len([]rune(sep))
remaining := maxW - len(nameRunes) - sepLen
if remaining >= 4 {
name += sep + truncate(album, remaining)
}
// remaining < 4: drop album, name alone fits or scrolls below.
}
runes := []rune(name)
if len(runes) <= maxW {
return trackStyle.Render("♫ " + name)
}
// Cyclic scrolling for long titles (only artist/song, album already handled)
padded := append(runes, titleScrollSep...)
total := len(padded)
off := m.titleOff % total
display := make([]rune, maxW)
for i := range maxW {
display[i] = padded[(off+i)%total]
}
return trackStyle.Render("♫ " + string(display))
}
func (m Model) renderTimeStatus() string {
// Use per-tick cached values to avoid repeated speaker.Lock() calls.
pos := m.cachedPos
dur := m.cachedDur
posMin := int(pos.Minutes())
posSec := int(pos.Seconds()) % 60
durMin := int(dur.Minutes())
durSec := int(dur.Seconds()) % 60
timeStr := fmt.Sprintf("%02d:%02d / %02d:%02d", posMin, posSec, durMin, durSec)
track, _ := m.currentPlaybackTrack()
var status string
switch {
case m.seek.active:
status = statusStyle.Render("⟳ Seeking...")
case m.buffering:
if elapsed := int(time.Since(m.bufferingAt).Seconds()); elapsed > 0 {
status = statusStyle.Render(fmt.Sprintf("◌ Buffering... (%ds)", elapsed))
} else {
status = statusStyle.Render("◌ Buffering...")
}
case m.player.IsPlaying() && m.player.IsPaused():
status = statusStyle.Render("⏸ Paused")
case m.player.IsPlaying() && track.Stream:
status = statusStyle.Render("● Streaming")
case m.player.IsPlaying():
status = statusStyle.Render("▶ Playing")
default:
status = dimStyle.Render("■ Stopped")
}
left := timeStyle.Render(timeStr)
gap := max(ui.PanelWidth-lipgloss.Width(left)-lipgloss.Width(status), 1)
return left + strings.Repeat(" ", gap) + status
}
func (m Model) renderSpectrum() string {
if m.vis.Mode == ui.VisNone {
return ""
}
return m.vis.Render()
}
// renderFullVisualizer renders a full-screen view showing only the visualizer
// with minimal track info and a seek bar.
func (m Model) renderFullVisualizer() string {
sections := []string{
m.renderTrackInfo(),
m.renderTimeStatus(),
"",
m.renderSpectrum(),
m.renderSeekBar(),
"",
helpKey("V", "Exit ") + helpKey("v", "Mode:"+m.vis.ModeName()+" ") + helpKey("Spc", "▶❚❚ ") + helpKey("<>", "Trk ") + helpKey("+-", "Vol"),
}
return strings.Join(sections, "\n")
}
func (m Model) renderSeekBar() string {
if ui.PanelWidth <= 0 {
return ""
}
// During buffering, show a dim bar — avoids speaker.Lock() contention.
if m.buffering {
return seekDimStyle.Render(strings.Repeat("━", ui.PanelWidth))
}
// Show a static streaming bar for non-seekable streams with no known duration.
if !m.player.Seekable() && m.player.IsPlaying() && m.cachedDur == 0 {
label := " STREAMING "
pad := ui.PanelWidth - lipgloss.Width(label)
if pad < 0 {
return seekFillStyle.Render(label[:ui.PanelWidth])
}
left := pad / 2
right := pad - left
return seekFillStyle.Render(strings.Repeat("━", left) + label + strings.Repeat("━", right))
}
pos := m.cachedPos
dur := m.cachedDur
var progress float64
if dur > 0 {
progress = float64(pos) / float64(dur)
}
progress = max(0, min(1, progress))
// Half-cell resolution: each cell is two sub-units, so a 4-minute track on
// an 80-wide bar advances the tip every ~1.5s instead of ~3s. The
// transition cell uses ╸ (heavy left-half) as a half-step stub.
w := ui.PanelWidth
subPos := int(progress * 2 * float64(w))
fullCells := subPos / 2
hasHalf := subPos%2 == 1 && fullCells < w
var b strings.Builder
b.WriteString(seekFillStyle.Render(strings.Repeat("━", fullCells)))
if hasHalf {
b.WriteString(seekFillStyle.Render("╸"))
b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells-1)))
} else {
b.WriteString(seekDimStyle.Render(strings.Repeat("━", w-fullCells)))
}
return b.String()
}
func (m Model) renderControls() string {
// ── EQ [Preset] (left) ····· VOL bar dB [Mono] (right) ──
bands := m.player.EQBands()
presetName := m.EQPresetName()
eqParts := make([]string, 10)
eqLabels := [10]string{"70", "180", "320", "600", "1k", "3k", "6k", "12k", "14k", "16k"}
for i, label := range eqLabels {
style := eqInactiveStyle
if bands[i] != 0 {
label = fmt.Sprintf("%+.0f", bands[i])
}
if m.focus == focusEQ && i == m.eqCursor {
style = eqActiveStyle
}
eqParts[i] = style.Render(label)
}
eqLabel := labelStyle.Render("EQ ")
if m.focus == focusEQ {
eqLabel = activeToggle.Render("EQ ▸ ")
}
left := eqLabel + dimStyle.Render("[") + activeToggle.Render(presetName) + dimStyle.Render("] ") + strings.Join(eqParts, " ")
vol := m.player.Volume()
volMin := m.player.VolumeMin()
frac := max(0, min(1, (vol-volMin)/(6-volMin)))
dbStr := fmt.Sprintf(" %+.0fdB", vol)
monoStr := ""
if m.player.Mono() {
monoStr = " " + activeToggle.Render("[M]")
}
leftW := lipgloss.Width(left)
volLabel := labelStyle.Render("VOL ")
volSuffix := dimStyle.Render(dbStr) + monoStr
volLabelW := lipgloss.Width(volLabel)
volSuffixW := lipgloss.Width(volSuffix)
barW := max(6, (ui.PanelWidth-leftW-2-volLabelW-volSuffixW)*3/4)
filled := int(frac * float64(barW))
bar := volBarStyle.Render(strings.Repeat("█", filled)) +
dimStyle.Render(strings.Repeat("░", barW-filled))
right := volLabel + bar + volSuffix
rightW := lipgloss.Width(right)
gap := max(1, ui.PanelWidth-leftW-rightW)
return left + strings.Repeat(" ", gap) + right
}
func (m Model) renderProviderPill() string {
if len(m.providers) <= 1 {
return ""
}
var pills []string
for i, pe := range m.providers {
name := pe.Name
if m.focus == focusProvPill && i == m.provPillIdx {
pills = append(pills, activeToggle.Render("["+name+"]"))
} else if i == m.provPillIdx {
pills = append(pills, dimStyle.Render("[")+trackStyle.Render(name)+dimStyle.Render("]"))
} else {
pills = append(pills, dimStyle.Render("["+name+"]"))
}
}
srcLabel := labelStyle.Render("SRC ")
if m.focus == focusProvPill {
srcLabel = activeToggle.Render("SRC ▸ ")
}
return srcLabel + strings.Join(pills, " ")
}
func (m Model) renderPlaylistHeader() string {
if ov, ok := m.activeOverlay(); ok {
return ov.header(&m)
}
if m.focus == focusProvider {
return dimStyle.Render(labeledSeparator("", fmt.Sprintf("%s Playlists", m.provider.Name())))
}
var shuffle string
if m.playlist.Shuffled() {
shuffle = activeToggle.Render("[Shuffle]")
} else {
shuffle = dimStyle.Render("[") + trackStyle.Render("Shuffle") + dimStyle.Render("]")
}
repeatVal := m.playlist.Repeat().String()
if m.playlist.Repeat() != 0 {
repeatStr := fmt.Sprintf("[Repeat: %s]", repeatVal)
repeatStr = activeToggle.Render(repeatStr)
shuffle += " " + repeatStr
} else {
repeatStr := dimStyle.Render("[") + trackStyle.Render("Repeat") + dimStyle.Render(": ") + dimStyle.Render(repeatVal) + dimStyle.Render("]")
shuffle += " " + repeatStr
}
var queueStr string
if qLen := m.playlist.QueueLen(); qLen > 0 {
queueStr = " " + activeToggle.Render(fmt.Sprintf("[Queue: %d]", qLen))
}
var bookmarkStr string
if bookmarkCount := m.playlist.BookmarkCount(); bookmarkCount > 0 {
bookmarkStr = " " + activeToggle.Render(fmt.Sprintf("[★ %d]", bookmarkCount))
}
var themeStr string
if name := m.ThemeName(); name != theme.DefaultName {
themeStr = " " + activeToggle.Render("[Theme: "+name+"]")
}
var posStr string
if total := m.playlist.Len(); total > 0 {
posStr = " " + dimStyle.Render(fmt.Sprintf("[%d/%d]", m.playlist.Index()+1, total))
}
headerStyle := dimStyle
headerLabel := "── Playlist ── "
if m.focus == focusPlaylist {
headerStyle = activeToggle
headerLabel = "▸─ Playlist ── "
}
return headerStyle.Render(headerLabel) + shuffle + queueStr + bookmarkStr + posStr + themeStr + " " + dimStyle.Render("──")
}
func (m Model) renderProviderList() string {
visibleBudget := m.effectivePlaylistVisible()
if visibleBudget <= 0 {
return ""
}
if m.provSignIn {
return dimStyle.Render(fmt.Sprintf(" Sign in to %s. Press Enter to continue.", m.provider.Name()))
}
if m.provLoading {
lines := []string{loadingLine(fmt.Sprintf("Loading %s…", m.provider.Name()))}
if m.provAuthURL != "" {
lines = append(lines,
"",
dimStyle.Render(" If your browser didn't open, visit this URL to sign in:"),
" "+m.provAuthURL,
)
}
for len(lines) < visibleBudget {
lines = append(lines, "")
}
return strings.Join(lines, "\n")
}
if len(m.providerLists) == 0 {
return m.renderProviderEmptyState(visibleBudget)
}
sl, isRadio := m.provider.(provider.SectionedList)
var lines []string
if m.provSearch.active {
lines = append(lines, playlistSelectedStyle.Render(" / "+m.provSearch.query+"_"))
if isRadio {
if m.provSearch.query == "" {
lines = append(lines, dimStyle.Render(" Type a station name, Enter to search…"))
} else {
lines = append(lines, dimStyle.Render(" Press Enter to search"))
}
} else {
if m.provSearch.query == "" {
lines = append(lines, dimStyle.Render(" Type to filter…"))
} else if len(m.provSearch.results) == 0 {
lines = append(lines, dimStyle.Render(" No matches"))
} else {
visible := max(0, min(visibleBudget-1, len(m.provSearch.results)))
scroll := m.provSearch.scroll
for j := scroll; j < scroll+visible && j < len(m.provSearch.results); j++ {
idx := m.provSearch.results[j]
p := m.providerLists[idx]
prefix, style := m.providerRowStyle(p, j == m.provSearch.cursor)
lines = append(lines, style.Render(playlistLabel(prefix, p)))
}
lines = append(lines, dimStyle.Render(fmt.Sprintf(" %d/%d playlists", len(m.provSearch.results), len(m.providerLists))))
}
}
} else {
scroll := max(0, m.provScroll)
if scroll >= len(m.providerLists) {
scroll = max(0, len(m.providerLists)-1)
}
if m.provCursor < scroll {
scroll = m.provCursor
}
hasSections := !isRadio && slices.ContainsFunc(m.providerLists, func(p playlist.PlaylistInfo) bool {
return p.Section != ""
})
if isRadio {
for scroll < len(m.providerLists)-1 && m.providerRowsFromScroll(sl, scroll, m.provCursor) > visibleBudget {
scroll++
}
} else if m.provCursor >= scroll+visibleBudget {
scroll = m.provCursor - visibleBudget + 1
}
prevPrefix := ""
if isRadio && scroll > 0 {
prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID)
}
prevSection := ""
if hasSections && scroll > 0 {
prevSection = m.providerLists[scroll-1].Section
}
for j := scroll; j < len(m.providerLists) && len(lines) < visibleBudget; j++ {
p := m.providerLists[j]
if isRadio {
pfx := sl.IDPrefix(p.ID)
if pfx != prevPrefix {
var header string
switch pfx {
case "f":
header = labeledSeparator(" ", "Favorites")
case "c":
header = labeledSeparator(" ", "Catalog")
case "s":
header = labeledSeparator(" ", "Search Results")
}
if header != "" && len(lines) < visibleBudget {
lines = append(lines, dimStyle.Render(header))
}
prevPrefix = pfx
}
} else if hasSections && p.Section != prevSection {
header := labeledSeparator(" ", p.Section)
if len(lines) < visibleBudget {
lines = append(lines, dimStyle.Render(header))
}
prevSection = p.Section
}
if len(lines) >= visibleBudget {
break
}
prefix, style := m.providerRowStyle(p, j == m.provCursor)
lines = append(lines, style.Render(playlistLabel(prefix, p)))
}
}
// Loading indicator for catalog batch (never displace selected row if full).
if isRadio && m.catalogBatch.loading && len(lines) < visibleBudget {
lines = append(lines, loadingLine("Loading more stations…"))
}
return strings.Join(fitLines(lines, visibleBudget), "\n")
}
func (m Model) renderPlaylist() string {
budget := m.effectivePlaylistVisible()
if budget <= 0 {
return ""
}
if m.focus == focusProvider {
return m.renderProviderList()
}
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
var lines []string
if m.feedLoading {
lines = append(lines, loadingLine("Loading feed…"))
} else {
lines = append(lines, dimStyle.Render(" No tracks loaded"))
}
return strings.Join(fitLines(lines, budget), "\n")
}
currentIdx := m.playlist.Index()
scroll := m.playlistScroll(budget)
lines := make([]string, 0, budget)
numWidth := len(fmt.Sprintf("%d", len(tracks)))
for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) {
if row.Index < 0 {
if len(lines)+1 >= budget {
break
}
lines = append(lines, m.albumSeparator(row.Album, row.Year))
continue
}
if len(lines) >= budget {
break
}
i, t := row.Index, row.Track
prefix := " "
style := playlistItemStyle
if i == currentIdx && m.player.IsPlaying() {
prefix = "▶ "
style = playlistActiveStyle
} else if strings.HasPrefix(t.Path, "ssh://") {
prefix = "↗ "
}
if m.focus == focusPlaylist && i == m.plCursor {
style = playlistSelectedStyle
}
if t.Unplayable {
if m.focus == focusPlaylist && i == m.plCursor {
style = dimStyle
} else {
style = playlistUnavailableStyle
}
}
name := t.DisplayName()
isBookmark := t.Bookmark
bookmarkBudget := 0
if isBookmark {
bookmarkBudget = 2 // "★ "
}
queueSuffix := ""
if qp := m.playlist.QueuePosition(i); qp > 0 {
queueSuffix = fmt.Sprintf(" [Q%d]", qp)
}
queueLen := utf8.RuneCountInString(queueSuffix)
linePrefixWidth := utf8.RuneCountInString(prefix) + numWidth + 2 // 2 for ". "
// Truncate the track name only against queue/bookmark overhead, never album.
name = truncate(name, ui.PanelWidth-linePrefixWidth-queueLen-bookmarkBudget)
// Truncate the album to fit whatever space remains after the track name.
albumSuffix := ""
nameLen := utf8.RuneCountInString(name)
if t.Unplayable {
remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen
if remaining >= 4 {
albumSuffix = truncate(" (unavailable)", remaining)
}
} else if album := t.Album; album != "" && !m.showAlbumHeaders {
remaining := ui.PanelWidth - linePrefixWidth - bookmarkBudget - nameLen - queueLen - 3 // 3 = " · "
if remaining >= 4 {
albumSuffix = " · " + truncate(album, remaining)
}
}
numStr := fmt.Sprintf("%s%*d. ", prefix, numWidth, i+1)
line := style.Render(numStr)
if isBookmark {
line += activeToggle.Render("★ ")
}
line += style.Render(name)
if albumSuffix != "" {
line += dimStyle.Render(albumSuffix)
}
if queueSuffix != "" {
line += activeToggle.Render(queueSuffix)
}
lines = append(lines, line)
}
return strings.Join(padLines(lines, budget, len(lines)), "\n")
}
func (m Model) renderHelp() string {
if ov, ok := m.activeOverlay(); ok {
return fitHelpLine(ov.help(&m))
}
if m.focus == focusProvider {
help := helpKey("↓↑", "Scroll ") + helpKey("Enter", "Load ") + helpKey("/", "Search ")
if _, ok := m.provider.(provider.FavoriteToggler); ok {
help += helpKey("f", "Fav ")
}
return help + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys")
}
if m.focus == focusProvPill {
return helpKey("←→", "Select ") + helpKey("Enter", "Open ") + helpKey("Esc", "Back ") + helpKey("Tab", "Focus ") + helpKey("Ctrl+K", "Keys")
}
// Show only the 4-5 most relevant keys per mode; Ctrl+K always anchored for full list.
var hints []helpHint
if m.focus == focusSpeed {
hints = append(hints,
helpHint{helpKey("←→", "Speed "), 100},
helpHint{helpKey("[]", "Speed "), 90},
helpHint{helpKey("Spc", "▶❚❚ "), 80},
helpHint{helpKey("Tab", "Focus "), 70},
helpHint{helpKey("Ctrl+K", "Keys"), 100},
)
} else if m.focus == focusEQ {
hints = append(hints,
helpHint{helpKey("←→", "Band "), 100},
helpHint{helpKey("↓↑", "Gain "), 100},
helpHint{helpKey("e", "Preset "), 90},
helpHint{helpKey("Spc", "▶❚❚ "), 80},
helpHint{helpKey("Tab", "Focus "), 70},
helpHint{helpKey("Ctrl+K", "Keys"), 100},
)
} else {
// focusPlaylist (default)
hints = append(hints,
helpHint{helpKey("↓↑", "Scroll "), 100},
helpHint{helpKey("Enter", "Play "), 100},
helpHint{helpKey("Spc", "▶❚❚ "), 90},
)
track, _ := m.currentPlaybackTrack()
if !track.Stream || m.player.Seekable() {
hints = append(hints, helpHint{helpKey("←→", "Seek "), 80})
}
if m.loadedPlaylist != "" {
hints = append(hints, helpHint{helpKey("f", "Bookmark "), 75})
}
hints = append(hints,
helpHint{helpKey("Tab", "Focus "), 70},
helpHint{helpKey("Ctrl+K", "Keys"), 100},
)
}
return fitHints(hints, ui.PanelWidth)
}
// helpHint is a rendered help key with an associated display priority.
type helpHint struct {
text string
priority int
}
// fitHints drops lowest-priority hints until they fit within maxWidth.
// Widths are pre-computed once to avoid repeated lipgloss.Width calls.
func fitHints(hints []helpHint, maxWidth int) string {
active := make([]bool, len(hints))
widths := make([]int, len(hints))
var total int
for i, h := range hints {
active[i] = true
widths[i] = lipgloss.Width(h.text)
total += widths[i]
}
for total > maxWidth {
// Find lowest-priority active hint and drop it.
minPri := math.MaxInt
minIdx := -1
for i, h := range hints {
if active[i] && h.priority < minPri {
minPri = h.priority
minIdx = i
}
}
if minIdx < 0 {
break
}
active[minIdx] = false
total -= widths[minIdx]
}
var sb strings.Builder
for i, h := range hints {
if active[i] {
sb.WriteString(h.text)
}
}
return sb.String()
}
// renderBottomStatus renders the bottom status line: speed (left) and
// network stats (right) on the same row.
func (m Model) renderBottomStatus() string {
// Left: speed indicator.
speed := m.player.Speed()
if speed == 0 {
speed = 1.0
}
speedVal := fmt.Sprintf("%.2gx", speed)
var left string
speedLabel := labelStyle.Render("SPD ")
if m.focus == focusSpeed {
speedLabel = activeToggle.Render("SPD ▸ ")
left = speedLabel + activeToggle.Render("["+speedVal+"]")
} else if speed != 1.0 {
left = speedLabel + activeToggle.Render("["+speedVal+"]")
} else {
left = speedLabel + dimStyle.Render("[") + trackStyle.Render(speedVal) + dimStyle.Render("]")
}
// Right: network stream stats (empty for local files).
var right string
downloaded, total := m.player.StreamBytes()
if downloaded > 0 || total > 0 {
mb := float64(downloaded) / (1024 * 1024)
if total > 0 {
totalMB := float64(total) / (1024 * 1024)
pct := float64(downloaded) / float64(total) * 100
right = fmt.Sprintf("↓ %.1f / %.1f MB (%.0f%%)", mb, totalMB, pct)
} else {
right = fmt.Sprintf("↓ %.1f MB", mb)
}
if m.network.speed > 0 {
kbs := m.network.speed / 1024
if kbs >= 1024 {
right += fmt.Sprintf(" %.1f MB/s", kbs/1024)
} else {
right += fmt.Sprintf(" %.0f KB/s", kbs)
}
}
right = dimStyle.Render(right)
}
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)
gap := max(1, ui.PanelWidth-leftW-rightW)
if right == "" {
return left
}
return left + strings.Repeat(" ", gap) + right
}