diff --git a/cmd/playlist_ops_test.go b/cmd/playlist_ops_test.go index d4dc5d4cc..346b4a887 100644 --- a/cmd/playlist_ops_test.go +++ b/cmd/playlist_ops_test.go @@ -61,8 +61,9 @@ func TestPlaylistListEmpty(t *testing.T) { if err != nil { t.Fatalf("PlaylistList: %v", err) } - if !strings.Contains(out, "No playlists") { - t.Errorf("output = %q, want 'No playlists...'", out) + // Favorites always appears as a virtual playlist even when empty. + if !strings.Contains(out, "Favorites") { + t.Errorf("output = %q, want Favorites virtual playlist to appear", out) } } diff --git a/docs/keybindings.md b/docs/keybindings.md index b1fc9fd62..7e4d28100 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -74,7 +74,8 @@ active when the picker opened. While typing a filter, `Enter` finishes it and | Key | Action | |---|---| -| `f` | Toggle bookmark ★ on selected track (or favorite radio station in radio browser) | +| `f` | Toggle bookmark ★ on selected track within the loaded playlist (or favorite a radio station in the radio browser) | +| `n` | Toggle favorite ♥ on selected track (cross-playlist; favorited tracks appear in the "Favorites" virtual playlist) | | `Ctrl+F` | Search — active provider's native search (Spotify, Qobuz, Navidrome, Jellyfin, Emby, Plex, Audiobookshelf, NetEase, Local) or YouTube fallback. Available from playlist and provider-browser views. | | `u` | Load URL (stream/playlist) | | `y` | Show or close lyrics | @@ -120,13 +121,24 @@ active when the picker opened. While typing a filter, `Enter` finishes it and | `[` `]` | Tracks: move highlighted track and save the playlist | | `s` | Tracks: sort and save, cycling `track`, `title`, `artist`, `album`, `artist+album`, `path` | | `o` | Tracks: open file browser to add files to this playlist | +| `D` | Tracks: open the directory-sources screen for this playlist | | `r` | List: rename the playlist | | `d` | List: delete playlist (confirms). Tracks: remove marked tracks, or highlighted track when none are marked | | `u` | Undo the last manager edit | | `←` `Backspace` `h` | Tracks screen: go back to the list | | `Esc` | Close the playlist manager or go back | -Shift-letter keys are reserved for provider switching, so playlist-manager track actions use lowercase or punctuation keys. +Shift-letter keys are reserved for provider switching, so playlist-manager track actions use lowercase or punctuation keys. `D` is the one exception: it opens the directory-sources screen. + +#### Directory sources screen (`D` from the tracks screen) + +| Key | Action | +|---|---| +| `↑` `↓` / `j` `k` | Navigate directory sources | +| `a` | Open the file browser to add a directory as a `[[dir]]` source | +| `d` then `y` | Remove the highlighted source (`y` confirms, anything else cancels) | +| `r` | Toggle `recursive` on the highlighted source | +| `←` `Backspace` `h` `Esc` | Back to the tracks screen | ## File browser @@ -139,6 +151,7 @@ Shift-letter keys are reserved for provider switching, so playlist-manager track | `a` | Select/unselect all visible audio files | | `R` | Replace the current queue with selected files (confirm when it is non-empty) | | `w` | Write selected files to a local playlist | +| `D` | Add the selected directory (or the current directory when none is selected) as a live `[[dir]]` source to the target playlist | | `~` `.` | Jump to home / current working directory | | `Esc` `o` | Close file browser | diff --git a/docs/playlists.md b/docs/playlists.md index 79a7a8b75..32cb985c7 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -363,7 +363,44 @@ title = "My Radio" | `s` | Sort tracks, cycling supported sort keys (tracks screen) | | `w` | Write marked/highlighted tracks, or the current queue from the list screen, to another playlist | | `o` | Add files to the open playlist (tracks screen) | +| `D` | Open the directory-sources screen for the open playlist (tracks screen) | | `[` `]` | Move track up/down and save (tracks screen) | | `d` | Delete playlist (confirms) / Remove marked tracks, or highlighted track if none are marked | | `u` | Undo the last playlist-manager edit | | `←` / `Backspace` | Go back from tracks screen to list | + +The playlist list flags playlists that reference `[[dir]]` sources with a +`· N dir(s)` indicator next to the track count. + +**Directory sources screen (tracks screen → `D`):** + +| Key | Action | +|-----|--------| +| `Up` `Down` / `j` `k` | Navigate directory sources | +| `a` | Open the file browser to add a directory as a `[[dir]]` source | +| `d` then `y` | Remove the highlighted source (confirm with `y`, cancel with anything else) | +| `r` | Toggle `recursive` on the highlighted source (re-scans immediately) | +| `←` / `Backspace` / `Esc` | Back to the tracks screen | + +From the file browser (opened with `a` above, or with `o` from the tracks +screen), press `D` to add the selected directory — or the directory you are +currently browsing when nothing is selected — as a live `[[dir]]` source +instead of expanding it into explicit tracks. Directories already referenced +are skipped and reported. + +## Favorites + +Press `n` on any track in the track list to toggle it as a +favorite. Favorited tracks are collected into a virtual **"Favorites"** +playlist that always appears at the top of the playlist list — regardless of +which playlist the track was favorited from, and even when empty. + +Favorites are cross-playlist: a track favorited while browsing "gym" shows up +in "Favorites" and vice versa. The "Favorites" playlist is backed by +`~/.config/cliamp/favorites.toml` and behaves like "Recently Played" — it is +a virtual playlist that cannot be renamed, deleted, or modified via the +playlist manager. Use `n` again to unfavorite a track. + +Favorited tracks display a `♥` marker in the track list. The bookmark +system (`f` key, `★` marker) is separate — bookmarks are per-playlist, +while favorites span all playlists. diff --git a/external/local/dirs.go b/external/local/dirs.go index da625e1d2..9cb087c24 100644 --- a/external/local/dirs.go +++ b/external/local/dirs.go @@ -13,14 +13,6 @@ import ( "github.com/bjarneo/cliamp/resolve" ) -// DirSource is a [[dir]] section in a playlist file: a directory that is -// scanned for audio files every time the playlist loads, instead of listing -// every file explicitly. -type DirSource struct { - Path string // directory path; supports ~ and environment variables - Recursive bool // scan subdirectories too (default true) -} - // ExpandPath expands a leading ~ and environment variables in p. func ExpandPath(p string) string { if p == "" { @@ -45,7 +37,7 @@ const ( // [[dir]] sources, with section order preserved for ordered expansion. type playlistDoc struct { tracks []playlist.Track - dirs []DirSource + dirs []playlist.DirSource order []uint8 // itemTrack or itemDir per section, in document order } @@ -62,7 +54,7 @@ func parsePlaylistDoc(data []byte) *playlistDoc { if f["path"] == "" { return } - doc.dirs = append(doc.dirs, DirSource{ + doc.dirs = append(doc.dirs, playlist.DirSource{ Path: f["path"], Recursive: f["recursive"] != "false", }) @@ -123,7 +115,7 @@ func (d *playlistDoc) expand(withTags bool) []playlist.Track { } // writeDir writes a single [[dir]] TOML section to w. -func writeDir(w io.Writer, src DirSource) { +func writeDir(w io.Writer, src playlist.DirSource) { fmt.Fprintln(w, "[[dir]]") fmt.Fprintf(w, "path = %q\n", src.Path) if !src.Recursive { @@ -135,7 +127,7 @@ func writeDir(w io.Writer, src DirSource) { type playlistSection struct { kind uint8 // itemTrack or itemDir track playlist.Track - dir DirSource + dir playlist.DirSource } // rebuildDoc merges the caller's explicit tracks back into an existing parsed @@ -150,7 +142,7 @@ type playlistSection struct { // before the directory section that would otherwise supply them, so a // materialized track keeps its position among the directory's tracks; tracks // no directory provides are appended at the end. -func rebuildDoc(existing *playlistDoc, explicit []playlist.Track) (tracks []playlist.Track, dirs []DirSource, order []uint8) { +func rebuildDoc(existing *playlistDoc, explicit []playlist.Track) (tracks []playlist.Track, dirs []playlist.DirSource, order []uint8) { origPaths := make([]string, len(existing.tracks)) for i, t := range existing.tracks { origPaths[i] = t.Path @@ -298,7 +290,7 @@ func validateDirSource(dir string) error { // non-recursive sources, not below an immediate subdirectory. The check is // path-only so save-time rewrites do not repeat the filesystem walk done at // load. -func dirSuppliesFile(dir DirSource, file string) bool { +func dirSuppliesFile(dir playlist.DirSource, file string) bool { if !player.SupportedExts[strings.ToLower(filepath.Ext(file))] { return false } diff --git a/external/local/dirs_test.go b/external/local/dirs_test.go index 16670fe3a..10889e7d0 100644 --- a/external/local/dirs_test.go +++ b/external/local/dirs_test.go @@ -231,6 +231,158 @@ func TestAddDirSource(t *testing.T) { } } +func TestRemoveDirSource(t *testing.T) { + p := newTestProvider(t) + audio1 := t.TempDir() + audio2 := t.TempDir() + writeAudioFile(t, filepath.Join(audio1, "a.mp3")) + writeAudioFile(t, filepath.Join(audio2, "b.mp3")) + writeAudioFile(t, filepath.Join(audio2, "c.mp3")) + + if _, err := p.AddDirSource("music", audio1); err != nil { + t.Fatalf("add audio1: %v", err) + } + if _, err := p.AddDirSource("music", audio2); err != nil { + t.Fatalf("add audio2: %v", err) + } + // A directory-sourced track list between the two dirs should now hold + // all three files. + if tracks, err := p.Tracks("music"); err != nil || len(tracks) != 3 { + t.Fatalf("tracks before remove = %d (err %v), want 3", len(tracks), err) + } + + // Removing audio1 drops only its file; audio2's two remain. + if err := p.RemoveDirSource("music", audio1); err != nil { + t.Fatalf("RemoveDirSource: %v", err) + } + dirs, err := p.DirSources("music") + if err != nil || len(dirs) != 1 || dirs[0].Path != audio2 { + t.Fatalf("after remove dirs = %+v err %v, want only audio2", dirs, err) + } + tracks, err := p.Tracks("music") + if err != nil || len(tracks) != 2 { + t.Fatalf("tracks after remove = %d (err %v), want 2", len(tracks), err) + } + + // Removing a dir that is not referenced is a no-op (not an error). + if err := p.RemoveDirSource("music", audio1); err != nil { + t.Fatalf("remove missing source should be no-op, got %v", err) + } + // Removing from a playlist that does not exist is a no-op. + if err := p.RemoveDirSource("nope", audio1); err != nil { + t.Fatalf("remove from missing playlist should be no-op, got %v", err) + } + // The history playlist is reserved. + if err := p.RemoveDirSource("Recently Played", audio1); err == nil { + t.Fatal("RemoveDirSource on history should error") + } +} + +func TestSetDirRecursive(t *testing.T) { + p := newTestProvider(t) + audio := t.TempDir() + makeAudioTree(t, audio) // two top-level files + one nested + + if _, err := p.AddDirSource("music", audio); err != nil { + t.Fatalf("add: %v", err) + } + dirs, _ := p.DirSources("music") + if !dirs[0].Recursive { + t.Fatalf("new dir should default to recursive, got %+v", dirs[0]) + } + // Recursive scan sees all three files. + if tracks, _ := p.Tracks("music"); len(tracks) != 3 { + t.Fatalf("recursive tracks = %d, want 3", len(tracks)) + } + + // Flip to flat: only the two top-level files remain. + if err := p.SetDirRecursive("music", audio, false); err != nil { + t.Fatalf("SetDirRecursive(false): %v", err) + } + dirs, _ = p.DirSources("music") + if dirs[0].Recursive { + t.Fatalf("dir should now be flat, got %+v", dirs[0]) + } + if tracks, _ := p.Tracks("music"); len(tracks) != 2 { + t.Fatalf("flat tracks = %d, want 2", len(tracks)) + } + + // Flip back to recursive. + if err := p.SetDirRecursive("music", audio, true); err != nil { + t.Fatalf("SetDirRecursive(true): %v", err) + } + if tracks, _ := p.Tracks("music"); len(tracks) != 3 { + t.Fatalf("re-enabled recursive tracks = %d, want 3", len(tracks)) + } + + // Setting the same value is a no-op (no error, no change). + if err := p.SetDirRecursive("music", audio, true); err != nil { + t.Fatalf("idempotent SetDirRecursive: %v", err) + } + // Missing source and missing playlist are no-ops. + if err := p.SetDirRecursive("music", t.TempDir(), false); err != nil { + t.Fatalf("missing source should be no-op, got %v", err) + } + if err := p.SetDirRecursive("nope", audio, false); err != nil { + t.Fatalf("missing playlist should be no-op, got %v", err) + } + // History is reserved. + if err := p.SetDirRecursive("Recently Played", audio, false); err == nil { + t.Fatal("SetDirRecursive on history should error") + } +} + +func TestDirIndexByPathMatchesTildeAndAbsolute(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + t.Skip("no home directory") + } + audio := filepath.Join(home, "Music") + doc := parsePlaylistDoc([]byte("[[dir]]\npath = \"~/Music\"\n")) + if got := dirIndexByPath(doc, "~/Music"); got != 0 { + t.Fatalf("dirIndexByPath ~/Music = %d, want 0", got) + } + if got := dirIndexByPath(doc, audio); got != 0 { + t.Fatalf("dirIndexByPath absolute = %d, want 0 (tilde should match absolute)", got) + } + if got := dirIndexByPath(doc, "/elsewhere"); got != -1 { + t.Fatalf("dirIndexByPath miss = %d, want -1", got) + } +} + +func TestPlaylistsDirSourceCount(t *testing.T) { + p := newTestProvider(t) + audio := t.TempDir() + writeAudioFile(t, filepath.Join(audio, "a.mp3")) + writeAudioFile(t, filepath.Join(audio, "b.flac")) + + if _, err := p.AddDirSource("music", audio); err != nil { + t.Fatalf("add: %v", err) + } + // A second playlist with no dirs for contrast. + if _, err := p.CreatePlaylist(context.Background(), "plain"); err != nil { + t.Fatalf("create plain: %v", err) + } + + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + byName := map[string]playlist.PlaylistInfo{} + for _, l := range lists { + byName[l.Name] = l + } + if byName["music"].DirSourceCount != 1 { + t.Fatalf("music DirSourceCount = %d, want 1", byName["music"].DirSourceCount) + } + if byName["plain"].DirSourceCount != 0 { + t.Fatalf("plain DirSourceCount = %d, want 0", byName["plain"].DirSourceCount) + } + if byName["music"].TrackCount != 2 { + t.Fatalf("music TrackCount = %d, want 2", byName["music"].TrackCount) + } +} + func TestSavePlaylistPreservesDirsAndSkipsDirTracks(t *testing.T) { p := newTestProvider(t) audio := t.TempDir() @@ -488,8 +640,8 @@ func TestAddTracksPersistsCrossPlaylistDirTrack(t *testing.T) { func TestWriteDirRoundTrip(t *testing.T) { var b strings.Builder - writeDir(&b, DirSource{Path: "/music", Recursive: true}) - writeDir(&b, DirSource{Path: "/other", Recursive: false}) + writeDir(&b, playlist.DirSource{Path: "/music", Recursive: true}) + writeDir(&b, playlist.DirSource{Path: "/other", Recursive: false}) doc := parsePlaylistDoc([]byte(b.String())) if len(doc.dirs) != 2 { t.Fatalf("round trip dirs = %d", len(doc.dirs)) @@ -693,11 +845,11 @@ func TestSavePlaylistMultiMaterializedKeepsDirPositions(t *testing.T) { func TestDirSuppliesFile(t *testing.T) { dir := t.TempDir() - rec := DirSource{Path: dir, Recursive: true} - nonRec := DirSource{Path: dir, Recursive: false} + rec := playlist.DirSource{Path: dir, Recursive: true} + nonRec := playlist.DirSource{Path: dir, Recursive: false} tests := []struct { name string - src DirSource + src playlist.DirSource file string want bool }{ diff --git a/external/local/provider.go b/external/local/provider.go index bb7fa826d..082ebb535 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -14,6 +14,7 @@ import ( "strconv" "strings" + "github.com/bjarneo/cliamp/favorites" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/internal/appdir" "github.com/bjarneo/cliamp/internal/fuzzy" @@ -24,20 +25,23 @@ import ( // Compile-time interface checks. var ( - _ provider.PlaylistWriter = (*Provider)(nil) - _ provider.PlaylistBatchWriter = (*Provider)(nil) - _ provider.PlaylistCreator = (*Provider)(nil) - _ provider.PlaylistSaver = (*Provider)(nil) - _ provider.PlaylistDeleter = (*Provider)(nil) - _ provider.PlaylistRenamer = (*Provider)(nil) - _ provider.BookmarkSetter = (*Provider)(nil) - _ provider.Searcher = (*Provider)(nil) + _ provider.PlaylistWriter = (*Provider)(nil) + _ provider.PlaylistBatchWriter = (*Provider)(nil) + _ provider.PlaylistCreator = (*Provider)(nil) + _ provider.PlaylistSaver = (*Provider)(nil) + _ provider.PlaylistDeleter = (*Provider)(nil) + _ provider.PlaylistRenamer = (*Provider)(nil) + _ provider.BookmarkSetter = (*Provider)(nil) + _ provider.Searcher = (*Provider)(nil) + _ provider.PlaylistDirSourceManager = (*Provider)(nil) + _ provider.FavoritesManager = (*Provider)(nil) ) // Provider reads and writes TOML-based playlists stored on disk. type Provider struct { - dir string // e.g. ~/.config/cliamp/playlists/ - history *history.Store + dir string // e.g. ~/.config/cliamp/playlists/ + history *history.Store + favorites *favorites.Store } // New creates a Provider using ~/.config/cliamp/playlists/ as the base directory. @@ -47,8 +51,9 @@ func New() *Provider { return nil } return &Provider{ - dir: filepath.Join(dir, "playlists"), - history: history.New(), + dir: filepath.Join(dir, "playlists"), + history: history.New(), + favorites: favorites.New(), } } @@ -79,11 +84,18 @@ func isHistoryName(name string) bool { return name == history.PlaylistName } +func isFavoritesName(name string) bool { + return name == favorites.PlaylistName +} + // Playlists scans the directory for .toml files and returns their metadata, // prepending the virtual "Recently Played" entry when the user has any // recorded plays. Returns an empty list (not error) when neither exists. func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { var lists []playlist.PlaylistInfo + if info, ok := p.favoritesInfo(); ok { + lists = append(lists, info) + } if info, ok := p.historyInfo(); ok { lists = append(lists, info) } @@ -111,10 +123,11 @@ func (p *Provider) Playlists() ([]playlist.PlaylistInfo, error) { // files they supply. tracks := doc.expand(false) lists = append(lists, playlist.PlaylistInfo{ - ID: name, - Name: name, - TrackCount: len(tracks), - DurationSecs: playlist.TotalDurationSecs(tracks), + ID: name, + Name: name, + TrackCount: len(tracks), + DurationSecs: playlist.TotalDurationSecs(tracks), + DirSourceCount: len(doc.dirs), }) } return lists, nil @@ -138,10 +151,36 @@ func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) { }, true } +// favoritesInfo returns the synthetic PlaylistInfo entry for "Favorites". +// The entry always appears when the favorites store is available, even when +// empty, so users can discover the feature and see an empty placeholder. +func (p *Provider) favoritesInfo() (playlist.PlaylistInfo, bool) { + if p.favorites == nil { + return playlist.PlaylistInfo{}, false + } + tracks, err := p.favorites.Tracks() + if err != nil { + return playlist.PlaylistInfo{}, false + } + return playlist.PlaylistInfo{ + ID: favorites.PlaylistName, + Name: favorites.PlaylistName, + Section: "Favorites", + TrackCount: len(tracks), + DurationSecs: playlist.TotalDurationSecs(tracks), + }, true +} + // Tracks returns the full track list for the named playlist: explicit // [[track]] entries plus tracks scanned from any [[dir]] sources, in document // order. The reserved "Recently Played" name is served from the history store. func (p *Provider) Tracks(playlistID string) ([]playlist.Track, error) { + if isFavoritesName(playlistID) { + if p.favorites == nil { + return nil, nil + } + return p.favorites.Tracks() + } if isHistoryName(playlistID) { if p.history == nil { return nil, nil @@ -178,6 +217,9 @@ func (p *Provider) AddTracks(playlistName string, tracks []playlist.Track) (adde if isHistoryName(playlistName) { return 0, 0, errReservedHistoryName } + if isFavoritesName(playlistName) { + return 0, 0, errReservedFavoritesName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return 0, 0, err } @@ -243,6 +285,9 @@ func (p *Provider) CreatePlaylist(_ context.Context, name string) (string, error if isHistoryName(name) { return "", errReservedHistoryName } + if isFavoritesName(name) { + return "", errReservedFavoritesName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return "", err } @@ -274,6 +319,9 @@ func (p *Provider) CreateDirPlaylist(name string, dirs []string) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } if err := os.MkdirAll(p.dir, 0o755); err != nil { return fmt.Errorf("creating playlist dir: %w", err) } @@ -300,7 +348,7 @@ func (p *Provider) CreateDirPlaylist(name string, dirs []string) error { if i > 0 { b.WriteByte('\n') } - writeDir(&b, DirSource{Path: dir, Recursive: true}) + writeDir(&b, playlist.DirSource{Path: dir, Recursive: true}) } tmp := path + ".tmp" @@ -327,6 +375,9 @@ func (p *Provider) AddDirSources(name string, dirs []string) ([]string, error) { if isHistoryName(name) { return nil, errReservedHistoryName } + if isFavoritesName(name) { + return nil, errReservedFavoritesName + } for _, dir := range dirs { if err := validateDirSource(dir); err != nil { return nil, err @@ -362,7 +413,7 @@ func (p *Provider) AddDirSources(name string, dirs []string) ([]string, error) { } known[target] = struct{}{} added = append(added, dir) - doc.dirs = append(doc.dirs, DirSource{Path: dir, Recursive: true}) + doc.dirs = append(doc.dirs, playlist.DirSource{Path: dir, Recursive: true}) doc.order = append(doc.order, itemDir) } if len(added) == 0 { @@ -383,7 +434,7 @@ func (p *Provider) AddDirSource(name, dir string) (bool, error) { } // DirSources returns the directory sources referenced by a playlist. -func (p *Provider) DirSources(name string) ([]DirSource, error) { +func (p *Provider) DirSources(name string) ([]playlist.DirSource, error) { if isHistoryName(name) { return nil, errReservedHistoryName } @@ -394,6 +445,96 @@ func (p *Provider) DirSources(name string) ([]DirSource, error) { return doc.dirs, nil } +// dirIndexByPath returns the index in doc.dirs of the source whose expanded +// path matches dir (also expanded), or -1 when none matches. Comparison uses +// cleaned filesystem paths so "~/Music" and "/home/user/Music" align. It is a +// pure path check, so it never re-walks the filesystem the load already did. +func dirIndexByPath(doc *playlistDoc, dir string) int { + target := filepath.Clean(ExpandPath(dir)) + for i, src := range doc.dirs { + if filepath.Clean(ExpandPath(src.Path)) == target { + return i + } + } + return -1 +} + +// RemoveDirSource removes the [[dir]] section whose path matches dir from the +// named playlist. Explicit [[track]] sections keep their slots and order. A +// missing source (or a missing playlist) is a no-op rather than an error, so +// callers can remove without first checking existence. +func (p *Provider) RemoveDirSource(name, dir string) error { + if isHistoryName(name) { + return errReservedHistoryName + } + if isFavoritesName(name) { + return errReservedFavoritesName + } + path, err := p.safePath(name) + if err != nil { + return fmt.Errorf("resolving playlist path: %w", err) + } + doc, err := p.loadDoc(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("loading playlist %q: %w", name, err) + } + di := dirIndexByPath(doc, dir) + if di < 0 { + return nil + } + // Drop the dir and its corresponding itemDir slot from doc.order so the + // ti/di counters used by saveDoc stay aligned with the remaining sections. + doc.dirs = append(doc.dirs[:di], doc.dirs[di+1:]...) + seen := 0 + for i, kind := range doc.order { + if kind != itemDir { + continue + } + if seen == di { + doc.order = append(doc.order[:i], doc.order[i+1:]...) + break + } + seen++ + } + return p.saveDoc(name, doc) +} + +// SetDirRecursive sets the recursive flag on the [[dir]] section whose path +// matches dir in the named playlist. It is a no-op (not an error) when the +// source is missing, the playlist is missing, or the flag is already the +// requested value, so callers can toggle without first checking state. +func (p *Provider) SetDirRecursive(name, dir string, recursive bool) error { + if isHistoryName(name) { + return errReservedHistoryName + } + if isFavoritesName(name) { + return errReservedFavoritesName + } + path, err := p.safePath(name) + if err != nil { + return fmt.Errorf("resolving playlist path: %w", err) + } + doc, err := p.loadDoc(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("loading playlist %q: %w", name, err) + } + di := dirIndexByPath(doc, dir) + if di < 0 { + return nil + } + if doc.dirs[di].Recursive == recursive { + return nil + } + doc.dirs[di].Recursive = recursive + return p.saveDoc(name, doc) +} + // saveDoc writes a parsed document back to disk, preserving section order. // The full document is rendered in memory before the atomic rename so a // partial write can never clobber the existing playlist. @@ -444,6 +585,10 @@ func (p *Provider) saveDoc(name string, doc *playlistDoc) error { // whether it refers to the virtual "Recently Played" history with at least // one entry recorded. func (p *Provider) Exists(name string) bool { + if isFavoritesName(name) { + _, ok := p.favoritesInfo() + return ok + } if isHistoryName(name) { _, ok := p.historyInfo() return ok @@ -513,6 +658,10 @@ func (p *Provider) existingDoc(path string) (*playlistDoc, error) { // otherwise mutate the synthetic history playlist. var errReservedHistoryName = errors.New(`"Recently Played" is a virtual history playlist and cannot be modified`) +// errReservedFavoritesName is returned when a caller tries to write to or +// otherwise mutate the synthetic favorites playlist. +var errReservedFavoritesName = errors.New(`"Favorites" is a virtual favorites playlist and cannot be modified`) + // SetBookmark toggles the bookmark flag on a track and rewrites the playlist. // The index refers to the expanded track list (explicit entries plus // directory-scanned ones). Bookmarking a directory-scanned track materializes @@ -522,6 +671,9 @@ func (p *Provider) SetBookmark(playlistName string, idx int) error { if isHistoryName(playlistName) { return errReservedHistoryName } + if isFavoritesName(playlistName) { + return errReservedFavoritesName + } tracks, err := p.expandedTracks(playlistName) if err != nil { return err @@ -543,6 +695,9 @@ func (p *Provider) SetBookmarkByPath(playlistName string, path string) error { if isHistoryName(playlistName) { return errReservedHistoryName } + if isFavoritesName(playlistName) { + return errReservedFavoritesName + } tracks, err := p.expandedTracks(playlistName) if err != nil { return err @@ -571,6 +726,9 @@ func (p *Provider) SavePlaylist(name string, tracks []playlist.Track) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } return p.savePlaylist(name, tracks) } @@ -666,6 +824,9 @@ func (p *Provider) RenamePlaylist(oldName, newName string) error { if isHistoryName(oldName) || isHistoryName(newName) { return errReservedHistoryName } + if isFavoritesName(oldName) || isFavoritesName(newName) { + return errReservedFavoritesName + } oldPath, err := p.safePath(oldName) if err != nil { return fmt.Errorf("invalid playlist name %q: %w", oldName, err) @@ -694,6 +855,9 @@ func (p *Provider) DeletePlaylist(name string) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } path, err := p.safePath(name) if err != nil { return err @@ -710,6 +874,47 @@ func (p *Provider) ClearHistory() error { return p.history.Clear() } +// ClearFavorites wipes the favorites list. Returns nil if no favorites exist. +func (p *Provider) ClearFavorites() error { + if p.favorites == nil { + return nil + } + return p.favorites.Clear() +} + +// FavoritesStore returns the underlying favorites store so the UI can toggle +// favorites without going through the playlist write path. +func (p *Provider) FavoritesStore() *favorites.Store { + return p.favorites +} + +// ToggleFavorite toggles a track in the favorites store. +// Implements provider.FavoritesManager. +func (p *Provider) ToggleFavorite(track playlist.Track) (bool, error) { + if p.favorites == nil { + return false, nil + } + return p.favorites.Toggle(track) +} + +// IsFavorited reports whether the given path is in the favorites store. +// Implements provider.FavoritesManager. +func (p *Provider) IsFavorited(path string) bool { + if p.favorites == nil { + return false + } + return p.favorites.IsFavorited(path) +} + +// FavoritesCount returns the number of favorited tracks. +// Implements provider.FavoritesManager. +func (p *Provider) FavoritesCount() int { + if p.favorites == nil { + return 0 + } + return p.favorites.Count() +} + // RemoveTrack removes a track by index from the named playlist. // The index refers to the expanded track list. Directory-scanned tracks // cannot be removed: they are re-derived from the [[dir]] source on every @@ -718,6 +923,9 @@ func (p *Provider) RemoveTrack(name string, index int) error { if isHistoryName(name) { return errReservedHistoryName } + if isFavoritesName(name) { + return errReservedFavoritesName + } tracks, err := p.expandedTracks(name) if err != nil { return err diff --git a/external/local/provider_test.go b/external/local/provider_test.go index eb8368e90..118f3a67d 100644 --- a/external/local/provider_test.go +++ b/external/local/provider_test.go @@ -10,8 +10,10 @@ import ( "testing" "time" + "github.com/bjarneo/cliamp/favorites" "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/provider" ) func newTestProvider(t *testing.T) *Provider { @@ -726,3 +728,152 @@ func TestSearchTracksLimit(t *testing.T) { t.Fatalf("got %d results, want 2 (limit)", len(got)) } } + +// --- Virtual "Favorites" playlist --- + +func newTestProviderWithFavorites(t *testing.T) *Provider { + t.Helper() + dir := t.TempDir() + favPath := filepath.Join(dir, "favorites.toml") + return &Provider{dir: filepath.Join(dir, "playlists"), favorites: favorites.NewAt(favPath)} +} + +func TestPlaylistsIncludesFavoritesWhenNonEmpty(t *testing.T) { + p := newTestProviderWithFavorites(t) + p.favorites.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 1 || lists[0].ID != "Favorites" { + t.Fatalf("Playlists = %+v, want [Favorites]", lists) + } + if lists[0].Section != "Favorites" { + t.Errorf("Section = %q, want %q", lists[0].Section, "Favorites") + } +} + +func TestPlaylistsIncludesFavoritesWhenEmpty(t *testing.T) { + p := newTestProviderWithFavorites(t) + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 1 || lists[0].ID != "Favorites" { + t.Fatalf("Playlists = %+v, want [Favorites] even when empty", lists) + } + if lists[0].TrackCount != 0 { + t.Errorf("TrackCount = %d, want 0", lists[0].TrackCount) + } +} + +func TestTracksReadsFromFavorites(t *testing.T) { + p := newTestProviderWithFavorites(t) + p.favorites.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + p.favorites.Toggle(playlist.Track{Path: "/b.mp3", Title: "B"}) + + tracks, err := p.Tracks("Favorites") + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 2 { + t.Fatalf("got %d tracks, want 2", len(tracks)) + } + // Newest first (B was toggled after A). + if tracks[0].Title != "B" || tracks[1].Title != "A" { + t.Errorf("order = [%s, %s], want [B, A]", tracks[0].Title, tracks[1].Title) + } +} + +func TestWritesRejectedForFavoritesName(t *testing.T) { + p := newTestProviderWithFavorites(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + tests := []struct { + name string + fn func() error + }{ + {"AddTrack", func() error { return p.AddTrack("Favorites", track) }}, + {"AddTracks", func() error { _, _, err := p.AddTracks("Favorites", []playlist.Track{track}); return err }}, + {"SavePlaylist", func() error { return p.SavePlaylist("Favorites", nil) }}, + {"DeletePlaylist", func() error { return p.DeletePlaylist("Favorites") }}, + {"RemoveTrack", func() error { return p.RemoveTrack("Favorites", 0) }}, + {"SetBookmark", func() error { return p.SetBookmark("Favorites", 0) }}, + {"SetBookmarkByPath", func() error { return p.SetBookmarkByPath("Favorites", "/a.mp3") }}, + {"RenamePlaylist", func() error { return p.RenamePlaylist("Favorites", "NewName") }}, + {"CreatePlaylist", func() error { _, err := p.CreatePlaylist(context.Background(), "Favorites"); return err }}, + {"AddDirSources", func() error { _, err := p.AddDirSources("Favorites", []string{"/some/dir"}); return err }}, + {"RemoveDirSource", func() error { return p.RemoveDirSource("Favorites", "/some/dir") }}, + {"SetDirRecursive", func() error { return p.SetDirRecursive("Favorites", "/some/dir", true) }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.fn() + if err == nil { + t.Fatalf("%s: expected error, got nil", tt.name) + } + if !strings.Contains(err.Error(), "Favorites") { + t.Fatalf("%s: error = %q, want message mentioning Favorites", tt.name, err) + } + }) + } +} + +func TestFavoritesManagerInterface(t *testing.T) { + p := newTestProviderWithFavorites(t) + fm, ok := any(p).(provider.FavoritesManager) + if !ok { + t.Fatal("Provider does not implement FavoritesManager") + } + + // Initially empty. + if fm.FavoritesCount() != 0 { + t.Fatalf("Count = %d, want 0", fm.FavoritesCount()) + } + if fm.IsFavorited("/a.mp3") { + t.Fatal("should not be favorited initially") + } + + // Toggle on. + added, err := fm.ToggleFavorite(playlist.Track{Path: "/a.mp3", Title: "A"}) + if err != nil { + t.Fatalf("ToggleFavorite: %v", err) + } + if !added { + t.Fatal("first toggle should return true") + } + if !fm.IsFavorited("/a.mp3") { + t.Fatal("should be favorited after toggle on") + } + if fm.FavoritesCount() != 1 { + t.Fatalf("Count = %d, want 1", fm.FavoritesCount()) + } + + // Toggle off. + added, err = fm.ToggleFavorite(playlist.Track{Path: "/a.mp3", Title: "A"}) + if err != nil { + t.Fatalf("ToggleFavorite: %v", err) + } + if added { + t.Fatal("second toggle should return false") + } + if fm.IsFavorited("/a.mp3") { + t.Fatal("should not be favorited after toggle off") + } +} + +func TestFavoritesTrackCount(t *testing.T) { + p := newTestProviderWithFavorites(t) + p.favorites.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + p.favorites.Toggle(playlist.Track{Path: "/b.mp3", Title: "B"}) + + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 1 || lists[0].TrackCount != 2 { + t.Fatalf("TrackCount = %d, want 2", lists[0].TrackCount) + } +} diff --git a/favorites/favorites.go b/favorites/favorites.go new file mode 100644 index 000000000..c8f613038 --- /dev/null +++ b/favorites/favorites.go @@ -0,0 +1,331 @@ +// Package favorites persists the user's favorite tracks to a TOML file in the +// cliamp config directory. Favorites are explicitly toggled by the user and +// span all playlists — a track favorited in playlist A appears when browsing +// the virtual "Favorites" playlist regardless of where it was starred. +// +// The store is safe for concurrent callers and writes atomically (temp file + +// rename) so a crash mid-write cannot leave a half-finished favorites.toml. +package favorites + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/bjarneo/cliamp/internal/appdir" + "github.com/bjarneo/cliamp/internal/tomlutil" + "github.com/bjarneo/cliamp/playlist" +) + +// PlaylistName is the virtual playlist name surfaced to the UI by the local +// provider. Browsing this name returns favorite tracks newest-first. +const PlaylistName = "Favorites" + +// Entry pairs a track with the wall-clock time it was favorited. +type Entry struct { + Track playlist.Track + FavoritedAt time.Time +} + +// Store reads and writes the favorites TOML file. +type Store struct { + path string + + mu sync.Mutex +} + +// New returns a Store backed by ~/.config/cliamp/favorites.toml. Returns nil if +// the config directory cannot be resolved. +func New() *Store { + dir, err := appdir.Dir() + if err != nil { + return nil + } + return &Store{path: filepath.Join(dir, "favorites.toml")} +} + +// NewAt returns a Store rooted at an explicit file path. Used by tests. +func NewAt(path string) *Store { + return &Store{path: path} +} + +// Path returns the on-disk file path. +func (s *Store) Path() string { return s.path } + +// Toggle favorites a track. If the track is already favorited, it is removed +// (unfavorited). Returns true when the track is now favorited after the call. +// Empty paths are ignored and return false. +func (s *Store) Toggle(track playlist.Track) (bool, error) { + if s == nil || strings.TrimSpace(track.Path) == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false, fmt.Errorf("load favorites: %w", err) + } + + idx := slices.IndexFunc(entries, func(e Entry) bool { + return e.Track.Path == track.Path + }) + + if idx >= 0 { + // Already favorited — remove it. + entries = slices.Delete(entries, idx, idx+1) + return false, s.saveLocked(entries) + } + + // Not yet favorited — add it at the front (newest first). + entry := Entry{Track: track, FavoritedAt: time.Now()} + entries = append([]Entry{entry}, entries...) + return true, s.saveLocked(entries) +} + +// Favorite adds a track to favorites. No-op if already present. +// Returns true when the track was newly added. +func (s *Store) Favorite(track playlist.Track) (bool, error) { + if s == nil || strings.TrimSpace(track.Path) == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false, fmt.Errorf("load favorites: %w", err) + } + + if slices.ContainsFunc(entries, func(e Entry) bool { + return e.Track.Path == track.Path + }) { + return false, nil + } + + entry := Entry{Track: track, FavoritedAt: time.Now()} + entries = append([]Entry{entry}, entries...) + return true, s.saveLocked(entries) +} + +// Remove unfavorites a track by path. Returns true when the track was present +// and removed. +func (s *Store) Remove(path string) (bool, error) { + if s == nil || strings.TrimSpace(path) == "" { + return false, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false, fmt.Errorf("load favorites: %w", err) + } + + idx := slices.IndexFunc(entries, func(e Entry) bool { + return e.Track.Path == path + }) + if idx < 0 { + return false, nil + } + + entries = slices.Delete(entries, idx, idx+1) + return true, s.saveLocked(entries) +} + +// IsFavorited reports whether the given path is in the favorites store. +func (s *Store) IsFavorited(path string) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return false + } + return slices.ContainsFunc(entries, func(e Entry) bool { + return e.Track.Path == path + }) +} + +// Count returns the number of favorited tracks. +func (s *Store) Count() int { + if s == nil { + return 0 + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return 0 + } + return len(entries) +} + +// Tracks returns all favorite tracks, newest-first, suitable for handing to a +// playlist.Playlist. The FavoritedAt timestamp is dropped. +func (s *Store) Tracks() ([]playlist.Track, error) { + if s == nil { + return nil, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + entries, err := s.loadLocked() + if err != nil { + return nil, err + } + out := make([]playlist.Track, len(entries)) + for i, e := range entries { + out[i] = e.Track + } + return out, nil +} + +// Clear deletes the favorites file. Returns nil if the file does not exist. +func (s *Store) Clear() error { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + err := os.Remove(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func (s *Store) loadLocked() ([]Entry, error) { + data, err := os.ReadFile(s.path) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + return parse(data), nil +} + +func (s *Store) saveLocked(entries []Entry) error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + var b strings.Builder + for i, e := range entries { + if i > 0 { + fmt.Fprintln(&b) + } + writeEntry(&b, e) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func writeEntry(w io.Writer, e Entry) { + fmt.Fprintf(w, "[[entry]]\n") + fmt.Fprintf(w, "favorited_at = %q\n", e.FavoritedAt.UTC().Format(time.RFC3339)) + fmt.Fprintf(w, "path = %q\n", e.Track.Path) + fmt.Fprintf(w, "title = %q\n", e.Track.Title) + if e.Track.Artist != "" { + fmt.Fprintf(w, "artist = %q\n", e.Track.Artist) + } + if e.Track.Album != "" { + fmt.Fprintf(w, "album = %q\n", e.Track.Album) + } + if e.Track.Genre != "" { + fmt.Fprintf(w, "genre = %q\n", e.Track.Genre) + } + if e.Track.Year != 0 { + fmt.Fprintf(w, "year = %d\n", e.Track.Year) + } + if e.Track.TrackNumber != 0 { + fmt.Fprintf(w, "track_number = %d\n", e.Track.TrackNumber) + } + if e.Track.DurationSecs != 0 { + fmt.Fprintf(w, "duration_secs = %d\n", e.Track.DurationSecs) + } +} + +// parse skips unknown keys to keep the on-disk format forward-compatible. +func parse(data []byte) []Entry { + var entries []Entry + var cur *Entry + + flush := func() { + if cur != nil { + entries = append(entries, *cur) + } + } + + for rawLine := range strings.SplitSeq(string(data), "\n") { + line := strings.TrimSpace(rawLine) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if line == "[[entry]]" { + flush() + cur = &Entry{} + continue + } + if cur == nil { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = tomlutil.Unquote(strings.TrimSpace(val)) + switch key { + case "favorited_at": + if t, err := time.Parse(time.RFC3339, val); err == nil { + cur.FavoritedAt = t + } + case "path": + cur.Track.Path = val + cur.Track.Stream = playlist.IsURL(val) + case "title": + cur.Track.Title = val + case "artist": + cur.Track.Artist = val + case "album": + cur.Track.Album = val + case "genre": + cur.Track.Genre = val + case "year": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.Year = n + } + case "track_number": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.TrackNumber = n + } + case "duration_secs": + if n, err := strconv.Atoi(val); err == nil { + cur.Track.DurationSecs = n + } + } + } + flush() + + // Drop entries that failed to parse a path (the only required field). + entries = slices.DeleteFunc(entries, func(e Entry) bool { + return strings.TrimSpace(e.Track.Path) == "" + }) + return entries +} diff --git a/favorites/favorites_test.go b/favorites/favorites_test.go new file mode 100644 index 000000000..60abca8f0 --- /dev/null +++ b/favorites/favorites_test.go @@ -0,0 +1,203 @@ +package favorites + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/bjarneo/cliamp/playlist" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + return NewAt(filepath.Join(t.TempDir(), "favorites.toml")) +} + +func TestToggleAdd(t *testing.T) { + s := newTestStore(t) + track := playlist.Track{Path: "/a.mp3", Title: "A", Artist: "Art"} + + added, err := s.Toggle(track) + if err != nil { + t.Fatalf("Toggle: %v", err) + } + if !added { + t.Fatal("Toggle should return true when adding") + } + if !s.IsFavorited("/a.mp3") { + t.Fatal("track should be favorited after Toggle") + } + if s.Count() != 1 { + t.Fatalf("count = %d, want 1", s.Count()) + } +} + +func TestToggleRemove(t *testing.T) { + s := newTestStore(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + s.Toggle(track) + added, err := s.Toggle(track) + if err != nil { + t.Fatalf("Toggle: %v", err) + } + if added { + t.Fatal("Toggle should return false when removing") + } + if s.IsFavorited("/a.mp3") { + t.Fatal("track should not be favorited after second Toggle") + } + if s.Count() != 0 { + t.Fatalf("count = %d, want 0", s.Count()) + } +} + +func TestFavoriteIdempotent(t *testing.T) { + s := newTestStore(t) + track := playlist.Track{Path: "/a.mp3", Title: "A"} + + added, _ := s.Favorite(track) + if !added { + t.Fatal("first Favorite should return true") + } + added, _ = s.Favorite(track) + if added { + t.Fatal("second Favorite should return false (already present)") + } + if s.Count() != 1 { + t.Fatalf("count = %d, want 1", s.Count()) + } +} + +func TestRemoveNonexistent(t *testing.T) { + s := newTestStore(t) + removed, err := s.Remove("/nope.mp3") + if err != nil { + t.Fatalf("Remove: %v", err) + } + if removed { + t.Fatal("Remove of nonexistent track should return false") + } +} + +func TestTracksOrdering(t *testing.T) { + s := newTestStore(t) + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + + s.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + // Simulate earlier favorited time by toggling and re-adding with a known time. + // Since Toggle is time.Now()-based, we just add two tracks in sequence. + s.Toggle(playlist.Track{Path: "/b.mp3", Title: "B"}) + + tracks, err := s.Tracks() + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 2 { + t.Fatalf("len = %d, want 2", len(tracks)) + } + // Newest first: B was added after A. + if tracks[0].Title != "B" || tracks[1].Title != "A" { + t.Fatalf("order wrong: %+v", tracks) + } + _ = base // used above for documentation +} + +func TestPersistAcrossInstances(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "favorites.toml") + + s1 := NewAt(path) + s1.Toggle(playlist.Track{Path: "/a.mp3", Title: "A", Artist: "Art", Album: "Alb", Year: 2026, DurationSecs: 180}) + + s2 := NewAt(path) + tracks, err := s2.Tracks() + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 1 { + t.Fatalf("reloaded %d tracks, want 1", len(tracks)) + } + tr := tracks[0] + if tr.Title != "A" || tr.Artist != "Art" || tr.Album != "Alb" { + t.Errorf("track meta lost: %+v", tr) + } + if tr.Year != 2026 || tr.DurationSecs != 180 { + t.Errorf("numeric meta lost: year=%d dur=%d", tr.Year, tr.DurationSecs) + } +} + +func TestClearRemovesFile(t *testing.T) { + s := newTestStore(t) + s.Toggle(playlist.Track{Path: "/a.mp3", Title: "A"}) + if err := s.Clear(); err != nil { + t.Fatalf("Clear: %v", err) + } + if _, err := os.Stat(s.Path()); !os.IsNotExist(err) { + t.Fatalf("file should be gone after Clear, err = %v", err) + } + if s.Count() != 0 { + t.Fatalf("count after Clear = %d, want 0", s.Count()) + } +} + +func TestClearMissingFileNoError(t *testing.T) { + s := newTestStore(t) + if err := s.Clear(); err != nil { + t.Fatalf("Clear on missing file: %v", err) + } +} + +func TestStreamFlagInferredOnReload(t *testing.T) { + s := newTestStore(t) + s.Toggle(playlist.Track{Path: "https://example.com/stream", Title: "Live"}) + + s2 := NewAt(s.Path()) + tracks, _ := s2.Tracks() + if len(tracks) != 1 || !tracks[0].Stream { + t.Fatalf("Stream flag not inferred: %+v", tracks) + } +} + +func TestNilStoreSafe(t *testing.T) { + var s *Store + added, err := s.Toggle(playlist.Track{Path: "/a.mp3"}) + if err != nil || added { + t.Errorf("nil Toggle: added=%v err=%v", added, err) + } + if _, err := s.Tracks(); err != nil { + t.Errorf("nil Tracks: %v", err) + } + if s.IsFavorited("/a.mp3") { + t.Error("nil IsFavorited should return false") + } + if s.Count() != 0 { + t.Errorf("nil Count = %d, want 0", s.Count()) + } + if err := s.Clear(); err != nil { + t.Errorf("nil Clear: %v", err) + } +} + +func TestToggleIgnoresEmptyPath(t *testing.T) { + s := newTestStore(t) + added, err := s.Toggle(playlist.Track{Title: "no path"}) + if err != nil || added { + t.Errorf("empty path Toggle: added=%v err=%v", added, err) + } + if s.Count() != 0 { + t.Fatalf("count = %d, want 0", s.Count()) + } +} + +func TestTracksEmpty(t *testing.T) { + s := newTestStore(t) + tracks, err := s.Tracks() + if err != nil { + t.Fatalf("Tracks: %v", err) + } + if len(tracks) != 0 { + t.Fatalf("empty Tracks = %d, want 0", len(tracks)) + } +} diff --git a/playlist/dirsource.go b/playlist/dirsource.go new file mode 100644 index 000000000..a0c06bbfb --- /dev/null +++ b/playlist/dirsource.go @@ -0,0 +1,9 @@ +package playlist + +// DirSource is a [[dir]] section in a playlist file: a directory that is +// scanned for audio files every time the playlist loads, instead of listing +// every file explicitly. +type DirSource struct { + Path string // directory path; supports ~ and environment variables + Recursive bool // scan subdirectories too (default true) +} diff --git a/playlist/provider.go b/playlist/provider.go index e1f7911f0..a4c3cedae 100644 --- a/playlist/provider.go +++ b/playlist/provider.go @@ -16,12 +16,17 @@ var ErrNeedsAuth = errors.New("sign-in required") // Adjacent rows that share a Section are rendered under one header; a change of // Section emits a "── header ──" divider. The radio provider uses // SectionedList.IDPrefix instead and leaves Section empty. +// +// DirSourceCount is optional: providers that back playlists with [[dir]] +// directory sources set it so the UI can flag them in the list. A zero value +// means "none/unknown" and the UI hides the indicator. type PlaylistInfo struct { - ID string - Name string - TrackCount int - DurationSecs int - Section string + ID string + Name string + TrackCount int + DurationSecs int + Section string + DirSourceCount int } // Provider is the interface for playlist sources (radio, Navidrome, Spotify, etc.). diff --git a/provider/interfaces.go b/provider/interfaces.go index 912546c37..f80bfb060 100644 --- a/provider/interfaces.go +++ b/provider/interfaces.go @@ -119,6 +119,17 @@ type BookmarkSetter interface { SetBookmarkByPath(playlistName string, path string) error } +// PlaylistDirSourceManager is implemented by providers whose playlists can +// reference directory sources that are re-scanned on each load. The local +// TOML provider implements this for its [[dir]] sections; other providers +// leave it unimplemented and the UI hides directory-source controls. +type PlaylistDirSourceManager interface { + DirSources(name string) ([]playlist.DirSource, error) + AddDirSource(name, dir string) (bool, error) + RemoveDirSource(name, dir string) error + SetDirRecursive(name, dir string, recursive bool) error +} + // CustomStreamer is implemented by providers that need a custom audio // decode path for non-standard URI schemes (e.g. spotify:track:xxx). type CustomStreamer interface { @@ -166,3 +177,16 @@ type SectionedList interface { type Closer interface { Close() } + +// FavoritesManager is implemented by providers that support a cross-playlist +// favorites virtual playlist. The UI uses this to toggle favorites from the +// track list without going through the per-playlist write path. +type FavoritesManager interface { + // ToggleFavorite toggles the given track in the favorites store. + // Returns true when the track is now favorited after the call. + ToggleFavorite(track playlist.Track) (bool, error) + // IsFavorited reports whether the given path is in the favorites store. + IsFavorited(path string) bool + // FavoritesCount returns the number of favorited tracks. + FavoritesCount() int +} diff --git a/site/index.html b/site/index.html index 000f0de66..080e8fe85 100644 --- a/site/index.html +++ b/site/index.html @@ -755,7 +755,7 @@

Browser-session setup

10-Band Equalizer

Parametric EQ presets plus a persistent Custom curve that survives preset changes and restarts.

Themes & Visualizers

21 contrast-checked built-in themes and spectrum, waveform, particle, and true-stereo modes. Stereo provides dedicated L/R horizontal LED peak meters. Hot-swap with t / v.

-
Playlists

TOML playlists with dynamic directory sources (--dir), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.

+
Playlists

TOML playlists with dynamic [[dir]] directory sources you can add, remove, and toggle-recursive from the playlist manager (D), cross-playlist favorites (n), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.

Recently Played

Auto-recorded listening history. Browse it as a virtual playlist or run cliamp history from the shell.

HTTP Streaming

Play from URLs, internet radio, remote M3U playlists, and HLS (.m3u8) live streams via ffmpeg.

Synced Lyrics

Embedded local lyrics first, then LRCLIB/NetEase fallback. Auto-scrolling for timestamped lyrics.

diff --git a/ui/model/command_registry.go b/ui/model/command_registry.go index 8bc974305..5901a3fe0 100644 --- a/ui/model/command_registry.go +++ b/ui/model/command_registry.go @@ -5,6 +5,7 @@ import ( "charm.land/lipgloss/v2" + "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/ui" ) @@ -26,6 +27,7 @@ const ( commandModeNavSearch commandModePlaylistManager commandModePlaylistManagerInput + commandModePlaylistManagerDirs commandModePlaylistPicker commandModePlaylistPickerInput commandModeQueue @@ -94,6 +96,7 @@ var commandRegistry = []commandSpec{ {Mode: commandModeMain, Keys: []string{"shift+up", "shift+down"}, KeyLabel: "Shift+Up Down", Label: "Move track up/down", Keymap: true}, {Mode: commandModeMain, Keys: []string{"h", "l"}, KeyLabel: "h l", Label: "EQ cursor left/right", Keymap: true}, {Mode: commandModeMain, Keys: []string{"enter"}, KeyLabel: "Enter", Label: "Play selected track", Keymap: true, ContextHelp: true, Primary: true}, + {Mode: commandModeMain, Keys: []string{"f", "n"}, KeyLabel: "f/n", Label: "★/♥", Keymap: true, ContextHelp: true}, {Mode: commandModeMain, Keys: []string{"a"}, KeyLabel: "a", Label: "Toggle queue (play next)", Keymap: true, ContextHelp: true}, {Mode: commandModeMain, Keys: []string{"A"}, KeyLabel: "A", Label: "Queue manager", Keymap: true}, {Mode: commandModeMain, Keys: []string{"x"}, KeyLabel: "x", Label: "Remove selected track from playlist", Destructive: true, Keymap: true}, @@ -121,7 +124,6 @@ var commandRegistry = []commandSpec{ return !m.heightExpanded && m.layout.bodyRows > m.plVisible }}, {Mode: commandModeMain, Keys: []string{"/"}, KeyLabel: "/", Label: "Filter/search list", Keymap: true, ContextHelp: true}, - {Mode: commandModeMain, Keys: []string{"f"}, KeyLabel: "f", Label: "Toggle bookmark/favorite", Keymap: true}, {Mode: commandModeMain, Keys: []string{"ctrl+f"}, KeyLabel: "Ctrl+F", Label: "Search active provider or YouTube", Keymap: true, ContextHelp: true}, {Mode: commandModeMain, Keys: []string{"u"}, KeyLabel: "u", Label: "Load URL (stream/playlist)", Keymap: true}, {Mode: commandModeMain, Keys: []string{"d"}, KeyLabel: "d", Label: "Audio device picker", Keymap: true}, @@ -167,6 +169,24 @@ var commandRegistry = []commandSpec{ {Mode: commandModeLyrics, Keys: []string{"r"}, KeyLabel: "r", Label: "Retry", ContextHelp: true, Primary: true, Enabled: func(m Model) bool { return !m.lyrics.loading && (m.lyrics.err != nil || len(m.lyrics.lines) == 0) }}, {Mode: commandModeLyrics, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Close", ContextHelp: true, Cancel: true}, {Mode: commandModeInfo, Keys: []string{"esc"}, KeyLabel: "Esc", Label: "Close", ContextHelp: true, Cancel: true}, + + {Mode: commandModePlaylistManager, Keys: []string{"D"}, KeyLabel: "D", Label: "Dir sources", ContextHelp: true, Enabled: func(m Model) bool { + return m.plManager.visible && m.plManager.screen == plMgrScreenTracks && m.plManager.selPlaylist != history.PlaylistName + }}, + {Mode: commandModePlaylistManager, Keys: []string{"f", "n"}, KeyLabel: "f/n", Label: "★/♥", ContextHelp: true, Enabled: func(m Model) bool { + return m.plManager.visible && m.plManager.screen == plMgrScreenTracks + }}, + {Mode: commandModePlaylistManager, Keys: []string{"[", "]"}, KeyLabel: "[ ]", Label: "Reorder", ContextHelp: true, Enabled: func(m Model) bool { + return m.plManager.visible && m.plManager.screen == plMgrScreenTracks + }}, + {Mode: commandModePlaylistManagerDirs, Keys: []string{"esc", "backspace", "h", "left"}, KeyLabel: "Esc", Label: "Back to tracks", ContextHelp: true, Cancel: true}, + {Mode: commandModePlaylistManagerDirs, Keys: []string{"a"}, KeyLabel: "a", Label: "Add dir", ContextHelp: true, Primary: true}, + {Mode: commandModePlaylistManagerDirs, Keys: []string{"d"}, KeyLabel: "d", Label: "Remove", Destructive: true, ContextHelp: true}, + {Mode: commandModePlaylistManagerDirs, Keys: []string{"r"}, KeyLabel: "r", Label: "Toggle recursive", ContextHelp: true}, + {Mode: commandModePlaylistManagerDirs, Keys: []string{"up", "down", "k", "j"}, KeyLabel: "Up Down", Label: "Navigate", ContextHelp: true}, + {Mode: commandModeFileBrowser, Keys: []string{"D"}, KeyLabel: "D", Label: "Add as dir source", ContextHelp: true, Enabled: func(m Model) bool { + return m.fileBrowser.visible && m.fileBrowser.targetPlaylist != "" + }}, } func (m Model) commandHelp(mode commandMode) string { diff --git a/ui/model/dirs_screen_test.go b/ui/model/dirs_screen_test.go new file mode 100644 index 000000000..89eada2a3 --- /dev/null +++ b/ui/model/dirs_screen_test.go @@ -0,0 +1,342 @@ +package model + +import ( + "errors" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/ui" +) + +// dirSourceTestProvider is a local provider that also implements +// provider.PlaylistDirSourceManager, so the manager's directory-sources screen +// can be exercised without touching the filesystem. +type dirSourceTestProvider struct { + commandsTestProvider + dirs []playlist.DirSource + removed []string + setRec []dirSetRecCall + added []string + failOn map[string]error // dirs that AddDirSource should fail on + favPaths map[string]struct{} +} + +type dirSetRecCall struct { + dir string + recursive bool +} + +func (p *dirSourceTestProvider) DirSources(string) ([]playlist.DirSource, error) { + return p.dirs, nil +} +func (p *dirSourceTestProvider) AddDirSource(_, dir string) (bool, error) { + if p.failOn != nil { + if err, ok := p.failOn[dir]; ok { + return false, err + } + } + for _, d := range p.dirs { + if d.Path == dir { + return false, nil + } + } + p.dirs = append(p.dirs, playlist.DirSource{Path: dir, Recursive: true}) + p.added = append(p.added, dir) + return true, nil +} +func (p *dirSourceTestProvider) RemoveDirSource(_, dir string) error { + p.removed = append(p.removed, dir) + out := p.dirs[:0] + for _, d := range p.dirs { + if d.Path != dir { + out = append(out, d) + } + } + p.dirs = out + return nil +} +func (p *dirSourceTestProvider) SetDirRecursive(_, dir string, recursive bool) error { + p.setRec = append(p.setRec, dirSetRecCall{dir, recursive}) + for i := range p.dirs { + if p.dirs[i].Path == dir { + p.dirs[i].Recursive = recursive + } + } + return nil +} + +func (p *dirSourceTestProvider) ToggleFavorite(track playlist.Track) (bool, error) { + if p.favPaths == nil { + p.favPaths = make(map[string]struct{}) + } + if _, ok := p.favPaths[track.Path]; ok { + delete(p.favPaths, track.Path) + return false, nil + } + p.favPaths[track.Path] = struct{}{} + return true, nil +} + +func (p *dirSourceTestProvider) IsFavorited(path string) bool { + if p.favPaths == nil { + return false + } + _, ok := p.favPaths[path] + return ok +} + +func (p *dirSourceTestProvider) FavoritesCount() int { + return len(p.favPaths) +} + +func (p *dirSourceTestProvider) Tracks(id string) ([]playlist.Track, error) { + if id == "Favorites" && p.favPaths != nil { + var out []playlist.Track + for path := range p.favPaths { + out = append(out, playlist.Track{Path: path, Title: path}) + } + return out, nil + } + return nil, nil +} + +func newDirsScreenTestModel(prov *dirSourceTestProvider) Model { + m := Model{ + playlist: playlist.New(), + localProvider: prov, + provider: prov, + favMgr: prov, + vis: ui.NewVisualizer(48000), + plManager: plManagerState{ + visible: true, + screen: plMgrScreenTracks, + selPlaylist: "music", + tracks: []playlist.Track{{Path: "/a.mp3", Title: "A"}}, + }, + } + return m +} + +func TestPlMgrDKeyOpensDirsScreen(t *testing.T) { + prov := &dirSourceTestProvider{ + dirs: []playlist.DirSource{{Path: "/home/me/Music", Recursive: true}}, + } + m := newDirsScreenTestModel(prov) + + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "D"}) + + if m.plManager.screen != plMgrScreenDirs { + t.Fatalf("screen = %v, want plMgrScreenDirs", m.plManager.screen) + } + if len(m.plManager.dirs) != 1 || m.plManager.dirs[0].Path != "/home/me/Music" { + t.Fatalf("dirs = %+v, want the loaded source", m.plManager.dirs) + } +} + +func TestPlMgrDKeyHistoryShowsNotice(t *testing.T) { + prov := &dirSourceTestProvider{} + m := newDirsScreenTestModel(prov) + m.plManager.selPlaylist = "Recently Played" + + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "D"}) + + if m.plManager.screen != plMgrScreenTracks { + t.Fatalf("screen = %v, want to stay on Tracks for the virtual history playlist", m.plManager.screen) + } + if len(prov.dirs) != 0 { + t.Fatalf("DirSources should not be called for history, dirs = %+v", prov.dirs) + } + if m.status.text == "" { + t.Fatal("expected a status notice for the virtual history playlist") + } +} + +func TestPlMgrDKeyNoticeWhenUnsupported(t *testing.T) { + // A plain commandsTestProvider does not implement PlaylistDirSourceManager, + // so the D key must show a notice and stay on the tracks screen. + plain := commandsTestProvider{name: "Local"} + m := newDirsScreenTestModel(&dirSourceTestProvider{}) + m.localProvider = plain + m.provider = plain + + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "D"}) + + if m.plManager.screen != plMgrScreenTracks { + t.Fatalf("screen = %v, want to stay on Tracks when unsupported", m.plManager.screen) + } + if m.status.text == "" { + t.Fatal("expected a status notice when the provider lacks dir sources") + } +} + +func TestPlMgrDirsToggleRecursive(t *testing.T) { + prov := &dirSourceTestProvider{ + dirs: []playlist.DirSource{{Path: "/Music", Recursive: true}}, + } + m := newDirsScreenTestModel(prov) + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "D"}) // open dirs screen + + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "r"}) + + if len(prov.setRec) != 1 { + t.Fatalf("SetDirRecursive calls = %d, want 1", len(prov.setRec)) + } + if prov.setRec[0].dir != "/Music" || prov.setRec[0].recursive { + t.Fatalf("toggle call = %+v, want /Music recursive=false", prov.setRec[0]) + } + if m.plManager.dirs[0].Recursive { + t.Fatalf("dir in manager state should now be flat, got %+v", m.plManager.dirs[0]) + } +} + +func TestPlMgrDirsRemoveConfirmFlow(t *testing.T) { + prov := &dirSourceTestProvider{ + dirs: []playlist.DirSource{{Path: "/Music", Recursive: true}}, + } + m := newDirsScreenTestModel(prov) + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "D"}) // open dirs screen + + // 'd' arms the confirmation prompt; nothing removed yet. + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "d"}) + if !m.plManager.confirmDel { + t.Fatal("confirmDel should be armed after 'd'") + } + if len(prov.removed) != 0 { + t.Fatalf("removed = %v before confirm, want none", prov.removed) + } + + // 'y' confirms and removes the highlighted source. + m.handlePlaylistManagerKey(tea.KeyPressMsg{Text: "y"}) + if len(prov.removed) != 1 || prov.removed[0] != "/Music" { + t.Fatalf("removed = %v, want [/Music]", prov.removed) + } + if m.plManager.confirmDel { + t.Fatal("confirmDel should be cleared after 'y'") + } + if len(m.plManager.dirs) != 0 { + t.Fatalf("dirs after remove = %+v, want empty", m.plManager.dirs) + } +} + +func TestFileBrowserDAddsDirSource(t *testing.T) { + prov := &dirSourceTestProvider{ + commandsTestProvider: commandsTestProvider{name: "Local"}, + } + m := newDirsScreenTestModel(prov) + // Reproduce the file-browser state set up by openFileBrowserForPlaylist: + // the current browsing dir becomes the added source when nothing is selected. + m.plManager.screen = plMgrScreenDirs + m.fileBrowser.visible = true + m.fileBrowser.targetPlaylist = "music" + m.fileBrowser.dir = "/home/me/Music" + + m.handleFileBrowserKey(tea.KeyPressMsg{Text: "D"}) + + if m.fileBrowser.visible { + t.Fatal("file browser should close after adding a dir source") + } + if len(prov.added) != 1 || prov.added[0] != "/home/me/Music" { + t.Fatalf("added = %v, want [/home/me/Music]", prov.added) + } + if len(m.plManager.dirs) != 1 { + t.Fatalf("manager dirs after add = %+v, want refreshed to 1", m.plManager.dirs) + } +} + +func TestFileBrowserDPartialFailureStillRefreshes(t *testing.T) { + prov := &dirSourceTestProvider{ + commandsTestProvider: commandsTestProvider{name: "Local"}, + failOn: map[string]error{"/d2": errors.New("boom")}, + } + m := newDirsScreenTestModel(prov) + m.plManager.screen = plMgrScreenDirs + m.plManager.selPlaylist = "music" + m.fileBrowser.visible = true + m.fileBrowser.targetPlaylist = "music" + // Two selected directories; the second fails partway through the loop. + m.fileBrowser.entries = []fbEntry{ + {name: "d1", path: "/d1", isDir: true}, + {name: "d2", path: "/d2", isDir: true}, + } + m.fileBrowser.selected = map[string]bool{"/d1": true, "/d2": true} + + m.handleFileBrowserKey(tea.KeyPressMsg{Text: "D"}) + + // The first dir was added even though the second failed. + if len(prov.added) != 1 || prov.added[0] != "/d1" { + t.Fatalf("added = %v, want only /d1 (partial success)", prov.added) + } + // The open manager must reflect the partial addition, not the pre-add state. + if len(m.plManager.dirs) != 1 || m.plManager.dirs[0].Path != "/d1" { + t.Fatalf("manager dirs after partial failure = %+v, want [/d1]", m.plManager.dirs) + } + if !strings.Contains(m.status.text, "then failed") { + t.Fatalf("status = %q, want a partial-failure message", m.status.text) + } +} + +func TestNKeyTogglesFavorite(t *testing.T) { + prov := &dirSourceTestProvider{ + commandsTestProvider: commandsTestProvider{name: "Local"}, + } + m := newDirsScreenTestModel(prov) + m.focus = focusPlaylist + m.loadedPlaylist = "music" + m.playlist = playlist.New() + m.playlist.Add(playlist.Track{Path: "/song.mp3", Title: "Song"}) + m.plCursor = 0 + m.plManager.visible = false + m.favSet = nil + + // Toggle on. + m.handleKey(tea.KeyPressMsg{Text: "n"}) + if !prov.IsFavorited("/song.mp3") { + t.Fatal("track should be favorited after n") + } + if m.favSet == nil { + t.Fatal("favSet should be populated after toggle") + } + if _, ok := m.favSet["/song.mp3"]; !ok { + t.Fatal("favSet should contain /song.mp3 after toggle") + } + if !strings.Contains(m.status.text, "♥") { + t.Fatalf("status = %q, want ♥ indicator", m.status.text) + } + + // Toggle off. + m.handleKey(tea.KeyPressMsg{Text: "n"}) + if prov.IsFavorited("/song.mp3") { + t.Fatal("track should be unfavorited after second n") + } + if m.favSet != nil { + if _, ok := m.favSet["/song.mp3"]; ok { + t.Fatal("favSet should not contain /song.mp3 after toggle off") + } + } + if !strings.Contains(m.status.text, "♡") { + t.Fatalf("status = %q, want ♡ indicator", m.status.text) + } +} + +func TestNKeyNoopWithoutFavMgr(t *testing.T) { + plain := commandsTestProvider{name: "Local"} + m := newDirsScreenTestModel(&dirSourceTestProvider{}) + m.localProvider = plain + m.provider = plain + m.favMgr = nil + m.focus = focusPlaylist + m.loadedPlaylist = "music" + m.playlist = playlist.New() + m.playlist.Add(playlist.Track{Path: "/song.mp3", Title: "Song"}) + m.plCursor = 0 + + m.handleKey(tea.KeyPressMsg{Text: "n"}) + + // No crash, no status change. + if m.status.text != "" { + t.Fatalf("status = %q, want empty (no favMgr)", m.status.text) + } +} diff --git a/ui/model/filebrowser.go b/ui/model/filebrowser.go index b8d521ffa..4564af191 100644 --- a/ui/model/filebrowser.go +++ b/ui/model/filebrowser.go @@ -463,6 +463,13 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd { return m.fbConfirmToPlaylist() } } + + case "D": + // Add the selected directory (or the current directory) as a [[dir]] + // source on the target playlist instead of expanding it into tracks. + if m.fileBrowser.targetPlaylist != "" { + m.fbAddDirSource() + } } // Change drive letter on Windows by pressing alt+[c..z] diff --git a/ui/model/init.go b/ui/model/init.go index b36f4372e..1bcddc145 100644 --- a/ui/model/init.go +++ b/ui/model/init.go @@ -10,6 +10,7 @@ import ( "github.com/bjarneo/cliamp/luaplugin" "github.com/bjarneo/cliamp/player" "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/provider" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" ) @@ -43,6 +44,10 @@ func New(p player.Engine, pl *playlist.Playlist, providers []ProviderEntry, defa historyStore: history.New(), showAlbumHeaders: false, } + if fm, ok := any(localProv).(provider.FavoritesManager); ok { + m.favMgr = fm + m.refreshFavSet() + } if luaMgr != nil { m.pluginEmit = &pluginEmitState{} } @@ -211,3 +216,21 @@ func (m Model) Init() tea.Cmd { } return tea.Batch(cmds...) } + +// refreshFavSet rebuilds the in-memory set of favorited paths from the +// favorites store. Call after every toggle and on init so the render path +// never hits disk. +func (m *Model) refreshFavSet() { + m.favSet = nil + if m.favMgr == nil { + return + } + tracks, err := m.localProvider.Tracks("Favorites") + if err != nil || len(tracks) == 0 { + return + } + m.favSet = make(map[string]struct{}, len(tracks)) + for _, t := range tracks { + m.favSet[t.Path] = struct{}{} + } +} diff --git a/ui/model/keys.go b/ui/model/keys.go index 264aaf15c..ff6dc7271 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -559,6 +559,25 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { } } + case "n": + if m.focus == focusPlaylist && m.plCursor >= 0 && m.plCursor < m.playlist.Len() && m.favMgr != nil { + track, ok := m.playlist.Track(m.plCursor) + if !ok { + return nil + } + added, err := m.favMgr.ToggleFavorite(track) + if err != nil { + m.status.Errorf(statusTTLDefault, "Favorite failed: %s", err) + return nil + } + m.refreshFavSet() + if added { + m.status.Showf(statusTTLDefault, "♥ %s", track.DisplayName()) + } else { + m.status.Showf(statusTTLDefault, "♡ %s", track.DisplayName()) + } + } + case "shift+up": if m.focus == focusPlaylist && m.plCursor > 0 { if m.playlist.Move(m.plCursor, m.plCursor-1) { @@ -1577,6 +1596,8 @@ func (m *Model) handlePlaylistManagerKey(msg tea.KeyPressMsg) tea.Cmd { return m.handlePlMgrListKey(msg) case plMgrScreenTracks: return m.handlePlMgrTracksKey(msg) + case plMgrScreenDirs: + return m.handlePlMgrDirsKey(msg) case plMgrScreenNewName: return m.handlePlMgrNewNameKey(msg) case plMgrScreenRename: @@ -1896,6 +1917,43 @@ func (m *Model) handlePlMgrTracksKey(msg tea.KeyPressMsg) tea.Cmd { } case "o": m.openFileBrowserForPlaylist(m.plManager.selPlaylist) + case "D": + m.plMgrOpenDirs() + case "f": + if bs, ok := m.localProvider.(provider.BookmarkSetter); ok { + realIdx := m.plMgrTrackRealIndex(m.plManager.cursor) + if realIdx >= 0 && realIdx < len(m.plManager.tracks) { + track := m.plManager.tracks[realIdx] + if err := bs.SetBookmarkByPath(m.plManager.selPlaylist, track.Path); err != nil { + m.status.Errorf(statusTTLDefault, "Save failed: %s", err) + return nil + } + m.plManager.tracks[realIdx].Bookmark = !m.plManager.tracks[realIdx].Bookmark + if m.plManager.tracks[realIdx].Bookmark { + m.status.Showf(statusTTLDefault, "★ %s", track.DisplayName()) + } else { + m.status.Showf(statusTTLDefault, "☆ %s", track.DisplayName()) + } + } + } + case "n": + if m.favMgr != nil { + realIdx := m.plMgrTrackRealIndex(m.plManager.cursor) + if realIdx >= 0 && realIdx < len(m.plManager.tracks) { + track := m.plManager.tracks[realIdx] + added, err := m.favMgr.ToggleFavorite(track) + if err != nil { + m.status.Errorf(statusTTLDefault, "Favorite failed: %s", err) + return nil + } + m.refreshFavSet() + if added { + m.status.Showf(statusTTLDefault, "♥ %s", track.DisplayName()) + } else { + m.status.Showf(statusTTLDefault, "♡ %s", track.DisplayName()) + } + } + } case "d": m.plMgrRemoveSelectedTracks() case "u": @@ -1944,6 +2002,113 @@ func (m *Model) plMgrLoadAndPlay(startIdx int) tea.Cmd { return cmd } +// handlePlMgrDirsKey handles keys on the directory-sources screen. The screen +// lists [[dir]] sources and supports add (via the file browser), remove +// (y/n confirm), and toggle-recursive. Navigation mirrors the other screens. +func (m *Model) handlePlMgrDirsKey(msg tea.KeyPressMsg) tea.Cmd { + count := len(m.plManager.dirs) + + // Remove-confirmation flow takes priority once armed. + if m.plManager.confirmDel { + switch msg.String() { + case "y", "Y": + i := m.plManager.cursor + if i >= 0 && i < count { + src := m.plManager.dirs[i] + if dm, ok := m.localProvider.(provider.PlaylistDirSourceManager); ok { + if err := dm.RemoveDirSource(m.plManager.selPlaylist, src.Path); err != nil { + m.status.Showf(statusTTLDefault, "Remove failed: %s", err) + } else { + m.plMgrReloadDirs() + m.plMgrRefreshTracksForSel() + m.plMgrRefreshList() + m.status.Showf(statusTTLDefault, "Removed %q from %q", src.Path, m.plManager.selPlaylist) + } + } + } + m.plManager.confirmDel = false + return nil + default: + m.plManager.confirmDel = false + return nil + } + } + + switch msg.String() { + case "ctrl+c": + m.plManager.visible = false + return m.quit() + case "up", "k": + if m.plManager.cursor > 0 { + m.plManager.cursor-- + } else if count > 0 { + m.plManager.cursor = count - 1 + } + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) + case "down", "j": + if m.plManager.cursor < count-1 { + m.plManager.cursor++ + } else if count > 0 { + m.plManager.cursor = 0 + } + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) + case "pgup", "ctrl+u": + if m.plManager.cursor > 0 { + visible := m.plMgrDirsVisible() + m.plManager.cursor -= min(m.plManager.cursor, visible) + m.plMgrDirsMaybeAdjustScroll(visible) + } + case "pgdown", "ctrl+d": + if m.plManager.cursor < count-1 { + visible := m.plMgrDirsVisible() + m.plManager.cursor = min(count-1, m.plManager.cursor+visible) + m.plMgrDirsMaybeAdjustScroll(visible) + } + case "home", "g": + m.plManager.cursor = 0 + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) + case "end", "G": + if count > 0 { + m.plManager.cursor = count - 1 + } + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) + case "a": + // Open the file browser to pick a directory; the browser's D action + // adds the picked directory as a [[dir]] source to this playlist. + m.openFileBrowserForPlaylist(m.plManager.selPlaylist) + return nil + case "d": + if count == 0 { + return nil + } + m.plManager.confirmDel = true + case "r": + if count == 0 { + return nil + } + src := m.plManager.dirs[m.plManager.cursor] + if dm, ok := m.localProvider.(provider.PlaylistDirSourceManager); ok { + next := !src.Recursive + if err := dm.SetDirRecursive(m.plManager.selPlaylist, src.Path, next); err != nil { + m.status.Showf(statusTTLDefault, "Toggle recursive: %s", err) + } else { + mode := "recursive" + if !next { + mode = "flat" + } + m.plMgrReloadDirs() + m.plMgrRefreshTracksForSel() + m.plMgrRefreshList() + m.status.Showf(statusTTLDefault, "Set %q %s", src.Path, mode) + } + } + case "esc", "backspace", "h", "left": + // Back to the tracks screen; reload tracks so dir changes are shown. + m.plMgrEnterTrackList(m.plManager.selPlaylist) + } + return nil +} + // handlePlMgrNewNameKey handles keys on screen 2 (new playlist name input). func (m *Model) handlePlMgrNewNameKey(msg tea.KeyPressMsg) tea.Cmd { switch msg.Code { diff --git a/ui/model/model.go b/ui/model/model.go index b5f573aa3..214e7ad6f 100644 --- a/ui/model/model.go +++ b/ui/model/model.go @@ -9,6 +9,7 @@ import ( "github.com/bjarneo/cliamp/luaplugin" "github.com/bjarneo/cliamp/player" "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/provider" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" ) @@ -181,6 +182,7 @@ type plMgrScreenType int const ( plMgrScreenList plMgrScreenType = iota plMgrScreenTracks + plMgrScreenDirs plMgrScreenNewName plMgrScreenRename ) @@ -364,6 +366,14 @@ type Model struct { // History recorder (nil if config dir unavailable; safe to call when nil) historyStore *history.Store + // Favorites manager (nil when local provider doesn't support it; safe to + // call when nil). Cached here to avoid a type assertion per rendered track. + favMgr provider.FavoritesManager + + // favSet is a cached set of favorited paths for O(1) lookup during + // rendering. Refreshed on init and after every toggle. + favSet map[string]struct{} + // initialDir is the starting path for the file browser ('o' key). initialDir string diff --git a/ui/model/overlays.go b/ui/model/overlays.go index 45d3ac4b3..3fbae2925 100644 --- a/ui/model/overlays.go +++ b/ui/model/overlays.go @@ -5,7 +5,9 @@ import ( "os" "strings" + "github.com/bjarneo/cliamp/history" "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/provider" "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" ) @@ -333,6 +335,18 @@ func (m *Model) plMgrTracksHelpLine() string { return m.commandHelp(commandModePlaylistManager) } +func (m *Model) plMgrDirsHelpLine() string { + return m.commandHelp(commandModePlaylistManagerDirs) +} + +func (m *Model) plMgrDirsVisible() int { + return m.effectivePlaylistVisible() +} + +func (m *Model) plMgrDirsMaybeAdjustScroll(visible int) { + clampScroll(&m.plManager.cursor, &m.plManager.scroll, len(m.plManager.dirs), visible) +} + func (m *Model) plMgrTracksVisible() int { return m.effectivePlaylistVisible() } @@ -425,6 +439,142 @@ func missingLocalTrack(track playlist.Track) bool { return os.IsNotExist(err) } +// plMgrOpenDirs loads the [[dir]] sources for the open playlist and switches +// to the directory-sources screen. Playlists whose provider does not implement +// provider.PlaylistDirSourceManager show a notice instead of switching. +func (m *Model) plMgrOpenDirs() { + if m.plManager.selPlaylist == history.PlaylistName { + m.status.Showf(statusTTLDefault, "%q is a virtual playlist with no directory sources", m.plManager.selPlaylist) + return + } + dm, ok := m.localProvider.(provider.PlaylistDirSourceManager) + if !ok { + m.status.Showf(statusTTLDefault, "%q does not support directory sources", m.plManager.selPlaylist) + return + } + dirs, err := dm.DirSources(m.plManager.selPlaylist) + if err != nil { + m.status.Showf(statusTTLDefault, "Load dir sources: %s", err) + return + } + m.plManager.dirs = dirs + m.plManager.screen = plMgrScreenDirs + m.plManager.cursor = 0 + m.plManager.scroll = 0 + m.plManager.confirmDel = false + m.plMgrResetFilter() + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) +} + +// plMgrReloadDirs re-reads the [[dir]] sources for the open playlist and +// clamps the cursor so it stays valid after an add or remove. +func (m *Model) plMgrReloadDirs() { + dm, ok := m.localProvider.(provider.PlaylistDirSourceManager) + if !ok { + return + } + dirs, err := dm.DirSources(m.plManager.selPlaylist) + if err != nil { + m.status.Showf(statusTTLDefault, "Reload dir sources: %s", err) + return + } + m.plManager.dirs = dirs + if m.plManager.cursor >= len(dirs) { + m.plManager.cursor = len(dirs) - 1 + } + if m.plManager.cursor < 0 { + m.plManager.cursor = 0 + } + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) +} + +// plMgrRefreshTracksForSel reloads the tracks of the open playlist so changes +// to its [[dir]] sources (add/remove/toggle-recursive) are reflected in the +// tracks screen immediately. +func (m *Model) plMgrRefreshTracksForSel() { + tracks, err := m.localProvider.Tracks(m.plManager.selPlaylist) + if err != nil { + return + } + // plMgrLoadTracks keeps the missingLocal cache in sync with the new + // track slice; assigning tracks directly would leave stale per-track + // missing-file indicators mapped onto the wrong entries. + m.plMgrLoadTracks(tracks) + m.setHeaderStateFromTracks(tracks) +} + +// fbAddDirSource adds the file browser's selected directories (or the current +// browsing directory when none are selected) as [[dir]] sources on the target +// playlist, then closes the browser. Invoked by the file browser's D key when +// a target playlist is set. +func (m *Model) fbAddDirSource() { + target := m.fileBrowser.targetPlaylist + if target == "" || target == history.PlaylistName { + if target == history.PlaylistName { + m.status.Showf(statusTTLDefault, "%q is a virtual playlist with no directory sources", target) + } + return + } + dm, ok := m.localProvider.(provider.PlaylistDirSourceManager) + if !ok { + m.status.Showf(statusTTLDefault, "This provider does not support directory sources") + return + } + var dirs []string + for _, e := range m.fileBrowser.entries { + if m.fileBrowser.selected[e.path] && e.isDir && !e.isParent { + dirs = append(dirs, e.path) + } + } + if len(dirs) == 0 { + dirs = []string{m.fileBrowser.dir} + } + added, skipped := 0, 0 + var firstErr error + for _, d := range dirs { + a, err := dm.AddDirSource(target, d) + if err != nil { + firstErr = err + break + } + if a { + added++ + } else { + skipped++ + } + } + m.fileBrowser.visible = false + // Reflect any successful additions in an open manager screen for this + // playlist before reporting a partial failure, so the open screen never + // shows stale sources or counts after an AddDirSource error mid-loop. + if added > 0 && m.plManager.visible && m.plManager.selPlaylist == target { + switch m.plManager.screen { + case plMgrScreenDirs: + m.plMgrReloadDirs() + m.plMgrRefreshTracksForSel() + case plMgrScreenTracks: + m.plMgrRefreshTracksForSel() + } + m.plMgrRefreshList() + } + if firstErr != nil { + if added > 0 { + m.status.Showf(statusTTLDefault, "Added %d dir source(s) to %q; then failed: %s", added, target, firstErr) + } else { + m.status.Showf(statusTTLDefault, "Add dir source failed: %s", firstErr) + } + return + } + switch { + case added > 0 && skipped > 0: + m.status.Showf(statusTTLDefault, "Added %d dir source(s) to %q (%d already referenced)", added, target, skipped) + case added > 0: + m.status.Showf(statusTTLDefault, "Added %d dir source(s) to %q", added, target) + default: + m.status.Showf(statusTTLDefault, "%q already references that directory", target) + } +} + // plMgrResetFilter clears any active `/` filter on the playlist manager. func (m *Model) plMgrResetFilter() { m.plManager.filtering = false @@ -508,14 +658,19 @@ func (m *Model) plMgrRefreshList() { if m.plManager.filter != "" { m.plMgrRecomputeFilter() } - total := m.plMgrListViewCount() - if m.plManager.cursor >= total { - m.plManager.cursor = total - 1 - } - if m.plManager.cursor < 0 { - m.plManager.cursor = 0 + // Cursor/scroll clamping is list-screen-specific: the tracks and + // directory-sources screens own their own cursor and re-adjust after + // refreshing. Only the list screen re-clamps here. + if m.plManager.screen == plMgrScreenList { + total := m.plMgrListViewCount() + if m.plManager.cursor >= total { + m.plManager.cursor = total - 1 + } + if m.plManager.cursor < 0 { + m.plManager.cursor = 0 + } + m.plMgrListMaybeAdjustScroll(m.plMgrListVisible()) } - m.plMgrListMaybeAdjustScroll(m.plMgrListVisible()) } // plMgrListViewCount returns the visible row count on the list screen diff --git a/ui/model/scroll.go b/ui/model/scroll.go index 97f15fb10..2d0a6b95e 100644 --- a/ui/model/scroll.go +++ b/ui/model/scroll.go @@ -140,6 +140,8 @@ func (m *Model) clampActiveScrollState() { m.plMgrListMaybeAdjustScroll(m.plMgrListVisible()) } else if m.plManager.screen == plMgrScreenTracks { m.plMgrTracksMaybeAdjustScroll(m.plMgrTracksVisible()) + } else if m.plManager.screen == plMgrScreenDirs { + m.plMgrDirsMaybeAdjustScroll(m.plMgrDirsVisible()) } case screenSpotSearch: if m.spotSearch.screen == spotSearchResults { diff --git a/ui/model/state.go b/ui/model/state.go index 6ff3a0a1f..fe793b824 100644 --- a/ui/model/state.go +++ b/ui/model/state.go @@ -137,9 +137,10 @@ type plManagerState struct { cursor int // view-index: offset into filtered when filter != "", else direct index scroll int playlists []playlist.PlaylistInfo - selPlaylist string // playlist name open in screen 1 - tracks []playlist.Track // tracks in the selected playlist - missingLocal []bool // cached missing-file state, indexed with tracks + selPlaylist string // playlist name open in screen 1 + tracks []playlist.Track // tracks in the selected playlist + missingLocal []bool // cached missing-file state, indexed with tracks + dirs []playlist.DirSource // [[dir]] sources for the selected playlist (screen 2) newName string confirmDel bool renameOldName string diff --git a/ui/model/view.go b/ui/model/view.go index 7066bd5c3..657780e10 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -97,13 +97,10 @@ func (m Model) isProviderRowActive(p playlist.PlaylistInfo) bool { // supply. Track count and total duration are appended when available. func playlistLabel(prefix string, p playlist.PlaylistInfo) string { out := prefix + p.Name - parts := make([]string, 0, 2) + var parts []string if p.TrackCount > 0 { parts = append(parts, fmt.Sprintf("%d tracks", p.TrackCount)) } - if d := formatPlaylistDuration(p.DurationSecs); d != "" { - parts = append(parts, d) - } if len(parts) > 0 { out += " · " + strings.Join(parts, " · ") } @@ -621,6 +618,13 @@ func (m Model) renderPlaylistHeader() string { bookmarkStr = " " + activeToggle.Render(fmt.Sprintf("[★ %d]", bookmarkCount)) } + var favStr string + if m.favMgr != nil { + if count := m.favMgr.FavoritesCount(); count > 0 { + favStr = " " + activeToggle.Render(fmt.Sprintf("[♥ %d]", count)) + } + } + var themeStr string if name := m.ThemeName(); name != theme.DefaultName { themeStr = " " + activeToggle.Render("[Theme: "+name+"]") @@ -637,7 +641,7 @@ func (m Model) renderPlaylistHeader() string { headerStyle = activeToggle headerLabel = "▸─ Playlist ── " } - return headerStyle.Render(headerLabel) + shuffle + queueStr + bookmarkStr + posStr + themeStr + " " + dimStyle.Render("──") + return headerStyle.Render(headerLabel) + shuffle + queueStr + bookmarkStr + favStr + posStr + themeStr + " " + dimStyle.Render("──") } func (m Model) renderProviderList() string { @@ -848,11 +852,17 @@ func (m Model) renderPlaylist() string { if t.Bookmark { bookmarkMarker = "★" } + favMarker := " " + if m.favSet != nil { + if _, ok := m.favSet[t.Path]; ok { + favMarker = "♥" + } + } unavailableMarker := " " if t.Unplayable { unavailableMarker = "!" } - markers := cursorMarker + playingMarker + queueMarker + bookmarkMarker + unavailableMarker + " " + markers := cursorMarker + playingMarker + queueMarker + bookmarkMarker + favMarker + unavailableMarker + " " name := t.DisplayName() queueSuffix := "" @@ -889,7 +899,7 @@ func (m Model) renderPlaylist() string { numStr := fmt.Sprintf("%*d. ", numWidth, i+1) line := dimStyle.Render(cursorMarker) + playlistActiveStyle.Render(playingMarker) + - activeToggle.Render(queueMarker+bookmarkMarker) + playlistUnavailableStyle.Render(unavailableMarker) + + activeToggle.Render(queueMarker+bookmarkMarker) + playlistUnavailableStyle.Render(favMarker+unavailableMarker) + " " + style.Render(numStr) line += style.Render(name) if albumSuffix != "" { diff --git a/ui/model/view_helpers_test.go b/ui/model/view_helpers_test.go index 966bf7299..7dfc4876c 100644 --- a/ui/model/view_helpers_test.go +++ b/ui/model/view_helpers_test.go @@ -62,7 +62,7 @@ func TestPlaylistLabel(t *testing.T) { want string }{ { - "name only when both unknown", + "name only when no tracks", " ", playlist.PlaylistInfo{Name: "Mix"}, " Mix", @@ -74,16 +74,16 @@ func TestPlaylistLabel(t *testing.T) { "> Mix · 12 tracks", }, { - "duration only", + "duration ignored", " ", playlist.PlaylistInfo{Name: "Mix", DurationSecs: 3660}, - " Mix · 1h 1m", + " Mix", }, { - "both", + "tracks and duration shows only tracks", " ", playlist.PlaylistInfo{Name: "Mix", TrackCount: 12, DurationSecs: 2700}, - " Mix · 12 tracks · 45m", + " Mix · 12 tracks", }, } for _, tt := range tests { diff --git a/ui/model/view_overlays.go b/ui/model/view_overlays.go index 3ad1045a2..5a16a036e 100644 --- a/ui/model/view_overlays.go +++ b/ui/model/view_overlays.go @@ -59,6 +59,9 @@ func (m Model) plMgrHeaderLine() string { label += " · sort: " + mode } return sepHeaderN(label, m.plManager.cursor+1, len(m.plManager.tracks)) + case plMgrScreenDirs: + label := "Directory sources: " + m.plManager.selPlaylist + return sepHeaderN(label, m.plManager.cursor+1, len(m.plManager.dirs)) case plMgrScreenNewName: return m.promptHeader("playlist-manager-new-name", "New Playlist", m.plManager.newName) case plMgrScreenRename: @@ -76,6 +79,8 @@ func (m Model) plMgrHelpLine() string { switch m.plManager.screen { case plMgrScreenTracks: return m.plMgrTracksHelpLine() + case plMgrScreenDirs: + return m.plMgrDirsHelpLine() case plMgrScreenNewName: return m.commandHelp(commandModePlaylistManagerInput) case plMgrScreenRename: @@ -89,6 +94,8 @@ func (m Model) renderPlMgrBody() string { switch m.plManager.screen { case plMgrScreenTracks: return m.renderPlMgrTracksBody() + case plMgrScreenDirs: + return m.renderPlMgrDirsBody() case plMgrScreenNewName, plMgrScreenRename: return m.renderPlMgrFormBody() default: @@ -250,6 +257,36 @@ func (m Model) renderPlMgrTracksBody() string { return bodyLines(lines, budget) } +// renderPlMgrDirsBody renders the [[dir]] directory sources for the open +// playlist. Each row shows the source path and its scan mode (recursive or +// flat). An empty list shows how to add a source. +func (m Model) renderPlMgrDirsBody() string { + budget := m.effectivePlaylistVisible() + + if len(m.plManager.dirs) == 0 { + return bodyLines([]string{ + dimStyle.Render(" No directory sources."), + dimStyle.Render(" Press `a` to pick a directory to scan."), + }, budget) + } + + scroll := m.plManager.scroll + lines := make([]string, 0, budget) + for i := scroll; i < len(m.plManager.dirs) && len(lines) < budget; i++ { + src := m.plManager.dirs[i] + mode := "recursive" + if !src.Recursive { + mode = "flat" + } + if m.plManager.confirmDel && i == m.plManager.cursor { + lines = append(lines, playlistSelectedStyle.Render("> Remove "+src.Path+"? [y/n]")) + continue + } + lines = append(lines, cursorLine(src.Path+" · "+mode, i == m.plManager.cursor)) + } + return bodyLines(lines, budget) +} + func (m Model) plMgrTrackLabel(realIdx int) string { t := m.plManager.tracks[realIdx] mark := " "