Fix playlist sort on bulk add and add x to remove from playlist (#210)

- File browser bulk-add now emits selected paths in directory listing
  order instead of map iteration order, so albums play in track order.
- Add Playlist.Remove plus `x` keybinding to drop the highlighted track
  from the casual playlist without restarting cliamp.
This commit is contained in:
Bjarne Øverli
2026-05-06 19:32:19 +02:00
parent f80985017c
commit b616440ae5
8 changed files with 177 additions and 4 deletions
+1
View File
@@ -69,6 +69,7 @@ Press `?` or `Ctrl+K` in the player to see all keybindings.
|---|---|
| `a` | Toggle queue (play next) |
| `A` | Queue manager |
| `x` | Remove the highlighted track from the current playlist |
| `p` | Playlist manager |
| `r` | Cycle repeat (Off / All / One) |
| `z` | Toggle shuffle |
+57
View File
@@ -722,6 +722,63 @@ func (p *Playlist) Move(from, to int) bool {
return true
}
// Remove deletes the track at index idx, updating order, queue, and position
// references so playback state is preserved when possible. Returns true if a
// track was removed. If the removed track was the active one, the position
// stays at the same order slot so playback advances naturally on next.
func (p *Playlist) Remove(idx int) bool {
if idx < 0 || idx >= len(p.tracks) {
return false
}
p.tracks = slices.Delete(p.tracks, idx, idx+1)
removedOrderPos := -1
newOrder := p.order[:0]
for i, ord := range p.order {
if ord == idx {
removedOrderPos = i
continue
}
if ord > idx {
ord--
}
newOrder = append(newOrder, ord)
}
p.order = newOrder
if removedOrderPos >= 0 && removedOrderPos < p.pos {
p.pos--
}
if p.pos >= len(p.order) {
p.pos = len(p.order) - 1
}
if p.pos < 0 {
p.pos = 0
}
newQueue := p.queue[:0]
for _, q := range p.queue {
if q == idx {
continue
}
if q > idx {
q--
}
newQueue = append(newQueue, q)
}
p.queue = newQueue
switch {
case p.queuedIdx == idx:
p.queuedIdx = -1
case p.queuedIdx > idx:
p.queuedIdx--
}
return true
}
// SetTrack replaces the track at index i.
func (p *Playlist) SetTrack(i int, t Track) {
if i >= 0 && i < len(p.tracks) {
+77
View File
@@ -297,6 +297,83 @@ func TestMoveQueue(t *testing.T) {
}
}
func TestRemoveShiftsHigherIndices(t *testing.T) {
p := makePlaylist(5, false) // A B C D E
p.SetIndex(3) // playing D
p.Queue(4) // queue E
if !p.Remove(1) { // remove B
t.Fatal("Remove returned false")
}
got := titles(p)
want := []string{"A", "C", "D", "E"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
cur, idx := p.Current()
if cur.Title != "D" || idx != 2 {
t.Errorf("current = (%s, %d), want (D, 2)", cur.Title, idx)
}
qt := p.QueueTracks()
if len(qt) != 1 || qt[0].Title != "E" {
t.Errorf("queue = %v, want [E]", qt)
}
}
func TestRemoveCurrentTrack(t *testing.T) {
p := makePlaylist(4, false) // A B C D
p.SetIndex(1) // playing B
if !p.Remove(1) {
t.Fatal("Remove returned false")
}
got := titles(p)
want := []string{"A", "C", "D"}
if !sliceEq(got, want) {
t.Errorf("tracks = %v, want %v", got, want)
}
// Position stays at order slot 1 so Next/PeekNext sees the next track.
cur, idx := p.Current()
if cur.Title != "C" || idx != 1 {
t.Errorf("current = (%s, %d), want (C, 1)", cur.Title, idx)
}
}
func TestRemoveLastTrack(t *testing.T) {
p := makePlaylist(2, false) // A B
p.SetIndex(1) // playing B
if !p.Remove(1) {
t.Fatal("Remove returned false")
}
if p.Len() != 1 {
t.Fatalf("len = %d, want 1", p.Len())
}
cur, idx := p.Current()
if cur.Title != "A" || idx != 0 {
t.Errorf("current = (%s, %d), want (A, 0)", cur.Title, idx)
}
}
func TestRemoveOutOfBounds(t *testing.T) {
p := makePlaylist(3, false)
if p.Remove(-1) {
t.Error("Remove(-1) should return false")
}
if p.Remove(3) {
t.Error("Remove(3) should return false")
}
if p.Len() != 3 {
t.Errorf("len changed to %d", p.Len())
}
}
func TestMoveQueueBoundary(t *testing.T) {
p := makePlaylist(3, false)
p.Queue(0)
+1
View File
@@ -1917,6 +1917,7 @@
<div class="key-row"><kbd>u</kbd><span>Load URL (stream / playlist)</span></div>
<div class="key-row"><kbd>o</kbd><span>Open file browser</span></div>
<div class="key-row"><kbd>a / A</kbd><span>Queue (play next) / Queue manager</span></div>
<div class="key-row"><kbd>x</kbd><span>Remove highlighted track from current playlist</span></div>
<div class="key-row"><kbd>p</kbd><span>Playlist manager</span></div>
<div class="key-row"><kbd>L</kbd><span>Browse local playlists</span></div>
<div class="key-row"><kbd>Ctrl+X</kbd><span>Expand playlist</span></div>
+7 -3
View File
@@ -490,11 +490,15 @@ func (m *Model) handleFileBrowserKey(msg tea.KeyPressMsg) tea.Cmd {
}
// fbConfirm collects selected paths, closes the overlay, and returns an async
// command that resolves the paths into tracks.
// command that resolves the paths into tracks. Paths are emitted in the
// directory listing's natural (alphabetical) order so albums play in track
// order rather than the random map iteration order.
func (m *Model) fbConfirm(replace bool) tea.Cmd {
paths := make([]string, 0, len(m.fileBrowser.selected))
for p := range m.fileBrowser.selected {
paths = append(paths, p)
for _, e := range m.fileBrowser.entries {
if m.fileBrowser.selected[e.path] {
paths = append(paths, e.path)
}
}
m.fileBrowser.visible = false
+2 -1
View File
@@ -44,6 +44,7 @@ var keymapEntries = []keymapEntry{
{key: "Enter", action: "Play selected track"},
{key: "a", action: "Toggle queue (play next)"},
{key: "A", action: "Queue manager"},
{key: "x", action: "Remove selected track from playlist"},
{key: "o", action: "Open file browser"},
{key: "N", action: "Navidrome browser"},
{key: "L", action: "Browse local playlists"},
@@ -104,7 +105,7 @@ var coreReservedKeys = []string{
"ctrl+s", "S", "/", "ctrl+f",
"ctrl+j", "J", "E", "p", "t", "i", "y", "o", "u",
"N", "L", "R", "P", "Y", "C",
"v", "V", "ctrl+x", "d", "ctrl+k", "?",
"v", "V", "ctrl+x", "x", "d", "ctrl+k", "?",
"ctrl+r",
}
+5
View File
@@ -747,6 +747,11 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
m.toggleExpandPlaylist()
}
case "x":
if m.focus == focusPlaylist {
m.removeSelectedFromPlaylist()
}
case "d":
m.devicePicker.visible = true
m.devicePicker.cursor = 0
+27
View File
@@ -140,6 +140,33 @@ func (m *Model) queueTrackNext(track playlist.Track) tea.Cmd {
return nil
}
// removeSelectedFromPlaylist removes the track at the current playlist cursor.
// If the active track is removed, playback is stopped; the cursor is clamped
// to the new playlist length.
func (m *Model) removeSelectedFromPlaylist() {
idx := m.plCursor
if idx < 0 || idx >= m.playlist.Len() {
return
}
track := m.playlist.Tracks()[idx]
wasActive := idx == m.playlist.Index()
if !m.playlist.Remove(idx) {
return
}
if wasActive {
m.player.Stop()
m.player.ClearPreload()
}
if newLen := m.playlist.Len(); newLen == 0 {
m.plCursor = 0
} else if m.plCursor >= newLen {
m.plCursor = newLen - 1
}
m.adjustScroll()
m.status.Showf(statusTTLDefault, "Removed: %s", track.DisplayName())
m.notifyPlayback()
}
// playTrack plays a track, using async HTTP for streams and sync I/O for local files.
// yt-dlp URLs are streamed via a piped yt-dlp | ffmpeg chain for instant playback.
func (m *Model) playTrack(track playlist.Track) tea.Cmd {