fix(ui): complete phase 0 interaction safety

This commit is contained in:
Bjarne Øverli
2026-07-20 23:04:59 +02:00
parent 02a95fb458
commit 853d40e479
23 changed files with 853 additions and 209 deletions
+1
View File
@@ -3,6 +3,7 @@ mp3/
navdata/
plans/
test.md
/roadmap.md
spotify.log
.DS_Store
docs/ideas.md
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -612,7 +612,7 @@ footer{border-top:1px solid var(--line);background:var(--ink-2);padding:46px 0 5
<span class="t-help-item"><kbd>↑↓</kbd> Scroll</span>
<span class="t-help-item"><kbd>Enter</kbd> Play</span>
<span class="t-help-item"><kbd>Spc</kbd> ▶❚❚</span>
<span class="t-help-item"><kbd>*</kbd> Fav</span>
<span class="t-help-item"><kbd>f</kbd> Fav</span>
<span class="t-help-item"><kbd>Tab</kbd> Focus</span>
<span class="t-help-item"><kbd>?</kbd> Keys</span>
</div>
+114 -83
View File
@@ -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}
}
}
+6 -2
View File
@@ -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 != "" {
+4 -1
View File
@@ -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))
+19 -19
View File
@@ -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
}
+11 -2
View File
@@ -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)
+103 -26
View File
@@ -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:
+24 -23
View File
@@ -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) {
+1 -1
View File
@@ -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
}
+6 -4
View File
@@ -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 != "" {
+56 -16
View File
@@ -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
}
+256
View File
@@ -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")
}
}
+32 -4
View File
@@ -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.
+6
View File
@@ -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)
+3 -3
View File
@@ -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))
}
+74 -6
View File
@@ -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}
}
}
+25
View File
@@ -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.
+97 -12
View File
@@ -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
+5 -2
View File
@@ -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,
+5
View File
@@ -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).