The Grid view and related components were highly coupled to Albums, including the filter button that is currently used only for album filtering. As part of the migration of Artists to Grid view, this is the smallest possible change to convert many of the existing code to generics/interfaces that will allow different media to be displayed and filtered by using Grid views. The main changes in this diff are: * Introduction of generics for Media types (`M`) and Filter options (`F`), which can be later extended to other entities besides Albums. * Complete decoupling of `GridViewPage` and related components from Albums (making these pages also generic). * Refactoring of Subsonic/Jellyfin specific code to understand these new generics when dealing with filtering logic.
404 lines
11 KiB
Go
404 lines
11 KiB
Go
package widgets
|
|
|
|
import (
|
|
"fmt"
|
|
"slices"
|
|
"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/sharedutil"
|
|
"github.com/dweymouth/supersonic/ui/layouts"
|
|
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
|
"github.com/dweymouth/supersonic/ui/util"
|
|
)
|
|
|
|
type AlbumFilterButton struct {
|
|
widget.Button
|
|
|
|
OnChanged func()
|
|
GenreDisabled bool
|
|
FavoriteDisabled bool
|
|
|
|
genreListChan chan []string
|
|
|
|
filter mediaprovider.AlbumFilter
|
|
dialog *widget.PopUp
|
|
}
|
|
|
|
func NewAlbumFilterButton(filter mediaprovider.AlbumFilter, fetchGenresFunc func() ([]*mediaprovider.Genre, error)) *AlbumFilterButton {
|
|
a := &AlbumFilterButton{
|
|
filter: filter,
|
|
Button: widget.Button{
|
|
Icon: theme.NewThemedResource(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
|
|
})
|
|
slices.Sort(genreNames)
|
|
a.genreListChan <- genreNames
|
|
}
|
|
}()
|
|
return a
|
|
}
|
|
|
|
func (a *AlbumFilterButton) Refresh() {
|
|
themedIcon := a.Icon.(*theme.ThemedResource)
|
|
if a.filterEmpty() {
|
|
themedIcon.ColorName = theme.ColorNameForeground
|
|
} else {
|
|
themedIcon.ColorName = theme.ColorNamePrimary
|
|
}
|
|
a.Button.Refresh()
|
|
}
|
|
|
|
func (a *AlbumFilterButton) Filter() mediaprovider.AlbumFilter {
|
|
return a.filter
|
|
}
|
|
|
|
func (a *AlbumFilterButton) SetOnChanged(fn func()) {
|
|
a.OnChanged = fn
|
|
}
|
|
|
|
func (a *AlbumFilterButton) filterEmpty() bool {
|
|
filterOptions := a.filter.Options()
|
|
return filterOptions.MinYear == 0 && filterOptions.MaxYear == 0 &&
|
|
(a.FavoriteDisabled || !filterOptions.ExcludeFavorited && !filterOptions.ExcludeUnfavorited) &&
|
|
(a.GenreDisabled || len(filterOptions.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
|
|
genreFilter *GenreFilterSubsection
|
|
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 {
|
|
l := len(curText) - len(selText)
|
|
return unicode.IsDigit(r) && l <= 3 && (l > 0 || r != '0')
|
|
}
|
|
minYear := NewTextRestrictedEntry(yearValidator)
|
|
minYear.SetMinCharWidth(4)
|
|
minYear.OnChanged = func(yearStr string) {
|
|
filterOptions := a.filterBtn.filter.Options()
|
|
if yearStr == "" {
|
|
filterOptions.MinYear = 0
|
|
} else if i, err := strconv.Atoi(yearStr); err == nil {
|
|
filterOptions.MinYear = i
|
|
}
|
|
a.filterBtn.filter.SetOptions(filterOptions)
|
|
debounceOnChanged()
|
|
}
|
|
filterOptions := a.filterBtn.filter.Options()
|
|
if filterOptions.MinYear > 0 {
|
|
minYear.Text = strconv.Itoa(filterOptions.MinYear)
|
|
}
|
|
maxYear := NewTextRestrictedEntry(yearValidator)
|
|
maxYear.SetMinCharWidth(4)
|
|
maxYear.OnChanged = func(yearStr string) {
|
|
filterOptions := a.filterBtn.filter.Options()
|
|
if yearStr == "" {
|
|
filterOptions.MaxYear = 0
|
|
} else if i, err := strconv.Atoi(yearStr); err == nil {
|
|
filterOptions.MaxYear = i
|
|
}
|
|
a.filterBtn.filter.SetOptions(filterOptions)
|
|
debounceOnChanged()
|
|
}
|
|
if filterOptions.MaxYear > 0 {
|
|
maxYear.Text = strconv.Itoa(filterOptions.MaxYear)
|
|
}
|
|
|
|
// setup is favorite/not favorite filters
|
|
a.isFavorite = widget.NewCheck("Is favorite", func(fav bool) {
|
|
filterOptions := a.filterBtn.filter.Options()
|
|
if fav {
|
|
a.isNotFavorite.SetChecked(false)
|
|
}
|
|
filterOptions.ExcludeUnfavorited = fav
|
|
a.filterBtn.filter.SetOptions(filterOptions)
|
|
debounceOnChanged()
|
|
})
|
|
a.isFavorite.Hidden = a.filterBtn.FavoriteDisabled
|
|
a.isNotFavorite = widget.NewCheck("Is not favorite", func(fav bool) {
|
|
filterOptions := a.filterBtn.filter.Options()
|
|
if fav {
|
|
a.isFavorite.SetChecked(false)
|
|
}
|
|
filterOptions.ExcludeFavorited = fav
|
|
a.filterBtn.filter.SetOptions(filterOptions)
|
|
debounceOnChanged()
|
|
})
|
|
a.isNotFavorite.Hidden = a.filterBtn.FavoriteDisabled
|
|
|
|
// create genre filter subsection
|
|
a.genreFilter = NewGenreFilterSubsection(func(selectedGenres []string) {
|
|
filterOptions := a.filterBtn.filter.Options()
|
|
filterOptions.Genres = selectedGenres
|
|
a.filterBtn.filter.SetOptions(filterOptions)
|
|
debounceOnChanged()
|
|
}, filterOptions.Genres)
|
|
a.genreFilter.Hidden = a.filterBtn.GenreDisabled
|
|
|
|
// 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),
|
|
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()
|
|
}
|
|
|
|
func (a *AlbumFilterPopup) emitOnChanged() {
|
|
if a.OnChanged != nil {
|
|
a.OnChanged()
|
|
}
|
|
}
|
|
|
|
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 *FocusList
|
|
|
|
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 = NewFocusList(
|
|
func() int {
|
|
g.listModelMutex.RLock()
|
|
defer g.listModelMutex.RUnlock()
|
|
return len(g.genreListViewModel)
|
|
},
|
|
func() fyne.CanvasObject {
|
|
row := newGenreListViewRow(g.onGenreSelected)
|
|
row.OnFocusNeighbor = func(up bool) {
|
|
g.genreListView.FocusNeighbor(row.ItemID(), up)
|
|
}
|
|
return row
|
|
},
|
|
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)
|
|
g.genreListView.SetItemForID(id, row)
|
|
row.ListItemID = id
|
|
row.Content.(*widget.Check).Text = genre
|
|
row.Content.(*widget.Check).Checked = selected
|
|
row.Refresh()
|
|
},
|
|
)
|
|
g.filterText = widget.NewEntry()
|
|
g.filterText.SetPlaceHolder("Filter genres")
|
|
i := NewTappableIcon(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 {
|
|
FocusListRowBase
|
|
}
|
|
|
|
func newGenreListViewRow(onChanged func(widget.ListItemID, bool)) *genreListViewRow {
|
|
g := &genreListViewRow{}
|
|
g.ExtendBaseWidget(g)
|
|
g.Content = widget.NewCheck("", func(b bool) {
|
|
onChanged(g.ItemID(), b)
|
|
})
|
|
g.OnTapped = func() {
|
|
chk := g.Content.(*widget.Check)
|
|
chk.SetChecked(!chk.Checked)
|
|
}
|
|
return g
|
|
}
|