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

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
}