926 lines
27 KiB
Go
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
|
|
}
|