feat(ui): add undo for playlist edits
This commit is contained in:
@@ -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 |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -201,6 +201,7 @@ type Model struct {
|
||||
height int
|
||||
layout frameLayout
|
||||
textInput textEditor
|
||||
playlistUndo playlistUndo
|
||||
|
||||
// Provider state
|
||||
provider playlist.Provider
|
||||
|
||||
+40
-13
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user