Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
dfcc55c
feat: cross-playlist favorites virtual playlist
tahadx Aug 20, 2026
4f777fc
fix: F/f keys now work from playlist manager tracks screen
tahadx Aug 20, 2026
ee24a18
fix: show favorites count [♥ N] in header + help bar entries for f/F
tahadx Aug 20, 2026
f04ead0
feat: n key for favorites, always-visible Favorites playlist
tahadx Aug 20, 2026
1fdd429
fix: simplify playlist labels — drop duration and dir source count
tahadx Aug 20, 2026
2b6875e
fix: update site index.html keybinding for favorites (F→n)
tahadx Aug 20, 2026
4f33eb9
fix(ui): guard Favorites from rename/delete/dir-source in manager
tahadx Aug 21, 2026
26694d4
fix(ui): drop unfavorited row from Favorites tracks screen
tahadx Aug 21, 2026
83a65ab
fix(ui): always show track count on Favorites playlist
tahadx Aug 21, 2026
efd5fb6
fix(ui): refresh playlist lists after favorite toggles
tahadx Aug 21, 2026
bcc1118
refactor: address review findings across favorites stack
tahadx Aug 21, 2026
d3b9a7e
feat(ui): live-reload the Favorites screen on toggles
tahadx Aug 21, 2026
5febb81
fix(ui,favorites): address CodeRabbit review findings on favorites stack
tahadx Aug 21, 2026
64e1937
feat(ui): compact themed favorite heart
tahadx Aug 23, 2026
582c086
feat(ui): dimmed filled heart for unfavorite feedback
tahadx Aug 23, 2026
4d00675
merge feat/dir-source-tui-manager into feat/cross-playlist-favorites
tahadx Aug 25, 2026
2f48ea9
favorites: address review findings 1+2
tahadx Aug 25, 2026
b223a39
Merge main into feat/cross-playlist-favorites
bjarneo Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cmd/playlist_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
1 change: 1 addition & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ 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) |
| `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, Tidal, Navidrome, Lyrion, Jellyfin, Emby, Plex, Audiobookshelf, Mixcloud, NetEase, Local) or YouTube fallback. Available from playlist and provider-browser views. |
| `u` | Load URL (stream/playlist) |
| `y` | Show or close lyrics |
Expand Down
30 changes: 24 additions & 6 deletions docs/playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ 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) |
| `D` | List: open the file browser to add `[[dir]]` sources to the highlighted playlist. Tracks: open the directory-sources screen for the open playlist |
| `[` `]` | Move track up/down and save (tracks screen) |
| `d` | Delete playlist (confirms; `Recently Played` cannot be deleted) / Remove marked tracks, or highlighted track if none are marked |
| `u` | Undo the last playlist-manager edit |
Expand All @@ -387,8 +387,26 @@ The playlist list flags playlists that reference `[[dir]]` sources with a
| `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 all selected directories — or the highlighted
directory, or the directory you are currently browsing when neither applies —
as live `[[dir]]` sources instead of expanding them into explicit tracks.
Directories already referenced are skipped and reported.
From the file browser (opened with `a` above, with `o` from the tracks
screen, or with `D` from the list screen), press `D` to add all selected
directories — or the highlighted directory, or the directory you are currently
browsing when neither applies — as live `[[dir]]` sources instead of expanding
them 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 small red `♥` marker in the track list. The bookmark
system (`f` key, `★` marker) is separate — bookmarks are per-playlist,
while favorites span all playlists.
64 changes: 64 additions & 0 deletions external/local/dirs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"testing"

"github.com/bjarneo/cliamp/favorites"
"github.com/bjarneo/cliamp/history"
"github.com/bjarneo/cliamp/playlist"
)
Expand Down Expand Up @@ -199,6 +200,14 @@ func TestCreateDirPlaylist(t *testing.T) {
if len(dirs) != 1 || dirs[0].Path != audio || !dirs[0].Recursive {
t.Fatalf("dirs = %+v", dirs)
}

// Virtual playlists have no directory sources: both reserved names must
// be rejected so the manager never opens the dirs screen for them.
for _, name := range []string{history.PlaylistName, favorites.PlaylistName} {
if dirs, err := p.DirSources(name); err == nil {
t.Fatalf("DirSources(%q) = %+v, want reserved-name error", name, dirs)
}
}
// Reject duplicate create.
if err := p.CreateDirPlaylist("music", []string{audio}); err == nil {
t.Fatal("duplicate create should fail")
Expand Down Expand Up @@ -1005,3 +1014,58 @@ func TestRestorePlaylistDocumentRejectsHistory(t *testing.T) {
t.Fatal("expected an error restoring over the reserved history name")
}
}

func TestPlaylistsMigratesLegacyFavoritesToml(t *testing.T) {
p := newTestProvider(t)
src := filepath.Join(p.dir, "Favorites.toml")
if err := os.WriteFile(src, []byte("[[track]]\npath = \"/a.mp3\"\n"), 0o644); err != nil {
t.Fatal(err)
}

lists, err := p.Playlists()
if err != nil {
t.Fatalf("Playlists: %v", err)
}

// Original must be gone.
if _, err := os.Stat(src); !errors.Is(err, fs.ErrNotExist) {
t.Fatal("Favorites.toml should have been migrated away")
}

// Renamed file must appear.
dst := filepath.Join(p.dir, favoritesLegacyName+".toml")
if _, err := os.Stat(dst); err != nil {
t.Fatalf("migrated file missing: %v", err)
}

found := false
for _, l := range lists {
if l.ID == favoritesLegacyName {
found = true
break
}
}
if !found {
t.Fatalf("migrated playlist not listed; got %v", lists)
}
}

func TestPlaylistsSkipsMigrationWhenDestExists(t *testing.T) {
p := newTestProvider(t)
src := filepath.Join(p.dir, "Favorites.toml")
dst := filepath.Join(p.dir, favoritesLegacyName+".toml")
if err := os.WriteFile(src, []byte("[[track]]\npath = \"/a.mp3\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("[[track]]\npath = \"/b.mp3\"\n"), 0o644); err != nil {
t.Fatal(err)
}

if _, err := p.Playlists(); err != nil {
t.Fatalf("Playlists: %v", err)
}
// Source must NOT be clobbered when destination already exists.
if _, err := os.Stat(src); err != nil {
t.Fatalf("Favorites.toml should remain when dest already exists: %v", err)
}
}
Loading
Loading