Merge pull request #406 from dweymouth/drag-and-drop-reorder

Enable drag and drop reordering of playlists and the play queue
This commit is contained in:
Drew Weymouth
2024-06-23 12:11:05 -07:00
committed by GitHub
14 changed files with 100 additions and 199 deletions
+1
View File
@@ -8,6 +8,7 @@ require (
github.com/Microsoft/go-winio v0.6.2
github.com/cenkalti/dominantcolor v1.0.2
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
github.com/dweymouth/fyne-advanced-list v0.0.0-20240623145729-9c6b8f99bcfe
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64
github.com/dweymouth/go-jellyfin v0.0.0-20240517151952-5ceca61cb645
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
+2
View File
@@ -84,6 +84,8 @@ github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dweymouth/fyne-advanced-list v0.0.0-20240623145729-9c6b8f99bcfe h1:owGwqph+Y+PqjDiWjZjOFOhlo8QsLs+LHrHojaaBo34=
github.com/dweymouth/fyne-advanced-list v0.0.0-20240623145729-9c6b8f99bcfe/go.mod h1:sbOhla4VcfFb4OjXiUFTLXMPTnhRUlVrDMhB8HtWR4o=
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64 h1:RUIrnGY034rDMlcOui/daurwX5b+52KdUKhH9aXaDSg=
github.com/dweymouth/fyne-lyrics v0.0.0-20240528234907-15eee7ce5e64/go.mod h1:3YrjFDHMlhCsSZ/OvmJCxWm9QHSgOVWZBxnraZz9Z7c=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20240604143614-256525c6a602 h1:k3jFLjmAuPJ5ZFNF57szZp8XrLIb6mIdEEGPkm6EZ7Q=
+23 -83
View File
@@ -1,9 +1,6 @@
package sharedutil
import (
"math"
"slices"
"github.com/dweymouth/supersonic/backend/mediaprovider"
)
@@ -106,90 +103,33 @@ func TracksToIDs(tracks []*mediaprovider.Track) []string {
})
}
type TrackReorderOp int
const (
MoveToTop TrackReorderOp = iota
MoveToBottom
MoveUp
MoveDown
)
// Reorder items and return a new track slice.
// idxToMove must contain only valid indexes into tracks, and no repeats
func ReorderItems[T any](items []T, idxToMove []int, op TrackReorderOp) []T {
newItems := make([]T, len(items))
switch op {
case MoveToTop:
topIdx := 0
botIdx := len(idxToMove)
idxToMoveSet := ToSet(idxToMove)
for i, t := range items {
if _, ok := idxToMoveSet[i]; ok {
newItems[topIdx] = t
topIdx++
} else {
newItems[botIdx] = t
botIdx++
}
func ReorderItems[T any](items []T, idxToMove []int, insertIdx int) []T {
idxToMoveSet := ToSet(idxToMove)
newItems := make([]T, 0, len(items))
// collect items that will end up before the insertion set
i := 0
for ; i < len(items); i++ {
if insertIdx == i {
break
}
case MoveToBottom:
topIdx := 0
botIdx := len(items) - len(idxToMove)
idxToMoveSet := ToSet(idxToMove)
for i, t := range items {
if _, ok := idxToMoveSet[i]; ok {
newItems[botIdx] = t
botIdx++
} else {
newItems[topIdx] = t
topIdx++
}
}
case MoveUp:
first := firstIdxCanMoveUp(idxToMove)
copy(newItems, items)
for _, i := range idxToMove {
if i < first {
continue
}
newItems[i-1], newItems[i] = newItems[i], newItems[i-1]
}
case MoveDown:
last := lastIdxCanMoveDown(idxToMove, len(items))
copy(newItems, items)
for i := len(idxToMove) - 1; i >= 0; i-- {
idx := idxToMove[i]
if idx > last {
continue
}
newItems[idx+1], newItems[idx] = newItems[idx], newItems[idx+1]
if _, ok := idxToMoveSet[i]; !ok {
newItems = append(newItems, items[i])
}
}
for _, idx := range idxToMove {
newItems = append(newItems, items[idx])
}
for ; i < len(items); i++ {
if _, ok := idxToMoveSet[i]; !ok {
newItems = append(newItems, items[i])
}
}
return newItems
}
func firstIdxCanMoveUp(idxs []int) int {
prevIdx := -1
slices.Sort(idxs)
for _, idx := range idxs {
if idx > prevIdx+1 {
return idx
}
prevIdx = idx
}
return math.MaxInt
}
func lastIdxCanMoveDown(idxs []int, lenSlice int) int {
prevIdx := lenSlice
slices.Sort(idxs)
for i := len(idxs) - 1; i >= 0; i-- {
idx := idxs[i]
if idx < prevIdx-1 {
return idx
}
prevIdx = idx
}
return -1
}
+3 -32
View File
@@ -8,6 +8,7 @@ import (
)
func Test_ReorderItems(t *testing.T) {
tracks := []*mediaprovider.Track{
{ID: "a"}, // 0
{ID: "b"}, // 1
@@ -27,7 +28,7 @@ func Test_ReorderItems(t *testing.T) {
{ID: "b"},
{ID: "e"},
}
newTracks := ReorderItems(tracks, idxToMove, MoveToTop)
newTracks := ReorderItems(tracks, idxToMove, 0)
if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveToTop order incorrect")
}
@@ -42,40 +43,10 @@ func Test_ReorderItems(t *testing.T) {
{ID: "c"},
{ID: "f"},
}
newTracks = ReorderItems(tracks, idxToMove, MoveToBottom)
newTracks = ReorderItems(tracks, idxToMove, len(tracks))
if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveToBottom order incorrect")
}
// test MoveUp:
idxToMove = []int{0, 1, 3, 5}
want = []*mediaprovider.Track{
{ID: "a"},
{ID: "b"},
{ID: "d"},
{ID: "c"},
{ID: "f"},
{ID: "e"},
}
newTracks = ReorderItems(tracks, idxToMove, MoveUp)
if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveUp order incorrect")
}
// test MoveDown:
idxToMove = []int{2, 4, 5}
want = []*mediaprovider.Track{
{ID: "a"},
{ID: "b"},
{ID: "d"},
{ID: "c"},
{ID: "e"},
{ID: "f"},
}
newTracks = ReorderItems(tracks, idxToMove, MoveDown)
if !tracklistsEqual(t, newTracks, want) {
t.Error("ReorderTracks: MoveDown order incorrect")
}
}
func tracklistsEqual(t *testing.T, a, b []*mediaprovider.Track) bool {
-1
View File
@@ -212,7 +212,6 @@ func NewGenreList(sorting widgets.ListHeaderSort) *GenreList {
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*GenreListRow)
a.list.SetItemForID(id, row)
if row.Item != a.genres[id] {
row.EnsureUnfocused()
row.ListItemID = id
+3 -2
View File
@@ -129,6 +129,7 @@ func NewNowPlayingPage(
a.queueList = widgets.NewPlayQueueList(a.im, false)
a.relatedList = widgets.NewPlayQueueList(a.im, true)
a.queueList.Reorderable = true
a.queueList.OnReorderItems = a.doSetNewTrackOrder
a.queueList.OnDownload = contr.ShowDownloadDialog
a.queueList.OnShare = func(tracks []*mediaprovider.Track) {
@@ -502,7 +503,7 @@ func (a *NowPlayingPage) Refresh() {
a.BaseWidget.Refresh()
}
func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.TrackReorderOp) {
func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, insertPos int) {
trackIDSet := sharedutil.ToSet(trackIDs)
idxs := make([]int, 0, len(trackIDs))
for i, tr := range a.queue {
@@ -510,7 +511,7 @@ func (a *NowPlayingPage) doSetNewTrackOrder(trackIDs []string, op sharedutil.Tra
idxs = append(idxs, i)
}
}
newTracks := sharedutil.ReorderItems(a.queue, idxs, op)
newTracks := sharedutil.ReorderItems(a.queue, idxs, insertPos)
a.pm.UpdatePlayQueue(newTracks)
}
+23 -19
View File
@@ -86,17 +86,16 @@ func newPlaylistPage(
a.tracklist.OnVisibleColumnsChanged = func(cols []string) {
conf.TracklistColumns = cols
}
a.tracklist.OnReorderTracks = a.doSetNewTrackOrder
_, canRate := a.sm.Server.(mediaprovider.SupportsRating)
_, canShare := a.sm.Server.(mediaprovider.SupportsSharing)
remove := fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist)
remove.Icon = theme.ContentClearIcon()
a.tracklist.Options = widgets.TracklistOptions{
DisableRating: !canRate,
DisableSharing: !canShare,
AuxiliaryMenuItems: []*fyne.MenuItem{
util.NewReorderTracksSubmenu(a.doSetNewTrackOrder),
remove,
},
Reorderable: true,
DisableRating: !canRate,
DisableSharing: !canShare,
AuxiliaryMenuItems: []*fyne.MenuItem{remove},
}
// connect tracklist actions
a.contr.ConnectTracklistActions(a.tracklist)
@@ -118,6 +117,7 @@ func (a *PlaylistPage) Save() SavedPage {
p.trackSort = a.tracklist.Sorting()
p.widgetPool.Release(util.WidgetTypePlaylistPageHeader, a.header)
a.tracklist.Clear()
a.tracklist.OnReorderTracks = nil
p.widgetPool.Release(util.WidgetTypeTracklist, a.tracklist)
return &p
}
@@ -178,28 +178,32 @@ func renumberTracks(tracks []*mediaprovider.Track) {
}
}
func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
func (a *PlaylistPage) doSetNewTrackOrder(ids []string, newPos int) {
// Since the tracklist view may be sorted in a different order than the
// actual running order, we need to get the IDs of the selected tracks
// from the tracklist and convert them to indices in the *original* run order
idSet := sharedutil.ToSet(a.tracklist.SelectedTrackIDs())
idSet := sharedutil.ToSet(ids)
idxs := make([]int, 0, len(idSet))
for i, tr := range a.tracks {
if _, ok := idSet[tr.ID]; ok {
idxs = append(idxs, i)
}
}
newTracks := sharedutil.ReorderItems(a.tracks, idxs, op)
ids := sharedutil.TracksToIDs(newTracks)
if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
log.Printf("error updating playlist: %s", err.Error())
} else {
renumberTracks(newTracks)
// force-switch back to unsorted view to show new track order
a.tracklist.SetSorting(widgets.TracklistSort{})
a.tracklist.SetTracks(newTracks)
a.tracklist.UnselectAll()
}
newTracks := sharedutil.ReorderItems(a.tracks, idxs, newPos)
// we can't block the UI waiting for the server so assume it will succeed
go func() {
ids = sharedutil.TracksToIDs(newTracks)
if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
log.Printf("error updating playlist: %s", err.Error())
}
}()
renumberTracks(newTracks)
// force-switch back to unsorted view to show new track order
a.tracklist.SetSorting(widgets.TracklistSort{})
a.tracklist.SetTracks(newTracks)
a.tracklist.UnselectAll()
a.tracks = newTracks
}
func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
-1
View File
@@ -354,7 +354,6 @@ func NewPlaylistList(initialSort widgets.ListHeaderSort) *PlaylistList {
row := item.(*PlaylistListRow)
if row.PlaylistID != a.playlists[id].ID {
row.EnsureUnfocused()
a.list.SetItemForID(id, row)
row.ListItemID = id
row.PlaylistID = a.playlists[id].ID
row.nameLabel.Text = a.playlists[id].Name
-1
View File
@@ -279,7 +279,6 @@ func NewRadioList(nowPlayingIDPtr *string) *RadioList {
},
func(id widget.ListItemID, item fyne.CanvasObject) {
row := item.(*RadioListRow)
a.list.SetItemForID(id, row)
changed := false
if row.Item != a.radios[id] {
row.EnsureUnfocused()
-13
View File
@@ -17,7 +17,6 @@ import (
"fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"golang.org/x/net/html"
)
@@ -227,18 +226,6 @@ func NewRatingSubmenu(onSetRating func(int)) *fyne.MenuItem {
return ratingMenu
}
func NewReorderTracksSubmenu(onReorderTracks func(sharedutil.TrackReorderOp)) *fyne.MenuItem {
reorderMenu := fyne.NewMenuItem("Reorder tracks", nil)
reorderMenu.Icon = myTheme.SortIcon
reorderMenu.ChildMenu = fyne.NewMenu("", []*fyne.MenuItem{
fyne.NewMenuItem("Move to top", func() { onReorderTracks(sharedutil.MoveToTop) }),
fyne.NewMenuItem("Move up", func() { onReorderTracks(sharedutil.MoveUp) }),
fyne.NewMenuItem("Move down", func() { onReorderTracks(sharedutil.MoveDown) }),
fyne.NewMenuItem("Move to bottom", func() { onReorderTracks(sharedutil.MoveToBottom) }),
}...)
return reorderMenu
}
func AddHeaderBackground(obj fyne.CanvasObject) *fyne.Container {
return AddHeaderBackgroundWithColorName(obj, myTheme.ColorNamePageHeader)
}
-1
View File
@@ -274,7 +274,6 @@ func NewGenreFilterSubsection(onChanged func([]string), initialSelectedGenres []
_, selected := g.selectedGenres[genre]
g.selectedGenresMutex.RUnlock()
row := obj.(*genreListViewRow)
g.genreListView.SetItemForID(id, row)
row.ListItemID = id
row.Content.(*widget.Check).Text = genre
row.Content.(*widget.Check).Checked = selected
+8 -23
View File
@@ -9,16 +9,17 @@ import (
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
list "github.com/dweymouth/fyne-advanced-list"
)
// FocusList extends List to be disabled so that the focus manager
// considers it unfocusable, and adds utilities for handling our
// own focus navigation on the rows directly (with FocusListRow
type FocusList struct {
widget.List
list.List
mutex sync.Mutex
itemForIndex map[widget.ListItemID]FocusListRow
mutex sync.Mutex
}
type FocusListRow interface {
@@ -33,13 +34,12 @@ type FocusListRow interface {
func NewFocusList(len func() int, create func() fyne.CanvasObject, update func(widget.GridWrapItemID, fyne.CanvasObject)) *FocusList {
g := &FocusList{
List: widget.List{
List: list.List{
HideSeparators: true,
Length: len,
CreateItem: create,
UpdateItem: update,
},
itemForIndex: make(map[int]FocusListRow),
}
g.ExtendBaseWidget(g)
return g
@@ -53,21 +53,6 @@ func (g *FocusList) Disable() {}
func (g *FocusList) Enable() {}
// MUST be called *before* updating the ListItemID field to the
// new ItemID this row will be bound to.
func (g *FocusList) SetItemForID(id widget.ListItemID, item FocusListRow) {
g.mutex.Lock()
if other, ok := g.itemForIndex[id]; ok && other == item {
delete(g.itemForIndex, other.ItemID())
}
g.itemForIndex[id] = item
g.mutex.Unlock()
}
func (g *FocusList) ClearItemForIDMap() {
g.itemForIndex = make(map[int]FocusListRow)
}
func (g *FocusList) FocusNeighbor(curItem widget.ListItemID, up bool) {
focusIdx := curItem + 1
if up {
@@ -77,10 +62,10 @@ func (g *FocusList) FocusNeighbor(curItem widget.ListItemID, up bool) {
g.ScrollTo(focusIdx)
}
g.mutex.Lock()
other, ok := g.itemForIndex[focusIdx]
other := g.ItemForID(focusIdx)
g.mutex.Unlock()
if ok {
fyne.CurrentApp().Driver().CanvasForObject(g).Focus(other)
if other != nil {
fyne.CurrentApp().Driver().CanvasForObject(g).Focus(other.(fyne.Focusable))
}
}
+21 -18
View File
@@ -31,6 +31,7 @@ type PlayQueueListModel struct {
type PlayQueueList struct {
widget.BaseWidget
Reorderable bool
DisableRating bool
DisableSharing bool
@@ -46,7 +47,7 @@ type PlayQueueList struct {
OnDownload func(tracks []*mediaprovider.Track, downloadName string)
OnShare func(tracks []*mediaprovider.Track)
OnShowArtistPage func(artistID string)
OnReorderItems func(itemIDs []string, op sharedutil.TrackReorderOp)
OnReorderItems func(itemIDs []string, reorderTo int)
useNonQueueMenu bool
menu *widget.PopUpMenu // ctx menu for when only tracks are selected
@@ -96,20 +97,29 @@ func NewPlayQueueList(im *backend.ImageManager, useNonQueueMenu bool) *PlayQueue
p.tracksMutex.RUnlock()
tr := item.(*PlayQueueListRow)
p.list.SetItemForID(itemID, tr)
if tr.trackID != model.Item.Metadata().ID || tr.ListItemID != itemID {
tr.ListItemID = itemID
}
tr.Update(model, itemID+1)
},
)
p.list.OnDragBegin = func(id int) {
if !p.items[id].Selected {
p.selectTrack(id)
p.list.Refresh()
}
}
p.list.OnDragEnd = func(dragged, insertPos int) {
if p.OnReorderItems != nil {
p.OnReorderItems(p.selectedItemIDs(), insertPos)
}
}
return p
}
func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) {
p.tracksMutex.Lock()
p.list.ClearItemForIDMap()
p.items = util.ToTrackListModels(trs)
p.tracksMutex.Unlock()
p.Refresh()
@@ -117,7 +127,6 @@ func (p *PlayQueueList) SetTracks(trs []*mediaprovider.Track) {
func (p *PlayQueueList) SetItems(items []mediaprovider.MediaItem) {
p.tracksMutex.Lock()
p.list.ClearItemForIDMap()
p.items = sharedutil.MapSlice(items, func(item mediaprovider.MediaItem) *util.TrackListModel {
return &util.TrackListModel{Item: item}
})
@@ -152,7 +161,12 @@ func (p *PlayQueueList) UnselectAll() {
p.tracksMutex.RLock()
util.UnselectAllItems(p.items)
p.tracksMutex.RUnlock()
p.Refresh()
p.list.Refresh()
}
func (p *PlayQueueList) Refresh() {
p.list.EnableDragging = p.Reorderable
p.BaseWidget.Refresh()
}
func (p *PlayQueueList) lenTracks() int {
@@ -295,15 +309,9 @@ func (p *PlayQueueList) ensureTracksMenu() {
}
})
remove.Icon = theme.ContentRemoveIcon()
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
if p.OnReorderItems != nil {
p.OnReorderItems(p.selectedItemIDs(), tro)
}
})
menuItems = append(menuItems,
fyne.NewMenuItemSeparator(),
remove,
reorder)
remove)
}
p.menu = widget.NewPopUpMenu(
@@ -322,13 +330,8 @@ func (p *PlayQueueList) ensureRadiosMenu() {
}
})
remove.Icon = theme.ContentRemoveIcon()
reorder := util.NewReorderTracksSubmenu(func(tro sharedutil.TrackReorderOp) {
if p.OnReorderItems != nil {
p.OnReorderItems(p.selectedItemIDs(), tro)
}
})
p.radiosMenu = widget.NewPopUpMenu(
fyne.NewMenu("", remove, reorder),
fyne.NewMenu("", remove),
fyne.CurrentApp().Driver().CanvasForObject(p),
)
}
+16 -5
View File
@@ -33,6 +33,9 @@ type TracklistOptions struct {
// or to use the number from the track's metadata
AutoNumber bool
// Reorderable sets whether the tracklist supports drag-and-drop reordering.
Reorderable bool
// ShowDiscNumber sets whether to display the disc number as part of the '#' column,
// (with format %d.%02d). Only applies if AutoNumber==false.
ShowDiscNumber bool
@@ -74,6 +77,7 @@ type Tracklist struct {
OnDownload func(tracks []*mediaprovider.Track, downloadName string)
OnShare func(trackID string)
OnPlaySongRadio func(track *mediaprovider.Track)
OnReorderTracks func(trackIDs []string, insertPos int)
OnShowArtistPage func(artistID string)
OnShowAlbumPage func(albumID string)
@@ -170,7 +174,6 @@ func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCo
t.tracksMutex.RUnlock()
tr := item.(TracklistRow)
t.list.SetItemForID(itemID, tr)
if tr.TrackID() != model.Item.Metadata().ID || tr.ItemID() != itemID {
tr.SetItemID(itemID)
}
@@ -183,6 +186,17 @@ func NewTracklist(tracks []*mediaprovider.Track, im *backend.ImageManager, useCo
t.OnTrackShown(itemID)
}
})
t.list.OnDragBegin = func(id int) {
if !t.tracks[id].Selected {
t.selectTrack(id)
t.list.Refresh()
}
}
t.list.OnDragEnd = func(dragged, insertPos int) {
if t.OnReorderTracks != nil {
t.OnReorderTracks(t.SelectedTrackIDs(), insertPos)
}
}
t.container = container.NewBorder(t.hdr, nil, nil, nil, t.list)
return t
}
@@ -300,7 +314,6 @@ func (t *Tracklist) Clear() {
defer t.tracksMutex.Unlock()
t.tracks = nil
t.tracksOrigOrder = nil
t.list.ClearItemForIDMap()
}
// Sets the tracks in the tracklist. Thread-safe.
@@ -312,9 +325,6 @@ func (t *Tracklist) SetTracks(trs []*mediaprovider.Track) {
func (t *Tracklist) _setTracks(trs []*mediaprovider.Track) {
t.tracksMutex.Lock()
defer t.tracksMutex.Unlock()
if t.list != nil {
t.list.ClearItemForIDMap()
}
t.tracksOrigOrder = util.ToTrackListModels(trs)
t.doSortTracks()
}
@@ -376,6 +386,7 @@ func (t *Tracklist) CreateRenderer() fyne.WidgetRenderer {
}
func (t *Tracklist) Refresh() {
t.list.EnableDragging = t.Options.Reorderable
t.hdr.DisableSorting = t.Options.DisableSorting
t.BaseWidget.Refresh()
}