Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,19 +114,30 @@ active when the picker opened. While typing a filter, `Enter` finishes it and
| `/` | Filter (incremental); `Esc` clears |
| `Enter` / `→` | List screen: open the highlighted playlist · Tracks screen: play the **highlighted** track |
| `p` | Tracks screen: play all from the top |
| `a` | List: add the now-playing track to the highlighted playlist. Tracks: mark/unmark all visible tracks. |
| `w` | List: save the current queue through the playlist picker. Tracks: copy marked/highlighted tracks to another playlist. |
| `Space` | Tracks: mark/unmark highlighted track and advance |
| `[` `]` | 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 |
| `r` | List: rename the playlist |
| `d` | List: delete playlist (confirms). Tracks: remove marked tracks, or highlighted track when none are marked |
| `D` | List: open the file browser to add `[[dir]]` sources to the highlighted playlist. Tracks: open the directory-sources screen |
| `a` | List: new playlist — after naming, the file browser opens at `~`: descend with `Enter`, select folders and/or files with `Space`, confirm with `Enter`, or finish with `Esc`. Tracks: mark/unmark all visible tracks. |
| `r` | List: rename the playlist (`Recently Played` cannot be renamed) |
| `d` | List: delete playlist (confirms; `Recently Played` cannot be deleted). 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

Expand All @@ -139,9 +150,17 @@ 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 all selected folders — or the highlighted folder, or the directory being browsed when neither applies — as a live `[[dir]]` source to the target playlist; the browser stays open so you can add more |
| `~` `.` | Jump to home / current working directory |
| `Esc` `o` | Close file browser |

When the browser is adding to a playlist (opened with `D` from the manager's
list screen, `o` from its tracks screen, or automatically after creating a
playlist with `a`), selected folders become `[[dir]]` sources and selected
audio files are written as explicit tracks.
In this mode `Esc` acts as "done": any pending selection is committed before
the browser closes.

## Provider browser (`N` key)

When you press `N` to drill into a provider (Navidrome, Plex, Jellyfin, Emby, Audiobookshelf, Spotify, Qobuz, YouTube Music), the album/artist/track screens use:
Expand Down Expand Up @@ -173,6 +192,7 @@ The playlists pane (visible when focus is on a provider — Spotify, Navidrome,
| `/` | Filter the playlist list |
| `Ctrl+F` | Online/server search (Spotify/Navidrome/NetEase/etc.'s own search) |
| `Ctrl+R` | Refresh — re-pull the playlist list from the provider |
| `p` | Open the playlist manager (Local pane only; create, rename, delete, add dirs/tracks) |
| `S` `N` `P` `J` `E` `Y` `C` `M` `Q` `L` `R` | Switch to that provider |
| `Tab` | Switch focus to EQ |
| `Esc` `b` | Back to the playlist pane |
Expand Down
50 changes: 36 additions & 14 deletions docs/playlists.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,18 +263,19 @@ Press `p` from any view to open the playlist manager:
1. **Browse**: see all playlists with track counts
2. **Filter**: press `/` to incrementally filter the list (works on both the playlists screen and the track screen). `Esc` clears the filter.
3. **Open**: press `Enter` or `→` to view tracks inside a playlist
4. **Add now-playing**: press `a` to add the currently playing track (the footer shows the track name so you know what gets added)
5. **Delete playlist**: press `d` then `y` to confirm deletion
6. **Mark tracks**: open a playlist, press `Space` to mark a track and advance, or `a` to mark or unmark all visible tracks
7. **Move tracks**: press `[` or `]`; the saved playlist is updated immediately
8. **Sort tracks**: press `s` to cycle `track`, `title`, `artist`, `album`, `artist+album`, and `path` sorting
9. **Remove tracks**: press `d` to remove the marked tracks, or the highlighted track when nothing is marked
10. **Undo manager edits**: press `u` after delete, remove, move, or sort
11. **Write tracks elsewhere**: press `w` to copy the marked or highlighted tracks to another playlist; duplicate paths are skipped
12. **Add files**: press `o` from inside a playlist to browse files and add them to that playlist
13. **Play this**: press `Enter` on the track list to start playback at the highlighted track. The rest of the playlist follows.
14. **Play all**: press `p` to start from the top, regardless of cursor position
15. **New playlist**: select "+ New Playlist...", type a name, and press Enter. If you create a playlist while a `/` filter is active, the filter text is pre-filled as the new playlist name.
4. **Create playlist**: press `a`, type a name, press `Enter` — the file browser opens at `~` targeted at the new playlist: select folders and/or files with `Space` (folders become live `[[dir]]` sources), confirm with `Enter`, or finish with `Esc`
5. **Rename playlist**: press `r` on the list screen
6. **Delete playlist**: press `d` then `y` to confirm deletion
7. **Mark tracks**: open a playlist, press `Space` to mark a track and advance, or `a` to mark or unmark all visible tracks
8. **Move tracks**: press `[` or `]`; the saved playlist is updated immediately
9. **Sort tracks**: press `s` to cycle `track`, `title`, `artist`, `album`, `artist+album`, and `path` sorting
10. **Remove tracks**: press `d` to remove the marked tracks, or the highlighted track when nothing is marked
11. **Undo manager edits**: press `u` after delete, remove, move, or sort
12. **Write tracks elsewhere**: press `w` to copy the marked or highlighted tracks to another playlist; duplicate paths are skipped
13. **Add files**: press `o` from inside a playlist to browse files and add them to that playlist
14. **Play this**: press `Enter` on the track list to start playback at the highlighted track. The rest of the playlist follows.
Comment thread
tahadx marked this conversation as resolved.
15. **Play all**: press `p` to start from the top, regardless of cursor position
16. **New playlist**: select "+ New Playlist...", type a name, and press Enter — the file browser opens so you can fill the playlist right away. If you create a playlist while a `/` filter is active, the filter text is pre-filled as the new playlist name.

Tracks with an `album` field are grouped by album with visual separator headers in the playlist manager (album grouping is hidden while a filter is active) and the main player view.

Expand Down Expand Up @@ -358,12 +359,33 @@ title = "My Radio"
| `/` | Filter playlists or tracks; `Esc` clears |
| `Enter` / `→` | Open playlist (list screen) / Play **highlighted** track (tracks screen) |
| `p` | Play all tracks from the top (tracks screen) |
| `a` | List: add currently playing track. Tracks: mark/unmark all visible tracks |
| `a` | List: new playlist — after naming, the file browser opens at `~` so you can pick folders and/or files (`Space` selects, `Esc` finishes). Tracks: mark/unmark all visible tracks |
| `r` | Rename the highlighted playlist (list screen; `Recently Played` cannot be renamed) |
| `Space` | Mark/unmark track and advance (tracks screen) |
| `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 |
| `d` | Delete playlist (confirms; `Recently Played` cannot be deleted) / Remove marked tracks, or highlighted track if none are marked |
| `u` | Undo the last playlist-manager edit |
| `←` / `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 all selected directories — or the highlighted
directory, or the directory you are currently browsing when neither applies —
as live `[[dir]]` sources instead of expanding them into explicit tracks.
Directories already referenced are skipped and reported.
20 changes: 6 additions & 14 deletions external/local/dirs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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
}

Expand All @@ -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",
})
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
162 changes: 157 additions & 5 deletions external/local/dirs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
}{
Expand Down
Loading