feat(ui): add undo for playlist edits

This commit is contained in:
Bjarne Øverli
2026-07-21 21:02:43 +02:00
parent b8efea7efb
commit 46b903a4d3
9 changed files with 139 additions and 14 deletions
+1
View File
@@ -31,6 +31,7 @@ Press `Ctrl+K` from any mode, or `?` from the player, to see all keybindings.
| `Enter` | Play selected track |
| `/` | Search playlist (navigate results with `↑` `↓` / `Ctrl+N` `Ctrl+P`; `Ctrl+U` clears the query) |
| `Ctrl+X` | Expand/collapse playlist |
| `Ctrl+Z` | Undo the last playlist removal or queue clear |
| `o` | Open file browser |
| `b` `Esc` | Back to provider |
+40
View File
@@ -315,6 +315,18 @@ type Playlist struct {
queuedIdx int // track index currently playing from queue, -1 if none
}
// Snapshot preserves the complete mutable playback state for later restoration.
// Its fields are intentionally private so callers can only return it to Restore.
type Snapshot struct {
tracks []Track
order []int
pos int
shuffle bool
repeat RepeatMode
queue []int
queuedIdx int
}
// New creates an empty Playlist.
func New() *Playlist {
return &Playlist{queuedIdx: -1}
@@ -708,6 +720,34 @@ func (p *Playlist) ClearQueue() {
p.queue = nil
}
// Snapshot returns an independent copy of the playlist's mutable state.
func (p *Playlist) Snapshot() Snapshot {
p.mu.Lock()
defer p.mu.Unlock()
return Snapshot{
tracks: slices.Clone(p.tracks),
order: slices.Clone(p.order),
pos: p.pos,
shuffle: p.shuffle,
repeat: p.repeat,
queue: slices.Clone(p.queue),
queuedIdx: p.queuedIdx,
}
}
// Restore replaces the playlist's mutable state with a prior Snapshot.
func (p *Playlist) Restore(snapshot Snapshot) {
p.mu.Lock()
defer p.mu.Unlock()
p.tracks = slices.Clone(snapshot.tracks)
p.order = slices.Clone(snapshot.order)
p.pos = snapshot.pos
p.shuffle = snapshot.shuffle
p.repeat = snapshot.repeat
p.queue = slices.Clone(snapshot.queue)
p.queuedIdx = snapshot.queuedIdx
}
// RemoveQueueAt removes the entry at the given 0-based queue position.
func (p *Playlist) RemoveQueueAt(pos int) {
p.mu.Lock()
+20
View File
@@ -64,6 +64,26 @@ func TestQueueTracks(t *testing.T) {
}
}
func TestRestoreSnapshotRestoresTracksAndQueue(t *testing.T) {
p := makePlaylist(3, false)
p.Queue(0)
p.Queue(2)
snapshot := p.Snapshot()
p.Remove(0)
p.ClearQueue()
p.Restore(snapshot)
tracks := p.Tracks()
if len(tracks) != 3 || tracks[0].Title != "A" {
t.Fatalf("tracks after restore = %#v, want original tracks", tracks)
}
queued := p.QueueTracks()
if len(queued) != 2 || queued[0].Title != "A" || queued[1].Title != "C" {
t.Fatalf("queue after restore = %#v, want [A C]", queued)
}
}
func TestClearQueue(t *testing.T) {
p := makePlaylist(3, false)
p.Queue(0)
+1
View File
@@ -775,6 +775,7 @@ user_id = "your-account-user-id"</code></pre>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Lua Plugins</div><p>Lua 5.1 sandboxed plugin system. Hook events, add visualizers, push data.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Save to Disk</div><p>Press <kbd>Ctrl+S</kbd> to save the current track to <code>~/Music</code>.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Responsive TUI</div><p>Full, compact, and minimal layouts keep playback usable in terminal splits and small SSH sessions.</p></div>
<div class="feature"><div class="feature-icon"></div><div class="feature-name">Undo Queue Edits</div><p>Restore the last playlist removal or queue clear with <kbd>Ctrl+Z</kbd>.</p></div>
</div>
</div>
</section>
@@ -53,3 +53,20 @@ func TestLyricsRetryStartsNewRequest(t *testing.T) {
t.Fatalf("lyrics.query = %q, want lookup key", m.lyrics.query)
}
}
func TestUndoRestoresClearedQueue(t *testing.T) {
p := playlist.New()
p.Add(playlist.Track{Title: "One"}, playlist.Track{Title: "Two"})
p.Queue(0)
p.Queue(1)
m := Model{playlist: p, queue: queueOverlay{visible: true}}
m.handleQueueKey(tea.KeyPressMsg{Text: "c"})
if got := p.QueueLen(); got != 0 {
t.Fatalf("queue length after clear = %d, want 0", got)
}
m.handleKey(tea.KeyPressMsg{Code: 'z', Mod: tea.ModCtrl})
if got := p.QueueTracks(); len(got) != 2 || got[0].Title != "One" || got[1].Title != "Two" {
t.Fatalf("queue after undo = %#v, want original queue", got)
}
}
+11 -1
View File
@@ -182,6 +182,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
if msg.String() == "ctrl+c" {
return m.quit()
}
if msg.String() == "ctrl+z" {
m.undoPlaylistMutation()
return nil
}
if msg.String() == "ctrl+k" && !m.keymap.visible {
if m.fullVis {
m.exitFullVisualizer()
@@ -2469,14 +2473,20 @@ func (m *Model) handleQueueKey(msg tea.KeyPressMsg) tea.Cmd {
m.queueMaybeAdjustScroll(m.queueVisible())
case "d":
if qLen > 0 {
m.playlistUndo = playlistUndo{active: true, snapshot: m.playlist.Snapshot()}
m.playlist.RemoveQueueAt(m.queue.cursor)
if m.queue.cursor >= m.playlist.QueueLen() && m.queue.cursor > 0 {
m.queue.cursor--
}
m.status.Show("Removed queued track (Ctrl+Z to undo)", statusTTLDefault)
}
m.queueMaybeAdjustScroll(m.queueVisible())
case "c":
m.playlist.ClearQueue()
if qLen > 0 {
m.playlistUndo = playlistUndo{active: true, snapshot: m.playlist.Snapshot()}
m.playlist.ClearQueue()
m.status.Show("Cleared queue (Ctrl+Z to undo)", statusTTLDefault)
}
m.queue.visible = false
case "esc", "A":
m.queue.visible = false
+1
View File
@@ -201,6 +201,7 @@ type Model struct {
height int
layout frameLayout
textInput textEditor
playlistUndo playlistUndo
// Provider state
provider playlist.Provider
+40 -13
View File
@@ -189,37 +189,38 @@ func (m *Model) removeSelectedFromPlaylist() {
if idx < 0 || idx >= m.playlist.Len() {
return
}
snapshot := m.playlist.Snapshot()
track := m.playlist.Tracks()[idx]
loaded := m.loadedPlaylist
var saved []playlist.Track
persisted := false
if loaded != "" {
if saver, ok := m.localProvider.(provider.PlaylistSaver); ok {
saved, err := m.localProvider.Tracks(loaded)
var err error
saved, err = m.localProvider.Tracks(loaded)
if err != nil {
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
return
}
removed := false
for i := range saved {
if saved[i].Path == track.Path {
saved = append(saved[:i], saved[i+1:]...)
removed = true
break
}
}
if !removed {
m.status.Showf(statusTTLDefault, "Remove failed: %s is not in %q", track.DisplayName(), loaded)
if idx >= len(saved) {
m.status.Showf(statusTTLDefault, "Remove failed: selected track is not in %q", loaded)
return
}
original := cloneTracks(saved)
saved = append(saved[:idx:idx], saved[idx+1:]...)
if err := saver.SavePlaylist(loaded, saved); err != nil {
m.status.Showf(statusTTLDefault, "Remove failed: %s", err)
return
}
saved = original
persisted = true
}
}
wasActive := idx == m.playlist.Index()
if !m.playlist.Remove(idx) {
return
}
m.playlistUndo = playlistUndo{active: true, snapshot: snapshot, loaded: loaded, saved: saved, persisted: persisted}
if wasActive {
m.player.Stop()
m.player.ClearPreload()
@@ -232,13 +233,39 @@ func (m *Model) removeSelectedFromPlaylist() {
}
m.adjustScroll()
if loaded != "" {
m.status.Showf(statusTTLDefault, "Removed from %q: %s", loaded, track.DisplayName())
m.status.Showf(statusTTLDefault, "Removed from %q: %s (Ctrl+Z to undo)", loaded, track.DisplayName())
} else {
m.status.Showf(statusTTLDefault, "Removed from queue: %s", track.DisplayName())
m.status.Showf(statusTTLDefault, "Removed from queue: %s (Ctrl+Z to undo)", track.DisplayName())
}
m.notifyPlayback()
}
func (m *Model) undoPlaylistMutation() {
undo := m.playlistUndo
if !undo.active {
m.status.Show("Nothing to undo", statusTTLShort)
return
}
if undo.persisted {
saver := m.localSaver()
if saver == nil {
m.status.Show("Undo unavailable", statusTTLDefault)
return
}
if err := saver.SavePlaylist(undo.loaded, cloneTracks(undo.saved)); err != nil {
m.status.Showf(statusTTLDefault, "Undo failed: %s", err)
return
}
}
m.playlist.Restore(undo.snapshot)
m.playlistUndo = playlistUndo{}
if m.plCursor >= m.playlist.Len() {
m.plCursor = max(0, m.playlist.Len()-1)
}
m.adjustScroll()
m.status.Show("Restored previous playlist state", statusTTLDefault)
}
// 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 {
+8
View File
@@ -24,6 +24,14 @@ type searchState struct {
scroll int
}
type playlistUndo struct {
active bool
snapshot playlist.Snapshot
loaded string
saved []playlist.Track
persisted bool
}
// netSearchScreenType identifies which screen of the net search overlay is active.
type netSearchScreenType int