From e8ab651d9d729f280c2cdb34185f4e9ef990cf68 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 18 May 2023 18:10:09 -0700 Subject: [PATCH] add genres to album filters panel --- ui/browsing/albumspage.go | 2 +- ui/browsing/favoritespage.go | 2 +- ui/browsing/genrepage.go | 3 +- ui/widgets/albumfilterbutton.go | 216 +++++++++++++++++++++++++++++++- ui/widgets/tappablewrappers.go | 6 +- 5 files changed, 222 insertions(+), 7 deletions(-) diff --git a/ui/browsing/albumspage.go b/ui/browsing/albumspage.go index c4cbfe4..b44cab3 100644 --- a/ui/browsing/albumspage.go +++ b/ui/browsing/albumspage.go @@ -87,7 +87,7 @@ 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 = widgets.NewAlbumFilterButton(&a.filter, a.mp.GetGenres) a.filterBtn.OnChanged = a.Reload } diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index aec026a..0e50052 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -81,7 +81,7 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int) { a.searcher = widgets.NewSearchEntry() a.searcher.OnSearched = a.OnSearched a.searcher.Entry.Text = a.searchText - a.filterBtn = widgets.NewAlbumFilterButton(&a.filter) + a.filterBtn = widgets.NewAlbumFilterButton(&a.filter, a.mp.GetGenres) a.filterBtn.FavoriteDisabled = true a.filterBtn.OnChanged = a.Reload } diff --git a/ui/browsing/genrepage.go b/ui/browsing/genrepage.go index be3b527..6686373 100644 --- a/ui/browsing/genrepage.go +++ b/ui/browsing/genrepage.go @@ -67,7 +67,8 @@ func (g *GenrePage) createSearchAndFilter() { g.searcher = widgets.NewSearchEntry() g.searcher.Text = g.searchText g.searcher.OnSearched = g.OnSearched - g.filterBtn = widgets.NewAlbumFilterButton(&g.filter) + // genre filter is disabled for this page, so no need to actually call genre list fetching function + g.filterBtn = widgets.NewAlbumFilterButton(&g.filter, func() ([]*mediaprovider.Genre, error) { return nil, nil }) g.filterBtn.GenreDisabled = true g.filterBtn.OnChanged = g.Reload } diff --git a/ui/widgets/albumfilterbutton.go b/ui/widgets/albumfilterbutton.go index c2fd783..d556eb3 100644 --- a/ui/widgets/albumfilterbutton.go +++ b/ui/widgets/albumfilterbutton.go @@ -1,16 +1,23 @@ package widgets import ( + "fmt" + "sort" "strconv" + "strings" + "sync" "time" "unicode" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" "github.com/dweymouth/supersonic/backend/mediaprovider" - "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/sharedutil" + "github.com/dweymouth/supersonic/ui/layouts" + myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/util" ) @@ -21,19 +28,31 @@ type AlbumFilterButton struct { GenreDisabled bool FavoriteDisabled bool + genreListChan chan []string + filter *mediaprovider.AlbumFilter dialog *widget.PopUp } -func NewAlbumFilterButton(filter *mediaprovider.AlbumFilter) *AlbumFilterButton { +func NewAlbumFilterButton(filter *mediaprovider.AlbumFilter, fetchGenresFunc func() ([]*mediaprovider.Genre, error)) *AlbumFilterButton { a := &AlbumFilterButton{ filter: filter, Button: widget.Button{ - Icon: theme.FilterIcon, + Icon: myTheme.FilterIcon, }, } a.OnTapped = a.showFilterDialog a.ExtendBaseWidget(a) + a.genreListChan = make(chan []string) + go func() { + if genres, err := fetchGenresFunc(); err == nil { + genreNames := sharedutil.MapSlice(genres, func(g *mediaprovider.Genre) string { + return g.Name + }) + sort.Strings(genreNames) + a.genreListChan <- genreNames + } + }() return a } @@ -76,6 +95,7 @@ type AlbumFilterPopup struct { isFavorite *widget.Check isNotFavorite *widget.Check + genreFilter *GenreFilterSubsection filterBtn *AlbumFilterButton container *fyne.Container } @@ -136,6 +156,13 @@ func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup { }) a.isNotFavorite.Hidden = a.filterBtn.FavoriteDisabled + // create genre filter subsection + a.genreFilter = NewGenreFilterSubsection(func(selectedGenres []string) { + a.filterBtn.filter.Genres = selectedGenres + debounceOnChanged() + }, a.filterBtn.filter.Genres) + a.genreFilter.Hidden = a.filterBtn.GenreDisabled + // setup container title := widget.NewLabel("Album filters") title.TextStyle.Bold = true @@ -143,14 +170,25 @@ func NewAlbumFilterPopup(filter *AlbumFilterButton) *AlbumFilterPopup { container.NewHBox(layout.NewSpacer(), title, layout.NewSpacer()), container.NewHBox(widget.NewLabel("Year from"), minYear, widget.NewLabel("to"), maxYear), container.NewHBox(a.isFavorite, a.isNotFavorite), + a.genreFilter, ) + go func() { + a.genreFilter.SetGenreList(<-a.filterBtn.genreListChan) + }() + return a } +func (a *AlbumFilterPopup) Tapped(_ *fyne.PointEvent) { + // swallow the Tapped event so that the popup is + // only dismissed by clicking outside of it +} + func (a *AlbumFilterPopup) Refresh() { a.isFavorite.Hidden = a.filterBtn.FavoriteDisabled a.isNotFavorite.Hidden = a.filterBtn.FavoriteDisabled + a.genreFilter.Hidden = a.filterBtn.GenreDisabled a.BaseWidget.Refresh() } @@ -163,3 +201,175 @@ func (a *AlbumFilterPopup) emitOnChanged() { func (a *AlbumFilterPopup) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(a.container) } + +type GenreFilterSubsection struct { + widget.BaseWidget + + genreList []string + onChanged func([]string) + + selectedGenres map[string]interface{} + selectedGenresMutex sync.RWMutex + + filterText *widget.Entry + numSelectedText *widget.Label + allBtn *widget.Button + noneBtn *widget.Button + listModelMutex sync.RWMutex + genreListViewModel []string + genreListView *widget.List + + container *fyne.Container +} + +func NewGenreFilterSubsection(onChanged func([]string), initialSelectedGenres []string) *GenreFilterSubsection { + g := &GenreFilterSubsection{ + onChanged: onChanged, + selectedGenres: make(map[string]interface{}), + } + g.ExtendBaseWidget(g) + + for _, genre := range initialSelectedGenres { + g.selectedGenres[genre] = nil + } + + g.genreListView = widget.NewList( + func() int { + g.listModelMutex.RLock() + defer g.listModelMutex.RUnlock() + return len(g.genreListViewModel) + }, + func() fyne.CanvasObject { + return newGenreListViewRow(g.onGenreSelected) + }, + func(id widget.ListItemID, obj fyne.CanvasObject) { + g.listModelMutex.RLock() + defer g.listModelMutex.RUnlock() + genre := g.genreListViewModel[id] + g.selectedGenresMutex.RLock() + _, selected := g.selectedGenres[genre] + g.selectedGenresMutex.RUnlock() + row := obj.(*genreListViewRow) + row.ID = id + row.Text = genre + row.Checked = selected + row.Refresh() + }, + ) + g.filterText = widget.NewEntry() + g.filterText.SetPlaceHolder("Filter genres") + i := NewTappbaleIcon(theme.ContentClearIcon()) + i.NoPointerCursor = true + i.OnTapped = func() { g.filterText.SetText("") } + g.filterText.ActionItem = i + debouncer := util.NewDebouncer(300*time.Millisecond, g.updateGenreListView) + g.filterText.OnChanged = func(_ string) { + debouncer() + } + g.allBtn = widget.NewButton("All", func() { g.selectAllOrNoneInView(false) }) + g.noneBtn = widget.NewButton("None", func() { g.selectAllOrNoneInView(true) }) + + title := widget.NewRichTextWithText("Genres") + title.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = true + g.numSelectedText = widget.NewLabel("(none selected)") + g.updateNumSelectedText() + titleRow := container.New(&layouts.HboxCustomPadding{ExtraPad: -10}, title, g.numSelectedText) + + filterRow := container.NewBorder(nil, nil, nil, container.NewHBox(g.allBtn, g.noneBtn), g.filterText) + g.container = container.NewBorder(titleRow, nil, nil, nil, + container.New(&layouts.MaxPadLayout{PadLeft: 5, PadRight: 5}, + container.NewBorder(filterRow, nil, nil, nil, g.genreListView), + ), + ) + return g +} + +func (g *GenreFilterSubsection) MinSize() fyne.Size { + return fyne.NewSize(g.BaseWidget.MinSize().Width, 250) +} + +func (g *GenreFilterSubsection) SetGenreList(genres []string) { + g.genreList = genres + g.updateGenreListView() +} + +func (g *GenreFilterSubsection) updateGenreListView() { + g.listModelMutex.Lock() + if g.filterText.Text == "" { + g.genreListViewModel = g.genreList + } else { + filterText := strings.ToLower(g.filterText.Text) + g.genreListViewModel = sharedutil.FilterSlice(g.genreList, func(genre string) bool { + return strings.Contains(strings.ToLower(genre), filterText) + }) + } + g.listModelMutex.Unlock() + g.genreListView.Refresh() +} + +func (g *GenreFilterSubsection) onGenreSelected(row widget.ListItemID, selected bool) { + g.listModelMutex.RLock() + g.selectedGenresMutex.Lock() + if selected { + g.selectedGenres[g.genreListViewModel[row]] = nil + } else { + delete(g.selectedGenres, g.genreListViewModel[row]) + } + g.selectedGenresMutex.Unlock() + g.listModelMutex.RUnlock() + g.invokeOnChanged() +} + +func (g *GenreFilterSubsection) selectAllOrNoneInView(none bool) { + g.listModelMutex.RLock() + g.selectedGenresMutex.Lock() + for _, genre := range g.genreListViewModel { + if none { + delete(g.selectedGenres, genre) + } else { + g.selectedGenres[genre] = nil + } + } + g.selectedGenresMutex.Unlock() + g.listModelMutex.RUnlock() + g.genreListView.Refresh() + g.invokeOnChanged() +} + +func (g *GenreFilterSubsection) invokeOnChanged() { + g.selectedGenresMutex.RLock() + g.updateNumSelectedText() + genres := make([]string, 0, len(g.selectedGenres)) + for genre := range g.selectedGenres { + genres = append(genres, genre) + } + g.selectedGenresMutex.RUnlock() + g.onChanged(genres) +} + +func (g *GenreFilterSubsection) updateNumSelectedText() { + numText := "none" + if l := len(g.selectedGenres); l > 0 { + numText = strconv.Itoa(l) + } + g.numSelectedText.SetText(fmt.Sprintf("(%s selected)", numText)) +} + +func (g *GenreFilterSubsection) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(g.container) +} + +type genreListViewRow struct { + widget.Check + + ID widget.ListItemID +} + +func newGenreListViewRow(onChanged func(widget.ListItemID, bool)) *genreListViewRow { + g := &genreListViewRow{} + g.ExtendBaseWidget(g) + g.OnChanged = func(b bool) { + onChanged(g.ID, b) + } + return g +} diff --git a/ui/widgets/tappablewrappers.go b/ui/widgets/tappablewrappers.go index a5a84be..89b99d1 100644 --- a/ui/widgets/tappablewrappers.go +++ b/ui/widgets/tappablewrappers.go @@ -11,7 +11,8 @@ import ( type TappableIcon struct { widget.Icon - OnTapped func() + NoPointerCursor bool + OnTapped func() } func NewTappbaleIcon(res fyne.Resource) *TappableIcon { @@ -42,6 +43,9 @@ func (t *TappableIcon) MouseOut() {} func (t *TappableIcon) MouseMoved(*desktop.MouseEvent) {} func (t *TappableIcon) Cursor() desktop.Cursor { + if t.NoPointerCursor { + return desktop.DefaultCursor + } return desktop.PointerCursor }