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

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
}
}