From df1a7c1a118b33bed876f9eea255263995b5b5c1 Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 19:54:22 +0330 Subject: [PATCH 1/8] feat(ui): manage [[dir]] playlist sources from the playlist manager Building on the dynamic [[dir]] directory sources added in #308, this gives the TUI full control over them instead of forcing CLI or TOML edits. Playlist list: each playlist that references [[dir]] sources shows a 'N dir(s)' indicator next to its track count, so directory-backed collections are visible at a glance. Directory-sources screen (press D from a playlist's tracks screen): lists every [[dir]] source with its scan mode (recursive or flat) and supports the full lifecycle: - a open the file browser to add a directory as a [[dir]] source - d remove the highlighted source (y/n confirm, since its tracks disappear from the playlist) - r toggle recursive on the highlighted source (re-scans at once) - Esc/Backspace back to the tracks screen File browser: pressing D adds the highlighted/selected directory (or the directory currently being browsed when nothing is selected) as a live [[dir]] source instead of expanding it into explicit tracks. Directories already referenced are skipped and reported. Provider surface: DirSource moves to the playlist package so the UI stays decoupled from external/local, and a new optional provider.PlaylistDirSourceManager interface exposes DirSources, AddDirSource, RemoveDirSource, and SetDirRecursive. PlaylistInfo gains a DirSourceCount field that local.Playlists populates from the parsed doc at no extra I/O cost. The virtual 'Recently Played' playlist is guarded so D shows a friendly notice rather than a reserved-name error. plMgrRefreshList no longer clamps the shared cursor/scroll off the list screen, so tracks/dirs screens keep their own cursor after a metadata refresh. Docs: docs/playlists.md and docs/keybindings.md document the new screen and keys; site/index.html notes TUI management in the Playlists blurb. --- docs/keybindings.md | 14 ++- docs/playlists.md | 20 ++++ external/local/dirs.go | 20 ++-- external/local/dirs_test.go | 162 +++++++++++++++++++++++++++- external/local/provider.go | 116 +++++++++++++++++--- playlist/dirsource.go | 9 ++ playlist/provider.go | 15 ++- provider/interfaces.go | 11 ++ site/index.html | 2 +- ui/model/command_registry.go | 14 +++ ui/model/dirs_screen_test.go | 202 +++++++++++++++++++++++++++++++++++ ui/model/filebrowser.go | 7 ++ ui/model/keys.go | 111 +++++++++++++++++++ ui/model/model.go | 1 + ui/model/overlays.go | 160 +++++++++++++++++++++++++-- ui/model/scroll.go | 2 + ui/model/state.go | 7 +- ui/model/view.go | 10 +- ui/model/view_overlays.go | 37 +++++++ 19 files changed, 868 insertions(+), 52 deletions(-) create mode 100644 playlist/dirsource.go create mode 100644 ui/model/dirs_screen_test.go diff --git a/docs/keybindings.md b/docs/keybindings.md index b1fc9fd62..243e746a1 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -120,13 +120,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 +150,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 highlighted/selected directory (or the current directory) 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..de745153c 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -363,7 +363,27 @@ 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 highlighted/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. 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..cf5c78f17 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -24,14 +24,15 @@ 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 reads and writes TOML-based playlists stored on disk. @@ -111,10 +112,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 @@ -300,7 +302,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" @@ -362,7 +364,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 +385,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 +396,90 @@ 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 + } + 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 + } + 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. 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..4c6f108e7 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 { diff --git a/site/index.html b/site/index.html index 000f0de66..b97310ac9 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 directory sources (--dir) you can add, remove, and toggle-recursive from the playlist manager (D), plus 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..cbdcb8a44 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 @@ -167,6 +169,18 @@ 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: 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..680c11ffd --- /dev/null +++ b/ui/model/dirs_screen_test.go @@ -0,0 +1,202 @@ +package model + +import ( + "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 +} + +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) { + 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 newDirsScreenTestModel(prov *dirSourceTestProvider) Model { + m := Model{ + playlist: playlist.New(), + localProvider: prov, + provider: 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) + } +} 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/keys.go b/ui/model/keys.go index 264aaf15c..6362bd35c 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -1577,6 +1577,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 +1898,8 @@ func (m *Model) handlePlMgrTracksKey(msg tea.KeyPressMsg) tea.Cmd { } case "o": m.openFileBrowserForPlaylist(m.plManager.selPlaylist) + case "D": + m.plMgrOpenDirs() case "d": m.plMgrRemoveSelectedTracks() case "u": @@ -1944,6 +1948,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..5dececbfd 100644 --- a/ui/model/model.go +++ b/ui/model/model.go @@ -181,6 +181,7 @@ type plMgrScreenType int const ( plMgrScreenList plMgrScreenType = iota plMgrScreenTracks + plMgrScreenDirs plMgrScreenNewName plMgrScreenRename ) diff --git a/ui/model/overlays.go b/ui/model/overlays.go index 45d3ac4b3..4755707a1 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,133 @@ 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 + } + m.plManager.tracks = 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 + if firstErr != nil { + m.status.Showf(statusTTLDefault, "Add dir source failed: %s", firstErr) + return + } + // Reflect the change in any open manager screen for this playlist. + if m.plManager.visible && m.plManager.selPlaylist == target { + switch m.plManager.screen { + case plMgrScreenDirs: + m.plMgrReloadDirs() + m.plMgrRefreshTracksForSel() + case plMgrScreenTracks: + m.plMgrRefreshTracksForSel() + } + m.plMgrRefreshList() + } + 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 +649,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..40f507268 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -97,7 +97,15 @@ 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) + parts := make([]string, 0, 3) + if p.DirSourceCount > 0 { + n := p.DirSourceCount + s := "dir" + if n != 1 { + s = "dirs" + } + parts = append(parts, fmt.Sprintf("%d %s", n, s)) + } if p.TrackCount > 0 { parts = append(parts, fmt.Sprintf("%d tracks", p.TrackCount)) } 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 := " " From b1cacca21a1a22b5d9b6c1ba77f99355bc687c30 Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 20:32:35 +0330 Subject: [PATCH 2/8] fix: address CodeRabbit review on #329 - plMgrRefreshTracksForSel: rebuild missingLocal cache via plMgrLoadTracks - fbAddDirSource: refresh manager before reporting partial failure - docs: remove highlighted from file-browser D description - site: use [[dir]] syntax instead of --dir for consistency with docs - test: add partial-failure refresh coverage --- docs/keybindings.md | 2 +- docs/playlists.md | 8 ++++---- site/index.html | 2 +- ui/model/dirs_screen_test.go | 40 ++++++++++++++++++++++++++++++++++++ ui/model/overlays.go | 23 ++++++++++++++------- 5 files changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/keybindings.md b/docs/keybindings.md index 243e746a1..7888556f6 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -150,7 +150,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 highlighted/selected directory (or the current directory) as a live `[[dir]]` source to the target 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 de745153c..651daf189 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -383,7 +383,7 @@ The playlist list flags playlists that reference `[[dir]]` sources with a | `←` / `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 highlighted/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. +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. diff --git a/site/index.html b/site/index.html index b97310ac9..584121650 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) you can add, remove, and toggle-recursive from the playlist manager (D), plus 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), plus 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/dirs_screen_test.go b/ui/model/dirs_screen_test.go index 680c11ffd..844572e1b 100644 --- a/ui/model/dirs_screen_test.go +++ b/ui/model/dirs_screen_test.go @@ -1,6 +1,8 @@ package model import ( + "errors" + "strings" "testing" tea "charm.land/bubbletea/v2" @@ -18,6 +20,7 @@ type dirSourceTestProvider struct { removed []string setRec []dirSetRecCall added []string + failOn map[string]error // dirs that AddDirSource should fail on } type dirSetRecCall struct { @@ -29,6 +32,11 @@ 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 @@ -200,3 +208,35 @@ func TestFileBrowserDAddsDirSource(t *testing.T) { 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) + } +} diff --git a/ui/model/overlays.go b/ui/model/overlays.go index 4755707a1..3fbae2925 100644 --- a/ui/model/overlays.go +++ b/ui/model/overlays.go @@ -496,7 +496,10 @@ func (m *Model) plMgrRefreshTracksForSel() { if err != nil { return } - m.plManager.tracks = tracks + // 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) } @@ -541,12 +544,10 @@ func (m *Model) fbAddDirSource() { } } m.fileBrowser.visible = false - if firstErr != nil { - m.status.Showf(statusTTLDefault, "Add dir source failed: %s", firstErr) - return - } - // Reflect the change in any open manager screen for this playlist. - if m.plManager.visible && m.plManager.selPlaylist == target { + // 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() @@ -556,6 +557,14 @@ func (m *Model) fbAddDirSource() { } 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) From cb75d2108ce7755aeaa61c2e3bb2211a847620f0 Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 21:01:06 +0330 Subject: [PATCH 3/8] feat: cross-playlist favorites virtual playlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New favorites/ package: Toggle, Favorite, Remove, IsFavorited, Tracks, Count, Clear — persisted to ~/.config/cliamp/favorites.toml - Virtual "Favorites" playlist appears at top of playlist list - F key (Shift+F) toggles favorite on any track from the track list - ♥ marker in track list for favorited tracks (cached set, no per-frame disk I/O) - provider.FavoritesManager interface for UI type-assertion - local.Provider: favoritesInfo(), Tracks("Favorites"), all write ops guarded - 12 favorites package tests + 6 provider tests + 2 UI tests - Docs: playlists.md, keybindings.md, site/index.html updated --- docs/keybindings.md | 3 +- docs/playlists.md | 17 ++ external/local/provider.go | 129 ++++++++++++- external/local/provider_test.go | 148 ++++++++++++++ favorites/favorites.go | 331 ++++++++++++++++++++++++++++++++ favorites/favorites_test.go | 203 ++++++++++++++++++++ provider/interfaces.go | 13 ++ site/index.html | 2 +- ui/model/command_registry.go | 3 +- ui/model/dirs_screen_test.go | 110 ++++++++++- ui/model/init.go | 23 +++ ui/model/keys.go | 19 ++ ui/model/model.go | 9 + ui/model/view.go | 10 +- 14 files changed, 1006 insertions(+), 14 deletions(-) create mode 100644 favorites/favorites.go create mode 100644 favorites/favorites_test.go diff --git a/docs/keybindings.md b/docs/keybindings.md index 7888556f6..f3de4675d 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) | +| `F` | 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 | diff --git a/docs/playlists.md b/docs/playlists.md index 651daf189..f603e1465 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -387,3 +387,20 @@ 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 `F` (Shift+F) on any track in the track list to toggle it as a +favorite. Favorited tracks are collected into a virtual **"Favorites"** +playlist that appears at the top of the playlist list — regardless of which +playlist the track was favorited from. + +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 `F` 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/provider.go b/external/local/provider.go index cf5c78f17..9971e5286 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" @@ -33,12 +34,14 @@ var ( _ 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. @@ -48,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(), } } @@ -80,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) } @@ -140,10 +151,35 @@ func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) { }, true } +// favoritesInfo returns the synthetic PlaylistInfo entry for "Favorites", +// or ok=false when the favorites store is unavailable or empty. +func (p *Provider) favoritesInfo() (playlist.PlaylistInfo, bool) { + if p.favorites == nil { + return playlist.PlaylistInfo{}, false + } + tracks, err := p.favorites.Tracks() + if err != nil || len(tracks) == 0 { + 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 @@ -180,6 +216,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 } @@ -245,6 +284,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 } @@ -276,6 +318,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) } @@ -329,6 +374,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 @@ -418,6 +466,9 @@ 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) @@ -458,6 +509,9 @@ 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) @@ -530,6 +584,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 @@ -599,6 +657,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 @@ -608,6 +670,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 @@ -629,6 +694,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 @@ -657,6 +725,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) } @@ -752,6 +823,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) @@ -780,6 +854,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 @@ -796,6 +873,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 @@ -804,6 +922,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..18f0b3948 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,149 @@ 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 TestPlaylistsOmitsFavoritesWhenEmpty(t *testing.T) { + p := newTestProviderWithFavorites(t) + lists, err := p.Playlists() + if err != nil { + t.Fatalf("Playlists: %v", err) + } + if len(lists) != 0 { + t.Fatalf("Playlists = %+v, want empty", lists) + } +} + +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/provider/interfaces.go b/provider/interfaces.go index 4c6f108e7..f80bfb060 100644 --- a/provider/interfaces.go +++ b/provider/interfaces.go @@ -177,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 584121650..01905311e 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 [[dir]] directory sources you can add, remove, and toggle-recursive from the playlist manager (D), plus 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 (F), 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 cbdcb8a44..65412e6f6 100644 --- a/ui/model/command_registry.go +++ b/ui/model/command_registry.go @@ -123,7 +123,8 @@ 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{"f"}, KeyLabel: "f", Label: "Toggle bookmark (per-playlist ★)", Keymap: true}, + {Mode: commandModeMain, Keys: []string{"F"}, KeyLabel: "F", Label: "Toggle favorite (cross-playlist ♥)", 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}, diff --git a/ui/model/dirs_screen_test.go b/ui/model/dirs_screen_test.go index 844572e1b..560994e24 100644 --- a/ui/model/dirs_screen_test.go +++ b/ui/model/dirs_screen_test.go @@ -16,11 +16,12 @@ import ( // 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 + 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 { @@ -67,11 +68,47 @@ func (p *dirSourceTestProvider) SetDirRecursive(_, dir string, recursive bool) e 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, @@ -240,3 +277,66 @@ func TestFileBrowserDPartialFailureStillRefreshes(t *testing.T) { t.Fatalf("status = %q, want a partial-failure message", m.status.text) } } + +func TestFKeyTogglesFavorite(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: "F"}) + if !prov.IsFavorited("/song.mp3") { + t.Fatal("track should be favorited after F") + } + 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: "F"}) + if prov.IsFavorited("/song.mp3") { + t.Fatal("track should be unfavorited after second F") + } + 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 TestFKeyNoopWithoutFavMgr(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: "F"}) + + // 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/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 6362bd35c..876116e3b 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 "F": + 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) { diff --git a/ui/model/model.go b/ui/model/model.go index 5dececbfd..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" ) @@ -365,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/view.go b/ui/model/view.go index 40f507268..29dcaeda5 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -856,11 +856,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 := "" @@ -897,7 +903,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 != "" { From 0889c3acb7e5f1f2bdce580094cc82be984f3ee9 Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 21:31:13 +0330 Subject: [PATCH 4/8] fix: F/f keys now work from playlist manager tracks screen The F (favorite) and f (bookmark) keys were only handled in handleKey(), which is bypassed when the playlist manager overlay is open. Added both handlers to handlePlMgrTracksKey() and registered help entries for the tracks screen so users see f=Bookmark and F=Favorite in the help bar. --- ui/model/command_registry.go | 6 ++++++ ui/model/keys.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/ui/model/command_registry.go b/ui/model/command_registry.go index 65412e6f6..ae8ea5aae 100644 --- a/ui/model/command_registry.go +++ b/ui/model/command_registry.go @@ -174,6 +174,12 @@ var commandRegistry = []commandSpec{ {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"}, KeyLabel: "f", Label: "Bookmark ★", 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"}, KeyLabel: "F", Label: "Favorite ♥", 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}, diff --git a/ui/model/keys.go b/ui/model/keys.go index 876116e3b..ee60c4c4c 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -1919,6 +1919,41 @@ func (m *Model) handlePlMgrTracksKey(msg tea.KeyPressMsg) tea.Cmd { 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 "F": + 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": From 083d6a2748c35a0b917c2931e3fdca2d448fe6ef Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 21:39:38 +0330 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20show=20favorites=20count=20[?= =?UTF-8?q?=E2=99=A5=20N]=20in=20header=20+=20help=20bar=20entries=20for?= =?UTF-8?q?=20f/F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add [♥ N] favorite count in the playlist header (like [★ N] bookmarks) - Help bar already has f=Bookmark and F=Favorite registered for the playlist manager tracks screen --- ui/model/view.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/model/view.go b/ui/model/view.go index 29dcaeda5..f02ae0bf7 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -629,6 +629,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+"]") @@ -645,7 +652,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 { From 97d1342e3c166556e4b8cb35452c41509a565aad Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 23:05:32 +0330 Subject: [PATCH 6/8] feat: n key for favorites, always-visible Favorites playlist - Change favorite keybinding from F to n (F was blocking reorder) - Combined f/n hint in help bar to fit within panel width - Favorites virtual playlist always appears (even when empty) - Move f/n entry earlier in command registry for help bar priority - Update docs and tests --- cmd/playlist_ops_test.go | 5 +++-- docs/keybindings.md | 2 +- docs/playlists.md | 8 ++++---- external/local/provider.go | 7 ++++--- external/local/provider_test.go | 9 ++++++--- ui/model/command_registry.go | 9 ++++----- ui/model/dirs_screen_test.go | 14 +++++++------- ui/model/keys.go | 4 ++-- 8 files changed, 31 insertions(+), 27 deletions(-) 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 f3de4675d..7e4d28100 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -75,7 +75,7 @@ active when the picker opened. While typing a filter, `Enter` finishes it and | Key | Action | |---|---| | `f` | Toggle bookmark ★ on selected track within the loaded playlist (or favorite a radio station in the radio browser) | -| `F` | Toggle favorite ♥ on selected track (cross-playlist; favorited tracks appear in the "Favorites" virtual playlist) | +| `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 | diff --git a/docs/playlists.md b/docs/playlists.md index f603e1465..32cb985c7 100644 --- a/docs/playlists.md +++ b/docs/playlists.md @@ -390,16 +390,16 @@ are skipped and reported. ## Favorites -Press `F` (Shift+F) on any track in the track list to toggle it as a +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 appears at the top of the playlist list — regardless of which -playlist the track was favorited from. +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 `F` again to unfavorite a track. +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, diff --git a/external/local/provider.go b/external/local/provider.go index 9971e5286..082ebb535 100644 --- a/external/local/provider.go +++ b/external/local/provider.go @@ -151,14 +151,15 @@ func (p *Provider) historyInfo() (playlist.PlaylistInfo, bool) { }, true } -// favoritesInfo returns the synthetic PlaylistInfo entry for "Favorites", -// or ok=false when the favorites store is unavailable or empty. +// 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 || len(tracks) == 0 { + if err != nil { return playlist.PlaylistInfo{}, false } return playlist.PlaylistInfo{ diff --git a/external/local/provider_test.go b/external/local/provider_test.go index 18f0b3948..118f3a67d 100644 --- a/external/local/provider_test.go +++ b/external/local/provider_test.go @@ -754,14 +754,17 @@ func TestPlaylistsIncludesFavoritesWhenNonEmpty(t *testing.T) { } } -func TestPlaylistsOmitsFavoritesWhenEmpty(t *testing.T) { +func TestPlaylistsIncludesFavoritesWhenEmpty(t *testing.T) { p := newTestProviderWithFavorites(t) lists, err := p.Playlists() if err != nil { t.Fatalf("Playlists: %v", err) } - if len(lists) != 0 { - t.Fatalf("Playlists = %+v, want empty", lists) + 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) } } diff --git a/ui/model/command_registry.go b/ui/model/command_registry.go index ae8ea5aae..5901a3fe0 100644 --- a/ui/model/command_registry.go +++ b/ui/model/command_registry.go @@ -96,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}, @@ -123,8 +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 (per-playlist ★)", Keymap: true}, - {Mode: commandModeMain, Keys: []string{"F"}, KeyLabel: "F", Label: "Toggle favorite (cross-playlist ♥)", 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}, @@ -174,10 +173,10 @@ var commandRegistry = []commandSpec{ {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"}, KeyLabel: "f", Label: "Bookmark ★", 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{"F"}, KeyLabel: "F", Label: "Favorite ♥", ContextHelp: true, Enabled: func(m Model) bool { + {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}, diff --git a/ui/model/dirs_screen_test.go b/ui/model/dirs_screen_test.go index 560994e24..89eada2a3 100644 --- a/ui/model/dirs_screen_test.go +++ b/ui/model/dirs_screen_test.go @@ -278,7 +278,7 @@ func TestFileBrowserDPartialFailureStillRefreshes(t *testing.T) { } } -func TestFKeyTogglesFavorite(t *testing.T) { +func TestNKeyTogglesFavorite(t *testing.T) { prov := &dirSourceTestProvider{ commandsTestProvider: commandsTestProvider{name: "Local"}, } @@ -292,9 +292,9 @@ func TestFKeyTogglesFavorite(t *testing.T) { m.favSet = nil // Toggle on. - m.handleKey(tea.KeyPressMsg{Text: "F"}) + m.handleKey(tea.KeyPressMsg{Text: "n"}) if !prov.IsFavorited("/song.mp3") { - t.Fatal("track should be favorited after F") + t.Fatal("track should be favorited after n") } if m.favSet == nil { t.Fatal("favSet should be populated after toggle") @@ -307,9 +307,9 @@ func TestFKeyTogglesFavorite(t *testing.T) { } // Toggle off. - m.handleKey(tea.KeyPressMsg{Text: "F"}) + m.handleKey(tea.KeyPressMsg{Text: "n"}) if prov.IsFavorited("/song.mp3") { - t.Fatal("track should be unfavorited after second F") + t.Fatal("track should be unfavorited after second n") } if m.favSet != nil { if _, ok := m.favSet["/song.mp3"]; ok { @@ -321,7 +321,7 @@ func TestFKeyTogglesFavorite(t *testing.T) { } } -func TestFKeyNoopWithoutFavMgr(t *testing.T) { +func TestNKeyNoopWithoutFavMgr(t *testing.T) { plain := commandsTestProvider{name: "Local"} m := newDirsScreenTestModel(&dirSourceTestProvider{}) m.localProvider = plain @@ -333,7 +333,7 @@ func TestFKeyNoopWithoutFavMgr(t *testing.T) { m.playlist.Add(playlist.Track{Path: "/song.mp3", Title: "Song"}) m.plCursor = 0 - m.handleKey(tea.KeyPressMsg{Text: "F"}) + m.handleKey(tea.KeyPressMsg{Text: "n"}) // No crash, no status change. if m.status.text != "" { diff --git a/ui/model/keys.go b/ui/model/keys.go index ee60c4c4c..ff6dc7271 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -559,7 +559,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { } } - case "F": + 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 { @@ -1936,7 +1936,7 @@ func (m *Model) handlePlMgrTracksKey(msg tea.KeyPressMsg) tea.Cmd { } } } - case "F": + case "n": if m.favMgr != nil { realIdx := m.plMgrTrackRealIndex(m.plManager.cursor) if realIdx >= 0 && realIdx < len(m.plManager.tracks) { From 1e7ea8306dc71971682d070c1fa720d6e7f40324 Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 23:20:26 +0330 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20simplify=20playlist=20labels=20?= =?UTF-8?q?=E2=80=94=20drop=20duration=20and=20dir=20source=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Duration was always 0 for regular playlists (tag reads skipped for speed) - Dir source count (3 dirs) was confusing jargon for end users - Playlist labels now show: name · N tracks --- ui/model/view.go | 13 +------------ ui/model/view_helpers_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/ui/model/view.go b/ui/model/view.go index f02ae0bf7..657780e10 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -97,21 +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, 3) - if p.DirSourceCount > 0 { - n := p.DirSourceCount - s := "dir" - if n != 1 { - s = "dirs" - } - parts = append(parts, fmt.Sprintf("%d %s", n, s)) - } + 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, " · ") } 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 { From b8db9499a05fa25348f97ea3bd8b876983127390 Mon Sep 17 00:00:00 2001 From: Taha Sadough Date: Thu, 20 Aug 2026 23:33:36 +0330 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20update=20site=20index.html=20keybind?= =?UTF-8?q?ing=20for=20favorites=20(F=E2=86=92n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/index.html b/site/index.html index 01905311e..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 [[dir]] directory sources you can add, remove, and toggle-recursive from the playlist manager (D), cross-playlist favorites (F), 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.