diff --git a/.gitignore b/.gitignore index 97aafc0..0de1793 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ mp3/ navdata/ plans/ test.md +/roadmap.md spotify.log .DS_Store docs/ideas.md diff --git a/docs/keybindings.md b/docs/keybindings.md index 6dff8e6..3bce4be 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -1,6 +1,6 @@ # Keybindings -Press `?` or `Ctrl+K` in the player to see all keybindings. +Press `Ctrl+K` from any mode, or `?` from the player, to see all keybindings. ## Playback @@ -129,7 +129,7 @@ When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Sp | `a` | Append all visible tracks to the queue | | `q` | Queue the highlighted track to play next | | `s` | Cycle album sort (album list only) | -| `S` `N` `P` `J` `E` `Y` `C` `M` `Q` `L` `R` | Quick-switch to that provider without going back through the main pane | +| `S` `N` `P` `J` `E` `Y` `C` `M` `Q` `L` | Quick-switch to that provider without going back through the main pane. `R` replaces the queue on the track screen. | | `Esc` `b` | Walk back one level / close the browser | The track screen shows a `N tracks · 47:22` subtitle and right-aligned per-track durations when the provider returns them. diff --git a/main.go b/main.go index 8c7d9de..54597fc 100644 --- a/main.go +++ b/main.go @@ -407,13 +407,13 @@ func run(overrides config.Overrides, positional []string, daemon bool) error { if spotifyProv != nil { spotify.SetAuthURLObserver(func(u string) { - prog.Send(model.ProvAuthURLMsg{URL: u}) + prog.Send(model.ProvAuthURLMsg{ProviderName: spotifyProv.Name(), URL: u}) }) defer spotify.SetAuthURLObserver(nil) } if qobuzProv != nil { qobuz.SetAuthURLObserver(func(u string) { - prog.Send(model.ProvAuthURLMsg{URL: u}) + prog.Send(model.ProvAuthURLMsg{ProviderName: qobuzProv.Name(), URL: u}) }) defer qobuz.SetAuthURLObserver(nil) } diff --git a/site/index.html b/site/index.html index 25b1760..1ed8a46 100644 --- a/site/index.html +++ b/site/index.html @@ -612,7 +612,7 @@ footer{border-top:1px solid var(--line);background:var(--ink-2);padding:46px 0 5 ↑↓ Scroll Enter Play Spc ▶❚❚ - * Fav + f Fav Tab Focus ? Keys diff --git a/ui/model/commands.go b/ui/model/commands.go index 164db39..ed7f15c 100644 --- a/ui/model/commands.go +++ b/ui/model/commands.go @@ -48,6 +48,15 @@ type tracksLoadedMsg struct { playlistID string providerName string playlistExact bool + gen uint64 + err error +} + +type playlistsLoadedMsg struct { + playlists []playlist.PlaylistInfo + providerName string + gen uint64 + err error } // feedsLoadedMsg carries tracks resolved from remote feed/M3U URLs, @@ -68,6 +77,8 @@ type feedTrackResolvedMsg struct { type lyricsLoadedMsg struct { lines []lyrics.Line err error + query string + gen uint64 } // netSearchResultsMsg carries the result set of a yt-dlp/sc-dlp search query @@ -75,13 +86,22 @@ type lyricsLoadedMsg struct { type netSearchResultsMsg struct { tracks []playlist.Track err error + query string + gen uint64 } // streamPlayedMsg signals that async stream Play() completed. -type streamPlayedMsg struct{ err error } +type streamPlayedMsg struct { + path string + gen uint64 + err error +} // streamPreloadedMsg signals that async stream Preload() completed. -type streamPreloadedMsg struct{} +type streamPreloadedMsg struct { + path string + gen uint64 +} type attachNotifierMsg struct{ notifier playback.Notifier } @@ -110,25 +130,42 @@ type ytdlSavedMsg struct { // — Navidrome browser message types — // navArtistsLoadedMsg carries the full artist list from a provider browser. -type navArtistsLoadedMsg []provider.ArtistInfo +type navArtistsLoadedMsg struct { + artists []provider.ArtistInfo + gen uint64 + err error +} // navAlbumsLoadedMsg carries one page of albums and the fetch offset. type navAlbumsLoadedMsg struct { albums []provider.AlbumInfo offset int // the offset this page was requested at isLast bool // true when the server returned fewer than the requested page size + gen uint64 + err error } // navTracksLoadedMsg carries the track list from a provider.AlbumTrackLoader. -type navTracksLoadedMsg []playlist.Track +type navTracksLoadedMsg struct { + tracks []playlist.Track + gen uint64 + err error +} // provAuthDoneMsg signals that interactive provider authentication completed. -type provAuthDoneMsg struct{ err error } +type provAuthDoneMsg struct { + providerName string + gen uint64 + err error +} // ProvAuthURLMsg carries the OAuth URL produced by a provider's interactive // auth flow so the TUI can display it. Used as a fallback when the launched // browser doesn't reach the user (e.g. inside containers or headless envs). -type ProvAuthURLMsg struct{ URL string } +type ProvAuthURLMsg struct { + ProviderName string + URL string +} // — Command constructors — @@ -151,19 +188,16 @@ func switchDeviceCmd(name string) tea.Cmd { } // authenticateProviderCmd runs the interactive auth flow for a provider. -func authenticateProviderCmd(auth playlist.Authenticator) tea.Cmd { +func authenticateProviderCmd(auth playlist.Authenticator, providerName string, gen uint64) tea.Cmd { return func() tea.Msg { - return provAuthDoneMsg{err: auth.Authenticate()} + return provAuthDoneMsg{providerName: providerName, gen: gen, err: auth.Authenticate()} } } -func fetchPlaylistsCmd(prov playlist.Provider) tea.Cmd { +func fetchPlaylistsCmd(prov playlist.Provider, gen uint64) tea.Cmd { return func() tea.Msg { pls, err := prov.Playlists() - if err != nil { - return err - } - return pls + return playlistsLoadedMsg{playlists: pls, providerName: prov.Name(), gen: gen, err: err} } } @@ -194,60 +228,60 @@ func resolveRemoteCmd(urls []string, autoPlay bool) tea.Cmd { } } -func fetchLyricsCmd(artist, title string) tea.Cmd { +func fetchLyricsCmd(artist, title, query string, gen uint64) tea.Cmd { return func() tea.Msg { lines, err := lyrics.Fetch(artist, title) - return lyricsLoadedMsg{lines: lines, err: err} + return lyricsLoadedMsg{lines: lines, err: err, query: query, gen: gen} } } -func fetchTrackLyricsCmd(track playlist.Track, artist, title string) tea.Cmd { +func fetchTrackLyricsCmd(track playlist.Track, artist, title, query string, gen uint64) tea.Cmd { return func() tea.Msg { if lines := lyrics.ParseEmbedded(track.EmbeddedLyrics); len(lines) > 0 { - return lyricsLoadedMsg{lines: lines} + return lyricsLoadedMsg{lines: lines, query: query, gen: gen} } lines, err := lyrics.Fetch(artist, title) - return lyricsLoadedMsg{lines: lines, err: err} + return lyricsLoadedMsg{lines: lines, err: err, query: query, gen: gen} } } -func fetchNetSearchCmd(query string) tea.Cmd { +func fetchNetSearchCmd(query string, gen uint64) tea.Cmd { return func() tea.Msg { tracks, err := resolve.Remote([]string{query}) - return netSearchResultsMsg{tracks: tracks, err: err} + return netSearchResultsMsg{tracks: tracks, err: err, query: query, gen: gen} } } -func playStreamCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd { +func playStreamCmd(p player.Engine, path string, knownDuration time.Duration, gen uint64) tea.Cmd { return func() tea.Msg { - return streamPlayedMsg{err: p.Play(path, knownDuration)} + return streamPlayedMsg{path: path, gen: gen, err: p.Play(path, knownDuration)} } } -func preloadStreamCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd { +func preloadStreamCmd(p player.Engine, path string, knownDuration time.Duration, gen uint64) tea.Cmd { return func() tea.Msg { p.Preload(path, knownDuration) // errors silently ignored - return streamPreloadedMsg{} + return streamPreloadedMsg{path: path, gen: gen} } } -func preloadLocalCmd(p player.Engine, path string, knownDuration time.Duration) tea.Cmd { +func preloadLocalCmd(p player.Engine, path string, knownDuration time.Duration, gen uint64) tea.Cmd { return func() tea.Msg { p.Preload(path, knownDuration) - return streamPreloadedMsg{} + return streamPreloadedMsg{path: path, gen: gen} } } -func playYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration) tea.Cmd { +func playYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration, gen uint64) tea.Cmd { return func() tea.Msg { - return streamPlayedMsg{err: p.PlayYTDL(pageURL, knownDuration)} + return streamPlayedMsg{path: pageURL, gen: gen, err: p.PlayYTDL(pageURL, knownDuration)} } } -func preloadYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration) tea.Cmd { +func preloadYTDLStreamCmd(p player.Engine, pageURL string, knownDuration time.Duration, gen uint64) tea.Cmd { return func() tea.Msg { p.PreloadYTDL(pageURL, knownDuration) // errors silently ignored - return streamPreloadedMsg{} + return streamPreloadedMsg{path: pageURL, gen: gen} } } @@ -258,16 +292,16 @@ func saveYTDLCmd(pageURL string, saveDir string) tea.Cmd { } } -func fetchTracksCmd(prov playlist.Provider, playlistID string) tea.Cmd { +func fetchTracksCmd(prov playlist.Provider, playlistID string, gen uint64) tea.Cmd { return func() tea.Msg { tracks, err := prov.Tracks(playlistID) if err != nil { - return err + return tracksLoadedMsg{playlistID: playlistID, providerName: prov.Name(), gen: gen, err: err} } // Resolve PLS/M3U wrapper URLs to actual stream URLs so the // player receives a direct audio stream instead of a playlist file. tracks, expanded := resolveWrapperURLs(tracks) - return tracksLoadedMsg{tracks: tracks, playlistID: playlistID, providerName: prov.Name(), playlistExact: !expanded} + return tracksLoadedMsg{tracks: tracks, playlistID: playlistID, providerName: prov.Name(), playlistExact: !expanded, gen: gen} } } @@ -304,61 +338,53 @@ func resolveWrapperURLs(tracks []playlist.Track) ([]playlist.Track, bool) { const navAlbumPageSize = 100 -func fetchNavArtistsCmd(b provider.ArtistBrowser) tea.Cmd { +func fetchNavArtistsCmd(b provider.ArtistBrowser, gen uint64) tea.Cmd { return func() tea.Msg { artists, err := b.Artists() - if err != nil { - return err - } - return navArtistsLoadedMsg(artists) + return navArtistsLoadedMsg{artists: artists, gen: gen, err: err} } } -func fetchNavArtistAlbumsCmd(b provider.ArtistBrowser, artistID string) tea.Cmd { +func fetchNavArtistAlbumsCmd(b provider.ArtistBrowser, artistID string, gen uint64) tea.Cmd { return func() tea.Msg { albums, err := b.ArtistAlbums(artistID) - if err != nil { - return err - } // Artist album lists are complete in one call — treat as last page. - return navAlbumsLoadedMsg{albums: albums, offset: 0, isLast: true} + return navAlbumsLoadedMsg{albums: albums, offset: 0, isLast: true, gen: gen, err: err} } } -func fetchNavAlbumListCmd(b provider.AlbumBrowser, sortType string, offset int) tea.Cmd { +func fetchNavAlbumListCmd(b provider.AlbumBrowser, sortType string, offset int, gen uint64) tea.Cmd { return func() tea.Msg { albums, err := b.AlbumList(sortType, offset, navAlbumPageSize) - if err != nil { - return err - } return navAlbumsLoadedMsg{ albums: albums, offset: offset, isLast: len(albums) < navAlbumPageSize, + gen: gen, + err: err, } } } -func fetchNavAlbumTracksCmd(l provider.AlbumTrackLoader, albumID string) tea.Cmd { +func fetchNavAlbumTracksCmd(l provider.AlbumTrackLoader, albumID string, gen uint64) tea.Cmd { return func() tea.Msg { tracks, err := l.AlbumTracks(albumID) - if err != nil { - return err - } - return navTracksLoadedMsg(tracks) + return navTracksLoadedMsg{tracks: tracks, gen: gen, err: err} } } // catalogSearchMsg carries the result of a provider.CatalogSearcher.SearchCatalog call. type catalogSearchMsg struct { - count int - err error + count int + providerName string + gen uint64 + err error } -func fetchCatalogSearchCmd(s provider.CatalogSearcher, query string) tea.Cmd { +func fetchCatalogSearchCmd(s provider.CatalogSearcher, providerName, query string, gen uint64) tea.Cmd { return func() tea.Msg { count, err := s.SearchCatalog(query) - return catalogSearchMsg{count: count, err: err} + return catalogSearchMsg{count: count, providerName: providerName, gen: gen, err: err} } } @@ -369,49 +395,58 @@ const catalogBatchSize = 100 // catalogBatchMsg carries the result of a provider.CatalogLoader.LoadCatalogPage call. type catalogBatchMsg struct { - added int - err error + added int + providerName string + gen uint64 + err error } -func fetchCatalogBatchCmd(loader provider.CatalogLoader, offset, limit int) tea.Cmd { +func fetchCatalogBatchCmd(loader provider.CatalogLoader, offset, limit int, providerName string, gen uint64) tea.Cmd { return func() tea.Msg { added, err := loader.LoadCatalogPage(offset, limit) - return catalogBatchMsg{added: added, err: err} + return catalogBatchMsg{added: added, providerName: providerName, gen: gen, err: err} } } // — Spotify search + add-to-playlist messages — type spotSearchResultsMsg struct { - tracks []playlist.Track - err error + tracks []playlist.Track + err error + providerName string + query string + gen uint64 } type spotPlaylistsMsg struct { - playlists []playlist.PlaylistInfo - err error + playlists []playlist.PlaylistInfo + err error + providerName string + gen uint64 } type spotAddedMsg struct { - name string - err error + name string + err error + providerName string + gen uint64 } type spotCreatedMsg struct { - name string - err error + name string + err error + providerName string + gen uint64 } -func fetchSpotSearchCmd(s provider.Searcher, query string) tea.Cmd { +func fetchSpotSearchCmd(ctx context.Context, s provider.Searcher, providerName, query string, gen uint64) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() tracks, err := s.SearchTracks(ctx, query, 20) - return spotSearchResultsMsg{tracks: tracks, err: err} + return spotSearchResultsMsg{tracks: tracks, err: err, providerName: providerName, query: query, gen: gen} } } -func fetchSpotPlaylistsCmd(prov playlist.Provider) tea.Cmd { +func fetchSpotPlaylistsCmd(prov playlist.Provider, gen uint64) tea.Cmd { return func() tea.Msg { playlists, err := prov.Playlists() if err == nil && prov.Name() == "Local" { @@ -423,28 +458,24 @@ func fetchSpotPlaylistsCmd(prov playlist.Provider) tea.Cmd { } playlists = filtered } - return spotPlaylistsMsg{playlists: playlists, err: err} + return spotPlaylistsMsg{playlists: playlists, err: err, providerName: prov.Name(), gen: gen} } } -func addToSpotPlaylistCmd(w provider.PlaylistWriter, playlistID string, track playlist.Track, name string) tea.Cmd { +func addToSpotPlaylistCmd(ctx context.Context, w provider.PlaylistWriter, playlistID string, track playlist.Track, providerName, name string, gen uint64) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() err := w.AddTrackToPlaylist(ctx, playlistID, track) - return spotAddedMsg{name: name, err: err} + return spotAddedMsg{name: name, err: err, providerName: providerName, gen: gen} } } -func createSpotPlaylistCmd(c provider.PlaylistCreator, w provider.PlaylistWriter, name string, track playlist.Track) tea.Cmd { +func createSpotPlaylistCmd(ctx context.Context, c provider.PlaylistCreator, w provider.PlaylistWriter, providerName, name string, track playlist.Track, gen uint64) tea.Cmd { return func() tea.Msg { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() id, err := c.CreatePlaylist(ctx, name) if err != nil { - return spotCreatedMsg{name: name, err: err} + return spotCreatedMsg{name: name, err: err, providerName: providerName, gen: gen} } err = w.AddTrackToPlaylist(ctx, id, track) - return spotCreatedMsg{name: name, err: err} + return spotCreatedMsg{name: name, err: err, providerName: providerName, gen: gen} } } diff --git a/ui/model/commands_test.go b/ui/model/commands_test.go index e824935..50a7482 100644 --- a/ui/model/commands_test.go +++ b/ui/model/commands_test.go @@ -41,12 +41,12 @@ func TestFetchSpotPlaylistsFiltersHistoryOnlyForLocal(t *testing.T) { {ID: "mix", Name: "Mix"}, } - msg := fetchSpotPlaylistsCmd(commandsTestProvider{name: "Spotify", lists: lists})().(spotPlaylistsMsg) + msg := fetchSpotPlaylistsCmd(commandsTestProvider{name: "Spotify", lists: lists}, 1)().(spotPlaylistsMsg) if len(msg.playlists) != 2 { t.Fatalf("Spotify playlists = %d, want 2", len(msg.playlists)) } - msg = fetchSpotPlaylistsCmd(commandsTestProvider{name: "Local", lists: lists})().(spotPlaylistsMsg) + msg = fetchSpotPlaylistsCmd(commandsTestProvider{name: "Local", lists: lists}, 2)().(spotPlaylistsMsg) if len(msg.playlists) != 1 || msg.playlists[0].Name != "Mix" { t.Fatalf("Local playlists = %+v, want only Mix", msg.playlists) } @@ -58,14 +58,17 @@ func TestTracksLoadedMsgMarksOnlyExactLocalPlaylist(t *testing.T) { player: player, playlist: playlist.New(), localProvider: commandsTestProvider{name: "Local"}, + provider: commandsTestProvider{name: "Local"}, vis: ui.NewVisualizer(float64(player.SampleRate())), } + m.requests.tracks = 1 updated, _ := m.Update(tracksLoadedMsg{ tracks: []playlist.Track{{Path: "/a.mp3", Title: "A"}}, playlistID: "mix", providerName: "Local", playlistExact: true, + gen: 1, }) m = updated.(Model) if m.loadedPlaylist != "mix" { @@ -77,6 +80,7 @@ func TestTracksLoadedMsgMarksOnlyExactLocalPlaylist(t *testing.T) { playlistID: "mix", providerName: "Local", playlistExact: false, + gen: 1, }) m = updated.(Model) if m.loadedPlaylist != "" { diff --git a/ui/model/init.go b/ui/model/init.go index 07c5517..31551b4 100644 --- a/ui/model/init.go +++ b/ui/model/init.go @@ -192,7 +192,10 @@ func (m Model) Init() tea.Cmd { } cmds := []tea.Cmd{tickCmd(), func() tea.Msg { return tea.RequestWindowSize() }} if m.provider != nil { - cmds = append(cmds, fetchPlaylistsCmd(m.provider)) + // Init has a value receiver, so it must not advance a request generation + // on its private model copy. The initial zero generation is current until + // the user starts another provider request. + cmds = append(cmds, fetchPlaylistsCmd(m.provider, m.requests.provider)) } if len(m.pendingURLs) > 0 { cmds = append(cmds, resolveRemoteCmd(m.pendingURLs, m.autoPlay)) diff --git a/ui/model/inline_overlays.go b/ui/model/inline_overlays.go index b4e62e1..d038a00 100644 --- a/ui/model/inline_overlays.go +++ b/ui/model/inline_overlays.go @@ -125,6 +125,16 @@ func (m Model) activeOverlay() (overlayView, bool) { switch { case m.keymap.visible: return overlayView{(*Model).keymapHeaderLine, (*Model).keymapHelpLine, (*Model).renderKeymapList}, true + case m.devicePicker.visible: + return overlayView{(*Model).deviceHeaderLine, (*Model).devicePickerHelpLine, (*Model).renderDeviceBody}, true + case m.plPicker.visible: + return overlayView{(*Model).plPickerHeaderLine, (*Model).plPickerHelpLine, (*Model).renderPlaylistPickerBody}, true + case m.fileBrowser.visible: + return overlayView{(*Model).fbHeaderLine, (*Model).fbHelpLine, (*Model).renderFileBrowserBody}, true + case m.spotSearch.visible: + return overlayView{(*Model).spotSearchHeaderLine, (*Model).spotSearchHelpLine, (*Model).renderSpotSearchBody}, true + case m.navBrowser.visible: + return overlayView{(*Model).navHeaderLine, (*Model).navHelpLine, (*Model).renderNavBody}, true case m.themePicker.visible: return overlayView{ func(m *Model) string { return sepHeaderN("Themes", m.themePicker.cursor+1, m.themeCount()) }, @@ -133,18 +143,8 @@ func (m Model) activeOverlay() (overlayView, bool) { return overlayView{ func(m *Model) string { return sepHeaderN("Visualizers", m.visPicker.cursor+1, len(m.visPicker.modes)) }, (*Model).visPickerHelpLine, (*Model).renderVisPickerList}, true - case m.devicePicker.visible: - return overlayView{(*Model).deviceHeaderLine, (*Model).devicePickerHelpLine, (*Model).renderDeviceBody}, true - case m.plPicker.visible: - return overlayView{(*Model).plPickerHeaderLine, (*Model).plPickerHelpLine, (*Model).renderPlaylistPickerBody}, true - case m.fileBrowser.visible: - return overlayView{(*Model).fbHeaderLine, (*Model).fbHelpLine, (*Model).renderFileBrowserBody}, true - case m.navBrowser.visible: - return overlayView{(*Model).navHeaderLine, (*Model).navHelpLine, (*Model).renderNavBody}, true case m.plManager.visible: return overlayView{(*Model).plMgrHeaderLine, (*Model).plMgrHelpLine, (*Model).renderPlMgrBody}, true - case m.spotSearch.visible: - return overlayView{(*Model).spotSearchHeaderLine, (*Model).spotSearchHelpLine, (*Model).renderSpotSearchBody}, true case m.queue.visible: return overlayView{ func(m *Model) string { return sepHeaderN("Queue", m.queue.cursor+1, m.playlist.QueueLen()) }, @@ -154,15 +154,6 @@ func (m Model) activeOverlay() (overlayView, bool) { func(*Model) string { return sepHeader("Track Info") }, func(*Model) string { return helpKey("Esc", "Close") }, (*Model).renderInfoBody}, true - case m.search.active: - return overlayView{(*Model).searchHeaderLine, (*Model).searchHelpLine, (*Model).renderSearchList}, true - case m.netSearch.active: - return overlayView{(*Model).netSearchHeaderLine, (*Model).netSearchHelpLine, (*Model).renderNetSearchBody}, true - case m.urlInputting: - return overlayView{ - func(m *Model) string { return promptHeader("Load URL", m.urlInput) }, - func(*Model) string { return helpKey("Enter", "Load ") + helpKey("Esc", "Cancel") }, - (*Model).renderURLBody}, true case m.lyrics.visible: return overlayView{ func(*Model) string { return sepHeader("Lyrics") }, @@ -172,6 +163,15 @@ func (m Model) activeOverlay() (overlayView, bool) { func(*Model) string { return sepHeader("Jump to Time") }, func(*Model) string { return helpKey("Enter", "Jump ") + helpKey("Esc", "Cancel") }, (*Model).renderJumpBody}, true + case m.urlInputting: + return overlayView{ + func(m *Model) string { return promptHeader("Load URL", m.urlInput) }, + func(*Model) string { return helpKey("Enter", "Load ") + helpKey("Esc", "Cancel") }, + (*Model).renderURLBody}, true + case m.search.active: + return overlayView{(*Model).searchHeaderLine, (*Model).searchHelpLine, (*Model).renderSearchList}, true + case m.netSearch.active: + return overlayView{(*Model).netSearchHeaderLine, (*Model).netSearchHelpLine, (*Model).renderNetSearchBody}, true } return overlayView{}, false } diff --git a/ui/model/inline_overlays_nav.go b/ui/model/inline_overlays_nav.go index 919d9b5..b356825 100644 --- a/ui/model/inline_overlays_nav.go +++ b/ui/model/inline_overlays_nav.go @@ -112,6 +112,9 @@ func (m Model) renderNavBody() string { if m.navBrowser.loading && len(m.navBrowser.artists) == 0 { return bodyLines([]string{loadingLine("Loading artists…")}, budget) } + if m.navBrowser.search != "" && len(m.navBrowser.searchIdx) == 0 { + return bodyMessage("No matches.", budget) + } if len(m.navBrowser.artists) == 0 { return bodyMessage("No artists found.", budget) } @@ -121,9 +124,12 @@ func (m Model) renderNavBody() string { }) return strings.Join(items, "\n") case navViewAlbums: - if m.navBrowser.loading && len(m.navBrowser.albums) == 0 { + if (m.navBrowser.loading || m.navBrowser.albumLoading) && len(m.navBrowser.albums) == 0 { return bodyLines([]string{loadingLine("Loading albums…")}, budget) } + if m.navBrowser.search != "" && len(m.navBrowser.searchIdx) == 0 { + return bodyMessage("No matches.", budget) + } if len(m.navBrowser.albums) == 0 { return bodyMessage("No albums found.", budget) } @@ -150,8 +156,11 @@ func (m Model) renderNavTrackBody(budget int) string { if len(m.navBrowser.tracks) == 0 { return bodyMessage("No tracks found.", budget) } + if m.navBrowser.search != "" && len(m.navBrowser.searchIdx) == 0 { + return bodyMessage("No matches.", budget) + } - if len(m.navBrowser.searchIdx) > 0 || m.navBrowser.search != "" { + if m.navBrowser.search != "" { items := m.navScrollItems(len(m.navBrowser.tracks), func(i int) string { t := m.navBrowser.tracks[i] return formatTrackRow(i+1, t.DisplayName()+trackAlbumSuffix(t, m.showAlbumHeaders), t.DurationSecs) diff --git a/ui/model/keys.go b/ui/model/keys.go index 73621b1..e995344 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -186,9 +186,19 @@ func (m *Model) providerToBottom() { // handleKey processes a single key press and returns an optional command. func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { + if msg.String() == "ctrl+c" { + return m.quit() + } + if m.fullVis { + return m.handleFullVisualizerKey(msg) + } if m.keymap.visible { return m.handleKeymapKey(msg) } + if msg.String() == "ctrl+k" { + m.openKeymap() + return nil + } // Audio device picker overlay if m.devicePicker.visible { @@ -255,6 +265,9 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "ctrl+c": return m.quit() case "esc", "y": + nextRequest(&m.requests.lyrics) + m.lyrics.loading = false + m.lyrics.query = "" m.lyrics.visible = false case "up", "k": if !(m.lyricsSyncable() && m.lyricsHaveTimestamps()) && m.lyrics.scroll > 0 { @@ -310,13 +323,13 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { if auth, ok := m.provider.(playlist.Authenticator); ok { m.provSignIn = false m.provLoading = true - return authenticateProviderCmd(auth) + return authenticateProviderCmd(auth, m.provider.Name(), nextRequest(&m.requests.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) + return m.fetchProviderTracks(m.providerLists[m.provCursor].ID) } case "tab": m.focus = focusEQ @@ -340,11 +353,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { 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) + return m.fetchProviderPlaylists() } case "f": return m.toggleProviderFavorite() @@ -372,6 +384,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { return m.switchToProvider("emby") case "S": return m.switchToProvider("spotify") + case "P": + return m.switchToProvider("plex") + case "Y": + return m.switchToProvider("yt") case "C": return m.switchToProvider("soundcloud") case "M": @@ -442,12 +458,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { 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 { + if m.focus == focusPlaylist { // Keep current expanded/collapsed height mode when switching focus. m.focus = focusProvider } @@ -740,7 +751,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { m.lyrics.loading = true m.lyrics.lines = nil m.lyrics.err = nil - return fetchTrackLyricsCmd(track, artist, title) + return m.fetchLyricsForTrack(track, artist, title) } } @@ -791,7 +802,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "V": m.fullVis = !m.fullVis if m.fullVis { - m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5) + m.vis.Rows = m.fullVisualizerRows() ui.PanelWidth = max(0, m.width-2*ui.PaddingH) } else { m.vis.Rows = ui.DefaultVisRows @@ -824,7 +835,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "[": m.changeSpeed(-0.25) - case "ctrl+k", "?": + case "?": m.openKeymap() default: @@ -836,6 +847,64 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { return nil } +func (m *Model) exitFullVisualizer() { + m.fullVis = false + m.vis.Rows = ui.DefaultVisRows + m.restorePanelWidth() + m.refreshChrome() +} + +func (m Model) fullVisualizerRows() int { + // Track info, time, two spacers, seek bar, and help occupy six rows. + const fixedRows = 6 + return max(1, m.height-fixedRows-2*ui.VerticalPadding()) +} + +func (m *Model) handleFullVisualizerKey(msg tea.KeyPressMsg) tea.Cmd { + switch msg.String() { + case "q": + return m.quit() + case "esc", "backspace", "b", "V": + m.exitFullVisualizer() + case "space": + cmd := m.togglePlayPause() + m.notifyPlayback() + return cmd + case ">", ".": + m.scrobbleCurrent() + cmd := m.nextTrack() + m.notifyPlayback() + return cmd + case "<", ",": + m.scrobbleCurrent() + cmd := m.prevTrack() + m.notifyPlayback() + return cmd + case "left": + return m.doSeek(-5 * time.Second) + case "shift+left": + return m.doSeek(-m.seekStepLarge) + case "right": + return m.doSeek(5 * time.Second) + case "shift+right": + return m.doSeek(m.seekStepLarge) + case "+", "=": + m.player.SetVolume(m.player.Volume() + 1) + m.notifyPlayback() + case "-": + m.player.SetVolume(m.player.Volume() - 1) + m.notifyPlayback() + case "v": + m.vis.CycleMode() + m.vis.RequestRefresh() + m.refreshChrome() + case "ctrl+k", "?": + m.exitFullVisualizer() + m.openKeymap() + } + 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. @@ -915,6 +984,10 @@ func (m *Model) openProviderSearch() { // 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.cancelSpotRequest() + nextRequest(&m.requests.spotSearch) + nextRequest(&m.requests.spotLists) + nextRequest(&m.requests.spotMutation) m.spotSearch = spotSearchState{ prov: prov, visible: true, @@ -922,6 +995,7 @@ func (m *Model) openProviderSearchWith(prov playlist.Provider) { } return } + nextRequest(&m.requests.netSearch) m.netSearch = netSearchState{ active: true, screen: netSearchInput, @@ -1049,7 +1123,7 @@ func (m *Model) handleProvSearchKey(msg tea.KeyPressMsg) tea.Cmd { m.provLoading = true m.provSearch.active = false m.activeProviderPlaylistID = m.providerLists[idx].ID - return fetchTracksCmd(m.provider, m.providerLists[idx].ID) + return m.fetchProviderTracks(m.providerLists[idx].ID) } case tea.KeyUp: if m.provSearch.cursor > 0 { @@ -1097,7 +1171,7 @@ func (m *Model) handleCatalogSearchKey(msg tea.KeyPressMsg, cs provider.CatalogS return nil } m.provLoading = true - return fetchCatalogSearchCmd(cs, m.provSearch.query) + return fetchCatalogSearchCmd(cs, m.provider.Name(), m.provSearch.query, nextRequest(&m.requests.catalog)) case tea.KeyBackspace, tea.KeyDelete: if m.provSearch.query != "" { m.provSearch.query = removeLastRune(m.provSearch.query) @@ -1161,6 +1235,17 @@ func (m *Model) handlePaste(content string) tea.Cmd { return nil } + // Provider search can sit above the navigation browser. + if m.spotSearch.visible { + switch m.spotSearch.screen { + case spotSearchInput: + m.spotSearch.query += content + case spotSearchNewName: + m.spotSearch.newName += content + } + return nil + } + // Nav browser search if m.navBrowser.visible && m.navBrowser.mode != navBrowseModeMenu && m.navBrowser.searching { m.navBrowser.search += content @@ -1207,16 +1292,6 @@ func (m *Model) handlePaste(content string) tea.Cmd { 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 { @@ -1377,7 +1452,9 @@ func (m *Model) handleNetSearchInputKey(msg tea.KeyPressMsg) tea.Cmd { } m.netSearch.loading = true m.netSearch.err = "" - return fetchNetSearchCmd(prefix + strings.TrimSpace(m.netSearch.query)) + query := prefix + strings.TrimSpace(m.netSearch.query) + m.netSearch.request = query + return fetchNetSearchCmd(query, nextRequest(&m.requests.netSearch)) } case tea.KeyBackspace: diff --git a/ui/model/keys_nav.go b/ui/model/keys_nav.go index 5539e93..030c998 100644 --- a/ui/model/keys_nav.go +++ b/ui/model/keys_nav.go @@ -23,7 +23,7 @@ func (m *Model) handleNavBrowserKey(msg tea.KeyPressMsg) tea.Cmd { // Shift+letter quick-switch to another provider — only when not typing // into the filter, so users can still type capital letters in queries. - if !m.navBrowser.searching { + if !m.navBrowser.searching && (key != "R" || m.navBrowser.screen != navBrowseScreenTracks) { if cmd := m.quickSwitchProvider(key); cmd != nil { return cmd } @@ -101,7 +101,7 @@ func (m *Model) handleNavMenuKey(msg tea.KeyPressMsg) tea.Cmd { m.navBrowser.albumLoading = true m.navBrowser.albumDone = false m.navBrowser.loading = false - return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0) + return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0, m.nextNavRequest()) case 1: // By Artist ab, ok := m.navBrowser.prov.(provider.ArtistBrowser) if !ok { @@ -113,7 +113,7 @@ func (m *Model) handleNavMenuKey(msg tea.KeyPressMsg) tea.Cmd { m.navBrowser.scroll = 0 m.navBrowser.artists = nil m.navBrowser.loading = true - return fetchNavArtistsCmd(ab) + return fetchNavArtistsCmd(ab, m.nextNavRequest()) case 2: // By Artist / Album ab, ok := m.navBrowser.prov.(provider.ArtistBrowser) if !ok { @@ -125,9 +125,10 @@ func (m *Model) handleNavMenuKey(msg tea.KeyPressMsg) tea.Cmd { m.navBrowser.scroll = 0 m.navBrowser.artists = nil m.navBrowser.loading = true - return fetchNavArtistsCmd(ab) + return fetchNavArtistsCmd(ab, m.nextNavRequest()) } case "esc", "N", "backspace", "b": + m.cancelNavRequests() m.navBrowser.visible = false } return nil @@ -169,7 +170,7 @@ func (m *Model) handleNavByArtistAlbumKey(msg tea.KeyPressMsg) tea.Cmd { func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd { // Determine effective list length (filtered or full). listLen := len(m.navBrowser.artists) - if len(m.navBrowser.searchIdx) > 0 { + if m.navBrowser.search != "" { listLen = len(m.navBrowser.searchIdx) } @@ -192,7 +193,7 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd { } m.navMaybeAdjustScroll() case "enter", "l", "right": - if m.navBrowser.loading || len(m.navBrowser.artists) == 0 { + if m.navBrowser.loading || listLen == 0 { return nil } ab, ok := m.navBrowser.prov.(provider.ArtistBrowser) @@ -201,7 +202,7 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd { } // Resolve raw index (filtered or direct). rawIdx := m.navBrowser.cursor - if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { + if m.navBrowser.search != "" && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor] } artist := m.navBrowser.artists[rawIdx] @@ -215,7 +216,7 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd { m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 m.navClearSearch() - return fetchNavArtistAlbumsCmd(ab, artist.ID) + return fetchNavArtistAlbumsCmd(ab, artist.ID, m.nextNavRequest()) } m.navClearSearch() return m.fetchNavArtistAllTracksCmd(ab, artist.ID) @@ -233,7 +234,7 @@ func (m *Model) handleNavArtistListKey(msg tea.KeyPressMsg) tea.Cmd { func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) tea.Cmd { // Determine effective list length (filtered or full). listLen := len(m.navBrowser.albums) - if len(m.navBrowser.searchIdx) > 0 { + if m.navBrowser.search != "" { listLen = len(m.navBrowser.searchIdx) } @@ -253,10 +254,10 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te m.navBrowser.cursor++ m.navMaybeAdjustScroll() // Lazy-load next page: only trigger on the raw (unfiltered) list. - if !artistAlbums && len(m.navBrowser.searchIdx) == 0 && !m.navBrowser.albumLoading && !m.navBrowser.albumDone && m.navBrowser.cursor >= len(m.navBrowser.albums)-10 { + if !artistAlbums && m.navBrowser.search == "" && !m.navBrowser.albumLoading && !m.navBrowser.albumDone && m.navBrowser.cursor >= len(m.navBrowser.albums)-10 { if ab, ok := m.navBrowser.prov.(provider.AlbumBrowser); ok { m.navBrowser.albumLoading = true - return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, len(m.navBrowser.albums)) + return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, len(m.navBrowser.albums), m.nextNavRequest()) } } } else if listLen > 0 { @@ -264,12 +265,12 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te m.navMaybeAdjustScroll() } case "enter", "l", "right": - if (m.navBrowser.loading && !artistAlbums) || len(m.navBrowser.albums) == 0 { + if (m.navBrowser.loading && !artistAlbums) || listLen == 0 { return nil } // Resolve raw index (filtered or direct). rawIdx := m.navBrowser.cursor - if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { + if m.navBrowser.search != "" && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor] } album := m.navBrowser.albums[rawIdx] @@ -277,7 +278,7 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te m.navBrowser.loading = true m.navClearSearch() if l, ok := m.navBrowser.prov.(provider.AlbumTrackLoader); ok { - return fetchNavAlbumTracksCmd(l, album.ID) + return fetchNavAlbumTracksCmd(l, album.ID, m.nextNavRequest()) } return nil case "s": @@ -300,7 +301,7 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te m.status.Showf(statusTTLDefault, "Sort save failed: %s", err) } } - return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0) + return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0, m.nextNavRequest()) case "esc", "h", "left", "backspace": m.navClearSearch() if artistAlbums { @@ -319,7 +320,7 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd { // Determine effective list length (filtered or full). listLen := len(m.navBrowser.tracks) - if len(m.navBrowser.searchIdx) > 0 { + if m.navBrowser.search != "" { listLen = len(m.navBrowser.searchIdx) } @@ -348,11 +349,11 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd { case "enter": // Play the highlighted track immediately, then enqueue everything from // that position to the end of the list (capped at 500 total tracks). - if len(m.navBrowser.tracks) == 0 { + if listLen == 0 { return nil } rawIdx := m.navBrowser.cursor - if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { + if m.navBrowser.search != "" && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor] } if rawIdx < len(m.navBrowser.tracks) { @@ -361,7 +362,7 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd { m.player.ClearPreload() var toAdd []playlist.Track - if len(m.navBrowser.searchIdx) > 0 { + if m.navBrowser.search != "" { for j := m.navBrowser.cursor; j < len(m.navBrowser.searchIdx) && len(toAdd) < maxAdd; j++ { toAdd = append(toAdd, m.navBrowser.tracks[m.navBrowser.searchIdx[j]]) } @@ -390,7 +391,7 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd { case "R": // Replace playlist with all displayed tracks and close browser. tracks := m.navBrowser.tracks - if len(m.navBrowser.searchIdx) > 0 { + if m.navBrowser.search != "" { filtered := make([]playlist.Track, 0, len(m.navBrowser.searchIdx)) for _, i := range m.navBrowser.searchIdx { filtered = append(filtered, m.navBrowser.tracks[i]) @@ -416,7 +417,7 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd { case "a": // Append all displayed tracks to the playlist (keep current playback). tracks := m.navBrowser.tracks - if len(m.navBrowser.searchIdx) > 0 { + if m.navBrowser.search != "" { filtered := make([]playlist.Track, 0, len(m.navBrowser.searchIdx)) for _, i := range m.navBrowser.searchIdx { filtered = append(filtered, m.navBrowser.tracks[i]) @@ -438,11 +439,11 @@ func (m *Model) handleNavTrackListKey(msg tea.KeyPressMsg) tea.Cmd { } case "q": // Add the highlighted track and queue it to play next. - if len(m.navBrowser.tracks) == 0 { + if listLen == 0 { return nil } rawIdx := m.navBrowser.cursor - if len(m.navBrowser.searchIdx) > 0 && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { + if m.navBrowser.search != "" && m.navBrowser.cursor < len(m.navBrowser.searchIdx) { rawIdx = m.navBrowser.searchIdx[m.navBrowser.cursor] } if rawIdx < len(m.navBrowser.tracks) { diff --git a/ui/model/keys_radio.go b/ui/model/keys_radio.go index 6627270..49a7644 100644 --- a/ui/model/keys_radio.go +++ b/ui/model/keys_radio.go @@ -21,7 +21,7 @@ func (m *Model) maybeLoadCatalogBatch() tea.Cmd { } if m.provCursor >= len(m.providerLists)-10 { m.catalogBatch.loading = true - return fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize) + return m.fetchCatalogBatch(loader) } return nil } diff --git a/ui/model/keys_spotify_search.go b/ui/model/keys_spotify_search.go index e0cd402..4c061dc 100644 --- a/ui/model/keys_spotify_search.go +++ b/ui/model/keys_spotify_search.go @@ -1,6 +1,8 @@ package model import ( + "time" + tea "charm.land/bubbletea/v2" "github.com/bjarneo/cliamp/provider" @@ -40,7 +42,7 @@ func (m *Model) handleSpotSearchInputKey(msg tea.KeyPressMsg) tea.Cmd { } m.spotSearch.loading = true m.spotSearch.err = "" - return fetchSpotSearchCmd(s, m.spotSearch.query) + return fetchSpotSearchCmd(m.newSpotRequestContext(30*time.Second), s, m.spotSearch.prov.Name(), m.spotSearch.query, nextRequest(&m.requests.spotSearch)) } case tea.KeyBackspace: if m.spotSearch.query != "" { @@ -105,7 +107,7 @@ func (m *Model) handleSpotSearchResultsKey(msg tea.KeyPressMsg) tea.Cmd { m.spotSearch.selTrack = m.spotSearch.results[m.spotSearch.cursor] m.spotSearch.loading = true m.spotSearch.err = "" - return fetchSpotPlaylistsCmd(m.spotSearch.prov) + return fetchSpotPlaylistsCmd(m.spotSearch.prov, nextRequest(&m.requests.spotLists)) } case "esc", "backspace": m.spotSearch.screen = spotSearchInput @@ -179,7 +181,7 @@ func (m *Model) handleSpotSearchPlaylistKey(msg tea.KeyPressMsg) tea.Cmd { } m.spotSearch.loading = true m.spotSearch.err = "" - return addToSpotPlaylistCmd(w, pl.ID, m.spotSearch.selTrack, pl.Name) + return addToSpotPlaylistCmd(m.newSpotRequestContext(15*time.Second), w, pl.ID, m.spotSearch.selTrack, m.spotSearch.prov.Name(), pl.Name, nextRequest(&m.requests.spotMutation)) } // "+ New Playlist..." selected. m.spotSearch.screen = spotSearchNewName @@ -211,7 +213,7 @@ func (m *Model) handleSpotSearchNewNameKey(msg tea.KeyPressMsg) tea.Cmd { } m.spotSearch.loading = true m.spotSearch.err = "" - return createSpotPlaylistCmd(c, w, m.spotSearch.newName, m.spotSearch.selTrack) + return createSpotPlaylistCmd(m.newSpotRequestContext(15*time.Second), c, w, m.spotSearch.prov.Name(), m.spotSearch.newName, m.spotSearch.selTrack, nextRequest(&m.requests.spotMutation)) } case tea.KeyBackspace: if m.spotSearch.newName != "" { diff --git a/ui/model/model.go b/ui/model/model.go index cf99c11..3bc662a 100644 --- a/ui/model/model.go +++ b/ui/model/model.go @@ -82,6 +82,45 @@ func (s topLevelScreen) hidesVisualizer() bool { return false } +func (s topLevelScreen) label() string { + switch s { + case screenKeymap: + return "Keys" + case screenThemePicker: + return "Themes" + case screenVisPicker: + return "Visualizers" + case screenDevicePicker: + return "Audio Device" + case screenPlaylistPicker: + return "Save to Playlist" + case screenFileBrowser: + return "Files" + case screenNavBrowser: + return "Browse" + case screenPlaylistManager: + return "Playlists" + case screenSpotSearch, screenNetSearch: + return "Search" + case screenQueue: + return "Queue" + case screenInfo: + return "Track Info" + case screenSearch: + return "Filter" + case screenURLInput: + return "Load URL" + case screenLyrics: + return "Lyrics" + case screenJump: + return "Jump to Time" + case screenFullVisualizer: + return "Visualizer" + default: + return "" + } +} + // maxPlVisible caps the playlist at a readable height even on tall terminals. // maxPlExpandVisible is the higher cap used when the user expands with 'x'. const ( @@ -197,6 +236,7 @@ type Model struct { status statusMsg logLines []logLine network networkStats + requests requestState speedSaveAfter time.Duration termTitle terminalTitleState @@ -311,40 +351,40 @@ type Model struct { func (m Model) activeScreen() topLevelScreen { switch { + case m.fullVis: + return screenFullVisualizer case m.keymap.visible: return screenKeymap - case m.themePicker.visible: - return screenThemePicker - case m.visPicker.visible: - return screenVisPicker case m.devicePicker.visible: return screenDevicePicker case m.plPicker.visible: return screenPlaylistPicker case m.fileBrowser.visible: return screenFileBrowser - case m.navBrowser.visible: - return screenNavBrowser - case m.plManager.visible: - return screenPlaylistManager case m.spotSearch.visible: return screenSpotSearch + case m.navBrowser.visible: + return screenNavBrowser + case m.themePicker.visible: + return screenThemePicker + case m.visPicker.visible: + return screenVisPicker + case m.plManager.visible: + return screenPlaylistManager case m.queue.visible: return screenQueue case m.showInfo: return screenInfo - case m.search.active: - return screenSearch - case m.netSearch.active: - return screenNetSearch - case m.urlInputting: - return screenURLInput case m.lyrics.visible: return screenLyrics case m.jumping: return screenJump - case m.fullVis: - return screenFullVisualizer + case m.urlInputting: + return screenURLInput + case m.search.active: + return screenSearch + case m.netSearch.active: + return screenNetSearch default: return screenMain } diff --git a/ui/model/phase0_test.go b/ui/model/phase0_test.go new file mode 100644 index 0000000..933e1b1 --- /dev/null +++ b/ui/model/phase0_test.go @@ -0,0 +1,256 @@ +package model + +import ( + "errors" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/bjarneo/cliamp/lyrics" + "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/provider" + "github.com/bjarneo/cliamp/ui" +) + +func TestProviderSearchTakesPrecedenceOverNavigationBrowser(t *testing.T) { + m := Model{ + spotSearch: spotSearchState{ + visible: true, + screen: spotSearchInput, + }, + navBrowser: navBrowserState{ + visible: true, + searching: true, + }, + } + + if screen := m.activeScreen(); screen != screenSpotSearch { + t.Fatalf("activeScreen() = %v, want provider search", screen) + } + if _, ok := m.activeOverlay(); !ok { + t.Fatal("activeOverlay() = none, want provider search") + } + + m.handleKey(tea.KeyPressMsg{Text: "x"}) + if m.spotSearch.query != "x" { + t.Fatalf("provider search query = %q, want x", m.spotSearch.query) + } + if m.navBrowser.search != "" { + t.Fatalf("navigation filter = %q, want empty", m.navBrowser.search) + } + + m.handlePaste("y") + if m.spotSearch.query != "xy" { + t.Fatalf("provider search query after paste = %q, want xy", m.spotSearch.query) + } + + m.handleKey(tea.KeyPressMsg{Code: tea.KeyEscape}) + if m.spotSearch.visible || !m.navBrowser.visible || m.activeScreen() != screenNavBrowser { + t.Fatalf("nested close state = spot:%t nav:%t screen:%v, want navigation browser", m.spotSearch.visible, m.navBrowser.visible, m.activeScreen()) + } +} + +func TestFullVisualizerBlocksHiddenPlaylistMutations(t *testing.T) { + player := &playbackFakeEngine{} + p := playlist.New() + p.Add(playlist.Track{Path: "one.mp3", Title: "One"}) + m := Model{ + player: player, + playlist: p, + vis: ui.NewVisualizer(float64(player.SampleRate())), + fullVis: true, + } + + m.handleKey(tea.KeyPressMsg{Text: "x"}) + if got := m.playlist.Len(); got != 1 { + t.Fatalf("playlist length after hidden remove = %d, want 1", got) + } + + m.handleKey(tea.KeyPressMsg{Code: 'k', Mod: tea.ModCtrl}) + if m.fullVis { + t.Fatal("fullVis = true after Ctrl+K, want false") + } + if !m.keymap.visible { + t.Fatal("keymap.visible = false after Ctrl+K, want true") + } +} + +func TestClosingProviderSearchCancelsItsRequest(t *testing.T) { + canceled := false + m := Model{spotSearch: spotSearchState{ + visible: true, + cancel: func() { canceled = true }, + }} + + m.closeSpotSearch() + if !canceled { + t.Fatal("provider search request was not canceled") + } +} + +func TestNavigationFilterWithNoMatchesCannotActOnHiddenTracks(t *testing.T) { + p := playlist.New() + p.Add(playlist.Track{Path: "existing.mp3", Title: "Existing"}) + m := Model{ + player: &playbackFakeEngine{}, + playlist: p, + navBrowser: navBrowserState{ + prov: commandsTestProvider{name: "Browse"}, + visible: true, + mode: navBrowseModeByAlbum, + screen: navBrowseScreenTracks, + tracks: []playlist.Track{{Path: "hidden.mp3", Title: "Hidden"}}, + search: "missing", + searchIdx: nil, + }, + } + + for _, key := range []tea.KeyPressMsg{ + {Code: tea.KeyEnter}, + {Text: "a"}, + {Text: "q"}, + {Text: "R"}, + } { + m.handleNavBrowserKey(key) + } + if got := m.playlist.Tracks()[0].Path; got != "existing.mp3" { + t.Fatalf("playlist changed by zero-result filter: %q", got) + } +} + +func TestNavigationTrackReplaceWinsOverRadioShortcut(t *testing.T) { + player := &playbackFakeEngine{} + p := playlist.New() + p.Add(playlist.Track{Path: "existing.mp3", Title: "Existing"}) + current := commandsTestProvider{name: "Current"} + m := Model{ + player: player, + playlist: p, + provider: current, + vis: ui.NewVisualizer(float64(player.SampleRate())), + providers: []ProviderEntry{ + {Key: "radio", Name: "Radio", Provider: commandsTestProvider{name: "Radio"}}, + }, + navBrowser: navBrowserState{ + prov: commandsTestProvider{name: "Browse"}, + visible: true, + mode: navBrowseModeByAlbum, + screen: navBrowseScreenTracks, + tracks: []playlist.Track{{Path: "replacement.mp3", Title: "Replacement"}}, + }, + } + + m.handleNavBrowserKey(tea.KeyPressMsg{Text: "R"}) + tracks := m.playlist.Tracks() + if len(tracks) != 1 || tracks[0].Path != "replacement.mp3" { + t.Fatalf("playlist after R = %#v, want replacement track", tracks) + } + if m.provider.Name() != "Current" { + t.Fatalf("provider after R = %q, want Current", m.provider.Name()) + } +} + +func TestStaleAsyncResponsesDoNotChangeCurrentState(t *testing.T) { + current := commandsTestProvider{name: "Current"} + m := Model{ + player: &playbackFakeEngine{}, + playlist: playlist.New(), + provider: current, + providerLists: []playlist.PlaylistInfo{{ID: "current", Name: "Current"}}, + provLoading: true, + navBrowser: navBrowserState{ + visible: true, + loading: true, + }, + netSearch: netSearchState{ + active: true, + loading: true, + request: "ytsearch10:current", + }, + lyrics: lyricsState{ + visible: true, + loading: true, + query: "Artist\nCurrent", + }, + buffering: true, + } + m.playlist.Add(playlist.Track{Path: "current.mp3", Title: "Current"}) + m.requests.tracks = 2 + m.requests.nav = 2 + m.requests.netSearch = 2 + m.requests.lyrics = 2 + m.requests.stream = 2 + + updates := []tea.Msg{ + tracksLoadedMsg{ + tracks: []playlist.Track{{Path: "stale.mp3", Title: "Stale"}}, + providerName: "Previous", + gen: 1, + }, + navArtistsLoadedMsg{ + artists: []provider.ArtistInfo{{ID: "stale", Name: "Stale"}}, + gen: 1, + }, + netSearchResultsMsg{ + tracks: []playlist.Track{{Path: "stale.mp3", Title: "Stale"}}, + query: "ytsearch10:stale", + gen: 1, + }, + lyricsLoadedMsg{ + lines: []lyrics.Line{{Text: "stale"}}, + query: "Artist\nStale", + gen: 1, + }, + streamPlayedMsg{ + path: "current.mp3", + gen: 1, + err: errors.New("stale failure"), + }, + } + for _, msg := range updates { + updated, _ := m.Update(msg) + m = updated.(Model) + } + + if got := m.playlist.Tracks()[0].Path; got != "current.mp3" { + t.Fatalf("playlist track = %q, want current.mp3", got) + } + if len(m.navBrowser.artists) != 0 || !m.navBrowser.loading { + t.Fatalf("navigation state = %+v, want stale result ignored", m.navBrowser) + } + if len(m.netSearch.results) != 0 || !m.netSearch.loading { + t.Fatalf("net search state = %+v, want stale result ignored", m.netSearch) + } + if len(m.lyrics.lines) != 0 || !m.lyrics.loading { + t.Fatalf("lyrics state = %+v, want stale result ignored", m.lyrics) + } + if !m.buffering || m.err != nil { + t.Fatalf("stream state = buffering:%t err:%v, want unchanged", m.buffering, m.err) + } +} + +func TestProviderRefreshFailureKeepsExistingLists(t *testing.T) { + current := commandsTestProvider{name: "Current"} + m := Model{ + provider: current, + providerLists: []playlist.PlaylistInfo{ + {ID: "mix", Name: "Mix"}, + }, + provLoading: true, + } + m.requests.provider = 1 + + updated, _ := m.Update(playlistsLoadedMsg{ + providerName: "Current", + gen: 1, + err: errors.New("offline"), + }) + m = updated.(Model) + + if len(m.providerLists) != 1 || m.providerLists[0].Name != "Mix" { + t.Fatalf("provider lists after refresh failure = %+v, want prior Mix list", m.providerLists) + } + if m.provLoading { + t.Fatal("provLoading = true after failed refresh, want false") + } +} diff --git a/ui/model/playback.go b/ui/model/playback.go index 7b4fedc..bc50584 100644 --- a/ui/model/playback.go +++ b/ui/model/playback.go @@ -1,6 +1,7 @@ package model import ( + "context" "errors" "strings" "time" @@ -135,6 +136,7 @@ func (m *Model) appendTrack(track playlist.Track) tea.Cmd { // closeNetSearch fully resets the net search overlay and restores focus, // dropping any cached results so they don't linger between sessions. func (m *Model) closeNetSearch() { + nextRequest(&m.requests.netSearch) m.netSearch = netSearchState{} m.focus = m.prevFocus } @@ -142,9 +144,27 @@ func (m *Model) closeNetSearch() { // closeSpotSearch fully resets the Spotify search overlay, dropping cached // results, playlists, and the selected track. func (m *Model) closeSpotSearch() { + m.cancelSpotRequest() + nextRequest(&m.requests.spotSearch) + nextRequest(&m.requests.spotLists) + nextRequest(&m.requests.spotMutation) m.spotSearch = spotSearchState{} } +func (m *Model) newSpotRequestContext(timeout time.Duration) context.Context { + m.cancelSpotRequest() + ctx, cancel := context.WithTimeout(context.Background(), timeout) + m.spotSearch.cancel = cancel + return ctx +} + +func (m *Model) cancelSpotRequest() { + if m.spotSearch.cancel != nil { + m.spotSearch.cancel() + m.spotSearch.cancel = nil + } +} + // queueTrackNext adds a track to the playlist and queues it to play next. func (m *Model) queueTrackNext(track playlist.Track) tea.Cmd { m.playlist.Add(track) @@ -237,9 +257,9 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd { m.err = nil dur := time.Duration(track.DurationSecs) * time.Second if fetchCmd != nil { - return tea.Batch(playYTDLStreamCmd(m.player, track.Path, dur), fetchCmd) + return tea.Batch(playYTDLStreamCmd(m.player, track.Path, dur, m.requests.stream), fetchCmd) } - return playYTDLStreamCmd(m.player, track.Path, dur) + return playYTDLStreamCmd(m.player, track.Path, dur, m.requests.stream) } // Fire now-playing notification for Navidrome tracks. m.nowPlaying(track) @@ -248,7 +268,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd { m.buffering = true m.bufferingAt = time.Now() m.err = nil - return tea.Batch(playStreamCmd(m.player, track.Path, dur), fetchCmd) + return tea.Batch(playStreamCmd(m.player, track.Path, dur, m.requests.stream), fetchCmd) } if err := m.player.Play(track.Path, dur); err != nil { // Provider session went stale (e.g. Spotify auth expired and @@ -311,6 +331,10 @@ func (m *Model) backfillLoadedPlaylistDuration(track playlist.Track) { // new active track. It is used both by explicit playback and by gapless // transitions, which advance audio without calling playTrack. func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cmd) { + nextRequest(&m.requests.stream) + nextRequest(&m.requests.preload) + m.preloading = false + nextRequest(&m.requests.lyrics) track = playlist.RefreshEmbeddedMetadata(track) m.setPlaybackTrack(track) m.reconnect.attempts = 0 @@ -333,12 +357,16 @@ func (m *Model) beginPlaybackTrack(track playlist.Track) (playlist.Track, tea.Cm } m.lyrics.loading = true m.lyrics.query = q - return track, fetchTrackLyricsCmd(track, track.Artist, track.Title) + return track, m.fetchLyricsForTrack(track, track.Artist, track.Title) } m.lyrics.loading = false return track, nil } +func (m *Model) fetchLyricsForTrack(track playlist.Track, artist, title string) tea.Cmd { + return fetchTrackLyricsCmd(track, artist, title, m.lyrics.query, nextRequest(&m.requests.lyrics)) +} + // togglePlayPause starts playback if stopped, or toggles pause if playing. // For live streams and long-paused yt-dlp streams, unpausing reconnects instead // of playing stale data sitting in OS/decoder buffers from before the pause. diff --git a/ui/model/playback_test.go b/ui/model/playback_test.go index 906669c..ca02e66 100644 --- a/ui/model/playback_test.go +++ b/ui/model/playback_test.go @@ -204,8 +204,10 @@ func TestPlayCurrentTrackUnplayableUsesSelectionOrder(t *testing.T) { m := Model{ player: player, playlist: p, + provider: commandsTestProvider{name: "Test"}, vis: ui.NewVisualizer(float64(player.SampleRate())), } + m.requests.tracks = 1 cmd := m.playCurrentTrack() if cmd == nil { @@ -270,14 +272,18 @@ func modelAfterProviderPlaylistLoadWhilePlaying(t *testing.T) (Model, *playbackF m := Model{ player: player, playlist: p, + provider: commandsTestProvider{name: "Test"}, vis: ui.NewVisualizer(float64(player.SampleRate())), } + m.requests.tracks = 1 updated, _ := m.Update(tracksLoadedMsg{ tracks: []playlist.Track{ {Title: "New 1", Path: "new1.mp3", DurationSecs: 180}, {Title: "New 2", Path: "new2.mp3", DurationSecs: 180}, }, + providerName: "Test", + gen: 1, }) m = updated.(Model) diff --git a/ui/model/preload.go b/ui/model/preload.go index d79a53a..850b25d 100644 --- a/ui/model/preload.go +++ b/ui/model/preload.go @@ -57,7 +57,7 @@ func (m *Model) preloadNext() tea.Cmd { } nextDur := time.Duration(next.DurationSecs) * time.Second m.preloading = true - return preloadYTDLStreamCmd(m.player, next.Path, nextDur) + return preloadYTDLStreamCmd(m.player, next.Path, nextDur, nextRequest(&m.requests.preload)) } if next.Stream { // For streams, only arm gapless if we're within the lead-time window. @@ -76,9 +76,9 @@ func (m *Model) preloadNext() tea.Cmd { // Mark in-flight so the tick loop doesn't dispatch a second concurrent // preload before this goroutine has finished arming gapless.SetNext. m.preloading = true - return preloadStreamCmd(m.player, next.Path, nextDur) + return preloadStreamCmd(m.player, next.Path, nextDur, nextRequest(&m.requests.preload)) } nextDur := time.Duration(next.DurationSecs) * time.Second m.preloading = true - return preloadLocalCmd(m.player, next.Path, nextDur) + return preloadLocalCmd(m.player, next.Path, nextDur, nextRequest(&m.requests.preload)) } diff --git a/ui/model/providers.go b/ui/model/providers.go index 9351a2f..df27d06 100644 --- a/ui/model/providers.go +++ b/ui/model/providers.go @@ -11,6 +11,10 @@ import ( // resetProviderNav resets provider navigation and search state to the top. func (m *Model) resetProviderNav() { + nextRequest(&m.requests.provider) + nextRequest(&m.requests.tracks) + nextRequest(&m.requests.auth) + nextRequest(&m.requests.catalog) m.provCursor = 0 m.provScroll = 0 m.provLoading = true @@ -43,12 +47,59 @@ func (m *Model) switchProvider(idx int) tea.Cmd { m.activeProviderPlaylistID = "" m.resetProviderNav() m.focus = focusProvider - return fetchPlaylistsCmd(m.provider) + return m.fetchProviderPlaylists() +} + +func (m *Model) fetchProviderPlaylists() tea.Cmd { + if m.provider == nil { + return nil + } + return fetchPlaylistsCmd(m.provider, nextRequest(&m.requests.provider)) +} + +func (m *Model) fetchProviderTracks(playlistID string) tea.Cmd { + if m.provider == nil { + return nil + } + return fetchTracksCmd(m.provider, playlistID, nextRequest(&m.requests.tracks)) +} + +func (m Model) isActiveProvider(name string) bool { + return m.provider != nil && m.provider.Name() == name +} + +func (m Model) isCurrentNavRequest(gen uint64) bool { + return m.navBrowser.visible && gen == m.requests.nav +} + +func (m Model) isCurrentSpotProvider(providerName string) bool { + return m.spotSearch.visible && + m.spotSearch.prov != nil && + m.spotSearch.prov.Name() == providerName +} + +func (m Model) isCurrentSpotRequest(gen uint64, providerName string) bool { + return m.isCurrentSpotProvider(providerName) && gen == m.requests.spotSearch +} + +func (m Model) isCurrentSpotListRequest(gen uint64, providerName string) bool { + return m.isCurrentSpotProvider(providerName) && gen == m.requests.spotLists +} + +func (m Model) isCurrentSpotMutation(gen uint64, providerName string) bool { + return m.isCurrentSpotProvider(providerName) && gen == m.requests.spotMutation +} + +func (m *Model) fetchCatalogBatch(loader provider.CatalogLoader) tea.Cmd { + if m.provider == nil { + return nil + } + return fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize, m.provider.Name(), nextRequest(&m.requests.catalog)) } // quickSwitchProvider closes any browser overlays and jumps to the provider // matched by key. Use the same Shift+letter shortcuts that switch providers -// from the main pane (S, N, P, J, E, Y, M, R, L). Returns nil when the key doesn't +// from the main pane (S, N, P, J, E, Y, C, M, Q, R, L). Returns nil when the key doesn't // match a known provider. func (m *Model) quickSwitchProvider(key string) tea.Cmd { provKey := providerKeyForShortcut(key) @@ -56,6 +107,7 @@ func (m *Model) quickSwitchProvider(key string) tea.Cmd { return nil } // Close any open overlays so the user lands on the provider pane. + m.cancelNavRequests() m.navBrowser.visible = false m.plManager.visible = false m.fileBrowser.visible = false @@ -78,8 +130,12 @@ func providerKeyForShortcut(key string) string { return "emby" case "Y": return "yt" + case "C": + return "soundcloud" case "M": return "netease" + case "Q": + return "qobuz" case "L": return "local" case "R": @@ -124,6 +180,7 @@ func (m *Model) findBrowseProvider() playlist.Provider { } func (m *Model) openNavBrowserWith(prov playlist.Provider) { + nextRequest(&m.requests.nav) m.navBrowser.prov = prov m.navBrowser.visible = true m.navBrowser.mode = navBrowseModeMenu @@ -148,6 +205,16 @@ func (m *Model) openNavBrowserWith(prov playlist.Provider) { } } +func (m *Model) nextNavRequest() uint64 { + return nextRequest(&m.requests.nav) +} + +func (m *Model) cancelNavRequests() { + nextRequest(&m.requests.nav) + m.navBrowser.loading = false + m.navBrowser.albumLoading = false +} + // navUpdateSearch rebuilds navSearchIdx from the current navSearch query // against whichever list is active on the current nav screen. func (m *Model) navUpdateSearch() { @@ -197,23 +264,24 @@ func (m *Model) navClearSearch() { // all tracks across every album. This is used by the "By Artist" browse mode. // The provider must implement both ArtistBrowser and AlbumTrackLoader. func (m *Model) fetchNavArtistAllTracksCmd(ab provider.ArtistBrowser, artistID string) tea.Cmd { + gen := m.nextNavRequest() loader, _ := m.navBrowser.prov.(provider.AlbumTrackLoader) return func() tea.Msg { albums, err := ab.ArtistAlbums(artistID) if err != nil { - return err + return navTracksLoadedMsg{gen: gen, err: err} } if loader == nil { - return navTracksLoadedMsg(nil) + return navTracksLoadedMsg{gen: gen} } var all []playlist.Track for _, album := range albums { tracks, err := loader.AlbumTracks(album.ID) if err != nil { - return err + return navTracksLoadedMsg{gen: gen, err: err} } all = append(all, tracks...) } - return navTracksLoadedMsg(all) + return navTracksLoadedMsg{tracks: all, gen: gen} } } diff --git a/ui/model/state.go b/ui/model/state.go index f3bcffc..0503fc2 100644 --- a/ui/model/state.go +++ b/ui/model/state.go @@ -43,6 +43,7 @@ type netSearchState struct { cursor int scroll int err string + request string } // provSearchState holds state for filtering the provider playlist list. @@ -209,6 +210,29 @@ type navBrowserState struct { searchIdx []int } +// requestState tracks the latest request in each independently asynchronous UI +// domain. Completion messages must match their generation before they can +// change the current screen. +type requestState struct { + provider uint64 + tracks uint64 + nav uint64 + lyrics uint64 + netSearch uint64 + spotSearch uint64 + spotLists uint64 + spotMutation uint64 + auth uint64 + catalog uint64 + stream uint64 + preload uint64 +} + +func nextRequest(gen *uint64) uint64 { + *gen = *gen + 1 + return *gen +} + // spotSearchScreenType identifies which screen of the Spotify search overlay is active. type spotSearchScreenType int @@ -233,6 +257,7 @@ type spotSearchState struct { selTrack playlist.Track // track selected to add newName string // new playlist name input err string + cancel func() } // catalogBatchState holds state for lazy-loading catalog entries from a provider.CatalogLoader. diff --git a/ui/model/update.go b/ui/model/update.go index ae14fc1..7b73ecc 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -69,7 +69,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ui.FrameStyle = ui.FrameStyle.Width(frameW) m.restorePanelWidth() if m.fullVis { - m.vis.Rows = max(ui.DefaultVisRows, (m.height-10)*4/5) + m.vis.Rows = m.fullVisualizerRows() ui.PanelWidth = max(0, m.width-2*ui.PaddingH) } m.recomputeChrome() @@ -205,7 +205,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lyrics.lines = nil m.lyrics.err = nil m.lyrics.scroll = 0 - lyricCmd = fetchLyricsCmd(artist, song) + lyricCmd = fetchLyricsCmd(artist, song, q, nextRequest(&m.requests.lyrics)) } } } @@ -339,17 +339,42 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, tickCmdAt(m.tickInterval())) return m, tea.Batch(cmds...) - case []playlist.PlaylistInfo: - m.providerLists = msg + case playlistsLoadedMsg: + if msg.gen != m.requests.provider || !m.isActiveProvider(msg.providerName) { + return m, nil + } m.provLoading = false + if msg.err != nil { + if errors.Is(msg.err, playlist.ErrNeedsAuth) { + m.provSignIn = true + m.err = nil + return m, nil + } + m.err = msg.err + return m, nil + } + m.providerLists = msg.playlists // Start loading catalog when the provider supports lazy catalog loading. if loader, ok := m.provider.(provider.CatalogLoader); ok && !m.catalogBatch.loading && !m.catalogBatch.done { m.catalogBatch.loading = true - return m, fetchCatalogBatchCmd(loader, m.catalogBatch.offset, catalogBatchSize) + return m, m.fetchCatalogBatch(loader) } return m, nil case tracksLoadedMsg: + if msg.gen != m.requests.tracks || !m.isActiveProvider(msg.providerName) { + return m, nil + } + m.provLoading = false + if msg.err != nil { + if errors.Is(msg.err, playlist.ErrNeedsAuth) { + m.provSignIn = true + m.err = nil + return m, nil + } + m.err = msg.err + return m, nil + } if m.player.IsPlaying() || m.buffering { m.detachPlaybackTrack() m.player.ClearPreload() @@ -372,18 +397,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focus = focusPlaylist m.applyHeightMode() m.adjustScroll() - m.provLoading = false m.notifyAll() return m, nil case navArtistsLoadedMsg: - m.navBrowser.artists = []provider.ArtistInfo(msg) + if !m.isCurrentNavRequest(msg.gen) { + return m, nil + } m.navBrowser.loading = false + if msg.err != nil { + m.status.Showf(statusTTLDefault, "Artist load failed: %s", msg.err) + return m, nil + } + m.navBrowser.artists = msg.artists m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 return m, nil case navAlbumsLoadedMsg: + if !m.isCurrentNavRequest(msg.gen) { + return m, nil + } + m.navBrowser.albumLoading = false + m.navBrowser.loading = false + if msg.err != nil { + m.status.Showf(statusTTLDefault, "Album load failed: %s", msg.err) + return m, nil + } if msg.offset == 0 { // Fresh load (new sort or drill-in): replace the list. m.navBrowser.albums = msg.albums @@ -395,26 +435,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.isLast { m.navBrowser.albumDone = true } - m.navBrowser.albumLoading = false if msg.offset == 0 { m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 } // If we just loaded the first page and it was a full menu → list transition, // also clear the general loading flag. - m.navBrowser.loading = false return m, nil case navTracksLoadedMsg: - m.navBrowser.tracks = []playlist.Track(msg) - m.setHeaderStateFromTracks(m.navBrowser.tracks) + if !m.isCurrentNavRequest(msg.gen) { + return m, nil + } m.navBrowser.loading = false + if msg.err != nil { + m.status.Showf(statusTTLDefault, "Track load failed: %s", msg.err) + return m, nil + } + m.navBrowser.tracks = msg.tracks + m.setHeaderStateFromTracks(m.navBrowser.tracks) m.navBrowser.cursor = 0 m.navBrowser.scroll = 0 m.navBrowser.screen = navBrowseScreenTracks return m, nil case catalogBatchMsg: + if msg.gen != m.requests.catalog || !m.isActiveProvider(msg.providerName) { + return m, nil + } m.catalogBatch.loading = false if msg.err != nil { m.catalogBatch.done = true @@ -435,6 +483,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case catalogSearchMsg: + if msg.gen != m.requests.catalog || !m.isActiveProvider(msg.providerName) { + return m, nil + } m.provLoading = false if msg.err != nil { m.status.Show("Search failed", statusTTLDefault) @@ -525,6 +576,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case netSearchResultsMsg: + if msg.gen != m.requests.netSearch || !m.netSearch.active || msg.query != m.netSearch.request { + return m, nil + } m.netSearch.loading = false m.netSearch.cursor = 0 m.netSearch.scroll = 0 @@ -541,6 +595,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case lyricsLoadedMsg: + if msg.gen != m.requests.lyrics || !m.lyrics.visible || msg.query != m.lyrics.query { + return m, nil + } m.lyrics.loading = false m.lyrics.err = msg.err m.lyrics.scroll = 0 @@ -599,6 +656,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case streamPlayedMsg: + track, _ := m.currentPlaybackTrack() + if msg.gen != m.requests.stream || msg.path != track.Path { + return m, nil + } m.buffering = false if msg.err != nil { m.err = msg.err @@ -615,6 +676,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.preloadNext() case streamPreloadedMsg: + if msg.gen != m.requests.preload { + return m, nil + } m.preloading = false return m, nil @@ -654,6 +718,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spotSearchResultsMsg: + if !m.isCurrentSpotRequest(msg.gen, msg.providerName) || m.spotSearch.query != msg.query { + return m, nil + } + m.cancelSpotRequest() m.spotSearch.loading = false m.spotSearch.cursor = 0 m.spotSearch.scroll = 0 @@ -670,6 +738,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spotPlaylistsMsg: + if !m.isCurrentSpotListRequest(msg.gen, msg.providerName) { + return m, nil + } m.spotSearch.loading = false m.spotSearch.cursor = 0 m.spotSearch.scroll = 0 @@ -683,6 +754,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spotAddedMsg: + if !m.isCurrentSpotMutation(msg.gen, msg.providerName) { + return m, nil + } + m.cancelSpotRequest() m.spotSearch.loading = false if msg.err != nil { m.spotSearch.err = "Add failed: " + msg.err.Error() @@ -693,6 +768,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case spotCreatedMsg: + if !m.isCurrentSpotMutation(msg.gen, msg.providerName) { + return m, nil + } + m.cancelSpotRequest() m.spotSearch.loading = false if msg.err != nil { m.spotSearch.err = "Create failed: " + msg.err.Error() @@ -703,6 +782,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case provAuthDoneMsg: + if msg.gen != m.requests.auth || !m.isActiveProvider(msg.providerName) { + return m, nil + } m.provAuthURL = "" if msg.err != nil { m.err = msg.err @@ -712,9 +794,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.provSignIn = false m.provLoading = true - return m, fetchPlaylistsCmd(m.provider) + return m, m.fetchProviderPlaylists() case ProvAuthURLMsg: + if !m.provLoading || !m.isActiveProvider(msg.ProviderName) { + return m, nil + } m.provAuthURL = msg.URL return m, nil diff --git a/ui/model/view.go b/ui/model/view.go index 91ff86c..bdd50fc 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -226,6 +226,9 @@ func (m Model) centerFrame(frame string) string { func (m Model) renderTitle() string { title := titleStyle.Render("C L I A M P") label := m.focus.label() + if screen := m.activeScreen(); screen != screenMain { + label = screen.label() + } if label == "" { return title } @@ -342,7 +345,7 @@ func (m Model) renderFullVisualizer() string { m.renderSpectrum(), m.renderSeekBar(), "", - helpKey("V", "Exit ") + helpKey("v", "Mode:"+m.vis.ModeName()+" ") + helpKey("Spc", "▶❚❚ ") + helpKey("<>", "Trk ") + helpKey("+-", "Vol"), + helpKey("V", "Exit ") + helpKey("v", "Mode:"+m.vis.ModeName()+" ") + helpKey("Spc", "▶❚❚ ") + helpKey("<>", "Trk ") + helpKey("+-", "Vol ") + helpKey("?", "Keys"), } return strings.Join(sections, "\n") @@ -534,7 +537,7 @@ func (m Model) renderProviderList() string { if m.provSignIn { return dimStyle.Render(fmt.Sprintf(" Sign in to %s. Press Enter to continue.", m.provider.Name())) } - if m.provLoading { + if m.provLoading && len(m.providerLists) == 0 { lines := []string{loadingLine(fmt.Sprintf("Loading %s…", m.provider.Name()))} if m.provAuthURL != "" { lines = append(lines, diff --git a/ui/styles.go b/ui/styles.go index bd27a83..31d9053 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -46,6 +46,11 @@ func SetPadding(h, v int) { FrameStyle = FrameStyle.Padding(paddingV, PaddingH) } +// VerticalPadding returns the current frame padding above and below content. +func VerticalPadding() int { + return paddingV +} + // FrameStyle is the outer frame style for the TUI. var FrameStyle = lipgloss.NewStyle(). Padding(paddingV, PaddingH).