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).