fix(ui): complete phase 0 interaction safety
This commit is contained in:
@@ -3,6 +3,7 @@ mp3/
|
||||
navdata/
|
||||
plans/
|
||||
test.md
|
||||
/roadmap.md
|
||||
spotify.log
|
||||
.DS_Store
|
||||
docs/ideas.md
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user