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

2489 lines
63 KiB
Go

package model
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/internal/fileutil"
"github.com/bjarneo/cliamp/playlist"
"github.com/bjarneo/cliamp/provider"
"github.com/bjarneo/cliamp/ui"
)
// quit shuts down the player and signals the TUI to exit.
func (m *Model) quit() tea.Cmd {
// Only save resume for seekable tracks:
// - local files (not stream)
// - HTTP streams with known duration (podcast MP3s, seek-by-reconnect)
// Exclude YTDL (position unreliable) and real-time live streams.
if track, _ := m.currentPlaybackTrack(); track.Path != "" &&
!playlist.IsYTDL(track.Path) && !track.IsLive() &&
m.player.IsPlaying() {
if secs := int(m.player.Position().Seconds()); secs > 0 {
m.exitResume.path = track.Path
m.exitResume.secs = secs
m.exitResume.playlist = m.loadedPlaylist
}
}
m.flushPendingSpeedSave()
m.player.Close()
m.clearPlaybackTrack()
m.quitting = true
return tea.Quit
}
// scrobbleCurrent fires a scrobble for the currently playing track if applicable.
func (m *Model) scrobbleCurrent() {
if track, idx := m.currentPlaybackTrack(); idx >= 0 {
m.maybeScrobble(track, m.player.Position(), m.player.Duration())
}
}
func (m *Model) handleSpeedKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "q", "ctrl+c":
return m.quit()
case "]", "right", "l", "up", "k":
m.changeSpeed(0.25)
case "[", "left", "h", "down", "j":
m.changeSpeed(-0.25)
case "tab":
m.focus = focusPlaylist
case "esc", "backspace":
if len(m.providers) > 1 {
m.focus = focusProvPill
} else {
m.focus = focusEQ
}
case "space":
return m.togglePlayPause()
}
return nil
}
func (m *Model) providerScrollStep() int {
return max(1, m.effectivePlaylistVisible())
}
func (m *Model) providerMaybeAdjustScroll() {
visible := m.providerScrollStep()
total := len(m.providerLists)
if total == 0 {
m.provScroll = 0
return
}
if m.provCursor < m.provScroll {
m.provScroll = m.provCursor
}
// Sectioned providers (e.g. radio) render extra header rows, so
// cursor visibility must be computed in rendered rows, not item count.
if sl, ok := m.provider.(provider.SectionedList); ok {
if m.provScroll >= total {
m.provScroll = max(0, total-1)
}
// Only push down when needed to keep the cursor visible.
// Do not "pull up" aggressively, which can make paging feel jumpy
// and keep the cursor stuck near the bottom of the viewport.
for m.provScroll < total && m.providerRowsFromScroll(sl, m.provScroll, m.provCursor) > visible {
m.provScroll++
}
return
}
// Non-sectioned providers: regular item-count based scrolling.
if m.provCursor >= m.provScroll+visible {
m.provScroll = m.provCursor - visible + 1
}
if m.provScroll+visible > total {
m.provScroll = max(0, total-visible)
}
}
func (m *Model) providerRowsFromScroll(sl provider.SectionedList, scroll, cursor int) int {
total := len(m.providerLists)
if total == 0 || cursor < scroll || scroll < 0 || cursor >= total {
return 0
}
rows := 0
prevPrefix := ""
if scroll > 0 {
prevPrefix = sl.IDPrefix(m.providerLists[scroll-1].ID)
}
for i := scroll; i <= cursor && i < total; i++ {
pfx := sl.IDPrefix(m.providerLists[i].ID)
if pfx != prevPrefix {
rows++ // section header row
}
rows++ // item row
prevPrefix = pfx
}
return rows
}
func (m *Model) providerMoveUp() {
if m.provCursor > 0 {
m.provCursor--
} else if len(m.providerLists) > 0 {
m.provCursor = len(m.providerLists) - 1
}
m.providerMaybeAdjustScroll()
}
func (m *Model) providerMoveDown() {
if m.provCursor < len(m.providerLists)-1 {
m.provCursor++
} else if len(m.providerLists) > 0 {
m.provCursor = 0
}
m.providerMaybeAdjustScroll()
}
func (m *Model) providerPageUp() {
step := m.providerScrollStep()
if m.provCursor > 0 {
m.provCursor -= min(m.provCursor, step)
}
// Top-anchor behavior: place cursor at top of viewport when paging up.
m.provScroll = m.provCursor
m.providerMaybeAdjustScroll()
}
func (m *Model) providerPageDown() {
step := m.providerScrollStep()
if m.provCursor < len(m.providerLists)-1 {
m.provCursor = min(len(m.providerLists)-1, m.provCursor+step)
}
// Bottom-anchor behavior: bias viewport so cursor lands near bottom when paging down.
m.provScroll = max(0, m.provCursor-step+1)
m.providerMaybeAdjustScroll()
}
func (m *Model) providerToTop() {
m.provCursor = 0
m.providerMaybeAdjustScroll()
}
func (m *Model) providerToBottom() {
if len(m.providerLists) > 0 {
m.provCursor = len(m.providerLists) - 1
}
m.providerMaybeAdjustScroll()
}
// handleKey processes a single key press and returns an optional command.
func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
if m.keymap.visible {
return m.handleKeymapKey(msg)
}
// Audio device picker overlay
if m.devicePicker.visible {
return m.handleDeviceKey(msg)
}
// Provider search overlay sits on top of the nav browser, so it must
// claim keys first when both are visible.
if m.plPicker.visible {
return m.handlePlaylistPickerKey(msg)
}
// File browser overlay can be opened from inside the playlist manager, so it
// must take precedence when both states are visible.
if m.fileBrowser.visible {
return m.handleFileBrowserKey(msg)
}
// Provider search overlay sits on top of the nav browser, so it must
// claim keys first when both are visible.
if m.spotSearch.visible {
return m.handleSpotSearchKey(msg)
}
// Navidrome explore browser overlay
if m.navBrowser.visible {
return m.handleNavBrowserKey(msg)
}
// Theme picker overlay — interactive navigation
if m.themePicker.visible {
return m.handleThemeKey(msg)
}
// Visualizer picker overlay — interactive navigation
if m.visPicker.visible {
return m.handleVisPickerKey(msg)
}
// Playlist manager overlay (browse, add, remove, delete)
if m.plManager.visible {
return m.handlePlaylistManagerKey(msg)
}
// Queue manager overlay
if m.queue.visible {
return m.handleQueueKey(msg)
}
// Track info overlay
if m.showInfo {
switch msg.String() {
case "ctrl+c":
return m.quit()
case "esc", "i":
m.showInfo = false
}
return nil
}
// Lyrics overlay
if m.lyrics.visible {
switch msg.String() {
case "ctrl+c":
return m.quit()
case "esc", "y":
m.lyrics.visible = false
case "up", "k":
if !(m.lyricsSyncable() && m.lyricsHaveTimestamps()) && m.lyrics.scroll > 0 {
m.lyrics.scroll--
}
case "down", "j":
if !(m.lyricsSyncable() && m.lyricsHaveTimestamps()) {
maxScroll := max(len(m.lyrics.lines)-1, 0)
if m.lyrics.scroll < maxScroll {
m.lyrics.scroll++
}
}
case "ctrl+x":
m.toggleExpandedView()
}
return nil
}
if m.jumping {
return m.handleJumpKey(msg)
}
if m.urlInputting {
return m.handleURLInputKey(msg)
}
if m.search.active {
return m.handleSearchKey(msg)
}
if m.netSearch.active {
return m.handleNetSearchKey(msg)
}
if m.provSearch.active {
return m.handleProvSearchKey(msg)
}
if m.focus == focusProvider {
switch msg.String() {
case "q", "ctrl+c":
return m.quit()
case "up", "k":
m.providerMoveUp()
case "space":
return m.togglePlayPause()
case "down", "j":
m.providerMoveDown()
// Auto-load next catalog page when scrolling near the bottom.
return m.maybeLoadCatalogBatch()
case "enter":
if m.provSignIn {
if auth, ok := m.provider.(playlist.Authenticator); ok {
m.provSignIn = false
m.provLoading = true
return authenticateProviderCmd(auth)
}
}
if len(m.providerLists) > 0 && !m.provLoading {
m.provLoading = true
m.activeProviderPlaylistID = m.providerLists[m.provCursor].ID
return fetchTracksCmd(m.provider, m.providerLists[m.provCursor].ID)
}
case "tab":
m.focus = focusEQ
case "esc", "backspace", "b":
// If viewing catalog search results, clear them first.
if cs, ok := m.provider.(provider.CatalogSearcher); ok && cs.IsSearching() {
m.restoreCatalog(cs)
return nil
}
if m.playlist.Len() > 0 {
m.focus = focusPlaylist
}
case "/":
m.provSearch.active = true
m.provSearch.query = ""
m.provSearch.results = nil
m.provSearch.cursor = 0
m.provSearch.scroll = 0
case "ctrl+r":
if m.provider != nil && !m.provLoading {
if r, ok := m.provider.(playlist.Refresher); ok {
r.Refresh()
}
m.providerLists = nil
m.provLoading = true
m.activeProviderPlaylistID = ""
m.status.Showf(statusTTLShort, "Refreshing %s…", m.provider.Name())
return fetchPlaylistsCmd(m.provider)
}
case "f":
return m.toggleProviderFavorite()
case "o":
m.openFileBrowser()
case "N":
if prov := m.findBrowseProvider(); prov != nil {
m.openNavBrowserWith(prov)
}
case "pgup", "ctrl+u":
m.providerPageUp()
case "pgdown", "ctrl+d":
m.providerPageDown()
return m.maybeLoadCatalogBatch()
case "g", "home":
m.providerToTop()
case "G", "end":
m.providerToBottom()
return m.maybeLoadCatalogBatch()
case "ctrl+j":
m.openJumpMode()
case "J":
return m.switchToProvider("jellyfin")
case "E":
return m.switchToProvider("emby")
case "S":
return m.switchToProvider("spotify")
case "C":
return m.switchToProvider("soundcloud")
case "M":
return m.switchToProvider("netease")
case "Q":
return m.switchToProvider("qobuz")
case "L":
return m.switchToProvider("local")
case "R":
return m.switchToProvider("radio")
case "ctrl+x":
m.toggleExpandedView()
case "ctrl+f":
m.openProviderSearch()
}
return nil
}
if m.focus == focusSpeed {
return m.handleSpeedKey(msg)
}
if m.focus == focusProvPill {
switch msg.String() {
case "q", "ctrl+c":
return m.quit()
case "left", "h":
if m.provPillIdx > 0 {
m.provPillIdx--
}
case "right", "l":
if m.provPillIdx < len(m.providers)-1 {
m.provPillIdx++
}
case "enter":
return m.switchProvider(m.provPillIdx)
case "tab":
m.focus = focusSpeed
case "esc", "backspace":
m.focus = focusEQ
case "space":
return m.togglePlayPause()
}
return nil
}
// Vim-style count prefix: a digit primes a pending percentage; the next `j`
// jumps there (e.g. `7j` → 70%). Any other key cancels and runs normally.
if s := msg.String(); m.focus == focusPlaylist && len(s) == 1 && s[0] >= '0' && s[0] <= '9' {
m.pendingSeekActive = true
m.pendingSeekPct = int(s[0] - '0')
m.status.Showf(statusTTLMedium, "%dj → seek to %d%%", m.pendingSeekPct, m.pendingSeekPct*10)
return nil
}
if m.pendingSeekActive {
pct := m.pendingSeekPct
m.pendingSeekActive = false
m.status.Clear()
if msg.String() == "j" && m.focus == focusPlaylist {
if dur := m.player.Duration(); dur > 0 {
return m.seekAbsolute(dur * time.Duration(pct) / 10)
}
return nil
}
}
switch msg.String() {
case "q", "ctrl+c":
return m.quit()
case "esc", "backspace", "b":
if m.fullVis {
m.fullVis = false
m.vis.Rows = ui.DefaultVisRows
m.restorePanelWidth()
m.refreshChrome()
} else if m.focus == focusPlaylist {
// Keep current expanded/collapsed height mode when switching focus.
m.focus = focusProvider
}
case "space":
cmd := m.togglePlayPause()
m.notifyPlayback()
return cmd
case "s":
m.player.Stop()
m.clearPlaybackTrack()
m.notifyPlayback()
case ">", ".":
m.scrobbleCurrent()
cmd := m.nextTrack()
m.notifyPlayback()
return cmd
case "<", ",":
m.scrobbleCurrent()
cmd := m.prevTrack()
m.notifyPlayback()
return cmd
case "left":
if m.focus == focusEQ {
if m.eqCursor > 0 {
m.eqCursor--
}
} else {
return m.doSeek(-5 * time.Second)
}
case "shift+left":
return m.doSeek(-m.seekStepLarge)
case "right":
if m.focus == focusEQ {
if m.eqCursor < eqBandCount-1 {
m.eqCursor++
}
} else {
return m.doSeek(5 * time.Second)
}
case "shift+right":
return m.doSeek(m.seekStepLarge)
case "f":
if m.focus == focusPlaylist && m.plCursor >= 0 && m.plCursor < m.playlist.Len() && m.loadedPlaylist != "" {
if bs, ok := m.localProvider.(provider.BookmarkSetter); ok {
tracks := m.playlist.Tracks()
track := tracks[m.plCursor]
if err := bs.SetBookmarkByPath(m.loadedPlaylist, track.Path); err != nil {
m.status.Showf(statusTTLDefault, "Save failed: %s", err)
return nil
}
m.playlist.ToggleBookmark(m.plCursor)
track = m.playlist.Tracks()[m.plCursor]
if track.Bookmark {
m.status.Showf(statusTTLDefault, "★ %s", track.DisplayName())
} else {
m.status.Showf(statusTTLDefault, "☆ %s", track.DisplayName())
}
}
}
case "shift+up":
if m.focus == focusPlaylist && m.plCursor > 0 {
if m.playlist.Move(m.plCursor, m.plCursor-1) {
m.plCursor--
m.persistLoadedPlaylistOrder()
m.adjustScroll()
}
}
case "shift+down":
if m.focus == focusPlaylist && m.plCursor < m.playlist.Len()-1 {
if m.playlist.Move(m.plCursor, m.plCursor+1) {
m.plCursor++
m.persistLoadedPlaylistOrder()
m.adjustScroll()
}
}
case "up", "k":
if m.focus == focusEQ {
bands := m.player.EQBands()
m.player.SetEQBand(m.eqCursor, bands[m.eqCursor]+1)
m.eqPresetIdx = -1 // manual tweak → custom
m.eqCustomLabel = ""
m.saveEQ()
} else {
if m.plCursor > 0 {
m.plCursor--
m.adjustScroll()
} else if m.playlist.Len() > 0 {
m.plCursor = m.playlist.Len() - 1
m.adjustScroll()
}
}
case "down", "j":
if m.focus == focusEQ {
bands := m.player.EQBands()
m.player.SetEQBand(m.eqCursor, bands[m.eqCursor]-1)
m.eqPresetIdx = -1 // manual tweak → custom
m.eqCustomLabel = ""
m.saveEQ()
} else {
if m.plCursor < m.playlist.Len()-1 {
m.plCursor++
m.adjustScroll()
} else if m.playlist.Len() > 0 {
m.plCursor = 0
m.adjustScroll()
}
}
case "pgup", "ctrl+u":
if m.focus == focusPlaylist && m.plCursor > 0 {
visible := max(1, m.effectivePlaylistVisible())
m.plCursor -= min(m.plCursor, visible)
m.adjustScroll()
}
case "pgdown", "ctrl+d":
if m.focus == focusPlaylist && m.plCursor < m.playlist.Len()-1 {
visible := max(1, m.effectivePlaylistVisible())
m.plCursor = min(m.playlist.Len()-1, m.plCursor+visible)
m.adjustScroll()
}
case "g", "home":
if m.focus == focusPlaylist && m.plCursor != 0 {
m.plCursor = 0
m.adjustScroll()
}
case "G", "end":
if m.focus == focusPlaylist && m.playlist.Len() > 0 && m.plCursor != m.playlist.Len()-1 {
m.plCursor = m.playlist.Len() - 1
m.adjustScroll()
}
case "enter":
if m.focus == focusPlaylist {
// No-op only if this exact track is still buffering.
if m.buffering && m.plCursor == m.playlist.Index() {
break
}
m.scrobbleCurrent()
m.playlist.SetIndex(m.plCursor)
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
case "+", "=":
m.player.SetVolume(m.player.Volume() + 1)
m.notifyPlayback()
case "-":
m.player.SetVolume(m.player.Volume() - 1)
m.notifyPlayback()
case "r":
m.playlist.CycleRepeat()
if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", m.playlist.Repeat().String())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
m.player.ClearPreload()
return m.preloadNext()
case "z":
m.playlist.ToggleShuffle()
if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", m.playlist.Shuffled())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
m.player.ClearPreload()
return m.preloadNext()
case "tab":
switch m.focus {
case focusPlaylist:
m.focus = focusEQ
case focusEQ:
if len(m.providers) > 1 {
m.focus = focusProvPill
} else {
m.focus = focusSpeed
}
case focusProvPill:
m.focus = focusSpeed
case focusSpeed:
m.focus = focusPlaylist
default:
m.focus = focusPlaylist
}
case "h":
if m.focus == focusEQ && m.eqCursor > 0 {
m.eqCursor--
}
case "l":
if m.focus == focusEQ && m.eqCursor < eqBandCount-1 {
m.eqCursor++
}
case "e":
m.eqPresetIdx++
if m.eqPresetIdx >= len(eqPresets) {
m.eqPresetIdx = 0
}
m.applyEQPreset()
m.saveEQ()
case "a":
if m.focus == focusPlaylist {
if !m.playlist.Dequeue(m.plCursor) {
m.playlist.Queue(m.plCursor)
}
}
case "w":
if m.focus == focusPlaylist && m.plCursor >= 0 && m.plCursor < m.playlist.Len() {
track := m.playlist.Tracks()[m.plCursor]
m.openPlaylistPicker([]playlist.Track{track}, "Track: "+track.DisplayName())
}
case "A":
if m.focus == focusPlaylist {
m.queue.visible = true
m.queue.cursor = 0
m.queue.scroll = 0
}
case "ctrl+s":
return m.saveTrack()
case "S":
return m.switchToProvider("spotify")
case "m":
m.player.ToggleMono()
case "/":
m.search.active = true
m.search.query = ""
m.search.results = nil
m.search.cursor = 0
m.search.scroll = 0
m.prevFocus = m.focus
m.focus = focusSearch
// Search now renders in the playlist region; recompute chrome so the
// search header/help are reflected in the visible-row budget.
m.refreshChrome()
m.applyHeightMode()
case "ctrl+f":
m.openProviderSearch()
case "ctrl+j":
m.openJumpMode()
case "J":
return m.switchToProvider("jellyfin")
case "E":
return m.switchToProvider("emby")
case "p":
if m.localProvider != nil {
m.openPlaylistManager()
}
case "t":
m.openThemePicker()
case "i":
m.showInfo = true
case "y":
m.lyrics.visible = !m.lyrics.visible
if m.lyrics.visible && !m.lyrics.loading {
track, _ := m.currentPlaybackTrack()
artist, title := m.lyricsArtistTitle()
q := lyricsLookupKey(track, artist, title)
if q != "" && q != m.lyrics.query {
m.lyrics.query = q
m.lyrics.loading = true
m.lyrics.lines = nil
m.lyrics.err = nil
return fetchTrackLyricsCmd(track, artist, title)
}
}
case "o":
m.openFileBrowser()
case "u":
m.urlInputting = true
m.urlInput = ""
case "N":
if prov := m.findBrowseProvider(); prov != nil {
m.openNavBrowserWith(prov)
}
case "L":
return m.switchToProvider("local")
case "R":
return m.switchToProvider("radio")
case "P":
return m.switchToProvider("plex")
case "Y":
return m.switchToProvider("yt")
case "C":
return m.switchToProvider("soundcloud")
case "M":
return m.switchToProvider("netease")
case "Q":
return m.switchToProvider("qobuz")
case "ctrl+h":
m.toggleAlbumHeadersManual()
m.adjustScroll()
case "v":
m.vis.CycleMode()
m.vis.RequestRefresh()
m.refreshChrome()
m.applyHeightMode()
m.adjustScroll()
if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil {
m.status.Showf(statusTTLDefault, "Config save failed: %s", err)
}
case "ctrl+v":
m.openVisPicker()
case "V":
m.fullVis = !m.fullVis
if m.fullVis {
m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5)
ui.PanelWidth = max(0, m.width-2*ui.PaddingH)
} else {
m.vis.Rows = ui.DefaultVisRows
m.restorePanelWidth()
}
m.refreshChrome()
case "ctrl+x":
if m.focus == focusPlaylist {
m.toggleExpandedView()
}
case "x":
if m.focus == focusPlaylist {
m.removeSelectedFromPlaylist()
}
case "d":
m.devicePicker.visible = true
m.devicePicker.cursor = 0
m.devicePicker.scroll = 0
if len(m.devicePicker.devices) == 0 {
m.devicePicker.loading = true
return listDevicesCmd()
}
case "]":
m.changeSpeed(0.25)
case "[":
m.changeSpeed(-0.25)
case "ctrl+k", "?":
m.openKeymap()
default:
if m.luaMgr != nil {
m.luaMgr.EmitKey(msg.String())
}
}
return nil
}
// saveTrack copies the current track to ~/Music/cliamp/ with a clean filename.
// For yt-dlp tracks (piped streams), triggers an async download via yt-dlp.
// For local temp files, copies synchronously.
func (m *Model) saveTrack() tea.Cmd {
track, idx := m.currentPlaybackTrack()
if idx < 0 {
m.status.Show("Nothing to save", statusTTLShort)
return nil
}
home, err := os.UserHomeDir()
if err != nil {
m.status.Showf(statusTTLShort, "Save failed: %s", err)
return nil
}
saveDir := filepath.Join(home, "Music", "cliamp")
if err := os.MkdirAll(saveDir, 0o755); err != nil {
m.status.Showf(statusTTLShort, "Save failed: %s", err)
return nil
}
// YouTube/yt-dlp tracks: async download directly to ~/Music/cliamp/.
if playlist.IsYouTubeURL(track.Path) || playlist.IsYTDL(track.Path) {
m.status.Clear()
m.save.startDownload()
return saveYTDLCmd(track.Path, saveDir)
}
// Only save local temp files (yt-dlp downloads), not streams or user's own files.
if track.Stream || !strings.HasPrefix(track.Path, os.TempDir()) {
m.status.Show("Only downloaded tracks can be saved", statusTTLShort)
return nil
}
ext := filepath.Ext(track.Path)
name := track.Title
if track.Artist != "" {
name = track.Artist + " - " + name
}
// Sanitize filename: remove path separators and other problematic chars.
name = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
return '_'
}
return r
}, name)
dest := filepath.Join(saveDir, name+ext)
if err := fileutil.CopyFile(track.Path, dest); err != nil {
m.status.Showf(statusTTLShort, "Save failed: %s", err)
return nil
}
m.status.Showf(statusTTLDefault, "Saved to ~/Music/cliamp/%s", name+ext)
return nil
}
func (m *Model) resetJumpInput() {
m.jumpInput = ""
}
func (m *Model) openJumpMode() {
m.jumping = true
m.resetJumpInput()
}
// openProviderSearch opens the active provider's native search overlay if it
// implements provider.Searcher; otherwise it falls back to the YouTube net
// search overlay.
func (m *Model) openProviderSearch() {
m.openProviderSearchWith(m.provider)
}
// openProviderSearchWith opens a search overlay against the given provider.
// Falls back to YouTube net search when prov doesn't implement Searcher.
func (m *Model) openProviderSearchWith(prov playlist.Provider) {
if _, ok := prov.(provider.Searcher); ok {
m.spotSearch = spotSearchState{
prov: prov,
visible: true,
screen: spotSearchInput,
}
return
}
m.netSearch = netSearchState{
active: true,
screen: netSearchInput,
}
m.prevFocus = m.focus
m.focus = focusNetSearch
}
func (m *Model) closeJumpMode() {
m.jumping = false
m.resetJumpInput()
}
// handleJumpKey processes key presses while in jump-time mode.
func (m *Model) handleJumpKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.closeJumpMode()
return m.quit()
}
switch msg.Code {
case tea.KeyEscape:
m.closeJumpMode()
return nil
case tea.KeyEnter:
target, err := parseJumpTarget(m.jumpInput)
if err != nil {
m.resetJumpInput()
return nil
}
if dur := m.player.Duration(); dur > 0 && target > dur {
m.resetJumpInput()
return nil
}
m.player.Seek(target - m.player.Position())
// finishSeek notifies plugins as well as MPRIS, matching every other
// completed seek; the previous manual block skipped Lua plugins.
m.finishSeek()
m.closeJumpMode()
return nil
case tea.KeyBackspace:
m.jumpInput = removeLastRune(m.jumpInput)
return nil
}
if len(msg.Text) > 0 {
m.jumpInput += msg.Text
}
return nil
}
func (m *Model) provSearchMaybeAdjustScroll() {
visible := m.providerScrollStep() - 1 // -1 for query line
if visible < 1 {
visible = 1
}
count := len(m.provSearch.results)
clampScroll(&m.provSearch.cursor, &m.provSearch.scroll, count, visible)
}
// handleProvSearchKey processes key presses while filtering the provider playlist list.
// For the radio provider, Enter fires an API search; for others, Enter loads the
// selected result. Esc cancels and restores the normal catalog view.
func (m *Model) handleProvSearchKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+x":
m.toggleExpandedView()
m.provSearchMaybeAdjustScroll()
return nil
case "ctrl+n":
if m.provSearch.cursor < len(m.provSearch.results)-1 {
m.provSearch.cursor++
} else if len(m.provSearch.results) > 0 {
m.provSearch.cursor = 0
}
m.provSearchMaybeAdjustScroll()
return nil
case "ctrl+p":
if m.provSearch.cursor > 0 {
m.provSearch.cursor--
} else if len(m.provSearch.results) > 0 {
m.provSearch.cursor = len(m.provSearch.results) - 1
}
m.provSearchMaybeAdjustScroll()
return nil
case "ctrl+u":
step := m.providerScrollStep() - 1
if step < 1 {
step = 1
}
if m.provSearch.cursor >= step {
m.provSearch.cursor -= step
} else {
m.provSearch.cursor = 0
}
m.provSearchMaybeAdjustScroll()
return nil
case "ctrl+d":
step := m.providerScrollStep() - 1
if step < 1 {
step = 1
}
m.provSearch.cursor += step
if m.provSearch.cursor >= len(m.provSearch.results) {
m.provSearch.cursor = max(0, len(m.provSearch.results)-1)
}
m.provSearchMaybeAdjustScroll()
return nil
}
// Catalog search: API-based search (no live client-side filtering).
if cs, ok := m.provider.(provider.CatalogSearcher); ok {
return m.handleCatalogSearchKey(msg, cs)
}
switch msg.Code {
case tea.KeyEscape:
m.provSearch.active = false
case tea.KeyEnter:
if len(m.provSearch.results) > 0 && !m.provLoading {
idx := m.provSearch.results[m.provSearch.cursor]
m.provCursor = idx
m.providerMaybeAdjustScroll()
m.provLoading = true
m.provSearch.active = false
m.activeProviderPlaylistID = m.providerLists[idx].ID
return fetchTracksCmd(m.provider, m.providerLists[idx].ID)
}
case tea.KeyUp:
if m.provSearch.cursor > 0 {
m.provSearch.cursor--
} else if len(m.provSearch.results) > 0 {
m.provSearch.cursor = len(m.provSearch.results) - 1
}
m.provSearchMaybeAdjustScroll()
case tea.KeyDown:
if m.provSearch.cursor < len(m.provSearch.results)-1 {
m.provSearch.cursor++
} else if len(m.provSearch.results) > 0 {
m.provSearch.cursor = 0
}
m.provSearchMaybeAdjustScroll()
case tea.KeyBackspace:
if m.provSearch.query != "" {
m.provSearch.query = removeLastRune(m.provSearch.query)
m.updateProvSearch()
}
case tea.KeySpace:
m.provSearch.query += " "
m.updateProvSearch()
default:
if len(msg.Text) > 0 {
m.provSearch.query += msg.Text
m.updateProvSearch()
}
}
return nil
}
// handleCatalogSearchKey handles search input for providers with catalog search.
// Types a query, Enter fires API search, Esc cancels/clears.
func (m *Model) handleCatalogSearchKey(msg tea.KeyPressMsg, cs provider.CatalogSearcher) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.provSearch.active = false
m.restoreCatalog(cs)
case tea.KeyEnter:
m.provSearch.active = false
if m.provSearch.query == "" {
m.restoreCatalog(cs)
return nil
}
m.provLoading = true
return fetchCatalogSearchCmd(cs, m.provSearch.query)
case tea.KeyBackspace, tea.KeyDelete:
if m.provSearch.query != "" {
m.provSearch.query = removeLastRune(m.provSearch.query)
}
case tea.KeySpace:
m.provSearch.query += " "
default:
if len(msg.Text) > 0 {
m.provSearch.query += msg.Text
}
}
return nil
}
// restoreCatalog clears search results and restores the normal catalog view.
func (m *Model) restoreCatalog(cs provider.CatalogSearcher) {
if !cs.IsSearching() {
return
}
cs.ClearSearch()
if lists, err := m.provider.Playlists(); err == nil {
m.providerLists = lists
}
m.provCursor = 0
m.provScroll = 0
}
func (m *Model) updateProvSearch() {
m.provSearch.results = nil
m.provSearch.cursor = 0
m.provSearch.scroll = 0
if m.provSearch.query == "" {
return
}
q := strings.ToLower(m.provSearch.query)
for i, pl := range m.providerLists {
if strings.Contains(strings.ToLower(pl.Name), q) {
m.provSearch.results = append(m.provSearch.results, i)
}
}
}
// toggleExpandedView toggles the UI between default and expanded height.
func (m *Model) toggleExpandedView() {
m.heightExpanded = !m.heightExpanded
m.applyHeightMode()
m.adjustScroll()
}
// handlePaste routes pasted text to the active text input field.
// The priority order mirrors handleKey so the correct input receives the content.
func (m *Model) handlePaste(content string) tea.Cmd {
if content == "" {
return nil
}
// Keymap overlay search
if m.keymap.visible {
m.keymap.search += content
m.updateKeymapFilter()
return nil
}
// Nav browser search
if m.navBrowser.visible && m.navBrowser.mode != navBrowseModeMenu && m.navBrowser.searching {
m.navBrowser.search += content
m.navBrowser.cursor = 0
m.navBrowser.scroll = 0
m.navUpdateSearch()
return nil
}
// Playlist manager new-name input
if m.plManager.visible && m.plManager.screen == plMgrScreenNewName {
m.plManager.newName += content
return nil
}
// Playlist manager `/` filter
if m.plManager.visible && m.plManager.filtering {
m.plManager.filter += content
m.plManager.cursor = 0
m.plMgrRecomputeFilter()
return nil
}
if m.jumping {
m.jumpInput += content
return nil
}
if m.urlInputting {
m.urlInput += content
return nil
}
if m.search.active {
m.search.query += content
m.updateSearch()
return nil
}
if m.netSearch.active {
if m.netSearch.screen == netSearchInput {
m.netSearch.query += content
}
return nil
}
if m.spotSearch.visible {
switch m.spotSearch.screen {
case spotSearchInput:
m.spotSearch.query += content
case spotSearchNewName:
m.spotSearch.newName += content
}
return nil
}
if m.provSearch.active {
m.provSearch.query += content
if _, ok := m.provider.(provider.CatalogSearcher); !ok {
m.updateProvSearch()
}
return nil
}
return nil
}
func (m *Model) searchMaybeAdjustScroll(visible int) {
clampScroll(&m.search.cursor, &m.search.scroll, len(m.search.results), visible)
}
func (m *Model) handleSearchKey(msg tea.KeyPressMsg) tea.Cmd {
// Allow opening overlays during search (ctrl combos don't conflict with text input).
switch msg.String() {
case "ctrl+k":
m.openKeymap()
return nil
case "ctrl+x":
m.toggleExpandedView()
m.searchMaybeAdjustScroll(m.searchVisible())
return nil
case "ctrl+n":
if m.search.cursor < len(m.search.results)-1 {
m.search.cursor++
} else if len(m.search.results) > 0 {
m.search.cursor = 0
}
m.searchMaybeAdjustScroll(m.searchVisible())
return nil
case "ctrl+p":
if m.search.cursor > 0 {
m.search.cursor--
} else if len(m.search.results) > 0 {
m.search.cursor = len(m.search.results) - 1
}
m.searchMaybeAdjustScroll(m.searchVisible())
return nil
case "ctrl+u":
step := m.searchVisible()
if step < 1 {
step = 1
}
if m.search.cursor >= step {
m.search.cursor -= step
} else {
m.search.cursor = 0
}
m.searchMaybeAdjustScroll(m.searchVisible())
return nil
case "ctrl+d":
step := m.searchVisible()
if step < 1 {
step = 1
}
m.search.cursor += step
if m.search.cursor >= len(m.search.results) {
m.search.cursor = max(0, len(m.search.results)-1)
}
m.searchMaybeAdjustScroll(m.searchVisible())
return nil
}
switch msg.Code {
case tea.KeyEscape:
m.search.active = false
m.focus = m.prevFocus
m.closeSearchLayout()
case tea.KeyEnter:
var cmd tea.Cmd
if len(m.search.results) > 0 {
idx := m.search.results[m.search.cursor]
m.playlist.SetIndex(idx)
m.plCursor = idx
cmd = m.playCurrentTrack()
m.notifyPlayback()
}
m.search.active = false
m.focus = focusPlaylist
m.closeSearchLayout()
return cmd
case tea.KeyTab:
// Toggle queue for selected search result.
if len(m.search.results) > 0 && m.search.cursor < len(m.search.results) {
idx := m.search.results[m.search.cursor]
if !m.playlist.Dequeue(idx) {
m.playlist.Queue(idx)
}
}
case tea.KeyUp:
if m.search.cursor > 0 {
m.search.cursor--
} else if len(m.search.results) > 0 {
m.search.cursor = len(m.search.results) - 1
}
m.searchMaybeAdjustScroll(m.searchVisible())
case tea.KeyDown:
if m.search.cursor < len(m.search.results)-1 {
m.search.cursor++
} else if len(m.search.results) > 0 {
m.search.cursor = 0
}
m.searchMaybeAdjustScroll(m.searchVisible())
case tea.KeyBackspace:
if m.search.query != "" {
m.search.query = removeLastRune(m.search.query)
m.updateSearch()
}
case tea.KeySpace:
m.search.query += " "
m.updateSearch()
default:
if len(msg.Text) > 0 {
m.search.query += msg.Text
m.updateSearch()
}
}
return nil
}
// handleNetSearchKey dispatches key presses to the active net search screen.
func (m *Model) handleNetSearchKey(msg tea.KeyPressMsg) tea.Cmd {
if msg.String() == "ctrl+k" {
m.openKeymap()
return nil
}
switch m.netSearch.screen {
case netSearchInput:
return m.handleNetSearchInputKey(msg)
case netSearchResults:
return m.handleNetSearchResultsKey(msg)
}
return nil
}
// handleNetSearchInputKey handles text entry on the net search overlay.
func (m *Model) handleNetSearchInputKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.closeNetSearch()
case tea.KeyEnter:
if strings.TrimSpace(m.netSearch.query) != "" && !m.netSearch.loading {
prefix := "ytsearch10:"
if m.netSearch.soundcloud {
prefix = "scsearch10:"
}
m.netSearch.loading = true
m.netSearch.err = ""
return fetchNetSearchCmd(prefix + strings.TrimSpace(m.netSearch.query))
}
case tea.KeyBackspace:
m.netSearch.query = removeLastRune(m.netSearch.query)
case tea.KeySpace:
m.netSearch.query += " "
default:
if len(msg.Text) > 0 {
m.netSearch.query += msg.Text
}
}
return nil
}
func (m *Model) netSearchResultsMaybeAdjustScroll(visible int) {
clampScroll(&m.netSearch.cursor, &m.netSearch.scroll, len(m.netSearch.results), visible)
}
// handleNetSearchResultsKey handles navigation through net search results.
func (m *Model) handleNetSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.netSearch.results)
switch msg.String() {
case "ctrl+x":
m.toggleExpandedView()
m.netSearchResultsMaybeAdjustScroll(m.netSearchResultsVisible())
case "up", "k", "ctrl+p":
if m.netSearch.cursor > 0 {
m.netSearch.cursor--
} else if count > 0 {
m.netSearch.cursor = count - 1
}
m.netSearchResultsMaybeAdjustScroll(m.netSearchResultsVisible())
case "down", "j", "ctrl+n":
if m.netSearch.cursor < count-1 {
m.netSearch.cursor++
} else if count > 0 {
m.netSearch.cursor = 0
}
m.netSearchResultsMaybeAdjustScroll(m.netSearchResultsVisible())
case "enter":
if count > 0 && !m.netSearch.loading {
track := m.netSearch.results[m.netSearch.cursor]
m.closeNetSearch()
return m.playTrackImmediate(track)
}
case "a":
if count > 0 && !m.netSearch.loading {
track := m.netSearch.results[m.netSearch.cursor]
m.closeNetSearch()
return m.appendTrack(track)
}
case "q":
if count > 0 && !m.netSearch.loading {
track := m.netSearch.results[m.netSearch.cursor]
m.closeNetSearch()
return m.queueTrackNext(track)
}
case "esc", "backspace":
m.netSearch.screen = netSearchInput
m.netSearch.results = nil
m.netSearch.cursor = 0
m.netSearch.scroll = 0
m.netSearch.err = ""
case "ctrl+u":
step := m.netSearchResultsVisible()
if step < 1 {
step = 1
}
if m.netSearch.cursor >= step {
m.netSearch.cursor -= step
} else {
m.netSearch.cursor = 0
}
m.netSearchResultsMaybeAdjustScroll(m.netSearchResultsVisible())
case "ctrl+d":
step := m.netSearchResultsVisible()
if step < 1 {
step = 1
}
m.netSearch.cursor += step
if m.netSearch.cursor >= count {
m.netSearch.cursor = max(0, count-1)
}
m.netSearchResultsMaybeAdjustScroll(m.netSearchResultsVisible())
}
return nil
}
// handleURLInputKey processes key presses while in URL input mode.
func (m *Model) handleURLInputKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.urlInputting = false
case tea.KeyEnter:
m.urlInputting = false
input := strings.TrimSpace(m.urlInput)
if input != "" {
m.feedLoading = true
m.status.Show("Loading URL...", statusTTLLong)
return resolveRemoteCmd([]string{input}, true)
}
case tea.KeyBackspace:
m.urlInput = removeLastRune(m.urlInput)
default:
if len(msg.Text) > 0 {
m.urlInput += msg.Text
}
}
return nil
}
// handlePlaylistManagerKey dispatches keys to the active manager screen.
func (m *Model) handlePlaylistManagerKey(msg tea.KeyPressMsg) tea.Cmd {
// Quick-switch (Shift+letter) jumps to another provider. Only honored when
// the manager isn't currently capturing text input (filter, new-name).
if m.plManager.screen != plMgrScreenNewName && m.plManager.screen != plMgrScreenRename && !m.plManager.filtering {
if cmd := m.quickSwitchProvider(msg.String()); cmd != nil {
return cmd
}
}
switch m.plManager.screen {
case plMgrScreenList:
return m.handlePlMgrListKey(msg)
case plMgrScreenTracks:
return m.handlePlMgrTracksKey(msg)
case plMgrScreenNewName:
return m.handlePlMgrNewNameKey(msg)
case plMgrScreenRename:
return m.handlePlMgrRenameKey(msg)
}
return nil
}
// handlePlMgrListKey handles keys on screen 0 (playlist list).
func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd {
// Filter input mode swallows most keys.
if m.plManager.filtering {
return m.handlePlMgrFilterKey(msg)
}
// If waiting for delete confirmation, only accept y/n.
if m.plManager.confirmDel {
switch msg.String() {
case "y", "Y":
realIdx := m.plMgrPlaylistRealIndex(m.plManager.cursor)
if realIdx >= 0 {
name := m.plManager.playlists[realIdx].Name
if tracks, err := m.localProvider.Tracks(name); err == nil {
m.plManager.undo = plManagerUndo{kind: plUndoPlaylist, name: name, tracks: cloneTracks(tracks)}
}
if d, ok := m.localProvider.(provider.PlaylistDeleter); ok {
if err := d.DeletePlaylist(name); err != nil {
m.status.Showf(statusTTLDefault, "Delete failed: %s", err)
} else {
m.status.Showf(statusTTLDefault, "Deleted %q (u to undo)", name)
}
}
m.plMgrRefreshList()
}
m.plManager.confirmDel = false
default:
m.plManager.confirmDel = false
}
return nil
}
count := m.plMgrListViewCount()
switch msg.String() {
case "ctrl+c":
m.plManager.visible = false
return m.quit()
case "/":
m.plManager.filtering = true
m.plManager.savedCursor = m.plManager.cursor
m.plManager.savedScroll = m.plManager.scroll
m.plManager.filter = ""
m.plManager.filtered = nil
m.plManager.cursor = 0
m.plManager.scroll = 0
return nil
case "up", "k":
if m.plManager.cursor > 0 {
m.plManager.cursor--
} else if count > 0 {
m.plManager.cursor = count - 1
}
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
case "down", "j":
if m.plManager.cursor < count-1 {
m.plManager.cursor++
} else if count > 0 {
m.plManager.cursor = 0
}
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
case "ctrl+x":
m.toggleExpandedView()
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
case "pgup", "ctrl+u":
if m.plManager.cursor > 0 {
visible := m.plMgrListVisible()
m.plManager.cursor -= min(m.plManager.cursor, visible)
m.plMgrListMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
if m.plManager.cursor < count-1 {
visible := m.plMgrListVisible()
m.plManager.cursor = min(count-1, m.plManager.cursor+visible)
m.plMgrListMaybeAdjustScroll(visible)
}
case "home", "g":
m.plManager.cursor = 0
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
case "end", "G":
if count > 0 {
m.plManager.cursor = count - 1
}
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
case "enter", "l", "right":
realIdx := m.plMgrPlaylistRealIndex(m.plManager.cursor)
if realIdx >= 0 {
m.plMgrEnterTrackList(m.plManager.playlists[realIdx].Name)
} else {
// "+ New Playlist..." selected. Pre-fill the input with the
// active filter so a no-match search doubles as "create this".
m.plManager.screen = plMgrScreenNewName
m.plManager.newName = m.plManager.filter
}
case "a":
// Quick-add current track to the highlighted playlist.
realIdx := m.plMgrPlaylistRealIndex(m.plManager.cursor)
if realIdx >= 0 {
m.addToPlaylist(m.plManager.playlists[realIdx].Name)
m.plMgrRefreshList()
}
case "w":
tracks := m.playlist.Tracks()
if len(tracks) == 0 {
m.status.Show("Queue is empty", statusTTLShort)
return nil
}
m.openPlaylistPicker(tracks, fmt.Sprintf("Save %d queued tracks", len(tracks)))
case "r":
realIdx := m.plMgrPlaylistRealIndex(m.plManager.cursor)
if realIdx < 0 {
return nil
}
name := m.plManager.playlists[realIdx].Name
if name == history.PlaylistName {
m.status.Show("Recently Played cannot be renamed", statusTTLDefault)
return nil
}
m.plManager.renameOldName = name
m.plManager.renameName = name
m.plManager.screen = plMgrScreenRename
case "d":
if m.plMgrPlaylistRealIndex(m.plManager.cursor) >= 0 {
m.plManager.confirmDel = true
}
case "u":
m.plMgrUndoLast()
case "esc", "p":
if m.plManager.filter != "" {
// First Esc clears an active filter rather than closing.
m.plMgrResetFilter()
return nil
}
m.plManager.visible = false
}
return nil
}
// handlePlMgrFilterKey handles keys while typing into the `/` filter on either
// the list or tracks screen.
func (m *Model) handlePlMgrFilterKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.plManager.visible = false
return m.quit()
case "esc":
// Cancel filter, restore cursor.
m.plMgrResetFilter()
m.plManager.cursor = m.plManager.savedCursor
m.plManager.scroll = m.plManager.savedScroll
clampCount := m.plMgrListViewCount()
if m.plManager.screen == plMgrScreenTracks {
clampCount = m.plMgrTracksViewCount()
}
if clampCount > 0 && m.plManager.cursor >= clampCount {
m.plManager.cursor = clampCount - 1
}
return nil
case "enter":
// Commit filter; leave query in place but stop intercepting keys.
m.plManager.filtering = false
if m.plManager.filter == "" {
m.plManager.cursor = m.plManager.savedCursor
m.plManager.scroll = m.plManager.savedScroll
}
return nil
case "down":
// Drop into result navigation immediately.
m.plManager.filtering = false
count := m.plMgrListViewCount()
if m.plManager.screen == plMgrScreenTracks {
count = m.plMgrTracksViewCount()
}
if count > 0 {
m.plManager.cursor = 0
if m.plManager.screen == plMgrScreenList {
m.plMgrListMaybeAdjustScroll(m.plMgrListVisible())
} else {
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
}
return nil
case "backspace":
if m.plManager.filter != "" {
m.plManager.filter = removeLastRune(m.plManager.filter)
m.plManager.cursor = 0
m.plMgrRecomputeFilter()
} else {
m.plManager.filtering = false
m.plManager.cursor = m.plManager.savedCursor
m.plManager.scroll = m.plManager.savedScroll
}
return nil
case "space":
m.plManager.filter += " "
m.plManager.cursor = 0
m.plMgrRecomputeFilter()
return nil
}
if len(msg.Text) > 0 {
m.plManager.filter += msg.Text
m.plManager.cursor = 0
m.plMgrRecomputeFilter()
}
return nil
}
// handlePlMgrTracksKey handles keys on screen 1 (track list inside a playlist).
func (m *Model) handlePlMgrTracksKey(msg tea.KeyPressMsg) tea.Cmd {
if m.plManager.filtering {
return m.handlePlMgrFilterKey(msg)
}
count := m.plMgrTracksViewCount()
switch msg.String() {
case "ctrl+h":
m.toggleAlbumHeadersManual()
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
return nil
case "ctrl+c":
m.plManager.visible = false
return m.quit()
case "/":
m.plManager.filtering = true
m.plManager.savedCursor = m.plManager.cursor
m.plManager.savedScroll = m.plManager.scroll
m.plManager.filter = ""
m.plManager.filtered = nil
m.plManager.cursor = 0
m.plManager.scroll = 0
return nil
case "up", "k":
if m.plManager.cursor > 0 {
m.plManager.cursor--
} else if count > 0 {
m.plManager.cursor = count - 1
}
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
case "down", "j":
if m.plManager.cursor < count-1 {
m.plManager.cursor++
} else if count > 0 {
m.plManager.cursor = 0
}
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
case "[":
m.plMgrMoveTrack(-1)
case "]":
m.plMgrMoveTrack(1)
case "ctrl+x":
m.toggleExpandedView()
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
case "pgup", "ctrl+u":
if m.plManager.cursor > 0 {
visible := m.plMgrTracksVisible()
m.plManager.cursor -= min(m.plManager.cursor, visible)
m.plMgrTracksMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
if m.plManager.cursor < count-1 {
visible := m.plMgrTracksVisible()
m.plManager.cursor = min(count-1, m.plManager.cursor+visible)
m.plMgrTracksMaybeAdjustScroll(visible)
}
case "home", "g":
m.plManager.cursor = 0
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
case "end", "G":
if count > 0 {
m.plManager.cursor = count - 1
}
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
case "enter":
// Play the highlighted track; the rest of the playlist follows.
if len(m.plManager.tracks) > 0 {
startIdx := m.plMgrTrackRealIndex(m.plManager.cursor)
if startIdx < 0 {
startIdx = 0
}
return m.plMgrLoadAndPlay(startIdx)
}
case "p":
// Play all from the top, regardless of cursor.
if len(m.plManager.tracks) > 0 {
return m.plMgrLoadAndPlay(0)
}
case "space":
realIdx := m.plMgrTrackRealIndex(m.plManager.cursor)
m.plMgrToggleMark(realIdx)
if m.plManager.cursor < count-1 {
m.plManager.cursor++
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
case "a":
m.plMgrToggleMarkAll()
case "s":
m.plMgrSortTracks()
case "w":
tracks := m.plMgrSelectedTracks()
if len(tracks) > 0 {
title := "Track: " + tracks[0].DisplayName()
if len(tracks) > 1 {
title = fmt.Sprintf("%d tracks selected", len(tracks))
}
m.openPlaylistPicker(tracks, title)
}
case "o":
m.openFileBrowserForPlaylist(m.plManager.selPlaylist)
case "d":
m.plMgrRemoveSelectedTracks()
case "u":
m.plMgrUndoLast()
case "esc", "backspace", "h", "left":
if m.plManager.filter != "" {
m.plMgrResetFilter()
return nil
}
// Go back to playlist list.
m.plMgrRefreshList()
m.plManager.screen = plMgrScreenList
m.plMgrResetFilter()
// Try to position cursor on the playlist we just left.
for i, pl := range m.plManager.playlists {
if pl.Name == m.plManager.selPlaylist {
m.plManager.cursor = i
break
}
}
m.plManager.confirmDel = false
}
return nil
}
// plMgrLoadAndPlay replaces the live playlist with the manager's tracks and
// starts playback at startIdx.
func (m *Model) plMgrLoadAndPlay(startIdx int) tea.Cmd {
m.player.Stop()
m.player.ClearPreload()
m.resetYTDLBatch()
m.playlist.Replace(m.plManager.tracks)
m.setHeaderStateFromTracks(m.plManager.tracks)
m.loadedPlaylist = m.plManager.selPlaylist
if startIdx < 0 || startIdx >= m.playlist.Len() {
startIdx = 0
}
m.plCursor = startIdx
m.playlist.SetIndex(startIdx)
m.adjustScroll()
m.plManager.visible = false
m.plMgrResetFilter()
m.focus = focusPlaylist
cmd := m.playCurrentTrack()
m.notifyPlayback()
return cmd
}
// handlePlMgrNewNameKey handles keys on screen 2 (new playlist name input).
func (m *Model) handlePlMgrNewNameKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.plManager.screen = plMgrScreenList
case tea.KeyEnter:
name := strings.TrimSpace(m.plManager.newName)
if name != "" {
m.createPlaylistFromManager(name)
m.plMgrRefreshList()
m.plManager.screen = plMgrScreenList
}
case tea.KeyBackspace:
m.plManager.newName = removeLastRune(m.plManager.newName)
case tea.KeySpace:
m.plManager.newName += " "
default:
if len(msg.Text) > 0 {
m.plManager.newName += msg.Text
}
}
return nil
}
// handlePlMgrRenameKey handles keys on screen 3 (rename playlist input).
func (m *Model) handlePlMgrRenameKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.Code {
case tea.KeyEscape:
m.plManager.screen = plMgrScreenList
case tea.KeyEnter:
m.plMgrCommitRename()
m.plManager.screen = plMgrScreenList
case tea.KeyBackspace:
m.plManager.renameName = removeLastRune(m.plManager.renameName)
case tea.KeySpace:
m.plManager.renameName += " "
default:
if len(msg.Text) > 0 {
m.plManager.renameName += msg.Text
}
}
return nil
}
// plMgrCommitRename applies the pending rename. No-op when the name is
// empty, unchanged, or the local provider doesn't support renaming.
func (m *Model) plMgrCommitRename() {
newName := strings.TrimSpace(m.plManager.renameName)
oldName := m.plManager.renameOldName
if newName == "" || newName == oldName {
return
}
r, ok := m.localProvider.(provider.PlaylistRenamer)
if !ok {
return
}
if err := r.RenamePlaylist(oldName, newName); err != nil {
m.status.Showf(statusTTLDefault, "Rename failed: %s", err)
return
}
m.status.Showf(statusTTLDefault, "Renamed %q to %q", oldName, newName)
if m.loadedPlaylist == oldName {
m.loadedPlaylist = newName
}
m.plMgrRefreshList()
}
func (m *Model) localSaver() provider.PlaylistSaver {
s, _ := m.localProvider.(provider.PlaylistSaver)
return s
}
func cloneTracks(tracks []playlist.Track) []playlist.Track {
return append([]playlist.Track(nil), tracks...)
}
func (m *Model) plMgrSetTrackUndo() {
m.plManager.undo = plManagerUndo{
kind: plUndoTracks,
name: m.plManager.selPlaylist,
tracks: cloneTracks(m.plManager.tracks),
}
}
func (m *Model) plMgrUndoLast() {
undo := m.plManager.undo
if undo.kind == plUndoNone || undo.name == "" {
m.status.Show("Nothing to undo", statusTTLShort)
return
}
saver := m.localSaver()
if saver == nil {
m.status.Show("Undo unavailable", statusTTLDefault)
return
}
if err := saver.SavePlaylist(undo.name, cloneTracks(undo.tracks)); err != nil {
m.status.Showf(statusTTLDefault, "Undo failed: %s", err)
return
}
m.plManager.undo = plManagerUndo{}
m.plMgrRefreshList()
if m.plManager.screen == plMgrScreenTracks && m.plManager.selPlaylist == undo.name {
m.plManager.tracks = cloneTracks(undo.tracks)
m.plManager.marked = make(map[int]bool)
m.plMgrRecomputeFilter()
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
m.status.Showf(statusTTLDefault, "Restored %q", undo.name)
}
func (m *Model) plMgrSelectedTrackIndices() []int {
if len(m.plManager.marked) > 0 {
indices := make([]int, 0, len(m.plManager.marked))
for idx := range m.plManager.marked {
if idx >= 0 && idx < len(m.plManager.tracks) {
indices = append(indices, idx)
}
}
sort.Ints(indices)
return indices
}
realIdx := m.plMgrTrackRealIndex(m.plManager.cursor)
if realIdx < 0 {
return nil
}
return []int{realIdx}
}
func (m *Model) plMgrSelectedTracks() []playlist.Track {
indices := m.plMgrSelectedTrackIndices()
tracks := make([]playlist.Track, 0, len(indices))
for _, idx := range indices {
tracks = append(tracks, m.plManager.tracks[idx])
}
return tracks
}
func (m *Model) plMgrToggleMark(realIdx int) {
if realIdx < 0 || realIdx >= len(m.plManager.tracks) {
return
}
if m.plManager.marked == nil {
m.plManager.marked = make(map[int]bool)
}
if m.plManager.marked[realIdx] {
delete(m.plManager.marked, realIdx)
return
}
m.plManager.marked[realIdx] = true
}
func (m *Model) plMgrToggleMarkAll() {
count := m.plMgrTracksViewCount()
if count == 0 {
return
}
if m.plManager.marked == nil {
m.plManager.marked = make(map[int]bool)
}
allMarked := true
for i := 0; i < count; i++ {
realIdx := m.plMgrTrackRealIndex(i)
if realIdx >= 0 && !m.plManager.marked[realIdx] {
allMarked = false
break
}
}
for i := 0; i < count; i++ {
realIdx := m.plMgrTrackRealIndex(i)
if realIdx < 0 {
continue
}
if allMarked {
delete(m.plManager.marked, realIdx)
} else {
m.plManager.marked[realIdx] = true
}
}
}
func (m *Model) plMgrSaveTracks(status string) bool {
saver := m.localSaver()
if saver == nil {
m.status.Show("Playlist saving is not supported", statusTTLDefault)
return false
}
if err := saver.SavePlaylist(m.plManager.selPlaylist, m.plManager.tracks); err != nil {
m.status.Showf(statusTTLDefault, "Save failed: %s", err)
return false
}
if status != "" {
m.status.Show(status, statusTTLDefault)
}
return true
}
func (m *Model) plMgrRemoveSelectedTracks() {
indices := m.plMgrSelectedTrackIndices()
if len(indices) == 0 {
return
}
m.plMgrSetTrackUndo()
for i := len(indices) - 1; i >= 0; i-- {
idx := indices[i]
m.plManager.tracks = append(m.plManager.tracks[:idx], m.plManager.tracks[idx+1:]...)
}
m.plManager.marked = make(map[int]bool)
if !m.plMgrSaveTracks(fmt.Sprintf("Removed %d track(s) from %q", len(indices), m.plManager.selPlaylist)) {
m.plManager.tracks = cloneTracks(m.plManager.undo.tracks)
return
}
if m.plManager.filter != "" {
m.plMgrRecomputeFilter()
}
newCount := m.plMgrTracksViewCount()
if m.plManager.cursor >= newCount {
m.plManager.cursor = newCount - 1
}
if m.plManager.cursor < 0 {
m.plManager.cursor = 0
}
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
}
func (m *Model) plMgrMoveTrack(delta int) {
if m.plManager.filter != "" {
m.status.Show("Clear filter before moving tracks", statusTTLDefault)
return
}
from := m.plManager.cursor
to := from + delta
if from < 0 || from >= len(m.plManager.tracks) || to < 0 || to >= len(m.plManager.tracks) {
return
}
m.plMgrSetTrackUndo()
m.plManager.tracks[from], m.plManager.tracks[to] = m.plManager.tracks[to], m.plManager.tracks[from]
m.plManager.cursor = to
m.plManager.marked = make(map[int]bool)
if m.plMgrSaveTracks(fmt.Sprintf("Reordered %q", m.plManager.selPlaylist)) {
m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible())
} else {
m.plManager.tracks = cloneTracks(m.plManager.undo.tracks)
}
}
var plMgrSortModes = []string{"track", "title", "artist", "album", "artist+album", "path"}
func (m *Model) plMgrSortTracks() {
if len(m.plManager.tracks) < 2 {
return
}
m.plMgrSetTrackUndo()
mode := plMgrSortModes[m.plManager.sortMode%len(plMgrSortModes)]
m.plManager.sortMode++
sort.SliceStable(m.plManager.tracks, func(i, j int) bool {
return compareUITracks(m.plManager.tracks[i], m.plManager.tracks[j], mode) < 0
})
m.plManager.marked = make(map[int]bool)
if m.plMgrSaveTracks(fmt.Sprintf("Sorted %q by %s", m.plManager.selPlaylist, mode)) {
m.plManager.cursor = 0
m.plManager.scroll = 0
m.plMgrRecomputeFilter()
} else {
m.plManager.tracks = cloneTracks(m.plManager.undo.tracks)
}
}
func compareUITracks(a, b playlist.Track, mode string) int {
cmpString := func(x, y string) int {
return strings.Compare(strings.ToLower(x), strings.ToLower(y))
}
first := func(values ...int) int {
for _, v := range values {
if v != 0 {
return v
}
}
return 0
}
switch mode {
case "track":
return first(a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "artist":
return first(cmpString(a.Artist, b.Artist), cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "album":
return first(cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "artist+album":
return first(cmpString(a.Artist, b.Artist), cmpString(a.Album, b.Album), a.TrackNumber-b.TrackNumber, cmpString(a.Title, b.Title), cmpString(a.Path, b.Path))
case "path":
return cmpString(a.Path, b.Path)
default:
return first(cmpString(a.Title, b.Title), cmpString(a.Artist, b.Artist), cmpString(a.Path, b.Path))
}
}
func (m *Model) persistLoadedPlaylistOrder() {
if m.loadedPlaylist == "" {
return
}
saver, ok := m.localProvider.(provider.PlaylistSaver)
if !ok {
return
}
if err := saver.SavePlaylist(m.loadedPlaylist, m.playlist.Tracks()); err != nil {
m.status.Showf(statusTTLDefault, "Save failed: %s", err)
return
}
m.status.Showf(statusTTLDefault, "Reordered %q", m.loadedPlaylist)
}
// addToPlaylist appends the current track to a local playlist and shows a status message.
func (m *Model) addToPlaylist(name string) {
track, idx := m.currentPlaybackTrack()
if idx < 0 {
m.status.Show("No track to add", statusTTLShort)
return
}
if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok {
added, skipped, err := bw.AddTracksToPlaylist(context.Background(), name, []playlist.Track{track})
if err != nil {
m.status.Showf(statusTTLDefault, "Failed: %s", err)
return
}
switch {
case added > 0:
m.status.Showf(statusTTLDefault, "Added to %q", name)
case skipped > 0:
m.status.Showf(statusTTLDefault, "Already in %q", name)
default:
m.status.Showf(statusTTLDefault, "Nothing added to %q", name)
}
return
}
if w, ok := m.localProvider.(provider.PlaylistWriter); ok {
if err := w.AddTrackToPlaylist(context.Background(), name, track); err != nil {
m.status.Showf(statusTTLDefault, "Failed: %s", err)
} else {
m.status.Showf(statusTTLDefault, "Added to %q", name)
}
}
}
func (m *Model) createPlaylistFromManager(name string) {
c, ok := m.localProvider.(provider.PlaylistCreator)
if !ok {
m.status.Show("Playlist creation is not supported", statusTTLDefault)
return
}
id, err := c.CreatePlaylist(context.Background(), name)
if err != nil {
m.status.Showf(statusTTLDefault, "Create failed: %s", err)
return
}
track, idx := m.currentPlaybackTrack()
if idx < 0 {
m.status.Showf(statusTTLDefault, "Created %q", name)
return
}
if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok {
added, skipped, err := bw.AddTracksToPlaylist(context.Background(), id, []playlist.Track{track})
if err != nil {
m.status.Showf(statusTTLDefault, "Created %q, add failed: %s", name, err)
return
}
if added > 0 {
m.status.Showf(statusTTLDefault, "Created %q & added track", name)
} else if skipped > 0 {
m.status.Showf(statusTTLDefault, "Created %q; track was duplicate", name)
}
return
}
if w, ok := m.localProvider.(provider.PlaylistWriter); ok {
if err := w.AddTrackToPlaylist(context.Background(), id, track); err != nil {
m.status.Showf(statusTTLDefault, "Created %q, add failed: %s", name, err)
return
}
m.status.Showf(statusTTLDefault, "Created %q & added track", name)
}
}
// handleThemeKey processes key presses while the theme picker is open.
func (m *Model) handleThemeKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.themes) + 1 // +1 for Default
switch msg.String() {
case "ctrl+c":
m.themePickerCancel()
return m.quit()
case "up", "k":
if m.themePicker.cursor > 0 {
m.themePicker.cursor--
} else if count > 0 {
m.themePicker.cursor = count - 1
}
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
case "down", "j":
if m.themePicker.cursor < count-1 {
m.themePicker.cursor++
} else if count > 0 {
m.themePicker.cursor = 0
}
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
case "ctrl+x":
m.toggleExpandedView()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
case "pgup", "ctrl+u":
if m.themePicker.cursor > 0 {
visible := m.themePickerVisible()
m.themePicker.cursor -= min(m.themePicker.cursor, visible)
m.themePickerApply()
m.themePickerMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
if m.themePicker.cursor < count-1 {
visible := m.themePickerVisible()
m.themePicker.cursor = min(count-1, m.themePicker.cursor+visible)
m.themePickerApply()
m.themePickerMaybeAdjustScroll(visible)
}
case "home", "g":
m.themePicker.cursor = 0
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
case "end", "G":
if count > 0 {
m.themePicker.cursor = count - 1
}
m.themePickerApply()
m.themePickerMaybeAdjustScroll(m.themePickerVisible())
case "enter":
m.themePickerSelect()
case "esc", "q", "t":
m.themePickerCancel()
}
return nil
}
// handleVisPickerKey processes key presses while the visualizer picker is open.
func (m *Model) handleVisPickerKey(msg tea.KeyPressMsg) tea.Cmd {
count := len(m.visPicker.modes)
switch msg.String() {
case "ctrl+c":
m.visPickerCancel()
return m.quit()
case "up", "k":
if m.visPicker.cursor > 0 {
m.visPicker.cursor--
} else if count > 0 {
m.visPicker.cursor = count - 1
}
m.visPickerApply()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
case "down", "j":
if m.visPicker.cursor < count-1 {
m.visPicker.cursor++
} else if count > 0 {
m.visPicker.cursor = 0
}
m.visPickerApply()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
case "ctrl+x":
m.toggleExpandedView()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
case "pgup", "ctrl+u":
if m.visPicker.cursor > 0 {
visible := m.visPickerVisible()
m.visPicker.cursor -= min(m.visPicker.cursor, visible)
m.visPickerApply()
m.visPickerMaybeAdjustScroll(visible)
}
case "pgdown", "ctrl+d":
if m.visPicker.cursor < count-1 {
visible := m.visPickerVisible()
m.visPicker.cursor = min(count-1, m.visPicker.cursor+visible)
m.visPickerApply()
m.visPickerMaybeAdjustScroll(visible)
}
case "home", "g":
m.visPicker.cursor = 0
m.visPickerApply()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
case "end", "G":
if count > 0 {
m.visPicker.cursor = count - 1
}
m.visPickerApply()
m.visPickerMaybeAdjustScroll(m.visPickerVisible())
case "enter":
m.visPickerSelect()
case "esc", "q", "ctrl+v":
m.visPickerCancel()
}
return nil
}
// handleQueueKey processes key presses while the queue manager overlay is open.
func (m *Model) queueMaybeAdjustScroll(visible int) {
clampScroll(&m.queue.cursor, &m.queue.scroll, m.playlist.QueueLen(), visible)
}
func (m *Model) handleQueueKey(msg tea.KeyPressMsg) tea.Cmd {
qLen := m.playlist.QueueLen()
switch msg.String() {
case "ctrl+c":
m.queue.visible = false
return m.quit()
case "ctrl+k", "?":
m.openKeymap()
case "ctrl+x":
m.toggleExpandedView()
m.queueMaybeAdjustScroll(m.queueVisible())
case "up", "k":
if m.queue.cursor > 0 {
m.queue.cursor--
} else if qLen > 0 {
m.queue.cursor = qLen - 1
}
m.queueMaybeAdjustScroll(m.queueVisible())
case "down", "j":
if m.queue.cursor < qLen-1 {
m.queue.cursor++
} else if qLen > 0 {
m.queue.cursor = 0
}
m.queueMaybeAdjustScroll(m.queueVisible())
case "shift+up":
if m.queue.cursor > 0 {
if m.playlist.MoveQueue(m.queue.cursor, m.queue.cursor-1) {
m.queue.cursor--
}
}
m.queueMaybeAdjustScroll(m.queueVisible())
case "shift+down":
if m.queue.cursor < qLen-1 {
if m.playlist.MoveQueue(m.queue.cursor, m.queue.cursor+1) {
m.queue.cursor++
}
}
m.queueMaybeAdjustScroll(m.queueVisible())
case "d":
if qLen > 0 {
m.playlist.RemoveQueueAt(m.queue.cursor)
if m.queue.cursor >= m.playlist.QueueLen() && m.queue.cursor > 0 {
m.queue.cursor--
}
}
m.queueMaybeAdjustScroll(m.queueVisible())
case "c":
m.playlist.ClearQueue()
m.queue.visible = false
case "esc", "A":
m.queue.visible = false
}
return nil
}
func (m *Model) deviceMaybeAdjustScroll(visible int) {
clampScroll(&m.devicePicker.cursor, &m.devicePicker.scroll, len(m.devicePicker.devices), visible)
}
// handleDeviceKey processes key presses while the audio device picker is open.
func (m *Model) handleDeviceKey(msg tea.KeyPressMsg) tea.Cmd {
switch msg.String() {
case "ctrl+c":
m.devicePicker.visible = false
return m.quit()
case "ctrl+x":
m.toggleExpandedView()
m.deviceMaybeAdjustScroll(m.devicePickerVisible())
case "up", "k":
if m.devicePicker.cursor > 0 {
m.devicePicker.cursor--
} else if len(m.devicePicker.devices) > 0 {
m.devicePicker.cursor = len(m.devicePicker.devices) - 1
}
m.deviceMaybeAdjustScroll(m.devicePickerVisible())
case "down", "j":
if m.devicePicker.cursor < len(m.devicePicker.devices)-1 {
m.devicePicker.cursor++
} else if len(m.devicePicker.devices) > 0 {
m.devicePicker.cursor = 0
}
m.deviceMaybeAdjustScroll(m.devicePickerVisible())
case "enter":
if len(m.devicePicker.devices) > 0 && m.devicePicker.cursor < len(m.devicePicker.devices) {
dev := m.devicePicker.devices[m.devicePicker.cursor]
m.devicePicker.visible = false
return switchDeviceCmd(dev.Name)
}
case "esc", "d":
m.devicePicker.visible = false
}
return nil
}