529 lines
15 KiB
Go
529 lines
15 KiB
Go
package model
|
|
|
|
import (
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/bjarneo/cliamp/playlist"
|
|
"github.com/bjarneo/cliamp/provider"
|
|
)
|
|
|
|
// handleNavBrowserKey processes key presses while the provider browser is open.
|
|
func (m *Model) handleNavBrowserKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
if m.navBrowser.prov == nil {
|
|
m.navBrowser.visible = false
|
|
return nil
|
|
}
|
|
|
|
key := msg.String()
|
|
|
|
if !m.navBrowser.searching && key == "ctrl+f" {
|
|
m.openProviderSearchWith(m.navBrowser.prov)
|
|
return nil
|
|
}
|
|
|
|
// Shift+letter quick-switch to another provider — only when not typing
|
|
// into the filter, so users can still type capital letters in queries.
|
|
if !m.navBrowser.searching {
|
|
if cmd := m.quickSwitchProvider(key); cmd != nil {
|
|
return cmd
|
|
}
|
|
}
|
|
|
|
// Search bar: active on any list/track screen (not the mode menu).
|
|
if m.navBrowser.mode != navBrowseModeMenu {
|
|
if m.navBrowser.searching {
|
|
return m.handleNavSearchKey(msg)
|
|
}
|
|
|
|
if key == "ctrl+x" {
|
|
m.toggleExpandedView()
|
|
m.navMaybeAdjustScroll()
|
|
return nil
|
|
}
|
|
|
|
if key == "/" {
|
|
// Toggle: if already filtered, clear; otherwise open.
|
|
if m.navBrowser.search != "" {
|
|
m.navClearSearch()
|
|
} else {
|
|
m.navBrowser.searching = true
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch m.navBrowser.mode {
|
|
case navBrowseModeMenu:
|
|
return m.handleNavMenuKey(msg)
|
|
case navBrowseModeByAlbum:
|
|
return m.handleNavByAlbumKey(msg)
|
|
case navBrowseModeByArtist:
|
|
return m.handleNavByArtistKey(msg)
|
|
case navBrowseModeByArtistAlbum:
|
|
return m.handleNavByArtistAlbumKey(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) handleNavMenuKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
const menuItems = 3
|
|
switch msg.String() {
|
|
case "ctrl+x":
|
|
m.toggleExpandedView()
|
|
return nil
|
|
case "ctrl+c":
|
|
m.navBrowser.visible = false
|
|
return m.quit()
|
|
case "up", "k":
|
|
if m.navBrowser.cursor > 0 {
|
|
m.navBrowser.cursor--
|
|
} else {
|
|
m.navBrowser.cursor = menuItems - 1
|
|
}
|
|
case "down", "j":
|
|
if m.navBrowser.cursor < menuItems-1 {
|
|
m.navBrowser.cursor++
|
|
} else {
|
|
m.navBrowser.cursor = 0
|
|
}
|
|
case "enter", "l", "right":
|
|
switch m.navBrowser.cursor {
|
|
case 0: // By Album
|
|
ab, ok := m.navBrowser.prov.(provider.AlbumBrowser)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m.navBrowser.mode = navBrowseModeByAlbum
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navBrowser.albums = nil
|
|
m.navBrowser.albumLoading = true
|
|
m.navBrowser.albumDone = false
|
|
m.navBrowser.loading = false
|
|
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0)
|
|
case 1: // By Artist
|
|
ab, ok := m.navBrowser.prov.(provider.ArtistBrowser)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m.navBrowser.mode = navBrowseModeByArtist
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navBrowser.artists = nil
|
|
m.navBrowser.loading = true
|
|
return fetchNavArtistsCmd(ab)
|
|
case 2: // By Artist / Album
|
|
ab, ok := m.navBrowser.prov.(provider.ArtistBrowser)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m.navBrowser.mode = navBrowseModeByArtistAlbum
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navBrowser.artists = nil
|
|
m.navBrowser.loading = true
|
|
return fetchNavArtistsCmd(ab)
|
|
}
|
|
case "esc", "N", "backspace", "b":
|
|
m.navBrowser.visible = false
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) handleNavByAlbumKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch m.navBrowser.screen {
|
|
case navBrowseScreenList:
|
|
return m.handleNavAlbumListKey(msg, false)
|
|
case navBrowseScreenTracks:
|
|
return m.handleNavTrackListKey(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) handleNavByArtistKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch m.navBrowser.screen {
|
|
case navBrowseScreenList:
|
|
return m.handleNavArtistListKey(msg)
|
|
case navBrowseScreenTracks:
|
|
return m.handleNavTrackListKey(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) handleNavByArtistAlbumKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch m.navBrowser.screen {
|
|
case navBrowseScreenList:
|
|
return m.handleNavArtistListKey(msg)
|
|
case navBrowseScreenAlbums:
|
|
return m.handleNavAlbumListKey(msg, true)
|
|
case navBrowseScreenTracks:
|
|
return m.handleNavTrackListKey(msg)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleNavArtistListKey handles the artist list screen.
|
|
func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
// Determine effective list length (filtered or full).
|
|
listLen := len(m.navBrowser.artists)
|
|
if len(m.navBrowser.searchIdx) > 0 {
|
|
listLen = len(m.navBrowser.searchIdx)
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
m.navBrowser.visible = false
|
|
return m.quit()
|
|
case "up", "k":
|
|
if m.navBrowser.cursor > 0 {
|
|
m.navBrowser.cursor--
|
|
} else if listLen > 0 {
|
|
m.navBrowser.cursor = listLen - 1
|
|
}
|
|
m.navMaybeAdjustScroll()
|
|
case "down", "j":
|
|
if m.navBrowser.cursor < listLen-1 {
|
|
m.navBrowser.cursor++
|
|
} else if listLen > 0 {
|
|
m.navBrowser.cursor = 0
|
|
}
|
|
m.navMaybeAdjustScroll()
|
|
case "enter", "l", "right":
|
|
if m.navBrowser.loading || len(m.navBrowser.artists) == 0 {
|
|
return nil
|
|
}
|
|
ab, ok := m.navBrowser.prov.(provider.ArtistBrowser)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
// Resolve raw index (filtered or direct).
|
|
rawIdx := m.navBrowser.cursor
|
|
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
|
|
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
|
|
}
|
|
artist := m.navBrowser.artists[rawIdx]
|
|
m.navBrowser.selArtist = artist
|
|
m.navBrowser.loading = true
|
|
if m.navBrowser.mode == navBrowseModeByArtistAlbum {
|
|
// Drill into album list for this artist.
|
|
m.navBrowser.albums = nil
|
|
m.navBrowser.albumLoading = false
|
|
m.navBrowser.screen = navBrowseScreenAlbums
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navClearSearch()
|
|
return fetchNavArtistAlbumsCmd(ab, artist.ID)
|
|
}
|
|
m.navClearSearch()
|
|
return m.fetchNavArtistAllTracksCmd(ab, artist.ID)
|
|
case "esc", "h", "left", "backspace":
|
|
// Back to menu.
|
|
m.navClearSearch()
|
|
m.navBrowser.mode = navBrowseModeMenu
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleNavAlbumListKey handles the album list screen.
|
|
// artistAlbums=true means this is the artist's album sub-screen (ArtistAlbum mode), not the global list.
|
|
func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) tea.Cmd {
|
|
// Determine effective list length (filtered or full).
|
|
listLen := len(m.navBrowser.albums)
|
|
if len(m.navBrowser.searchIdx) > 0 {
|
|
listLen = len(m.navBrowser.searchIdx)
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+c":
|
|
m.navBrowser.visible = false
|
|
return m.quit()
|
|
case "up", "k":
|
|
if m.navBrowser.cursor > 0 {
|
|
m.navBrowser.cursor--
|
|
} else if listLen > 0 {
|
|
m.navBrowser.cursor = listLen - 1
|
|
}
|
|
m.navMaybeAdjustScroll()
|
|
case "down", "j":
|
|
if m.navBrowser.cursor < listLen-1 {
|
|
m.navBrowser.cursor++
|
|
m.navMaybeAdjustScroll()
|
|
// Lazy-load next page: only trigger on the raw (unfiltered) list.
|
|
if !artistAlbums && len(m.navBrowser.searchIdx) == 0 && !m.navBrowser.albumLoading && !m.navBrowser.albumDone && m.navBrowser.cursor >= len(m.navBrowser.albums)-10 {
|
|
if ab, ok := m.navBrowser.prov.(provider.AlbumBrowser); ok {
|
|
m.navBrowser.albumLoading = true
|
|
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, len(m.navBrowser.albums))
|
|
}
|
|
}
|
|
} else if listLen > 0 {
|
|
m.navBrowser.cursor = 0
|
|
m.navMaybeAdjustScroll()
|
|
}
|
|
case "enter", "l", "right":
|
|
if (m.navBrowser.loading && !artistAlbums) || len(m.navBrowser.albums) == 0 {
|
|
return nil
|
|
}
|
|
// Resolve raw index (filtered or direct).
|
|
rawIdx := m.navBrowser.cursor
|
|
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
|
|
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
|
|
}
|
|
album := m.navBrowser.albums[rawIdx]
|
|
m.navBrowser.selAlbum = album
|
|
m.navBrowser.loading = true
|
|
m.navClearSearch()
|
|
if l, ok := m.navBrowser.prov.(provider.AlbumTrackLoader); ok {
|
|
return fetchNavAlbumTracksCmd(l, album.ID)
|
|
}
|
|
return nil
|
|
case "s":
|
|
if artistAlbums {
|
|
return nil // Sort only applies to global album list.
|
|
}
|
|
ab, ok := m.navBrowser.prov.(provider.AlbumBrowser)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
m.navBrowser.sortType = navNextSort(m.navBrowser.sortType, ab.AlbumSortTypes())
|
|
m.navBrowser.albums = nil
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navBrowser.albumLoading = true
|
|
m.navBrowser.albumDone = false
|
|
m.navClearSearch()
|
|
if saver, ok := m.navBrowser.prov.(provider.AlbumSortSaver); ok {
|
|
if err := saver.SaveAlbumSort(m.navBrowser.sortType); err != nil {
|
|
m.status.Showf(statusTTLDefault, "Sort save failed: %s", err)
|
|
}
|
|
}
|
|
return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0)
|
|
case "esc", "h", "left", "backspace":
|
|
m.navClearSearch()
|
|
if artistAlbums {
|
|
// Back to artist list.
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
} else {
|
|
// Back to menu.
|
|
m.navBrowser.mode = navBrowseModeMenu
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleNavTrackListKey handles the final track-list screen (used by all modes).
|
|
func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
// Determine effective list length (filtered or full).
|
|
listLen := len(m.navBrowser.tracks)
|
|
if len(m.navBrowser.searchIdx) > 0 {
|
|
listLen = len(m.navBrowser.searchIdx)
|
|
}
|
|
|
|
switch msg.String() {
|
|
case "ctrl+h":
|
|
m.toggleAlbumHeadersManual()
|
|
m.navMaybeAdjustScroll()
|
|
return nil
|
|
case "ctrl+c":
|
|
m.navBrowser.visible = false
|
|
return m.quit()
|
|
case "up", "k":
|
|
if m.navBrowser.cursor > 0 {
|
|
m.navBrowser.cursor--
|
|
} else if listLen > 0 {
|
|
m.navBrowser.cursor = listLen - 1
|
|
}
|
|
m.navMaybeAdjustScroll()
|
|
case "down", "j":
|
|
if m.navBrowser.cursor < listLen-1 {
|
|
m.navBrowser.cursor++
|
|
} else if listLen > 0 {
|
|
m.navBrowser.cursor = 0
|
|
}
|
|
m.navMaybeAdjustScroll()
|
|
case "enter":
|
|
// Play the highlighted track immediately, then enqueue everything from
|
|
// that position to the end of the list (capped at 500 total tracks).
|
|
if len(m.navBrowser.tracks) == 0 {
|
|
return nil
|
|
}
|
|
rawIdx := m.navBrowser.cursor
|
|
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
|
|
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
|
|
}
|
|
if rawIdx < len(m.navBrowser.tracks) {
|
|
const maxAdd = 500
|
|
m.player.Stop()
|
|
m.player.ClearPreload()
|
|
|
|
var toAdd []playlist.Track
|
|
if len(m.navBrowser.searchIdx) > 0 {
|
|
for j := m.navBrowser.cursor; j < len(m.navBrowser.searchIdx) && len(toAdd) < maxAdd; j++ {
|
|
toAdd = append(toAdd, m.navBrowser.tracks[m.navBrowser.searchIdx[j]])
|
|
}
|
|
} else {
|
|
for i := rawIdx; i < len(m.navBrowser.tracks) && len(toAdd) < maxAdd; i++ {
|
|
toAdd = append(toAdd, m.navBrowser.tracks[i])
|
|
}
|
|
}
|
|
|
|
m.playlist.Add(toAdd...)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState(toAdd)
|
|
newIdx := m.playlist.Len() - len(toAdd)
|
|
m.playlist.SetIndex(newIdx)
|
|
m.plCursor = newIdx
|
|
m.adjustScroll()
|
|
if len(toAdd) > 1 {
|
|
m.status.Showf(statusTTLMedium, "Playing: %s (+%d queued)", toAdd[0].DisplayName(), len(toAdd)-1)
|
|
} else {
|
|
m.status.Showf(statusTTLMedium, "Playing: %s", toAdd[0].DisplayName())
|
|
}
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
case "R":
|
|
// Replace playlist with all displayed tracks and close browser.
|
|
tracks := m.navBrowser.tracks
|
|
if len(m.navBrowser.searchIdx) > 0 {
|
|
filtered := make([]playlist.Track, 0, len(m.navBrowser.searchIdx))
|
|
for _, i := range m.navBrowser.searchIdx {
|
|
filtered = append(filtered, m.navBrowser.tracks[i])
|
|
}
|
|
tracks = filtered
|
|
}
|
|
if len(tracks) > 0 {
|
|
m.player.Stop()
|
|
m.player.ClearPreload()
|
|
m.resetYTDLBatch()
|
|
m.playlist.Replace(tracks)
|
|
m.loadedPlaylist = ""
|
|
m.setHeaderStateFromTracks(tracks)
|
|
m.plCursor = 0
|
|
m.plScroll = 0
|
|
m.playlist.SetIndex(0)
|
|
m.focus = focusPlaylist
|
|
m.navBrowser.visible = false
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
case "a":
|
|
// Append all displayed tracks to the playlist (keep current playback).
|
|
tracks := m.navBrowser.tracks
|
|
if len(m.navBrowser.searchIdx) > 0 {
|
|
filtered := make([]playlist.Track, 0, len(m.navBrowser.searchIdx))
|
|
for _, i := range m.navBrowser.searchIdx {
|
|
filtered = append(filtered, m.navBrowser.tracks[i])
|
|
}
|
|
tracks = filtered
|
|
}
|
|
if len(tracks) > 0 {
|
|
wasEmpty := m.playlist.Len() == 0
|
|
m.playlist.Add(tracks...)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState(tracks)
|
|
m.status.Showf(statusTTLMedium, "Added %d tracks", len(tracks))
|
|
if wasEmpty || !m.player.IsPlaying() {
|
|
m.playlist.SetIndex(0)
|
|
cmd := m.playCurrentTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
}
|
|
case "q":
|
|
// Add the highlighted track and queue it to play next.
|
|
if len(m.navBrowser.tracks) == 0 {
|
|
return nil
|
|
}
|
|
rawIdx := m.navBrowser.cursor
|
|
if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) {
|
|
rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor]
|
|
}
|
|
if rawIdx < len(m.navBrowser.tracks) {
|
|
t := m.navBrowser.tracks[rawIdx]
|
|
m.playlist.Add(t)
|
|
m.loadedPlaylist = ""
|
|
m.addToHeaderState([]playlist.Track{t})
|
|
newIdx := m.playlist.Len() - 1
|
|
m.playlist.Queue(newIdx)
|
|
m.status.Showf(statusTTLMedium, "Queued: %s", t.DisplayName())
|
|
if !m.player.IsPlaying() {
|
|
cmd := m.nextTrack()
|
|
m.notifyPlayback()
|
|
return cmd
|
|
}
|
|
}
|
|
case "esc", "h", "left", "backspace":
|
|
// Navigate back one level depending on the mode and how we got here.
|
|
m.navClearSearch()
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
switch m.navBrowser.mode {
|
|
case navBrowseModeByAlbum:
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
case navBrowseModeByArtist:
|
|
m.navBrowser.screen = navBrowseScreenList
|
|
case navBrowseModeByArtistAlbum:
|
|
m.navBrowser.screen = navBrowseScreenAlbums
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleNavSearchKey handles key input while the nav search bar is open.
|
|
func (m *Model) handleNavSearchKey(msg tea.KeyPressMsg) tea.Cmd {
|
|
switch msg.Code {
|
|
case tea.KeyEscape:
|
|
m.navBrowser.searching = false
|
|
return nil
|
|
case tea.KeyEnter:
|
|
m.navBrowser.searching = false
|
|
return nil
|
|
case tea.KeyBackspace, tea.KeyDelete:
|
|
if m.navBrowser.search != "" {
|
|
m.navBrowser.search = removeLastRune(m.navBrowser.search)
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navUpdateSearch()
|
|
}
|
|
return nil
|
|
}
|
|
if len(msg.Text) > 0 {
|
|
m.navBrowser.search += msg.Text
|
|
m.navBrowser.cursor = 0
|
|
m.navBrowser.scroll = 0
|
|
m.navUpdateSearch()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// navNextSort returns the next sort option, wrapping around the list.
|
|
func navNextSort(s string, types []provider.SortType) string {
|
|
for i, t := range types {
|
|
if t.ID == s {
|
|
return types[(i+1)%len(types)].ID
|
|
}
|
|
}
|
|
if len(types) > 0 {
|
|
return types[0].ID
|
|
}
|
|
return s
|
|
}
|
|
|
|
// navMaybeAdjustScroll keeps navCursor visible within the rendered list window.
|
|
func (m *Model) navMaybeAdjustScroll() {
|
|
visible := m.navVisible()
|
|
if m.navBrowser.cursor < m.navBrowser.scroll {
|
|
m.navBrowser.scroll = m.navBrowser.cursor
|
|
}
|
|
if m.navBrowser.cursor >= m.navBrowser.scroll+visible {
|
|
m.navBrowser.scroll = m.navBrowser.cursor - visible + 1
|
|
}
|
|
}
|