Merge pull request #174 from dweymouth/feature/sort-tracklist-cols

Add sorting by columns to tracklist view
This commit is contained in:
Drew Weymouth
2023-05-24 09:28:12 -07:00
committed by GitHub
14 changed files with 468 additions and 136 deletions
+22
View File
@@ -40,6 +40,28 @@ func MapSlice[T any, U any](ts []T, f func(T) U) []U {
return result
}
func IndexOf[T comparable](ts []T, t T) int {
for i, tt := range ts {
if t == tt {
return i
}
}
return -1
}
func Reversed[T any](ts []T) []T {
if ts == nil {
return nil
}
new := make([]T, len(ts))
j := len(ts) - 1
for i := range ts {
new[i] = ts[j]
j--
}
return new
}
func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Track {
for _, tr := range tracks {
if id == tr.ID {
+22 -5
View File
@@ -26,6 +26,7 @@ type AlbumPage struct {
albumPageState
header *AlbumPageHeader
tracks []*mediaprovider.Track
tracklist *widgets.Tracklist
nowPlayingID string
container *fyne.Container
@@ -33,6 +34,7 @@ type AlbumPage struct {
type albumPageState struct {
albumID string
sort widgets.TracklistSort
cfg *backend.AlbumPageConfig
mp mediaprovider.MediaProvider
pm *backend.PlaybackManager
@@ -47,6 +49,18 @@ func NewAlbumPage(
mp mediaprovider.MediaProvider,
im *backend.ImageManager,
contr *controller.Controller,
) *AlbumPage {
return newAlbumPage(albumID, cfg, pm, mp, im, contr, widgets.TracklistSort{})
}
func newAlbumPage(
albumID string,
cfg *backend.AlbumPageConfig,
pm *backend.PlaybackManager,
mp mediaprovider.MediaProvider,
im *backend.ImageManager,
contr *controller.Controller,
sort widgets.TracklistSort,
) *AlbumPage {
a := &AlbumPage{
albumPageState: albumPageState{
@@ -62,6 +76,7 @@ func NewAlbumPage(
a.header = NewAlbumPageHeader(a)
a.tracklist = widgets.NewTracklist(nil)
a.tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
a.tracklist.SetSorting(sort)
a.tracklist.OnVisibleColumnsChanged = func(cols []string) {
a.cfg.TracklistColumns = cols
}
@@ -81,6 +96,7 @@ func (a *AlbumPage) CreateRenderer() fyne.WidgetRenderer {
func (a *AlbumPage) Save() SavedPage {
s := a.albumPageState
s.sort = a.tracklist.Sorting()
return &s
}
@@ -119,7 +135,8 @@ func (a *AlbumPage) load() {
}
a.header.Update(album, a.im)
a.tracklist.ShowDiscNumber = album.Tracks[0].DiscNumber != album.Tracks[len(album.Tracks)-1].DiscNumber
a.tracklist.Tracks = album.Tracks
a.tracks = album.Tracks
a.tracklist.SetTracks(album.Tracks)
a.tracklist.SetNowPlaying(a.nowPlayingID)
}
@@ -169,7 +186,7 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader {
go page.pm.PlayAlbum(page.albumID, 0, false)
})
shuffleBtn := widget.NewButtonWithIcon(" Shuffle", myTheme.ShuffleIcon, func() {
page.pm.LoadTracks(page.tracklist.Tracks, false, true)
page.pm.LoadTracks(page.tracklist.GetTracks(), false, true)
page.pm.PlayFromBeginning()
})
var pop *widget.PopUpMenu
@@ -178,11 +195,11 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader {
if pop == nil {
menu := fyne.NewMenu("",
fyne.NewMenuItem("Add to queue", func() {
a.page.pm.LoadAlbum(a.albumID, true /*append*/, false /*shuffle*/)
go a.page.pm.LoadAlbum(a.albumID, true /*append*/, false /*shuffle*/)
}),
fyne.NewMenuItem("Add to playlist...", func() {
a.page.contr.DoAddTracksToPlaylistWorkflow(
sharedutil.TracksToIDs(a.page.tracklist.Tracks))
sharedutil.TracksToIDs(a.page.tracks))
}))
pop = widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(a))
}
@@ -260,5 +277,5 @@ func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
}
func (s *albumPageState) Restore() Page {
return NewAlbumPage(s.albumID, s.cfg, s.pm, s.mp, s.im, s.contr)
return newAlbumPage(s.albumID, s.cfg, s.pm, s.mp, s.im, s.contr, s.sort)
}
+9 -3
View File
@@ -26,6 +26,7 @@ var _ fyne.Widget = (*ArtistPage)(nil)
type artistPageState struct {
artistID string
activeView int
trackSort widgets.TracklistSort
cfg *backend.ArtistPageConfig
pm *backend.PlaybackManager
@@ -53,10 +54,10 @@ func NewArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.P
if cfg.InitialView == "Top Tracks" {
activeView = 1
}
return newArtistPage(artistID, cfg, pm, mp, im, contr, activeView)
return newArtistPage(artistID, cfg, pm, mp, im, contr, activeView, widgets.TracklistSort{})
}
func newArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager, contr *controller.Controller, activeView int) *ArtistPage {
func newArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager, contr *controller.Controller, activeView int, sort widgets.TracklistSort) *ArtistPage {
a := &ArtistPage{artistPageState: artistPageState{
artistID: artistID,
cfg: cfg,
@@ -65,6 +66,7 @@ func newArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.P
im: im,
contr: contr,
activeView: activeView,
trackSort: sort,
}}
a.ExtendBaseWidget(a)
a.header = NewArtistPageHeader(a)
@@ -111,6 +113,9 @@ func (a *ArtistPage) Reload() {
func (a *ArtistPage) Save() SavedPage {
s := a.artistPageState
if a.tracklistCtr != nil {
s.trackSort = a.tracklistCtr.Objects[0].(*widgets.Tracklist).Sorting()
}
return &s
}
@@ -187,6 +192,7 @@ func (a *ArtistPage) showTopTracks() {
tl := widgets.NewTracklist(ts)
tl.AutoNumber = true
tl.SetVisibleColumns(a.cfg.TracklistColumns)
tl.SetSorting(a.trackSort)
tl.OnVisibleColumnsChanged = func(cols []string) {
a.cfg.TracklistColumns = cols
}
@@ -222,7 +228,7 @@ func (a *ArtistPage) CreateRenderer() fyne.WidgetRenderer {
}
func (s *artistPageState) Restore() Page {
return newArtistPage(s.artistID, s.cfg, s.pm, s.mp, s.im, s.contr, s.activeView)
return newArtistPage(s.artistID, s.cfg, s.pm, s.mp, s.im, s.contr, s.activeView, s.trackSort)
}
type ArtistPageHeader struct {
+8 -1
View File
@@ -28,6 +28,7 @@ type FavoritesPage struct {
im *backend.ImageManager
mp mediaprovider.MediaProvider
trackSort widgets.TracklistSort
filter mediaprovider.AlbumFilter
searchText string
nowPlayingID string
@@ -103,6 +104,7 @@ func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
im: saved.im,
searchText: saved.searchText,
filter: saved.filter,
trackSort: saved.trackSort,
}
a.ExtendBaseWidget(a)
a.createHeader(saved.activeToggleBtn)
@@ -155,7 +157,7 @@ func (a *FavoritesPage) Reload() {
if a.tracklistCtr != nil {
// refresh favorite songs view
tr := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
tr.Tracks = starred.Tracks
tr.SetTracks(starred.Tracks)
if a.toggleBtns.ActivatedButtonIndex() == 2 {
// favorite songs view is visible
tr.Refresh()
@@ -188,6 +190,9 @@ func (a *FavoritesPage) Save() SavedPage {
if a.searchGrid != nil {
sf.searchGridState = a.searchGrid.SaveToState()
}
if a.tracklistCtr != nil {
sf.trackSort = a.tracklistCtr.Objects[0].(*widgets.Tracklist).Sorting()
}
return sf
}
@@ -323,6 +328,7 @@ func (a *FavoritesPage) onShowFavoriteSongs() {
tracklist := widgets.NewTracklist(fav.Tracks)
tracklist.AutoNumber = true
tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
tracklist.SetSorting(a.trackSort)
tracklist.OnVisibleColumnsChanged = func(cols []string) {
a.cfg.TracklistColumns = cols
}
@@ -357,6 +363,7 @@ type savedFavoritesPage struct {
filter mediaprovider.AlbumFilter
searchText string
activeToggleBtn int
trackSort widgets.TracklistSort
}
func (s *savedFavoritesPage) Restore() Page {
+2 -1
View File
@@ -178,7 +178,8 @@ func NewGenreList(items []*mediaprovider.Genre) *GenreList {
}
a.ExtendBaseWidget(a)
a.hdr = widgets.NewListHeader([]widgets.ListColumn{
{"Name", false, false}, {"Album Count", true, false}, {"Track Count", true, false}}, a.columnsLayout)
{"Name", fyne.TextAlignLeading, false}, {"Album Count", fyne.TextAlignTrailing, false}, {"Track Count", fyne.TextAlignTrailing, false}}, a.columnsLayout)
a.hdr.DisableSorting = true
a.list = widget.NewList(
func() int { return len(a.Items) },
func() fyne.CanvasObject {
+1 -1
View File
@@ -107,7 +107,7 @@ func (a *NowPlayingPage) onRemoveSelectedFromQueue() {
// does not make calls to server - can safely be run in UI callbacks
func (a *NowPlayingPage) load(highlightedTrackID string) {
queue := a.pm.GetPlayQueue()
a.tracklist.Tracks = queue
a.tracklist.SetTracks(queue)
a.tracklist.SetNowPlaying(a.nowPlayingID)
if highlightedTrackID != "" {
a.tracklist.SelectAndScrollToTrack(highlightedTrackID)
+36 -11
View File
@@ -26,6 +26,7 @@ type PlaylistPage struct {
header *PlaylistPageHeader
tracklist *widgets.Tracklist
tracks []*mediaprovider.Track
nowPlayingID string
container *fyne.Container
}
@@ -37,6 +38,7 @@ type playlistPageState struct {
sm *backend.ServerManager
pm *backend.PlaybackManager
im *backend.ImageManager
trackSort widgets.TracklistSort
}
func NewPlaylistPage(
@@ -46,12 +48,25 @@ func NewPlaylistPage(
sm *backend.ServerManager,
pm *backend.PlaybackManager,
im *backend.ImageManager,
) *PlaylistPage {
return newPlaylistPage(playlistID, conf, contr, sm, pm, im, widgets.TracklistSort{})
}
func newPlaylistPage(
playlistID string,
conf *backend.PlaylistPageConfig,
contr *controller.Controller,
sm *backend.ServerManager,
pm *backend.PlaybackManager,
im *backend.ImageManager,
trackSort widgets.TracklistSort,
) *PlaylistPage {
a := &PlaylistPage{playlistPageState: playlistPageState{playlistID: playlistID, conf: conf, contr: contr, sm: sm, pm: pm, im: im}}
a.ExtendBaseWidget(a)
a.header = NewPlaylistPageHeader(a)
a.tracklist = widgets.NewTracklist(nil)
a.tracklist.SetVisibleColumns(conf.TracklistColumns)
a.tracklist.SetSorting(trackSort)
a.tracklist.OnVisibleColumnsChanged = func(cols []string) {
conf.TracklistColumns = cols
}
@@ -81,6 +96,7 @@ func (a *PlaylistPage) CreateRenderer() fyne.WidgetRenderer {
func (a *PlaylistPage) Save() SavedPage {
p := a.playlistPageState
p.trackSort = a.tracklist.Sorting()
return &p
}
@@ -117,7 +133,8 @@ func (a *PlaylistPage) load() {
log.Printf("Failed to get playlist: %s", err.Error())
return
}
a.tracklist.Tracks = playlist.Tracks
a.tracks = playlist.Tracks
a.tracklist.SetTracks(playlist.Tracks)
a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.Refresh()
a.header.Update(playlist)
@@ -140,16 +157,24 @@ func (a *PlaylistPage) onMoveSelectedToBottom() {
}
func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
idxs := a.tracklist.SelectedTrackIndexes()
newTracks := sharedutil.ReorderTracks(a.tracklist.Tracks, idxs, op)
ids := make([]string, len(newTracks))
for i, tr := range newTracks {
ids[i] = tr.ID
// 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
ids := a.tracklist.SelectedTrackIDs()
idxs := make([]int, 0, len(ids))
for i, tr := range a.tracks {
if sharedutil.SliceContains(ids, tr.ID) {
idxs = append(idxs, i)
}
}
newTracks := sharedutil.ReorderTracks(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 {
a.tracklist.Tracks = 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.tracklist.Refresh()
}
@@ -199,12 +224,12 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
})
a.editButton.Hidden = true
playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() {
page.pm.LoadTracks(page.tracklist.Tracks, false, false)
page.pm.LoadTracks(page.tracks, false, false)
page.pm.PlayFromBeginning()
})
// TODO: find way to pad shuffle svg rather than using a space in the label string
shuffleBtn := widget.NewButtonWithIcon(" Shuffle", myTheme.ShuffleIcon, func() {
page.pm.LoadTracks(page.tracklist.Tracks, false /*append*/, true /*shuffle*/)
page.pm.LoadTracks(page.tracks, false /*append*/, true /*shuffle*/)
page.pm.PlayFromBeginning()
})
var pop *widget.PopUpMenu
@@ -217,7 +242,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
}),
fyne.NewMenuItem("Add to playlist...", func() {
a.page.contr.DoAddTracksToPlaylistWorkflow(
sharedutil.TracksToIDs(a.page.tracklist.Tracks))
sharedutil.TracksToIDs(a.page.tracks))
}))
pop = widget.NewPopUpMenu(menu, fyne.CurrentApp().Driver().CanvasForObject(a))
}
@@ -280,5 +305,5 @@ func (a *PlaylistPageHeader) formatPlaylistTrackTimeStr(p *mediaprovider.Playlis
}
func (s *playlistPageState) Restore() Page {
return NewPlaylistPage(s.playlistID, s.conf, s.contr, s.sm, s.pm, s.im)
return newPlaylistPage(s.playlistID, s.conf, s.contr, s.sm, s.pm, s.im, s.trackSort)
}
+5 -4
View File
@@ -287,10 +287,11 @@ func NewPlaylistList() *PlaylistList {
func (p *PlaylistList) buildHeader() {
p.header = widgets.NewListHeader([]widgets.ListColumn{
{"Name", false, false},
{"Description", false, false},
{"Owner", false, false},
{"Track Count", true, false}}, p.columnsLayout)
{"Name", fyne.TextAlignLeading, false},
{"Description", fyne.TextAlignLeading, false},
{"Owner", fyne.TextAlignLeading, false},
{"Track Count", fyne.TextAlignTrailing, false}}, p.columnsLayout)
p.header.DisableSorting = true
}
+1
View File
@@ -44,6 +44,7 @@ func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig,
t.ExtendBaseWidget(t)
t.tracklist = widgets.NewTracklist(nil)
t.tracklist.DisableSorting = true
t.tracklist.AutoNumber = true
t.tracklist.SetVisibleColumns(conf.TracklistColumns)
t.tracklist.OnVisibleColumnsChanged = func(cols []string) {
+1 -1
View File
@@ -95,7 +95,7 @@ func (m *Controller) ConnectTracklistActions(tracklist *widgets.Tracklist) {
m.App.PlaybackManager.LoadTracks(tracks, true, false)
}
tracklist.OnPlayTrackAt = func(idx int) {
m.App.PlaybackManager.LoadTracks(tracklist.Tracks, false, false)
m.App.PlaybackManager.LoadTracks(tracklist.GetTracks(), false, false)
m.App.PlaybackManager.PlayTrackAt(idx)
}
tracklist.OnPlaySelection = func(tracks []*mediaprovider.Track, shuffle bool) {
+9 -5
View File
@@ -1,6 +1,8 @@
package layouts
import "fyne.io/fyne/v2"
import (
"fyne.io/fyne/v2"
)
// ColumnsLayout lays out a number of items into columns.
// There are two types of columns: fixed-width and variable width.
@@ -58,11 +60,13 @@ func (c *ColumnsLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
if !objects[i].Visible() {
continue
}
w := objects[i].MinSize().Width
if i < len(c.ColumnWidths) && c.ColumnWidths[i] > w {
w = c.ColumnWidths[i]
} else if c.ColumnWidths[i] < 0 && expandObjW > w {
w = expandObjW
if i >= len(c.ColumnWidths) || c.ColumnWidths[i] < 0 {
// expanding width column
w = fyne.Max(expandObjW, w)
} else {
w = fyne.Max(c.ColumnWidths[i], w)
}
objects[i].Resize(fyne.NewSize(w, size.Height))
objects[i].Move(fyne.NewPos(x, 0))
+42 -11
View File
@@ -2,6 +2,7 @@ package layouts
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
)
@@ -12,47 +13,77 @@ type HboxCustomPadding struct {
DisableThemePad bool
}
func (v *HboxCustomPadding) MinSize(objects []fyne.CanvasObject) fyne.Size {
func (*HboxCustomPadding) isSpacer(obj fyne.CanvasObject) bool {
if !obj.Visible() {
return false
}
if spacer, ok := obj.(layout.SpacerObject); ok {
return spacer.ExpandHorizontal()
}
return false
}
func (h *HboxCustomPadding) MinSize(objects []fyne.CanvasObject) fyne.Size {
minSize := fyne.NewSize(0, 0)
addPadding := false
padding := h.themePad() + h.ExtraPad
for _, child := range objects {
if !child.Visible() {
continue
}
if h.isSpacer(child) {
continue
}
minSize.Height = fyne.Max(child.MinSize().Height, minSize.Height)
minSize.Width += child.MinSize().Width
if addPadding {
minSize.Width += padding
}
addPadding = true
}
minSize.Width += (v.themePad() + v.ExtraPad) * float32(len(objects)-1)
return minSize
}
func (v *HboxCustomPadding) Layout(objects []fyne.CanvasObject, size fyne.Size) {
func (h *HboxCustomPadding) Layout(objects []fyne.CanvasObject, size fyne.Size) {
spacers := 0
total := float32(0)
for _, child := range objects {
if !child.Visible() {
continue
}
if h.isSpacer(child) {
spacers++
continue
}
total += child.MinSize().Width
}
x, y := float32(0), float32(0)
padding := v.themePad() + v.ExtraPad
extra := float32(0)
padding := h.themePad() + h.ExtraPad
extra := size.Width - total - (padding * float32(len(objects)-spacers-1))
extraCell := float32(0)
if spacers > 0 {
extraCell = extra / float32(spacers)
}
for _, child := range objects {
if !child.Visible() {
continue
}
if h.isSpacer(child) {
x += extraCell
}
width := child.MinSize().Width
child.Move(fyne.NewPos(x+extra, y))
x += width
child.Move(fyne.NewPos(x, y))
child.Resize(fyne.NewSize(width, size.Height))
extra += padding
x += padding + width
}
}
func (v *HboxCustomPadding) themePad() float32 {
if v.DisableThemePad {
func (h *HboxCustomPadding) themePad() float32 {
if h.DisableThemePad {
return 0
}
return theme.Padding()
+140 -10
View File
@@ -5,25 +5,44 @@ import (
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
type SortType int
const (
SortNone SortType = iota
SortAscending
SortDescending
)
type ListHeaderSort struct {
ColNumber int
Type SortType
}
type ListColumn struct {
Text string
AlignTrailing bool
Alignment fyne.TextAlign
CanToggleVisible bool
}
type ListHeader struct {
widget.BaseWidget
DisableSorting bool
OnColumnSortChanged func(ListHeaderSort)
OnColumnVisibilityChanged func(int, bool)
OnColumnVisibilityMenuShown func(*widget.PopUp)
sort ListHeaderSort
columns []ListColumn
columnVisible []bool
columnsLayout *layouts.ColumnsLayout
@@ -40,7 +59,7 @@ func NewListHeader(cols []ListColumn, layout *layouts.ColumnsLayout) *ListHeader
columnsContainer: container.New(layout),
}
l.columnVisible = make([]bool, len(cols))
for i, _ := range l.columnVisible {
for i := range l.columnVisible {
l.columnVisible[i] = true
}
l.container = container.NewMax(myTheme.NewThemedRectangle(theme.ColorNameBackground), l.columnsContainer)
@@ -64,15 +83,33 @@ func (l *ListHeader) SetColumnVisible(colNum int, visible bool) {
}
func (l *ListHeader) buildColumns() {
for _, c := range l.columns {
t := widget.NewRichTextWithText(c.Text)
t.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = true
al := fyne.TextAlignLeading
if c.AlignTrailing {
al = fyne.TextAlignTrailing
for i, c := range l.columns {
hdr := newColHeader(c, &l.DisableSorting)
hdr.OnSortChanged = func(i int) func(SortType) {
return func(sort SortType) { l.SetSorting(ListHeaderSort{ColNumber: i, Type: sort}) }
}(i)
hdr.OnTappedSecondary = l.TappedSecondary
l.columnsContainer.Add(hdr)
}
t.Segments[0].(*widget.TextSegment).Style.Alignment = al
l.columnsContainer.Add(t)
}
// Sets the sorting for the ListHeader. Will invoke
// OnColumnSortChanged if set.
func (l *ListHeader) SetSorting(sort ListHeaderSort) {
if l.sort == sort {
return
}
l.sort = sort
for i, c := range l.columnsContainer.Objects {
if i == sort.ColNumber {
c.(*colHeader).Sort = sort.Type
} else {
c.(*colHeader).Sort = SortNone
}
}
l.Refresh()
if l.OnColumnSortChanged != nil {
l.OnColumnSortChanged(sort)
}
}
@@ -119,3 +156,96 @@ func (l *ListHeader) createOnChangedCallbk(colNum int) func(bool) {
}
}
}
type colHeader struct {
widget.BaseWidget
Sort SortType
OnSortChanged func(SortType)
OnTappedSecondary func(*fyne.PointEvent)
sortDisabled *bool
columnCfg ListColumn
label *widget.RichText
sortIcon *widget.Icon
sortIconNegSpacer fyne.CanvasObject
container *fyne.Container
}
func newColHeader(columnCfg ListColumn, sortDisabled *bool) *colHeader {
c := &colHeader{columnCfg: columnCfg, sortDisabled: sortDisabled}
c.ExtendBaseWidget(c)
c.label = widget.NewRichTextWithText(columnCfg.Text)
c.label.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
TextStyle: fyne.TextStyle{Bold: true},
Alignment: columnCfg.Alignment,
}
c.sortIcon = widget.NewIcon(theme.MenuDropDownIcon())
// hack to remove extra icon space
// should be hidden whenever sortIcon is hidden
c.sortIconNegSpacer = util.NewHSpace(0)
return c
}
func (c *colHeader) Tapped(*fyne.PointEvent) {
if *c.sortDisabled {
return
}
switch c.Sort {
case SortNone:
c.Sort = SortAscending
case SortAscending:
c.Sort = SortDescending
case SortDescending:
c.Sort = SortNone
default:
log.Println("notReached colHeader.Tapped")
}
c.Refresh()
if c.OnSortChanged != nil {
c.OnSortChanged(c.Sort)
}
}
func (c *colHeader) TappedSecondary(e *fyne.PointEvent) {
if c.OnTappedSecondary != nil {
c.OnTappedSecondary(e)
}
}
func (c *colHeader) Refresh() {
if c.Sort == SortDescending {
c.sortIcon.Resource = theme.MenuDropDownIcon()
} else {
c.sortIcon.Resource = theme.MenuDropUpIcon()
}
if c.Sort > 0 && c.sortIcon.Hidden {
c.sortIcon.Show()
c.container.Add(c.sortIconNegSpacer)
} else if (c.Sort == SortNone || *c.sortDisabled) && !c.sortIcon.Hidden {
c.sortIcon.Hide()
c.container.Remove(c.sortIconNegSpacer)
}
c.BaseWidget.Refresh()
}
func (c *colHeader) CreateRenderer() fyne.WidgetRenderer {
if c.container == nil {
c.container = container.New(&layouts.HboxCustomPadding{DisableThemePad: true, ExtraPad: -8})
if c.columnCfg.Alignment != fyne.TextAlignLeading {
c.container.Add(layout.NewSpacer())
}
c.container.Add(c.label)
c.container.Add(c.sortIcon)
c.container.Add(c.sortIconNegSpacer)
if c.columnCfg.Alignment == fyne.TextAlignCenter {
c.container.Add(layout.NewSpacer())
}
}
return widget.NewSimpleRenderer(c.container)
}
+169 -82
View File
@@ -3,7 +3,9 @@ package widgets
import (
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"github.com/dweymouth/supersonic/backend/mediaprovider"
@@ -21,6 +23,8 @@ import (
)
const (
ColumnNum = "Num"
ColumnTitle = "Title"
ColumnArtist = "Artist"
ColumnAlbum = "Album"
ColumnTime = "Time"
@@ -33,14 +37,19 @@ const (
ColumnPath = "Path"
)
var columns = []string{
ColumnNum, ColumnTitle, ColumnArtist, ColumnAlbum, ColumnTime, ColumnYear,
ColumnFavorite, ColumnRating, ColumnPlays, ColumnBitrate, ColumnSize, ColumnPath,
}
type TracklistSort struct {
SortOrder SortType
ColumnName string
}
type Tracklist struct {
widget.BaseWidget
// Tracks is the set of tracks displayed by the widget.
// Direct access to this is not thread-safe but OK for
// views that only load tracks into the widget once at page load.
Tracks []*mediaprovider.Track
// AutoNumber sets whether to auto-number the tracks 1..N in display order,
// or to use the number from the track's metadata
AutoNumber bool
@@ -57,6 +66,9 @@ type Tracklist struct {
// the tracklist context menu.
DisablePlaybackMenu bool
// Disables sorting the tracklist by clicking individual columns.
DisableSorting bool
// user action callbacks
OnPlayTrackAt func(int)
OnPlaySelection func(tracks []*mediaprovider.Track, shuffle bool)
@@ -73,8 +85,12 @@ type Tracklist struct {
OnTrackShown func(tracknum int)
visibleColumns []bool
sorting TracklistSort
tracksMutex sync.RWMutex
tracks []*mediaprovider.Track
tracksOrigOrder []*mediaprovider.Track
selectionMgr util.ListSelectionManager
nowPlayingID string
colLayout *layouts.ColumnsLayout
@@ -85,13 +101,14 @@ type Tracklist struct {
}
func NewTracklist(tracks []*mediaprovider.Track) *Tracklist {
t := &Tracklist{Tracks: tracks, visibleColumns: make([]bool, 12)}
t := &Tracklist{tracks: tracks, tracksOrigOrder: tracks, visibleColumns: make([]bool, 12)}
t.ExtendBaseWidget(t)
t.selectionMgr = util.NewListSelectionManager(t.lenTracks)
// #, Title, Artist, Album, Time, Year, Favorite, Rating, Plays, Bitrate, Size, Path
t.colLayout = layouts.NewColumnsLayout([]float32{40, -1, -1, -1, 50, 50, 45, 95, 55, 65, 70, -1})
t.colLayout = layouts.NewColumnsLayout([]float32{40, -1, -1, -1, 60, 60, 55, 100, 65, 75, 75, -1})
t.buildHeader()
t.hdr.OnColumnSortChanged = t.onSorted
t.hdr.OnColumnVisibilityChanged = t.setColumnVisible
t.hdr.OnColumnVisibilityMenuShown = func(pop *widget.PopUp) {
if t.OnColumnVisibilityMenuShown != nil {
@@ -127,18 +144,18 @@ func NewTracklist(tracks []*mediaprovider.Track) *Tracklist {
func (t *Tracklist) buildHeader() {
t.hdr = NewListHeader([]ListColumn{
{Text: "#", AlignTrailing: true, CanToggleVisible: false},
{Text: "Title", AlignTrailing: false, CanToggleVisible: false},
{Text: "Artist", AlignTrailing: false, CanToggleVisible: true},
{Text: "Album", AlignTrailing: false, CanToggleVisible: true},
{Text: "Time", AlignTrailing: true, CanToggleVisible: true},
{Text: "Year", AlignTrailing: true, CanToggleVisible: true},
{Text: "Fav.", AlignTrailing: false, CanToggleVisible: true},
{Text: "Rating", AlignTrailing: false, CanToggleVisible: true},
{Text: "Plays", AlignTrailing: true, CanToggleVisible: true},
{Text: "Bitrate", AlignTrailing: true, CanToggleVisible: true},
{Text: "Size", AlignTrailing: true, CanToggleVisible: true},
{Text: "File Path", AlignTrailing: false, CanToggleVisible: true}},
{Text: "#", Alignment: fyne.TextAlignTrailing, CanToggleVisible: false},
{Text: "Title", Alignment: fyne.TextAlignLeading, CanToggleVisible: false},
{Text: "Artist", Alignment: fyne.TextAlignLeading, CanToggleVisible: true},
{Text: "Album", Alignment: fyne.TextAlignLeading, CanToggleVisible: true},
{Text: "Time", Alignment: fyne.TextAlignTrailing, CanToggleVisible: true},
{Text: "Year", Alignment: fyne.TextAlignTrailing, CanToggleVisible: true},
{Text: " Fav.", Alignment: fyne.TextAlignCenter, CanToggleVisible: true},
{Text: "Rating", Alignment: fyne.TextAlignLeading, CanToggleVisible: true},
{Text: "Plays", Alignment: fyne.TextAlignTrailing, CanToggleVisible: true},
{Text: "Bitrate", Alignment: fyne.TextAlignTrailing, CanToggleVisible: true},
{Text: "Size", Alignment: fyne.TextAlignTrailing, CanToggleVisible: true},
{Text: "File Path", Alignment: fyne.TextAlignLeading, CanToggleVisible: true}},
t.colLayout)
}
@@ -146,10 +163,10 @@ func (t *Tracklist) buildHeader() {
func (t *Tracklist) TrackAt(idx int) *mediaprovider.Track {
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
if idx >= len(t.Tracks) {
if idx >= len(t.tracks) {
return nil
}
return t.Tracks[idx]
return t.tracks[idx]
}
func (t *Tracklist) SetVisibleColumns(cols []string) {
@@ -191,6 +208,22 @@ func (t *Tracklist) setColumnVisible(colNum int, vis bool) {
}
}
func (t *Tracklist) Sorting() TracklistSort {
return t.sorting
}
func (t *Tracklist) SetSorting(sorting TracklistSort) {
if sorting.ColumnName == "" {
// nil case - reset current sort
if sharedutil.SliceContains(columns, t.sorting.ColumnName) {
t.hdr.SetSorting(ListHeaderSort{ColNumber: ColNumber(t.sorting.ColumnName), Type: SortNone})
}
return
}
// actual sorting will be handled in callback from header
t.hdr.SetSorting(ListHeaderSort{ColNumber: ColNumber(sorting.ColumnName), Type: sorting.SortOrder})
}
func (t *Tracklist) SetNowPlaying(trackID string) {
t.nowPlayingID = trackID
t.list.Refresh()
@@ -198,7 +231,7 @@ func (t *Tracklist) SetNowPlaying(trackID string) {
func (t *Tracklist) IncrementPlayCount(trackID string) {
t.tracksMutex.RLock()
tr := sharedutil.FindTrackByID(trackID, t.Tracks)
tr := sharedutil.FindTrackByID(trackID, t.tracks)
t.tracksMutex.RUnlock()
if tr != nil {
tr.PlayCount += 1
@@ -206,19 +239,34 @@ func (t *Tracklist) IncrementPlayCount(trackID string) {
}
}
// Remove all tracks from the tracklist. Thread-safe.
// Remove all tracks from the tracklist. Does not issue Refresh call. Thread-safe.
func (t *Tracklist) Clear() {
t.selectionMgr.UnselectAll()
t.tracksMutex.Lock()
defer t.tracksMutex.Unlock()
t.Tracks = nil
t.tracks = nil
}
// Append more tracks to the tracklist. Thread-safe.
// Sets the tracks in the tracklist. Does not issue Refresh call. Thread-safe.
func (t *Tracklist) SetTracks(trs []*mediaprovider.Track) {
t.tracksMutex.Lock()
defer t.tracksMutex.Unlock()
t.tracksOrigOrder = trs
t.doSortTracks()
}
// Returns the tracks in the tracklist in the current display order.
func (t *Tracklist) GetTracks() []*mediaprovider.Track {
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
return t.tracks
}
// Append more tracks to the tracklist. Does not issue Refresh call. Thread-safe.
func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) {
t.tracksMutex.Lock()
defer t.tracksMutex.Unlock()
t.Tracks = append(t.Tracks, trs...)
t.tracks = append(t.tracks, trs...)
}
func (t *Tracklist) SelectAll() {
@@ -234,7 +282,7 @@ func (t *Tracklist) UnselectAll() {
func (t *Tracklist) SelectAndScrollToTrack(trackID string) {
t.tracksMutex.RLock()
idx := -1
for i, tr := range t.Tracks {
for i, tr := range t.tracks {
if tr.ID == trackID {
idx = i
break
@@ -251,6 +299,86 @@ func (t *Tracklist) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(t.container)
}
func (t *Tracklist) Refresh() {
t.hdr.DisableSorting = t.DisableSorting
t.BaseWidget.Refresh()
}
func (t *Tracklist) stringSort(fieldFn func(*mediaprovider.Track) string) {
new := make([]*mediaprovider.Track, len(t.tracksOrigOrder))
copy(new, t.tracksOrigOrder)
sort.SliceStable(new, func(i, j int) bool {
cmp := strings.Compare(fieldFn(new[i]), fieldFn(new[j]))
if t.sorting.SortOrder == SortDescending {
return cmp > 0
}
return cmp < 0
})
t.tracks = new
}
func (t *Tracklist) intSort(fieldFn func(*mediaprovider.Track) int64) {
new := make([]*mediaprovider.Track, len(t.tracksOrigOrder))
copy(new, t.tracksOrigOrder)
sort.SliceStable(new, func(i, j int) bool {
if t.sorting.SortOrder == SortDescending {
return fieldFn(new[i]) > fieldFn(new[j])
}
return fieldFn(new[i]) < fieldFn(new[j])
})
t.tracks = new
}
func (t *Tracklist) doSortTracks() {
if t.sorting.SortOrder == SortNone {
t.tracks = t.tracksOrigOrder
return
}
switch t.sorting.ColumnName {
case ColumnNum:
if t.sorting.SortOrder == SortDescending {
t.tracks = sharedutil.Reversed(t.tracksOrigOrder)
} else {
t.tracks = t.tracksOrigOrder
}
case ColumnTitle:
t.stringSort(func(tr *mediaprovider.Track) string { return tr.Name })
case ColumnArtist:
t.stringSort(func(tr *mediaprovider.Track) string { return tr.ArtistNames[0] })
case ColumnAlbum:
t.stringSort(func(tr *mediaprovider.Track) string { return tr.Album })
case ColumnPath:
t.stringSort(func(tr *mediaprovider.Track) string { return tr.FilePath })
case ColumnRating:
t.intSort(func(tr *mediaprovider.Track) int64 { return int64(tr.Rating) })
case ColumnTime:
t.intSort(func(tr *mediaprovider.Track) int64 { return int64(tr.Duration) })
case ColumnYear:
t.intSort(func(tr *mediaprovider.Track) int64 { return int64(tr.Year) })
case ColumnSize:
t.intSort(func(tr *mediaprovider.Track) int64 { return tr.Size })
case ColumnPlays:
t.intSort(func(tr *mediaprovider.Track) int64 { return int64(tr.PlayCount) })
case ColumnBitrate:
t.intSort(func(tr *mediaprovider.Track) int64 { return int64(tr.BitRate) })
case ColumnFavorite:
t.intSort(func(tr *mediaprovider.Track) int64 {
if tr.Favorite {
return 1
}
return 0
})
}
}
func (t *Tracklist) onSorted(sort ListHeaderSort) {
t.sorting = TracklistSort{ColumnName: colName(sort.ColNumber), SortOrder: sort.Type}
t.tracksMutex.Lock()
t.doSortTracks()
t.tracksMutex.Unlock()
t.Refresh()
}
func (t *Tracklist) onPlayTrackAt(idx int) {
if t.OnPlayTrackAt != nil {
t.OnPlayTrackAt(idx)
@@ -301,7 +429,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
t.ctxMenu.Items = append(t.ctxMenu.Items,
fyne.NewMenuItem("Add to playlist...", func() {
if t.OnAddToPlaylist != nil {
t.OnAddToPlaylist(t.selectedTrackIDs())
t.OnAddToPlaylist(t.SelectedTrackIDs())
}
}))
t.ctxMenu.Items = append(t.ctxMenu.Items, fyne.NewMenuItemSeparator())
@@ -327,7 +455,7 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
t.tracksMutex.RLock()
tr := sharedutil.FindTrackByID(trackID, t.Tracks)
tr := sharedutil.FindTrackByID(trackID, t.tracks)
t.tracksMutex.RUnlock()
t.onSetFavorites([]*mediaprovider.Track{tr}, fav, false)
}
@@ -348,7 +476,7 @@ func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, need
func (t *Tracklist) onSetRating(trackID string, rating int) {
// update our own track model
t.tracksMutex.RLock()
tr := sharedutil.FindTrackByID(trackID, t.Tracks)
tr := sharedutil.FindTrackByID(trackID, t.tracks)
t.tracksMutex.RUnlock()
t.onSetRatings([]*mediaprovider.Track{tr}, rating, false)
}
@@ -384,18 +512,18 @@ func (t *Tracklist) selectedTracks() []*mediaprovider.Track {
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
for _, idx := range sel {
tracks = append(tracks, t.Tracks[idx])
tracks = append(tracks, t.tracks[idx])
}
return tracks
}
func (t *Tracklist) selectedTrackIDs() []string {
func (t *Tracklist) SelectedTrackIDs() []string {
sel := t.selectionMgr.GetSelection()
tracks := make([]string, 0, len(sel))
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
for _, idx := range sel {
tracks = append(tracks, t.Tracks[idx].ID)
tracks = append(tracks, t.tracks[idx].ID)
}
return tracks
}
@@ -407,64 +535,23 @@ func (t *Tracklist) SelectedTrackIndexes() []int {
func (t *Tracklist) lenTracks() int {
t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock()
return len(t.Tracks)
return len(t.tracks)
}
func ColNumber(colName string) int {
// built-in columns # and Title are always visible
switch colName {
case ColumnArtist:
return 2
case ColumnAlbum:
return 3
case ColumnTime:
return 4
case ColumnYear:
return 5
case ColumnFavorite:
return 6
case ColumnRating:
return 7
case ColumnPlays:
return 8
case ColumnBitrate:
return 9
case ColumnSize:
return 10
case ColumnPath:
return 11
default:
i := sharedutil.IndexOf(columns, colName)
if i < 0 {
log.Printf("error: Tracklist: invalid column name %s", colName)
return -100
}
return i
}
func colName(i int) string {
// built-in columns # and Title are always visible
switch i {
case 2:
return ColumnArtist
case 3:
return ColumnAlbum
case 4:
return ColumnTime
case 5:
return ColumnYear
case 6:
return ColumnFavorite
case 7:
return ColumnRating
case 8:
return ColumnPlays
case 9:
return ColumnBitrate
case 10:
return ColumnSize
case 11:
return ColumnPath
default:
return ""
if i < len(columns) {
return columns[i]
}
log.Println("notReached: Tracklist.colName")
return ""
}
type TrackRow struct {