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 }