507 lines
17 KiB
Go
507 lines
17 KiB
Go
package model
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/bjarneo/cliamp/lyrics"
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/theme"
|
|
"github.com/bjarneo/cliamp/ui"
|
|
)
|
|
|
|
// Inline overlays render in the playlist region while the now-playing,
|
|
// visualizer, and controls chrome stays live above them. Each overlay supplies
|
|
// three pieces, all the same vertical size as the normal playlist chrome so
|
|
// opening an overlay never shifts the layout height:
|
|
//
|
|
// - a header line (via overlayHeaderLine, used by renderPlaylistHeader)
|
|
// - a body (via overlayBody, fills effectivePlaylistVisible rows)
|
|
// - a help line (via overlayHelpLine, used by renderHelp)
|
|
//
|
|
// The switches below are ordered to match activeScreen so the header, body, and
|
|
// help always describe the same overlay.
|
|
|
|
// — shared header/body helpers —
|
|
|
|
// sepHeader renders a labeled separator. The label is embedded before the "─"
|
|
// fill, so separatorLine truncates it to the panel width: it never wraps.
|
|
func sepHeader(label string) string {
|
|
return dimStyle.Render(labeledSeparator("", label))
|
|
}
|
|
|
|
// sepHeaderN appends an "n/total" position counter to a separator label.
|
|
func sepHeaderN(label string, pos, total int) string {
|
|
if total <= 0 {
|
|
return sepHeader(label)
|
|
}
|
|
return sepHeader(fmt.Sprintf("%s %d/%d", label, pos, total))
|
|
}
|
|
|
|
// promptHeader renders an editable "label: value_" input as the header line,
|
|
// truncated to the panel width.
|
|
func promptHeader(label, value string) string {
|
|
return playlistSelectedStyle.Render(truncate(" "+label+": "+value+"_", ui.PanelWidth))
|
|
}
|
|
|
|
// filterPromptHeader renders a `/` filter input as the header line.
|
|
func filterPromptHeader(query string) string {
|
|
return playlistSelectedStyle.Render(truncate(" / "+query+"_", ui.PanelWidth))
|
|
}
|
|
|
|
// filterCountHeader renders a `/` filter prompt with a trailing match count,
|
|
// kept to one panel-wide row by clipping the query to leave room for the count.
|
|
func filterCountHeader(query, count string) string {
|
|
maxPrompt := max(1, ui.PanelWidth-len(count)-2)
|
|
return playlistSelectedStyle.Render(truncate(" / "+query+"_", maxPrompt)) + dimStyle.Render(" "+count)
|
|
}
|
|
|
|
// windowList renders items[scroll:] into at most budget rows, applying the
|
|
// cursor highlight via cursorLine.
|
|
func windowList(items []string, cursor, scroll, budget int) string {
|
|
if budget <= 0 {
|
|
return ""
|
|
}
|
|
lines := make([]string, 0, budget)
|
|
for i := scroll; i < len(items) && len(lines) < budget; i++ {
|
|
lines = append(lines, cursorLine(items[i], i == cursor))
|
|
}
|
|
return strings.Join(padLines(lines, budget, len(lines)), "\n")
|
|
}
|
|
|
|
// bodyLines fits pre-built lines into the budget (truncate + pad to budget).
|
|
func bodyLines(lines []string, budget int) string {
|
|
if budget <= 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(fitLines(lines, budget), "\n")
|
|
}
|
|
|
|
// bodyMessage renders a single dim message line into the budget.
|
|
func bodyMessage(msg string, budget int) string {
|
|
return bodyLines([]string{dimStyle.Render(" " + msg)}, budget)
|
|
}
|
|
|
|
// renderTrackRowsBody renders a track list with album-header separators into
|
|
// the playlist-region budget, highlighting the row at cursor. Shared by the
|
|
// nav browser and playlist manager unfiltered track views.
|
|
func (m Model) renderTrackRowsBody(tracks []playlist.Track, cursor, scroll, budget int) string {
|
|
lines := make([]string, 0, budget)
|
|
for row := range m.playlistRows(tracks, scroll, m.showAlbumHeaders) {
|
|
if len(lines) >= budget {
|
|
break
|
|
}
|
|
if row.Index < 0 {
|
|
lines = append(lines, m.albumSeparator(row.Album, row.Year))
|
|
continue
|
|
}
|
|
i, t := row.Index, row.Track
|
|
label := formatTrackRow(i+1, t.DisplayName()+trackAlbumSuffix(t, m.showAlbumHeaders), t.DurationSecs)
|
|
lines = append(lines, cursorLine(label, i == cursor))
|
|
}
|
|
return bodyLines(lines, budget)
|
|
}
|
|
|
|
// — dispatch —
|
|
|
|
// overlayView bundles the three render pieces of an inline overlay: the header
|
|
// line (shown where the playlist header is), the help line, and the body that
|
|
// fills the playlist region. The pieces are method expressions (func(*Model)),
|
|
// not bound method values, so building an overlayView does not copy the Model
|
|
// onto the heap on the render hot path.
|
|
type overlayView struct {
|
|
header func(*Model) string
|
|
help func(*Model) string
|
|
body func(*Model) string
|
|
}
|
|
|
|
// activeOverlay returns the render pieces for the active inline overlay, or
|
|
// ok=false when no overlay is open (the normal playlist is shown). Describing
|
|
// each overlay once here keeps its header, help, and body in sync, and the
|
|
// cases are ordered to match activeScreen. renderPlaylistHeader, renderHelp,
|
|
// and renderMainBody each call this and invoke the piece they need with &m.
|
|
func (m Model) activeOverlay() (overlayView, bool) {
|
|
switch {
|
|
case m.keymap.visible:
|
|
return overlayView{(*Model).keymapHeaderLine, (*Model).keymapHelpLine, (*Model).renderKeymapList}, true
|
|
case m.themePicker.visible:
|
|
return overlayView{
|
|
func(m *Model) string { return sepHeaderN("Themes", m.themePicker.cursor+1, m.themeCount()) },
|
|
(*Model).themePickerHelpLine, (*Model).renderThemeBody}, true
|
|
case m.visPicker.visible:
|
|
return overlayView{
|
|
func(m *Model) string { return sepHeaderN("Visualizers", m.visPicker.cursor+1, len(m.visPicker.modes)) },
|
|
(*Model).visPickerHelpLine, (*Model).renderVisPickerList}, true
|
|
case m.devicePicker.visible:
|
|
return overlayView{(*Model).deviceHeaderLine, (*Model).devicePickerHelpLine, (*Model).renderDeviceBody}, true
|
|
case m.plPicker.visible:
|
|
return overlayView{(*Model).plPickerHeaderLine, (*Model).plPickerHelpLine, (*Model).renderPlaylistPickerBody}, true
|
|
case m.fileBrowser.visible:
|
|
return overlayView{(*Model).fbHeaderLine, (*Model).fbHelpLine, (*Model).renderFileBrowserBody}, true
|
|
case m.navBrowser.visible:
|
|
return overlayView{(*Model).navHeaderLine, (*Model).navHelpLine, (*Model).renderNavBody}, true
|
|
case m.plManager.visible:
|
|
return overlayView{(*Model).plMgrHeaderLine, (*Model).plMgrHelpLine, (*Model).renderPlMgrBody}, true
|
|
case m.spotSearch.visible:
|
|
return overlayView{(*Model).spotSearchHeaderLine, (*Model).spotSearchHelpLine, (*Model).renderSpotSearchBody}, true
|
|
case m.queue.visible:
|
|
return overlayView{
|
|
func(m *Model) string { return sepHeaderN("Queue", m.queue.cursor+1, m.playlist.QueueLen()) },
|
|
(*Model).queueHelpLine, (*Model).renderQueueBody}, true
|
|
case m.showInfo:
|
|
return overlayView{
|
|
func(*Model) string { return sepHeader("Track Info") },
|
|
func(*Model) string { return helpKey("Esc", "Close") },
|
|
(*Model).renderInfoBody}, true
|
|
case m.search.active:
|
|
return overlayView{(*Model).searchHeaderLine, (*Model).searchHelpLine, (*Model).renderSearchList}, true
|
|
case m.netSearch.active:
|
|
return overlayView{(*Model).netSearchHeaderLine, (*Model).netSearchHelpLine, (*Model).renderNetSearchBody}, true
|
|
case m.urlInputting:
|
|
return overlayView{
|
|
func(m *Model) string { return promptHeader("Load URL", m.urlInput) },
|
|
func(*Model) string { return helpKey("Enter", "Load ") + helpKey("Esc", "Cancel") },
|
|
(*Model).renderURLBody}, true
|
|
case m.lyrics.visible:
|
|
return overlayView{
|
|
func(*Model) string { return sepHeader("Lyrics") },
|
|
(*Model).lyricsHelpLine, (*Model).renderLyricsBody}, true
|
|
case m.jumping:
|
|
return overlayView{
|
|
func(*Model) string { return sepHeader("Jump to Time") },
|
|
func(*Model) string { return helpKey("Enter", "Jump ") + helpKey("Esc", "Cancel") },
|
|
(*Model).renderJumpBody}, true
|
|
}
|
|
return overlayView{}, false
|
|
}
|
|
|
|
// renderMainBody returns the active overlay's body, or the playlist when no
|
|
// overlay is open.
|
|
func (m Model) renderMainBody() string {
|
|
if ov, ok := m.activeOverlay(); ok {
|
|
return ov.body(&m)
|
|
}
|
|
return m.renderPlaylist()
|
|
}
|
|
|
|
// — search —
|
|
|
|
func (m Model) searchHeaderLine() string {
|
|
return filterCountHeader(m.search.query, m.formatListMatchCount(len(m.search.results), m.playlist.Len()))
|
|
}
|
|
|
|
// — theme picker —
|
|
|
|
func (m Model) themeCount() int { return len(m.themes) + 1 }
|
|
|
|
func (m Model) renderThemeBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
items := make([]string, 0, m.themeCount())
|
|
items = append(items, theme.DefaultName)
|
|
for _, t := range m.themes {
|
|
items = append(items, t.Name)
|
|
}
|
|
return windowList(items, m.themePicker.cursor, m.themePicker.scroll, budget)
|
|
}
|
|
|
|
// — device picker —
|
|
|
|
func (m Model) deviceHeaderLine() string {
|
|
if m.devicePicker.loading {
|
|
return sepHeader("Audio Devices")
|
|
}
|
|
return sepHeaderN("Audio Devices", m.devicePicker.cursor+1, len(m.devicePicker.devices))
|
|
}
|
|
|
|
func (m Model) renderDeviceBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
if m.devicePicker.loading {
|
|
return bodyLines([]string{loadingLine("Loading devices…")}, budget)
|
|
}
|
|
if len(m.devicePicker.devices) == 0 {
|
|
return bodyMessage("No audio output devices found.", budget)
|
|
}
|
|
items := make([]string, len(m.devicePicker.devices))
|
|
for i, d := range m.devicePicker.devices {
|
|
label := d.Description
|
|
if label == "" {
|
|
label = d.Name
|
|
}
|
|
if d.Active {
|
|
label += " " + activeToggle.Render("●")
|
|
}
|
|
items[i] = label
|
|
}
|
|
return windowList(items, m.devicePicker.cursor, m.devicePicker.scroll, budget)
|
|
}
|
|
|
|
// — queue —
|
|
|
|
func (m Model) renderQueueBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
tracks := m.playlist.QueueTracks()
|
|
if len(tracks) == 0 {
|
|
return bodyMessage("(empty)", budget)
|
|
}
|
|
items := make([]string, len(tracks))
|
|
for i, t := range tracks {
|
|
items[i] = fmt.Sprintf("%d. %s", i+1, truncate(t.DisplayName(), ui.PanelWidth-8))
|
|
}
|
|
return windowList(items, m.queue.cursor, m.queue.scroll, budget)
|
|
}
|
|
|
|
// — track info —
|
|
|
|
func (m Model) renderInfoBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
track, _ := m.currentPlaybackTrack()
|
|
|
|
var lines []string
|
|
field := func(label, value string) {
|
|
if value != "" {
|
|
lines = append(lines, dimStyle.Render(" "+label+": ")+trackStyle.Render(value))
|
|
}
|
|
}
|
|
field("Title", track.Title)
|
|
field("Artist", track.Artist)
|
|
field("Album", track.Album)
|
|
field("Genre", track.Genre)
|
|
if track.Year != 0 {
|
|
field("Year", fmt.Sprintf("%d", track.Year))
|
|
}
|
|
if track.TrackNumber != 0 {
|
|
field("Track", fmt.Sprintf("%d", track.TrackNumber))
|
|
}
|
|
field("Path", track.Path)
|
|
if len(lines) == 0 {
|
|
lines = append(lines, dimStyle.Render(" No track metadata available."))
|
|
}
|
|
return bodyLines(lines, budget)
|
|
}
|
|
|
|
// — URL input —
|
|
|
|
func (m Model) renderURLBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
return bodyMessage("Paste a stream, track, or playlist URL above.", budget)
|
|
}
|
|
|
|
// — jump to time —
|
|
|
|
func (m Model) renderJumpBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
pos := m.player.Position()
|
|
dur := m.player.Duration()
|
|
inputLine := dimStyle.Faint(true).Render(" " + formatJumpPlaceholder(dur))
|
|
if m.jumpInput != "" {
|
|
inputLine = playlistSelectedStyle.Render(" " + m.jumpInput + "_")
|
|
}
|
|
return bodyLines([]string{
|
|
dimStyle.Render(fmt.Sprintf(" %s / %s", formatJumpClock(pos), formatJumpClock(dur))),
|
|
"",
|
|
inputLine,
|
|
}, budget)
|
|
}
|
|
|
|
// — lyrics —
|
|
|
|
func (m Model) lyricsHelpLine() string {
|
|
if m.lyricsSyncable() && m.lyricsHaveTimestamps() {
|
|
return helpKey("Esc", "Close")
|
|
}
|
|
return helpKey("↓↑", "Scroll ") + helpKey("Esc", "Close")
|
|
}
|
|
|
|
func (m Model) renderLyricsBody() string {
|
|
visible := m.effectivePlaylistVisible()
|
|
if visible <= 0 {
|
|
return ""
|
|
}
|
|
|
|
var lines []string
|
|
switch {
|
|
case m.lyrics.loading:
|
|
lines = append(lines, dimStyle.Render(" Searching for lyrics..."))
|
|
case m.lyrics.err != nil:
|
|
if errors.Is(m.lyrics.err, lyrics.ErrNotFound) {
|
|
lines = append(lines, dimStyle.Render(" No lyrics found for this track."))
|
|
} else {
|
|
lines = append(lines, helpStyle.Render(" Lyrics fetch failed: "+m.lyrics.err.Error()))
|
|
}
|
|
case len(m.lyrics.lines) == 0:
|
|
artist, title := m.lyricsArtistTitle()
|
|
if artist == "" && title == "" {
|
|
lines = append(lines, dimStyle.Render(" No artist/title metadata available."))
|
|
if track, idx := m.currentPlaybackTrack(); idx >= 0 && track.Stream {
|
|
lines = append(lines, dimStyle.Render(" Waiting for stream metadata..."))
|
|
}
|
|
} else {
|
|
lines = append(lines, dimStyle.Render(" No lyrics loaded. Press y to retry."))
|
|
}
|
|
case m.lyricsSyncable() && m.lyricsHaveTimestamps():
|
|
pos := m.player.Position()
|
|
activeIdx := -1
|
|
for i, line := range m.lyrics.lines {
|
|
if line.Start <= pos {
|
|
activeIdx = i
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
half := visible / 2
|
|
startIdx := max(activeIdx-half, 0)
|
|
endIdx := startIdx + visible
|
|
if endIdx > len(m.lyrics.lines) {
|
|
endIdx = len(m.lyrics.lines)
|
|
startIdx = max(endIdx-visible, 0)
|
|
}
|
|
for i := startIdx; i < endIdx; i++ {
|
|
text := m.lyrics.lines[i].Text
|
|
if text == "" {
|
|
text = "♪"
|
|
}
|
|
if i == activeIdx {
|
|
lines = append(lines, playlistSelectedStyle.Render(" "+text))
|
|
} else {
|
|
lines = append(lines, dimStyle.Render(" "+text))
|
|
}
|
|
}
|
|
default:
|
|
endIdx := min(m.lyrics.scroll+visible, len(m.lyrics.lines))
|
|
for i := m.lyrics.scroll; i < endIdx; i++ {
|
|
text := m.lyrics.lines[i].Text
|
|
if text == "" {
|
|
text = "♪"
|
|
}
|
|
lines = append(lines, dimStyle.Render(" "+text))
|
|
}
|
|
}
|
|
return bodyLines(lines, visible)
|
|
}
|
|
|
|
// — online (net) search —
|
|
|
|
func (m Model) netSearchSource() string {
|
|
if m.netSearch.soundcloud {
|
|
return "SoundCloud"
|
|
}
|
|
return "YouTube"
|
|
}
|
|
|
|
func (m Model) netSearchHeaderLine() string {
|
|
if m.netSearch.screen == netSearchResults {
|
|
return sepHeaderN("Online Results", m.netSearch.cursor+1, len(m.netSearch.results))
|
|
}
|
|
return promptHeader(m.netSearchSource()+" search", m.netSearch.query)
|
|
}
|
|
|
|
func (m Model) netSearchHelpLine() string {
|
|
if m.netSearch.screen == netSearchResults {
|
|
return m.netSearchResultsHelpLine()
|
|
}
|
|
return helpKey("Enter", "Search ") + helpKey("Esc", "Cancel")
|
|
}
|
|
|
|
func (m Model) renderNetSearchBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
if m.netSearch.screen == netSearchInput {
|
|
var lines []string
|
|
if m.netSearch.loading {
|
|
lines = append(lines, dimStyle.Render(" Searching "+m.netSearchSource()+"..."))
|
|
} else {
|
|
lines = append(lines, dimStyle.Render(" Type a query and press Enter to search "+m.netSearchSource()+"."))
|
|
}
|
|
if m.netSearch.err != "" {
|
|
lines = append(lines, "", helpStyle.Render(" "+m.netSearch.err))
|
|
}
|
|
return bodyLines(lines, budget)
|
|
}
|
|
|
|
if len(m.netSearch.results) == 0 {
|
|
return bodyMessage("No results", budget)
|
|
}
|
|
items := make([]string, len(m.netSearch.results))
|
|
for i, t := range m.netSearch.results {
|
|
items[i] = truncate(t.DisplayName(), ui.PanelWidth-8)
|
|
}
|
|
return windowList(items, m.netSearch.cursor, m.netSearch.scroll, budget)
|
|
}
|
|
|
|
// — provider (Spotify) search —
|
|
|
|
func (m Model) spotSearchHeaderLine() string {
|
|
switch m.spotSearch.screen {
|
|
case spotSearchResults:
|
|
return sepHeaderN("Results", m.spotSearch.cursor+1, len(m.spotSearch.results))
|
|
case spotSearchPlaylist:
|
|
return sepHeaderN("Add to Playlist", m.spotSearch.cursor+1, len(m.spotSearch.playlists)+1)
|
|
case spotSearchNewName:
|
|
return promptHeader("New Playlist", m.spotSearch.newName)
|
|
default:
|
|
return promptHeader("Search", m.spotSearch.query)
|
|
}
|
|
}
|
|
|
|
func (m Model) spotSearchHelpLine() string {
|
|
switch m.spotSearch.screen {
|
|
case spotSearchResults:
|
|
return m.spotSearchResultsHelpLine()
|
|
case spotSearchPlaylist:
|
|
return m.spotSearchPlaylistHelpLine()
|
|
case spotSearchNewName:
|
|
return helpKey("Enter", "Create & add ") + helpKey("Esc", "Cancel")
|
|
default:
|
|
return helpKey("Enter", "Search ") + helpKey("Esc", "Cancel")
|
|
}
|
|
}
|
|
|
|
func (m Model) renderSpotSearchBody() string {
|
|
budget := m.effectivePlaylistVisible()
|
|
var body string
|
|
switch m.spotSearch.screen {
|
|
case spotSearchResults:
|
|
if len(m.spotSearch.results) == 0 {
|
|
body = bodyMessage("No results", budget)
|
|
} else {
|
|
items := make([]string, len(m.spotSearch.results))
|
|
for i, t := range m.spotSearch.results {
|
|
items[i] = truncate(fmt.Sprintf("%s - %s", t.Artist, t.Title), ui.PanelWidth-8)
|
|
}
|
|
body = windowList(items, m.spotSearch.cursor, m.spotSearch.scroll, budget)
|
|
}
|
|
case spotSearchPlaylist:
|
|
if m.spotSearch.loading {
|
|
body = bodyLines([]string{loadingLine("Loading playlists…")}, budget)
|
|
break
|
|
}
|
|
track := m.spotSearch.selTrack
|
|
head := dimStyle.Render(" " + truncate(fmt.Sprintf("%s - %s", track.Artist, track.Title), ui.PanelWidth-2))
|
|
count := len(m.spotSearch.playlists) + 1
|
|
items := make([]string, count)
|
|
for i := range count {
|
|
if i < len(m.spotSearch.playlists) {
|
|
items[i] = m.spotSearch.playlists[i].Name
|
|
} else {
|
|
items[i] = "+ New Playlist..."
|
|
}
|
|
}
|
|
list := windowList(items, m.spotSearch.cursor, m.spotSearch.scroll, max(0, budget-1))
|
|
body = strings.Join([]string{head, list}, "\n")
|
|
case spotSearchNewName:
|
|
body = bodyMessage("Enter a name for the new playlist above.", budget)
|
|
default:
|
|
var lines []string
|
|
if m.spotSearch.loading {
|
|
lines = append(lines, dimStyle.Render(" Searching..."))
|
|
} else {
|
|
lines = append(lines, dimStyle.Render(" Type a query and press Enter to search."))
|
|
}
|
|
body = bodyLines(lines, budget)
|
|
}
|
|
if m.spotSearch.err != "" && m.spotSearch.screen != spotSearchPlaylist {
|
|
return strings.Join([]string{body, helpStyle.Render(" " + m.spotSearch.err)}, "\n")
|
|
}
|
|
return body
|
|
}
|