diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index d01268c..d7ad92b 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -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 { diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index a21e51a..878e60b 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -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) } diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index cf31b1e..6fb2596 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -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 { diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 8525e4f..a4abaad 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -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 { diff --git a/ui/browsing/genrespage.go b/ui/browsing/genrespage.go index f8ce5f5..67f6ce1 100644 --- a/ui/browsing/genrespage.go +++ b/ui/browsing/genrespage.go @@ -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 { diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index ff1f0b4..46b121e 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -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) diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 710afa9..1f14404 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -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) } diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index eba154a..394eac0 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -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 } diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index d8a94bc..2c21197 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -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) { diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 5e84a6c..2f0fb03 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -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) { diff --git a/ui/layouts/columnslayout.go b/ui/layouts/columnslayout.go index 4cd9089..9ae141f 100644 --- a/ui/layouts/columnslayout.go +++ b/ui/layouts/columnslayout.go @@ -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)) diff --git a/ui/layouts/hboxcustompadding.go b/ui/layouts/hboxcustompadding.go index ddc71c5..6005d35 100644 --- a/ui/layouts/hboxcustompadding.go +++ b/ui/layouts/hboxcustompadding.go @@ -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() diff --git a/ui/widgets/listheader.go b/ui/widgets/listheader.go index 41aa076..7f3d418 100644 --- a/ui/widgets/listheader.go +++ b/ui/widgets/listheader.go @@ -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) + } +} + +// 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 } - t.Segments[0].(*widget.TextSegment).Style.Alignment = al - l.columnsContainer.Add(t) + } + 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) +} diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 7c583cf..866386c 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -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 - tracksMutex sync.RWMutex 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 {