23b3222a62
* feat(spotify): return albums from search
/v1/search was asked for type=track,episode, so an album could never appear
in the results. Searching an artist returned whichever of their tracks
Spotify ranked highest, and there was no way to reach a record as a record.
Albums are now requested too and lead the results, as an album placeholder:
a Track carrying the album's name, artist and year, marked through
ProviderMeta so the UI can tell it apart without knowing which provider
produced it. Placeholders are not streamable, because spotify:album: URIs
are not something go-librespot can play, so SpotifyProvider now implements
provider.AlbumTrackLoader to expand a chosen one into its tracks.
/v1/albums/{id}/tracks returns simplified track objects without the album
they belong to, so the album's own metadata is fetched once and filled in
on every track for display.
* feat(ui): play a whole album from the search results
Enter, a and q on an album expand it through AlbumTrackLoader and then act
on the full record, matching what they already did for a single track:
Enter starts it now, a appends it, q queues it next. Like playTrackImmediate
they add rather than replace, so a queue built up over an evening survives
picking an album.
The overlay stays open while the expansion runs, showing "Loading album...":
closing it would bump the request generation and drop the response. The
in-flight flag is separate from the playlist fetch's so the results screen
only claims to be loading an album when it is.
p is refused on an album with an explanation. The playlist picker adds one
track, an album is many, and Spotify has no single call to add a record to
a playlist.
* feat(ui): group search results into labeled sections
With albums and tracks in one flat list an album read exactly like one of
its own tracks. The results now carry "Albums" and "Tracks" separators in
the same style the playlist already uses for album headers, and the label
repeats at the top of the viewport when it opens mid-section.
Separators take rows of their own, so scrolling counts rendered rows rather
than results, the way albumSeparatorRows does for the playlist. Without it
the cursor could sit below the bottom of the window.
271 lines
8.2 KiB
Go
271 lines
8.2 KiB
Go
package model
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
)
|
|
|
|
// handleSpotSearchKey dispatches key presses to the active provider search screen.
|
|
func (m *Model) handleSpotSearchKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
m.closeSpotSearch()
|
|
return m.quit()
|
|
}
|
|
|
|
switch m.spotSearch.screen {
|
|
case spotSearchInput:
|
|
return m.handleSpotSearchInputKey(msg)
|
|
case spotSearchResults:
|
|
return m.handleSpotSearchResultsKey(msg)
|
|
case spotSearchPlaylist:
|
|
return m.handleSpotSearchPlaylistKey(msg)
|
|
case spotSearchNewName:
|
|
return m.handleSpotSearchNewNameKey(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleSpotSearchInputKey handles text input for the search query.
|
|
func (m *Model) handleSpotSearchInputKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch msg.Code {
|
|
case tea.KeyEscape:
|
|
m.closeSpotSearch()
|
|
case tea.KeyEnter:
|
|
if m.spotSearch.query == "" {
|
|
m.spotSearch.err = "Enter a search query."
|
|
return nil
|
|
}
|
|
if !m.spotSearch.loading {
|
|
s, ok := m.spotSearch.prov.(provider.Searcher)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m.spotSearch.loading = true
|
|
m.spotSearch.err = ""
|
|
return fetchSpotSearchCmd(m.newSpotRequestContext(30*time.Second), s, m.spotSearch.prov.Name(), m.spotSearch.query, nextRequest(&m.requests.spotSearch))
|
|
}
|
|
default:
|
|
if m.editText("spot-search", &m.spotSearch.query, msg) {
|
|
m.spotSearch.err = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) spotSearchResultsMaybeAdjustScroll(visible int) {
|
|
clampScroll(&m.spotSearch.cursor, &m.spotSearch.scroll, len(m.spotSearch.results), visible)
|
|
// Section separators take rows of their own, so a window sized purely by
|
|
// result count can push the cursor off the bottom.
|
|
for m.spotSearch.scroll < m.spotSearch.cursor &&
|
|
spotSearchRowsToCursor(m.spotSearch.results, m.spotSearch.scroll, m.spotSearch.cursor) > visible {
|
|
m.spotSearch.scroll++
|
|
}
|
|
}
|
|
|
|
// handleSpotSearchResultsKey handles navigation through search results.
|
|
func (m *Model) handleSpotSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
count := len(m.spotSearch.results)
|
|
|
|
switch msg.String() {
|
|
case "ctrl+x":
|
|
m.toggleExpandedView()
|
|
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
|
|
case "up", "k", "ctrl+p":
|
|
if m.spotSearch.cursor > 0 {
|
|
m.spotSearch.cursor--
|
|
} else if count > 0 {
|
|
m.spotSearch.cursor = count - 1
|
|
}
|
|
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
|
|
case "down", "j", "ctrl+n":
|
|
if m.spotSearch.cursor < count-1 {
|
|
m.spotSearch.cursor++
|
|
} else if count > 0 {
|
|
m.spotSearch.cursor = 0
|
|
}
|
|
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
|
|
case "enter":
|
|
if count > 0 && !m.spotSearchBusy() {
|
|
track := m.spotSearch.results[m.spotSearch.cursor]
|
|
if track.IsAlbum() {
|
|
return m.expandSpotAlbum(track, spotAlbumPlay)
|
|
}
|
|
m.closeSpotSearch()
|
|
return m.playTrackImmediate(track)
|
|
}
|
|
case "a":
|
|
if count > 0 && !m.spotSearchBusy() {
|
|
track := m.spotSearch.results[m.spotSearch.cursor]
|
|
if track.IsAlbum() {
|
|
return m.expandSpotAlbum(track, spotAlbumAppend)
|
|
}
|
|
m.closeSpotSearch()
|
|
return m.appendTrack(track)
|
|
}
|
|
case "q":
|
|
if count > 0 && !m.spotSearchBusy() {
|
|
track := m.spotSearch.results[m.spotSearch.cursor]
|
|
if track.IsAlbum() {
|
|
return m.expandSpotAlbum(track, spotAlbumQueueNext)
|
|
}
|
|
m.closeSpotSearch()
|
|
return m.queueTrackNext(track)
|
|
}
|
|
case "p":
|
|
if count > 0 && !m.spotSearchBusy() {
|
|
track := m.spotSearch.results[m.spotSearch.cursor]
|
|
// The playlist picker adds one track; an album is many, and Spotify
|
|
// has no single call to add a whole record.
|
|
if track.IsAlbum() {
|
|
m.spotSearch.err = "Open the album with Enter, then add tracks from the queue."
|
|
return nil
|
|
}
|
|
m.spotSearch.selTrack = track
|
|
m.spotSearch.loading = true
|
|
m.spotSearch.err = ""
|
|
return fetchSpotPlaylistsCmd(m.spotSearch.prov, nextRequest(&m.requests.spotLists))
|
|
}
|
|
case "esc", "backspace":
|
|
m.spotSearch.screen = spotSearchInput
|
|
m.spotSearch.err = ""
|
|
case "ctrl+u":
|
|
step := m.spotSearchResultsVisible()
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
if m.spotSearch.cursor >= step {
|
|
m.spotSearch.cursor -= step
|
|
} else {
|
|
m.spotSearch.cursor = 0
|
|
}
|
|
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
|
|
case "ctrl+d":
|
|
step := m.spotSearchResultsVisible()
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.spotSearch.cursor += step
|
|
if m.spotSearch.cursor >= count {
|
|
m.spotSearch.cursor = max(0, count-1)
|
|
}
|
|
m.spotSearchResultsMaybeAdjustScroll(m.spotSearchResultsVisible())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// spotSearchBusy reports whether a request for the results screen is in flight,
|
|
// covering both the playlist fetch and an album expansion.
|
|
func (m *Model) spotSearchBusy() bool {
|
|
return m.spotSearch.loading || m.spotSearch.albumLoading
|
|
}
|
|
|
|
// expandSpotAlbum fetches the tracks of the selected album placeholder. The
|
|
// overlay stays open while it runs: closing it would bump the request
|
|
// generation and drop the response.
|
|
func (m *Model) expandSpotAlbum(album playlist.Track, action spotAlbumAction) tea.Cmd {
|
|
loader, ok := m.spotSearch.prov.(provider.AlbumTrackLoader)
|
|
if !ok {
|
|
m.spotSearch.err = "This provider cannot open albums."
|
|
return nil
|
|
}
|
|
m.spotSearch.albumLoading = true
|
|
m.spotSearch.err = ""
|
|
return fetchSpotAlbumTracksCmd(loader, album, action, nextRequest(&m.requests.spotAlbum))
|
|
}
|
|
|
|
func (m *Model) spotSearchPlaylistMaybeAdjustScroll(visible int) {
|
|
count := len(m.spotSearch.playlists) + 1
|
|
clampScroll(&m.spotSearch.cursor, &m.spotSearch.scroll, count, max(1, visible-1))
|
|
}
|
|
|
|
// handleSpotSearchPlaylistKey handles picking a playlist to add to.
|
|
func (m *Model) handleSpotSearchPlaylistKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
count := len(m.spotSearch.playlists) + 1 // +1 for "+ New Playlist..."
|
|
|
|
switch msg.String() {
|
|
case "ctrl+x":
|
|
m.toggleExpandedView()
|
|
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
|
|
case "up", "k":
|
|
if m.spotSearch.cursor > 0 {
|
|
m.spotSearch.cursor--
|
|
} else if count > 0 {
|
|
m.spotSearch.cursor = count - 1
|
|
}
|
|
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
|
|
case "down", "j":
|
|
if m.spotSearch.cursor < count-1 {
|
|
m.spotSearch.cursor++
|
|
} else if count > 0 {
|
|
m.spotSearch.cursor = 0
|
|
}
|
|
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
|
|
case "enter":
|
|
if m.spotSearch.loading {
|
|
return nil
|
|
}
|
|
w, ok := m.spotSearch.prov.(provider.PlaylistWriter)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if m.spotSearch.cursor < len(m.spotSearch.playlists) {
|
|
// Add to existing playlist.
|
|
pl := m.spotSearch.playlists[m.spotSearch.cursor]
|
|
// Skip "Your Music" — uses a different endpoint.
|
|
if pl.ID == "YOUR MUSIC" {
|
|
return nil
|
|
}
|
|
m.spotSearch.loading = true
|
|
m.spotSearch.err = ""
|
|
return addToSpotPlaylistCmd(m.newSpotRequestContext(15*time.Second), w, pl.ID, m.spotSearch.selTrack, m.spotSearch.prov.Name(), pl.Name, nextRequest(&m.requests.spotMutation))
|
|
}
|
|
// "+ New Playlist..." selected.
|
|
m.spotSearch.screen = spotSearchNewName
|
|
m.spotSearch.newName = ""
|
|
m.spotSearch.cursor = 0
|
|
m.spotSearch.scroll = 0
|
|
case "esc", "backspace":
|
|
m.spotSearch.screen = spotSearchResults
|
|
m.spotSearch.cursor = 0
|
|
m.spotSearch.scroll = 0
|
|
m.spotSearch.err = ""
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleSpotSearchNewNameKey handles text input for new playlist name.
|
|
func (m *Model) handleSpotSearchNewNameKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch msg.Code {
|
|
case tea.KeyEscape:
|
|
m.spotSearch.screen = spotSearchPlaylist
|
|
m.spotSearch.cursor = len(m.spotSearch.playlists)
|
|
m.spotSearchPlaylistMaybeAdjustScroll(m.spotSearchPlaylistVisible())
|
|
case tea.KeyEnter:
|
|
if strings.TrimSpace(m.spotSearch.newName) == "" {
|
|
m.spotSearch.err = "Playlist name is required."
|
|
return nil
|
|
}
|
|
if !m.spotSearch.loading {
|
|
c, cOk := m.spotSearch.prov.(provider.PlaylistCreator)
|
|
w, wOk := m.spotSearch.prov.(provider.PlaylistWriter)
|
|
if !cOk || !wOk {
|
|
return nil
|
|
}
|
|
m.spotSearch.loading = true
|
|
m.spotSearch.err = ""
|
|
return createSpotPlaylistCmd(m.newSpotRequestContext(15*time.Second), c, w, m.spotSearch.prov.Name(), m.spotSearch.newName, m.spotSearch.selTrack, nextRequest(&m.requests.spotMutation))
|
|
}
|
|
default:
|
|
if m.editText("spot-playlist-name", &m.spotSearch.newName, msg) {
|
|
m.spotSearch.err = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|