diff --git a/backend/albumiterator.go b/backend/albumiterator.go
index d1d7cfa..70c7b05 100644
--- a/backend/albumiterator.go
+++ b/backend/albumiterator.go
@@ -3,8 +3,10 @@ package backend
import (
"log"
"strconv"
+ "strings"
"github.com/dweymouth/go-subsonic/subsonic"
+ "github.com/dweymouth/supersonic/sharedutil"
)
type AlbumSortOrder string
@@ -33,43 +35,81 @@ var (
}
)
-func (l *LibraryManager) AlbumsIter(sort AlbumSortOrder) AlbumIterator {
+type AlbumFilter struct {
+ MinYear int
+ MaxYear int // 0 == unset/match any
+ Genres []string // len(0) == unset/match any
+
+ ExcludeFavorited bool // mut. exc. with ExcludeUnfavorited
+ ExcludeUnfavorited bool // mut. exc. with ExcludeFavorited
+}
+
+func (f *AlbumFilter) Matches(album *subsonic.AlbumID3) bool {
+ if album == nil {
+ return false
+ }
+ if f.ExcludeFavorited && !album.Starred.IsZero() {
+ return false
+ }
+ if f.ExcludeUnfavorited && album.Starred.IsZero() {
+ return false
+ }
+ if y := album.Year; y < f.MinYear || (f.MaxYear > 0 && y > f.MaxYear) {
+ return false
+ }
+ if len(f.Genres) == 0 {
+ return true
+ }
+ for _, g := range f.Genres {
+ if strings.EqualFold(g, album.Genre) {
+ return true
+ }
+ }
+ return false
+}
+
+func (f *AlbumFilter) IsEmpty() bool {
+ return !f.ExcludeFavorited && !f.ExcludeUnfavorited &&
+ f.MinYear == 0 && f.MaxYear == 0 && len(f.Genres) == 0
+}
+
+func (l *LibraryManager) AlbumsIter(sort AlbumSortOrder, filter AlbumFilter) AlbumIterator {
switch sort {
case AlbumSortRecentlyAdded:
- return l.newBaseIter("newest", make(map[string]string))
+ return l.newBaseIter("newest", filter, make(map[string]string))
case AlbumSortRecentlyPlayed:
- return l.newBaseIter("recent", make(map[string]string))
+ return l.newBaseIter("recent", filter, make(map[string]string))
case AlbumSortFrequentlyPlayed:
- return l.newBaseIter("frequent", make(map[string]string))
+ return l.newBaseIter("frequent", filter, make(map[string]string))
case AlbumSortRandom:
return l.newRandomIter()
case AlbumSortTitleAZ:
- return l.newBaseIter("alphabeticalByName", make(map[string]string))
+ return l.newBaseIter("alphabeticalByName", filter, make(map[string]string))
case AlbumSortArtistAZ:
- return l.newBaseIter("alphabeticalByArtist", make(map[string]string))
+ return l.newBaseIter("alphabeticalByArtist", filter, make(map[string]string))
case AlbumSortYearAscending:
- return l.newBaseIter("byYear", map[string]string{"fromYear": "0", "toYear": "3000"})
+ return l.newBaseIter("byYear", filter, map[string]string{"fromYear": "0", "toYear": "3000"})
case AlbumSortYearDescending:
- return l.newBaseIter("byYear", map[string]string{"fromYear": "3000", "toYear": "0"})
+ return l.newBaseIter("byYear", filter, map[string]string{"fromYear": "3000", "toYear": "0"})
default:
log.Printf("Undefined album sort order: %s", sort)
return nil
}
}
-func (l *LibraryManager) StarredIter() AlbumIterator {
- return l.newBaseIter("starred", make(map[string]string))
+func (l *LibraryManager) StarredIter(filter AlbumFilter) AlbumIterator {
+ return l.newBaseIter("starred", filter, make(map[string]string))
}
-func (l *LibraryManager) GenreIter(genre string) AlbumIterator {
- return l.newBaseIter("byGenre", map[string]string{"genre": genre})
+func (l *LibraryManager) GenreIter(genre string, filter AlbumFilter) AlbumIterator {
+ return l.newBaseIter("byGenre", filter, map[string]string{"genre": genre})
}
func (l *LibraryManager) SearchIter(query string) AlbumIterator {
- return l.newSearchIter(query, func(*subsonic.AlbumID3) bool { return true })
+ return l.newSearchIter(query, AlbumFilter{})
}
-func (l *LibraryManager) SearchIterWithFilter(query string, filter func(*subsonic.AlbumID3) bool) AlbumIterator {
+func (l *LibraryManager) SearchIterWithFilter(query string, filter AlbumFilter) AlbumIterator {
return l.newSearchIter(query, filter)
}
@@ -83,7 +123,8 @@ func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) {
type baseIter struct {
listType string
- pos int
+ filter AlbumFilter
+ serverPos int
l *LibraryManager
s *subsonic.Client
opts map[string]string
@@ -92,9 +133,10 @@ type baseIter struct {
done bool
}
-func (l *LibraryManager) newBaseIter(listType string, opts map[string]string) *baseIter {
+func (l *LibraryManager) newBaseIter(listType string, filter AlbumFilter, opts map[string]string) *baseIter {
return &baseIter{
listType: listType,
+ filter: filter,
l: l,
s: l.s.Server,
opts: opts,
@@ -105,35 +147,33 @@ func (r *baseIter) Next() *subsonic.AlbumID3 {
if r.done {
return nil
}
- if r.prefetched != nil {
+ if r.prefetched != nil && r.prefetchedPos < len(r.prefetched) {
a := r.prefetched[r.prefetchedPos]
r.prefetchedPos++
- if r.prefetchedPos == len(r.prefetched) {
- r.prefetched = nil
- r.prefetchedPos = 0
- r.pos++
- }
- r.pos++
-
return a
}
- r.opts["offset"] = strconv.Itoa(r.pos)
- albums, err := r.s.GetAlbumList2(r.listType, r.opts)
- if err != nil {
- log.Println(err)
- albums = nil
+ r.prefetched = nil
+ for { // keep fetching until we are done or have mathcing results
+ r.opts["offset"] = strconv.Itoa(r.serverPos)
+ albums, err := r.s.GetAlbumList2(r.listType, r.opts)
+ if err != nil {
+ log.Printf("error fetching albums: %s", err.Error())
+ albums = nil
+ }
+ if len(albums) == 0 {
+ r.done = true
+ return nil
+ }
+ r.serverPos += len(albums)
+ albums = sharedutil.FilterSlice(albums, r.filter.Matches)
+ r.prefetched = albums
+ if len(albums) > 0 {
+ break
+ }
}
- if len(albums) == 0 {
- r.done = true
- return nil
- } else if len(albums) == 1 {
- r.done = true
- return albums[0]
- }
- r.prefetched = albums
r.prefetchedPos = 1
if r.l.PreCacheCoverFn != nil {
- for _, album := range albums {
+ for _, album := range r.prefetched {
go r.l.PreCacheCoverFn(album.CoverArt)
}
}
@@ -145,14 +185,14 @@ type searchIter struct {
searchIterBase
l *LibraryManager
- filter func(*subsonic.AlbumID3) bool
+ filter AlbumFilter
prefetched []*subsonic.AlbumID3
prefetchedPos int
albumIDset map[string]bool
done bool
}
-func (l *LibraryManager) newSearchIter(query string, filter func(*subsonic.AlbumID3) bool) *searchIter {
+func (l *LibraryManager) newSearchIter(query string, filter AlbumFilter) *searchIter {
return &searchIter{
searchIterBase: searchIterBase{
query: query,
@@ -228,7 +268,7 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) {
if _, have := s.albumIDset[album.ID]; have {
continue
}
- if !s.filter(album) {
+ if !s.filter.Matches(album) {
continue
}
s.prefetched = append(s.prefetched, album)
diff --git a/backend/trackiterator.go b/backend/trackiterator.go
index 02ccefa..ef9fc98 100644
--- a/backend/trackiterator.go
+++ b/backend/trackiterator.go
@@ -9,7 +9,7 @@ import (
func (l *LibraryManager) AllTracksIterator() TrackIterator {
return &allTracksIterator{
l: l,
- albumIter: l.AlbumsIter(AlbumSortArtistAZ),
+ albumIter: l.AlbumsIter(AlbumSortArtistAZ, AlbumFilter{}),
}
}
diff --git a/go.mod b/go.mod
index 9d85482..9f2b521 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
fyne.io/fyne/v2 v2.3.4
github.com/20after4/configdir v0.1.1
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
- github.com/dweymouth/go-subsonic v0.0.0-20230507163800-c92892982103
+ github.com/dweymouth/go-subsonic v0.0.0-20230513020020-0790f53c2868
github.com/google/uuid v1.3.0
github.com/pelletier/go-toml v1.9.3
github.com/zalando/go-keyring v0.2.1
diff --git a/go.sum b/go.sum
index fcb1a34..06cece0 100644
--- a/go.sum
+++ b/go.sum
@@ -74,8 +74,8 @@ github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230505012127-ca61c153b2a5 h1:uXbHzg9
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230505012127-ca61c153b2a5/go.mod h1:X2+NrR+62mvAiAt2fwKT7035zQsE77KVV1NlvWo4vW8=
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
-github.com/dweymouth/go-subsonic v0.0.0-20230507163800-c92892982103 h1:tjDrQ5yy3YQA/sAnl9d40dhO/24LP8/jmHZc3KNpyJ0=
-github.com/dweymouth/go-subsonic v0.0.0-20230507163800-c92892982103/go.mod h1:fUez6NFiEJiQTZizZ1BThZr5GJXAbigzGYjEPNm4tdI=
+github.com/dweymouth/go-subsonic v0.0.0-20230513020020-0790f53c2868 h1:403Dden/cdQyDM8ydonHLxTBPjgQXZ76PUfmBG92+Dw=
+github.com/dweymouth/go-subsonic v0.0.0-20230513020020-0790f53c2868/go.mod h1:fUez6NFiEJiQTZizZ1BThZr5GJXAbigzGYjEPNm4tdI=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
diff --git a/res/bundled.go b/res/bundled.go
index 4bf4716..c7d7800 100644
--- a/res/bundled.go
+++ b/res/bundled.go
@@ -140,6 +140,11 @@ var ResListSvg = &fyne.StaticResource{
StaticContent: []byte(
"\r"),
}
+var ResFilterSvg = &fyne.StaticResource{
+ StaticName: "filter.svg",
+ StaticContent: []byte(
+ "\r\n"),
+}
var ResLICENSE = &fyne.StaticResource{
StaticName: "LICENSE",
StaticContent: []byte(
diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh
index 3bb5b19..f2e3cb3 100755
--- a/res/bundled_gen.sh
+++ b/res/bundled_gen.sh
@@ -27,6 +27,7 @@ fyne bundle -append -prefix Res icons/publicdomain/star-outline.svg >> bundled.g
fyne bundle -append -prefix Res icons/publicdomain/star-filled.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/grid.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/list.svg >> bundled.go
+fyne bundle -append -prefix Res icons/publicdomain/filter.svg >> bundled.go
fyne bundle -append -prefix Res ../LICENSE >> bundled.go
fyne bundle -append -prefix Res licenses/BSDLICENSE >> bundled.go
diff --git a/res/icons/publicdomain/filter.svg b/res/icons/publicdomain/filter.svg
new file mode 100644
index 0000000..c16beb3
--- /dev/null
+++ b/res/icons/publicdomain/filter.svg
@@ -0,0 +1,9 @@
+
diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go
index 8502cde..d07098c 100644
--- a/sharedutil/sharedutil.go
+++ b/sharedutil/sharedutil.go
@@ -17,6 +17,9 @@ func SliceContains[T comparable](ts []T, t T) bool {
}
func FilterSlice[T any](ss []T, test func(T) bool) []T {
+ if ss == nil {
+ return nil
+ }
result := make([]T, 0)
for _, s := range ss {
if test(s) {
@@ -27,6 +30,9 @@ func FilterSlice[T any](ss []T, test func(T) bool) []T {
}
func MapSlice[T any, U any](ts []T, f func(T) U) []U {
+ if ts == nil {
+ return nil
+ }
result := make([]U, len(ts))
for i, t := range ts {
result[i] = f(t)
diff --git a/ui/browsing/albumspage.go b/ui/browsing/albumspage.go
index 5faf155..11e2c25 100644
--- a/ui/browsing/albumspage.go
+++ b/ui/browsing/albumspage.go
@@ -27,7 +27,9 @@ type AlbumsPage struct {
grid *widgets.GridView
searchGrid *widgets.GridView
searcher *widgets.SearchEntry
+ filterBtn *widgets.AlbumFilterButton
searchText string
+ filter backend.AlbumFilter
titleDisp *widget.RichText
sortOrder *selectWidget
container *fyne.Container
@@ -71,16 +73,23 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
cfg.SortOrder = string(backend.AlbumSortRecentlyAdded)
}
a.sortOrder.Selected = cfg.SortOrder
- iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected))
+ iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected), a.filter)
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
contr.ConnectAlbumGridActions(a.grid)
- a.searcher = widgets.NewSearchEntry()
- a.searcher.OnSearched = a.OnSearched
+ a.createSearchAndFilter()
a.createContainer(false)
return a
}
+func (a *AlbumsPage) createSearchAndFilter() {
+ a.searcher = widgets.NewSearchEntry()
+ a.searcher.Text = a.searchText
+ a.searcher.OnSearched = a.OnSearched
+ a.filterBtn = widgets.NewAlbumFilterButton(&a.filter)
+ a.filterBtn.OnChanged = a.Reload
+}
+
func (a *AlbumsPage) createContainer(searchgrid bool) {
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
sortVbox := container.NewVBox(layout.NewSpacer(), a.sortOrder, layout.NewSpacer())
@@ -89,7 +98,7 @@ func (a *AlbumsPage) createContainer(searchgrid bool) {
g = a.searchGrid
}
a.container = container.NewBorder(
- container.NewHBox(util.NewHSpace(6), a.titleDisp, sortVbox, layout.NewSpacer(), searchVbox, util.NewHSpace(12)),
+ container.NewHBox(util.NewHSpace(6), a.titleDisp, sortVbox, layout.NewSpacer(), container.NewCenter(a.filterBtn), searchVbox, util.NewHSpace(12)),
nil,
nil,
nil,
@@ -99,11 +108,13 @@ func (a *AlbumsPage) createContainer(searchgrid bool) {
func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
a := &AlbumsPage{
- cfg: saved.cfg,
- contr: saved.contr,
- pm: saved.pm,
- lm: saved.lm,
- im: saved.im,
+ cfg: saved.cfg,
+ contr: saved.contr,
+ pm: saved.pm,
+ lm: saved.lm,
+ im: saved.im,
+ searchText: saved.searchText,
+ filter: saved.filter,
}
a.ExtendBaseWidget(a)
@@ -115,13 +126,10 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
a.sortOrder.Selected = saved.sortOrder
a.sortOrder.OnChanged = a.onSortOrderChanged
a.grid = widgets.NewGridViewFromState(saved.gridState)
- a.searcher = widgets.NewSearchEntry()
- a.searcher.OnSearched = a.OnSearched
- a.searcher.Entry.Text = saved.searchText
- a.searchText = saved.searchText
if a.searchText != "" {
a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
}
+ a.createSearchAndFilter()
a.createContainer(saved.searchText != "")
return a
@@ -154,7 +162,7 @@ func (a *AlbumsPage) Reload() {
if a.searchText != "" {
a.doSearch(a.searchText)
} else {
- iter := a.lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected))
+ iter := a.lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected), a.filter)
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
a.grid.Refresh()
}
@@ -168,6 +176,7 @@ func (a *AlbumsPage) Save() SavedPage {
lm: a.lm,
im: a.im,
searchText: a.searchText,
+ filter: a.filter,
sortOrder: a.sortOrder.Selected,
gridState: a.grid.SaveToState(),
}
@@ -182,7 +191,7 @@ func (a *AlbumsPage) doSearch(query string) {
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)), a.im)
a.contr.ConnectAlbumGridActions(a.searchGrid)
} else {
- a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)))
+ a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(a.lm.SearchIterWithFilter(query, a.filter)))
}
a.container.Objects[0] = a.searchGrid
a.Refresh()
@@ -190,7 +199,7 @@ func (a *AlbumsPage) doSearch(query string) {
func (a *AlbumsPage) onSortOrderChanged(order string) {
a.cfg.SortOrder = a.sortOrder.Selected
- iter := a.lm.AlbumsIter(backend.AlbumSortOrder(order))
+ iter := a.lm.AlbumsIter(backend.AlbumSortOrder(order), a.filter)
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
if a.searchText == "" {
a.container.Objects[0] = a.grid
@@ -205,6 +214,7 @@ func (a *AlbumsPage) CreateRenderer() fyne.WidgetRenderer {
type savedAlbumsPage struct {
searchText string
+ filter backend.AlbumFilter
cfg *backend.AlbumsPageConfig
contr *controller.Controller
pm *backend.PlaybackManager
diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go
index 3e836b3..41a0027 100644
--- a/ui/browsing/favoritespage.go
+++ b/ui/browsing/favoritespage.go
@@ -2,7 +2,6 @@ package browsing
import (
"log"
- "time"
"github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/ui/controller"
@@ -30,6 +29,7 @@ type FavoritesPage struct {
sm *backend.ServerManager
lm *backend.LibraryManager
+ filter backend.AlbumFilter
searchText string
nowPlayingID string
pendingViewSwitch bool
@@ -39,6 +39,7 @@ type FavoritesPage struct {
artistListCtr *fyne.Container
tracklistCtr *fyne.Container
searcher *widgets.SearchEntry
+ filterBtn *widgets.AlbumFilterButton
titleDisp *widget.RichText
toggleBtns *widgets.ToggleButtonGroup
container *fyne.Container
@@ -46,16 +47,18 @@ type FavoritesPage struct {
func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Controller, sm *backend.ServerManager, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *FavoritesPage {
a := &FavoritesPage{
- cfg: cfg,
- contr: contr,
- pm: pm,
- lm: lm,
- sm: sm,
- im: im,
+ filter: backend.AlbumFilter{ExcludeUnfavorited: true},
+ cfg: cfg,
+ contr: contr,
+ pm: pm,
+ lm: lm,
+ sm: sm,
+ im: im,
}
a.ExtendBaseWidget(a)
- a.createHeader(0, "")
- a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(lm.StarredIter()), a.im)
+ a.createHeader(0)
+ iter := lm.StarredIter(a.filter)
+ a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
a.contr.ConnectAlbumGridActions(a.grid)
if cfg.InitialView == "Artists" {
a.toggleBtns.SetActivatedButton(1)
@@ -69,7 +72,7 @@ func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Contro
return a
}
-func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
+func (a *FavoritesPage) createHeader(activeBtnIdx int) {
a.titleDisp = widget.NewRichTextWithText("Favorites")
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
SizeName: theme.SizeNameHeadingText,
@@ -80,27 +83,32 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
widget.NewButtonWithIcon("", myTheme.TracksIcon, a.onShowFavoriteSongs))
a.searcher = widgets.NewSearchEntry()
a.searcher.OnSearched = a.OnSearched
- a.searcher.Entry.Text = searchText
+ a.searcher.Entry.Text = a.searchText
+ a.filterBtn = widgets.NewAlbumFilterButton(&a.filter)
+ a.filterBtn.FavoriteDisabled = true
+ a.filterBtn.OnChanged = a.Reload
}
func (a *FavoritesPage) createContainer(initialView fyne.CanvasObject) {
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
- a.container = container.NewBorder(
- container.NewHBox(util.NewHSpace(9), a.titleDisp, container.NewCenter(a.toggleBtns), layout.NewSpacer(), searchVbox, util.NewHSpace(15)),
+ a.container = container.NewBorder(container.NewHBox(util.NewHSpace(9),
+ a.titleDisp, container.NewCenter(a.toggleBtns), layout.NewSpacer(), container.NewCenter(a.filterBtn), searchVbox, util.NewHSpace(15)),
nil, nil, nil, initialView)
}
func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
a := &FavoritesPage{
- cfg: saved.cfg,
- contr: saved.contr,
- pm: saved.pm,
- lm: saved.lm,
- sm: saved.sm,
- im: saved.im,
+ cfg: saved.cfg,
+ contr: saved.contr,
+ pm: saved.pm,
+ lm: saved.lm,
+ sm: saved.sm,
+ im: saved.im,
+ searchText: saved.searchText,
+ filter: saved.filter,
}
a.ExtendBaseWidget(a)
- a.createHeader(saved.activeToggleBtn, saved.searchText)
+ a.createHeader(saved.activeToggleBtn)
a.grid = widgets.NewGridViewFromState(saved.gridState)
if saved.searchText != "" {
@@ -129,7 +137,8 @@ func (a *FavoritesPage) Reload() {
if a.searchText != "" {
a.doSearchAlbums(a.searchText)
} else {
- a.grid.Reset(widgets.NewGridViewAlbumIterator(a.lm.StarredIter()))
+ iter := a.lm.StarredIter(a.filter)
+ a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
}
if a.tracklistCtr != nil || a.artistListCtr != nil {
go func() {
@@ -169,6 +178,7 @@ func (a *FavoritesPage) Save() SavedPage {
sm: a.sm,
im: a.im,
lm: a.lm,
+ filter: a.filter,
searchText: a.searchText,
gridState: a.grid.SaveToState(),
activeToggleBtn: a.toggleBtns.ActivatedButtonIndex(),
@@ -219,9 +229,7 @@ func (a *FavoritesPage) SelectAll() {
}
func (a *FavoritesPage) doSearchAlbums(query string) {
- iter := a.lm.SearchIterWithFilter(query, func(al *subsonic.AlbumID3) bool {
- return al.Starred.After(time.Time{})
- })
+ iter := a.lm.SearchIterWithFilter(query, a.filter)
if a.searchGrid == nil {
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
a.contr.ConnectAlbumGridActions(a.searchGrid)
@@ -235,6 +243,7 @@ func (a *FavoritesPage) doSearchAlbums(query string) {
func (a *FavoritesPage) onShowFavoriteAlbums() {
a.cfg.InitialView = "Albums" // save setting
a.searcher.Entry.Show()
+ a.filterBtn.Show()
if a.searchText == "" {
a.container.Objects[0] = a.grid
} else {
@@ -246,6 +255,7 @@ func (a *FavoritesPage) onShowFavoriteAlbums() {
func (a *FavoritesPage) onShowFavoriteArtists() {
a.cfg.InitialView = "Artists" // save setting
a.searcher.Entry.Hide() // disable search on artists for now
+ a.filterBtn.Hide()
if a.artistListCtr == nil {
if a.pendingViewSwitch {
return
@@ -294,6 +304,7 @@ func buildArtistListModel(artists []*subsonic.ArtistID3) []widgets.ArtistGenreLi
func (a *FavoritesPage) onShowFavoriteSongs() {
a.cfg.InitialView = "Songs" // save setting
a.searcher.Entry.Hide() // disable search on songs for now
+ a.filterBtn.Hide()
if a.tracklistCtr == nil {
if a.pendingViewSwitch {
return
@@ -343,6 +354,7 @@ type savedFavoritesPage struct {
lm *backend.LibraryManager
gridState widgets.GridViewState
searchGridState widgets.GridViewState
+ filter backend.AlbumFilter
searchText string
activeToggleBtn int
}
diff --git a/ui/browsing/genrepage.go b/ui/browsing/genrepage.go
index 0b8ee76..f53de6e 100644
--- a/ui/browsing/genrepage.go
+++ b/ui/browsing/genrepage.go
@@ -12,8 +12,6 @@ import (
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
-
- "github.com/dweymouth/go-subsonic/subsonic"
)
// TODO: there is a lot of code duplication between this and albumspage. Refactor?
@@ -29,6 +27,8 @@ type GenrePage struct {
searchGrid *widgets.GridView
searcher *widgets.SearchEntry
searchText string
+ filter backend.AlbumFilter
+ filterBtn *widgets.AlbumFilterButton
titleDisp *widget.RichText
playRandom *widget.Button
@@ -39,11 +39,12 @@ type GenrePage struct {
func NewGenrePage(genre string, contr *controller.Controller, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *GenrePage {
g := &GenrePage{
- genre: genre,
- contr: contr,
- pm: pm,
- lm: lm,
- im: im,
+ genre: genre,
+ filter: backend.AlbumFilter{Genres: []string{genre}},
+ contr: contr,
+ pm: pm,
+ lm: lm,
+ im: im,
}
g.ExtendBaseWidget(g)
@@ -52,16 +53,24 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba
SizeName: theme.SizeNameHeadingText,
}
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
- iter := g.lm.GenreIter(g.genre)
+ iter := g.lm.GenreIter(g.genre, g.filter)
g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
g.contr.ConnectAlbumGridActions(g.grid)
- g.searcher = widgets.NewSearchEntry()
- g.searcher.OnSearched = g.OnSearched
+ g.createSearchAndFilter()
g.createContainer(false)
return g
}
+func (g *GenrePage) createSearchAndFilter() {
+ g.searcher = widgets.NewSearchEntry()
+ g.searcher.Text = g.searchText
+ g.searcher.OnSearched = g.OnSearched
+ g.filterBtn = widgets.NewAlbumFilterButton(&g.filter)
+ g.filterBtn.GenreDisabled = true
+ g.filterBtn.OnChanged = g.Reload
+}
+
func (g *GenrePage) createContainer(searchGrid bool) {
searchVbox := container.NewVBox(layout.NewSpacer(), g.searcher, layout.NewSpacer())
gr := g.grid
@@ -70,21 +79,21 @@ func (g *GenrePage) createContainer(searchGrid bool) {
}
playRandomVbox := container.NewVBox(layout.NewSpacer(), g.playRandom, layout.NewSpacer())
g.container = container.NewBorder(
- container.NewHBox(util.NewHSpace(6), g.titleDisp, playRandomVbox, layout.NewSpacer(), searchVbox, util.NewHSpace(15)),
- nil,
- nil,
- nil,
- gr,
+ container.NewHBox(util.NewHSpace(6),
+ g.titleDisp, playRandomVbox, layout.NewSpacer(), container.NewCenter(g.filterBtn), searchVbox, util.NewHSpace(15)),
+ nil, nil, nil, gr,
)
}
func restoreGenrePage(saved *savedGenrePage) *GenrePage {
g := &GenrePage{
- genre: saved.genre,
- contr: saved.contr,
- pm: saved.pm,
- lm: saved.lm,
- im: saved.im,
+ genre: saved.genre,
+ contr: saved.contr,
+ pm: saved.pm,
+ lm: saved.lm,
+ im: saved.im,
+ searchText: saved.searchText,
+ filter: saved.filter,
}
g.ExtendBaseWidget(g)
@@ -94,10 +103,7 @@ func restoreGenrePage(saved *savedGenrePage) *GenrePage {
}
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
g.grid = widgets.NewGridViewFromState(saved.gridState)
- g.searcher = widgets.NewSearchEntry()
- g.searcher.OnSearched = g.OnSearched
- g.searcher.Entry.Text = saved.searchText
- g.searchText = saved.searchText
+ g.createSearchAndFilter()
if g.searchText != "" {
g.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
}
@@ -118,7 +124,8 @@ func (g *GenrePage) Reload() {
if g.searchText != "" {
g.doSearch(g.searchText)
} else {
- g.grid.Reset(widgets.NewGridViewAlbumIterator(g.lm.GenreIter(g.genre)))
+ iter := g.lm.GenreIter(g.genre, g.filter)
+ g.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
g.grid.Refresh()
}
}
@@ -126,6 +133,7 @@ func (g *GenrePage) Reload() {
func (g *GenrePage) Save() SavedPage {
sg := &savedGenrePage{
genre: g.genre,
+ filter: g.filter,
searchText: g.searchText,
contr: g.contr,
pm: g.pm,
@@ -159,9 +167,7 @@ func (g *GenrePage) OnSearched(query string) {
}
func (g *GenrePage) doSearch(query string) {
- iter := g.lm.SearchIterWithFilter(query, func(al *subsonic.AlbumID3) bool {
- return al.Genre == g.genre
- })
+ iter := g.lm.SearchIterWithFilter(query, g.filter)
if g.searchGrid == nil {
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
g.contr.ConnectAlbumGridActions(g.searchGrid)
@@ -179,6 +185,7 @@ func (g *GenrePage) playRandomSongs() {
type savedGenrePage struct {
genre string
searchText string
+ filter backend.AlbumFilter
contr *controller.Controller
pm *backend.PlaybackManager
lm *backend.LibraryManager
diff --git a/ui/dialogs/settingsdialog.go b/ui/dialogs/settingsdialog.go
index fbb444e..0e8dbc2 100644
--- a/ui/dialogs/settingsdialog.go
+++ b/ui/dialogs/settingsdialog.go
@@ -113,8 +113,8 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
// Scrobble settings
- twoDigitValidator := func(text string, r rune) bool {
- return unicode.IsDigit(r) && len(text) < 2
+ twoDigitValidator := func(text, selText string, r rune) bool {
+ return unicode.IsDigit(r) && len(text)-len(selText) < 2
}
percentEntry := widgets.NewTextRestrictedEntry(twoDigitValidator)
@@ -247,7 +247,7 @@ func (s *SettingsDialog) createPlaybackTab() *container.TabItem {
replayGainSelect.SetSelectedIndex(0)
}
- preampGain := widgets.NewTextRestrictedEntry(func(curText string, r rune) bool {
+ preampGain := widgets.NewTextRestrictedEntry(func(curText, _ string, r rune) bool {
return (curText == "" && r == '-') ||
(curText == "" && unicode.IsDigit(r)) ||
((curText == "-" || curText == "0") && unicode.IsDigit(r))
diff --git a/ui/theme/theme.go b/ui/theme/theme.go
index a865d4f..b237c72 100644
--- a/ui/theme/theme.go
+++ b/ui/theme/theme.go
@@ -121,6 +121,7 @@ var (
PlaylistIcon fyne.Resource
ShuffleIcon fyne.Resource
TracksIcon fyne.Resource
+ FilterIcon fyne.Resource = theme.NewThemedResource(res.ResFilterSvg)
)
// MUST be called at startup!
diff --git a/ui/widgets/albumfilterbutton.go b/ui/widgets/albumfilterbutton.go
new file mode 100644
index 0000000..0fc59b2
--- /dev/null
+++ b/ui/widgets/albumfilterbutton.go
@@ -0,0 +1,164 @@
+package widgets
+
+import (
+ "strconv"
+ "time"
+ "unicode"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/layout"
+ "fyne.io/fyne/v2/widget"
+ "github.com/dweymouth/supersonic/backend"
+ "github.com/dweymouth/supersonic/ui/theme"
+ "github.com/dweymouth/supersonic/ui/util"
+)
+
+type AlbumFilterButton struct {
+ widget.Button
+
+ OnChanged func()
+ GenreDisabled bool
+ FavoriteDisabled bool
+
+ filter *backend.AlbumFilter
+ dialog *widget.PopUp
+}
+
+func NewAlbumFilterButton(filter *backend.AlbumFilter) *AlbumFilterButton {
+ a := &AlbumFilterButton{
+ filter: filter,
+ Button: widget.Button{
+ Icon: theme.FilterIcon,
+ },
+ }
+ a.OnTapped = a.showFilterDialog
+ a.ExtendBaseWidget(a)
+ return a
+}
+
+func (a *AlbumFilterButton) Refresh() {
+ if a.filterEmpty() {
+ a.Importance = widget.MediumImportance
+ } else {
+ a.Importance = widget.HighImportance
+ }
+ a.Button.Refresh()
+}
+
+func (a *AlbumFilterButton) filterEmpty() bool {
+ return a.filter.MinYear == 0 && a.filter.MaxYear == 0 &&
+ (a.FavoriteDisabled || !a.filter.ExcludeFavorited && !a.filter.ExcludeUnfavorited) &&
+ (a.GenreDisabled || len(a.filter.Genres) == 0)
+}
+
+func (a *AlbumFilterButton) onFilterChanged() {
+ a.Refresh()
+ if a.OnChanged != nil {
+ a.OnChanged()
+ }
+}
+
+func (a *AlbumFilterButton) showFilterDialog() {
+ if a.dialog == nil {
+ filterDlg := NewAlbumFilterPopup(a)
+ filterDlg.OnChanged = a.onFilterChanged
+ a.dialog = widget.NewPopUp(filterDlg, fyne.CurrentApp().Driver().CanvasForObject(a))
+ }
+ pos := fyne.CurrentApp().Driver().AbsolutePositionForObject(a)
+ a.dialog.ShowAtPosition(fyne.NewPos(pos.X+a.Size().Width/2-a.dialog.MinSize().Width/2, pos.Y+a.Size().Height))
+}
+
+type AlbumFilterPopup struct {
+ widget.BaseWidget
+
+ OnChanged func()
+
+ isFavorite *widget.Check
+ isNotFavorite *widget.Check
+ filterBtn *AlbumFilterButton
+ container *fyne.Container
+}
+
+func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup {
+ a := &AlbumFilterPopup{filterBtn: filter}
+ a.ExtendBaseWidget(a)
+
+ debounceOnChanged := util.NewDebouncer(350*time.Millisecond, a.emitOnChanged)
+
+ // setup min and max year filters
+ yearValidator := func(curText, selText string, r rune) bool {
+ return unicode.IsDigit(r) && (len(selText) > 0 || len(curText) <= 3)
+ }
+ minYear := NewTextRestrictedEntry(yearValidator)
+ minYear.SetMinCharWidth(4)
+ minYear.OnChanged = func(yearStr string) {
+ if yearStr == "" {
+ a.filterBtn.filter.MinYear = 0
+ } else if i, err := strconv.Atoi(yearStr); err == nil {
+ a.filterBtn.filter.MinYear = i
+ }
+ debounceOnChanged()
+ }
+ if a.filterBtn.filter.MinYear > 0 {
+ minYear.Text = strconv.Itoa(a.filterBtn.filter.MinYear)
+ }
+ maxYear := NewTextRestrictedEntry(yearValidator)
+ maxYear.SetMinCharWidth(4)
+ maxYear.OnChanged = func(yearStr string) {
+ if yearStr == "" {
+ a.filterBtn.filter.MaxYear = 0
+ } else if i, err := strconv.Atoi(yearStr); err == nil {
+ a.filterBtn.filter.MaxYear = i
+ }
+ debounceOnChanged()
+ }
+ if a.filterBtn.filter.MaxYear > 0 {
+ maxYear.Text = strconv.Itoa(a.filterBtn.filter.MaxYear)
+ }
+
+ // setup is favorite/not favorite filters
+ a.isFavorite = widget.NewCheck("Is favorite", func(fav bool) {
+ if fav {
+ a.isNotFavorite.SetChecked(false)
+ }
+ a.filterBtn.filter.ExcludeUnfavorited = fav
+ debounceOnChanged()
+ })
+ a.isFavorite.Hidden = a.filterBtn.FavoriteDisabled
+ a.isNotFavorite = widget.NewCheck("Is not favorite", func(fav bool) {
+ if fav {
+ a.isFavorite.SetChecked(false)
+ }
+ a.filterBtn.filter.ExcludeFavorited = fav
+ debounceOnChanged()
+ })
+ a.isNotFavorite.Hidden = a.filterBtn.FavoriteDisabled
+
+ // setup container
+ title := widget.NewLabel("Album filters")
+ title.TextStyle.Bold = true
+ a.container = container.NewVBox(
+ container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()),
+ container.NewHBox(widget.NewLabel("Year from"), minYear, widget.NewLabel("to"), maxYear),
+ container.NewHBox(a.isFavorite, a.isNotFavorite),
+ )
+
+ return a
+}
+
+func (a *AlbumFilterPopup) Refresh() {
+ a.isFavorite.Hidden = a.filterBtn.FavoriteDisabled
+ a.isNotFavorite.Hidden = a.filterBtn.FavoriteDisabled
+ a.BaseWidget.Refresh()
+}
+
+func (a *AlbumFilterPopup) emitOnChanged() {
+ if a.OnChanged != nil {
+ a.OnChanged()
+ }
+}
+
+func (a *AlbumFilterPopup) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(a.container)
+}
diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go
index 11704ed..4d04f0e 100644
--- a/ui/widgets/gridview.go
+++ b/ui/widgets/gridview.go
@@ -129,11 +129,11 @@ func (g *GridView) Clear() {
func (g *GridView) Reset(iter GridViewIterator) {
g.itemsMutex.Lock()
g.items = nil
- g.itemsMutex.Unlock()
g.fetching = false
g.done = false
g.highestShown = 0
g.iter = iter
+ g.itemsMutex.Unlock()
g.fetchMoreItems(36)
}
@@ -194,8 +194,13 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
if itemIdx > g.highestShown {
g.highestShown = itemIdx
}
+ var item GridViewItemModel
g.itemsMutex.RLock()
- item := g.items[itemIdx]
+ // itemIdx can rarely be out of range if the data is being updated
+ // as the view is requested to refresh
+ if itemIdx < len(g.items) {
+ item = g.items[itemIdx]
+ }
g.itemsMutex.RUnlock()
if card.PrevID == item.ID {
// nothing to do
@@ -208,26 +213,31 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
card.ImgLoadCancel()
card.ImgLoadCancel = nil
}
- if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
- card.Cover.SetImage(img)
- } else {
- card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
- // asynchronously fetch cover image
- ctx, cancel := context.WithCancel(context.Background())
- card.ImgLoadCancel = cancel
- go func(ctx context.Context) {
- i, err := g.imageFetcher.GetCoverThumbnail(item.CoverArtID)
- select {
- case <-ctx.Done():
- return
- default:
- if err == nil {
- card.Cover.SetImage(i)
- } else {
- log.Printf("error fetching image: %s", err.Error())
+ if item.CoverArtID != "" {
+ if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
+ card.Cover.SetImage(img)
+ } else {
+ card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
+ // asynchronously fetch cover image
+ ctx, cancel := context.WithCancel(context.Background())
+ card.ImgLoadCancel = cancel
+ go func(ctx context.Context) {
+ i, err := g.imageFetcher.GetCoverThumbnail(item.CoverArtID)
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ if err == nil {
+ card.Cover.SetImage(i)
+ } else {
+ log.Printf("error fetching image: %s", err.Error())
+ }
}
- }
- }(ctx)
+ }(ctx)
+ }
+ } else {
+ // use the placeholder image for an item that has no cover art ID
+ card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
}
// if user has scrolled near the bottom, fetch more
diff --git a/ui/widgets/textrestrictedentry.go b/ui/widgets/textrestrictedentry.go
index c76989e..3df4a6d 100644
--- a/ui/widgets/textrestrictedentry.go
+++ b/ui/widgets/textrestrictedentry.go
@@ -13,19 +13,21 @@ import (
type TextRestrictedEntry struct {
widget.Entry
- charAllowed func(string, rune) bool
+ charAllowed CharAllowedFunc
minWidth float32
}
-func NewTextRestrictedEntry(charAllowed func(curText string, r rune) bool) *TextRestrictedEntry {
+type CharAllowedFunc func(curText string, selectedText string, r rune) bool
+
+func NewTextRestrictedEntry(charAllowed CharAllowedFunc) *TextRestrictedEntry {
e := &TextRestrictedEntry{charAllowed: charAllowed}
e.ExtendBaseWidget(e)
return e
}
func (e *TextRestrictedEntry) TypedRune(r rune) {
- if e.charAllowed == nil || e.charAllowed(e.Text, r) {
+ if e.charAllowed == nil || e.charAllowed(e.Text, e.SelectedText(), r) {
e.Entry.TypedRune(r)
}
}