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:
@@ -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 |
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user