Merge pull request #162 from dweymouth/feature/albumviewfilter
Add album filters (excl. genre filter for now) to album grid views
This commit is contained in:
+81
-41
@@ -3,8 +3,10 @@ package backend
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
"github.com/dweymouth/go-subsonic/subsonic"
|
||||||
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlbumSortOrder string
|
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 {
|
switch sort {
|
||||||
case AlbumSortRecentlyAdded:
|
case AlbumSortRecentlyAdded:
|
||||||
return l.newBaseIter("newest", make(map[string]string))
|
return l.newBaseIter("newest", filter, make(map[string]string))
|
||||||
case AlbumSortRecentlyPlayed:
|
case AlbumSortRecentlyPlayed:
|
||||||
return l.newBaseIter("recent", make(map[string]string))
|
return l.newBaseIter("recent", filter, make(map[string]string))
|
||||||
case AlbumSortFrequentlyPlayed:
|
case AlbumSortFrequentlyPlayed:
|
||||||
return l.newBaseIter("frequent", make(map[string]string))
|
return l.newBaseIter("frequent", filter, make(map[string]string))
|
||||||
case AlbumSortRandom:
|
case AlbumSortRandom:
|
||||||
return l.newRandomIter()
|
return l.newRandomIter()
|
||||||
case AlbumSortTitleAZ:
|
case AlbumSortTitleAZ:
|
||||||
return l.newBaseIter("alphabeticalByName", make(map[string]string))
|
return l.newBaseIter("alphabeticalByName", filter, make(map[string]string))
|
||||||
case AlbumSortArtistAZ:
|
case AlbumSortArtistAZ:
|
||||||
return l.newBaseIter("alphabeticalByArtist", make(map[string]string))
|
return l.newBaseIter("alphabeticalByArtist", filter, make(map[string]string))
|
||||||
case AlbumSortYearAscending:
|
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:
|
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:
|
default:
|
||||||
log.Printf("Undefined album sort order: %s", sort)
|
log.Printf("Undefined album sort order: %s", sort)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *LibraryManager) StarredIter() AlbumIterator {
|
func (l *LibraryManager) StarredIter(filter AlbumFilter) AlbumIterator {
|
||||||
return l.newBaseIter("starred", make(map[string]string))
|
return l.newBaseIter("starred", filter, make(map[string]string))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *LibraryManager) GenreIter(genre string) AlbumIterator {
|
func (l *LibraryManager) GenreIter(genre string, filter AlbumFilter) AlbumIterator {
|
||||||
return l.newBaseIter("byGenre", map[string]string{"genre": genre})
|
return l.newBaseIter("byGenre", filter, map[string]string{"genre": genre})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *LibraryManager) SearchIter(query string) AlbumIterator {
|
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)
|
return l.newSearchIter(query, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +123,8 @@ func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) {
|
|||||||
|
|
||||||
type baseIter struct {
|
type baseIter struct {
|
||||||
listType string
|
listType string
|
||||||
pos int
|
filter AlbumFilter
|
||||||
|
serverPos int
|
||||||
l *LibraryManager
|
l *LibraryManager
|
||||||
s *subsonic.Client
|
s *subsonic.Client
|
||||||
opts map[string]string
|
opts map[string]string
|
||||||
@@ -92,9 +133,10 @@ type baseIter struct {
|
|||||||
done bool
|
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{
|
return &baseIter{
|
||||||
listType: listType,
|
listType: listType,
|
||||||
|
filter: filter,
|
||||||
l: l,
|
l: l,
|
||||||
s: l.s.Server,
|
s: l.s.Server,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
@@ -105,35 +147,33 @@ func (r *baseIter) Next() *subsonic.AlbumID3 {
|
|||||||
if r.done {
|
if r.done {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if r.prefetched != nil {
|
if r.prefetched != nil && r.prefetchedPos < len(r.prefetched) {
|
||||||
a := r.prefetched[r.prefetchedPos]
|
a := r.prefetched[r.prefetchedPos]
|
||||||
r.prefetchedPos++
|
r.prefetchedPos++
|
||||||
if r.prefetchedPos == len(r.prefetched) {
|
|
||||||
r.prefetched = nil
|
|
||||||
r.prefetchedPos = 0
|
|
||||||
r.pos++
|
|
||||||
}
|
|
||||||
r.pos++
|
|
||||||
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
r.opts["offset"] = strconv.Itoa(r.pos)
|
r.prefetched = nil
|
||||||
albums, err := r.s.GetAlbumList2(r.listType, r.opts)
|
for { // keep fetching until we are done or have mathcing results
|
||||||
if err != nil {
|
r.opts["offset"] = strconv.Itoa(r.serverPos)
|
||||||
log.Println(err)
|
albums, err := r.s.GetAlbumList2(r.listType, r.opts)
|
||||||
albums = nil
|
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
|
r.prefetchedPos = 1
|
||||||
if r.l.PreCacheCoverFn != nil {
|
if r.l.PreCacheCoverFn != nil {
|
||||||
for _, album := range albums {
|
for _, album := range r.prefetched {
|
||||||
go r.l.PreCacheCoverFn(album.CoverArt)
|
go r.l.PreCacheCoverFn(album.CoverArt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,14 +185,14 @@ type searchIter struct {
|
|||||||
searchIterBase
|
searchIterBase
|
||||||
|
|
||||||
l *LibraryManager
|
l *LibraryManager
|
||||||
filter func(*subsonic.AlbumID3) bool
|
filter AlbumFilter
|
||||||
prefetched []*subsonic.AlbumID3
|
prefetched []*subsonic.AlbumID3
|
||||||
prefetchedPos int
|
prefetchedPos int
|
||||||
albumIDset map[string]bool
|
albumIDset map[string]bool
|
||||||
done 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{
|
return &searchIter{
|
||||||
searchIterBase: searchIterBase{
|
searchIterBase: searchIterBase{
|
||||||
query: query,
|
query: query,
|
||||||
@@ -228,7 +268,7 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) {
|
|||||||
if _, have := s.albumIDset[album.ID]; have {
|
if _, have := s.albumIDset[album.ID]; have {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !s.filter(album) {
|
if !s.filter.Matches(album) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.prefetched = append(s.prefetched, album)
|
s.prefetched = append(s.prefetched, album)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
func (l *LibraryManager) AllTracksIterator() TrackIterator {
|
func (l *LibraryManager) AllTracksIterator() TrackIterator {
|
||||||
return &allTracksIterator{
|
return &allTracksIterator{
|
||||||
l: l,
|
l: l,
|
||||||
albumIter: l.AlbumsIter(AlbumSortArtistAZ),
|
albumIter: l.AlbumsIter(AlbumSortArtistAZ, AlbumFilter{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
fyne.io/fyne/v2 v2.3.4
|
fyne.io/fyne/v2 v2.3.4
|
||||||
github.com/20after4/configdir v0.1.1
|
github.com/20after4/configdir v0.1.1
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
|
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/google/uuid v1.3.0
|
||||||
github.com/pelletier/go-toml v1.9.3
|
github.com/pelletier/go-toml v1.9.3
|
||||||
github.com/zalando/go-keyring v0.2.1
|
github.com/zalando/go-keyring v0.2.1
|
||||||
|
|||||||
@@ -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/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 h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
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-20230513020020-0790f53c2868 h1:403Dden/cdQyDM8ydonHLxTBPjgQXZ76PUfmBG92+Dw=
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20230507163800-c92892982103/go.mod h1:fUez6NFiEJiQTZizZ1BThZr5GJXAbigzGYjEPNm4tdI=
|
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.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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ var ResListSvg = &fyne.StaticResource{
|
|||||||
StaticContent: []byte(
|
StaticContent: []byte(
|
||||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\r<svg width=\"800px\" height=\"800px\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\" >\r<g id=\"grid\">\r\n\t<rect x=\"4\" y=\"6\" width=\"40\" height=\"8\"/>\r\n\t<rect x=\"4\" y=\"20\" width=\"40\" height=\"8\"/>\r\n\t<rect x=\"4\" y=\"34\" width=\"40\" height=\"8\"/>\r\n</g>\r\n</svg>"),
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\r<svg width=\"800px\" height=\"800px\" viewBox=\"0 0 48 48\" xmlns=\"http://www.w3.org/2000/svg\" >\r<g id=\"grid\">\r\n\t<rect x=\"4\" y=\"6\" width=\"40\" height=\"8\"/>\r\n\t<rect x=\"4\" y=\"20\" width=\"40\" height=\"8\"/>\r\n\t<rect x=\"4\" y=\"34\" width=\"40\" height=\"8\"/>\r\n</g>\r\n</svg>"),
|
||||||
}
|
}
|
||||||
|
var ResFilterSvg = &fyne.StaticResource{
|
||||||
|
StaticName: "filter.svg",
|
||||||
|
StaticContent: []byte(
|
||||||
|
"<svg fill=\"#000000\" version=\"1.1\" id=\"Capa_1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" \r\n\t width=\"800px\" height=\"800px\" viewBox=\"0 0 971.986 971.986\"\r\n\t xml:space=\"preserve\">\r\n<g>\r\n\t<path d=\"M370.216,459.3c10.2,11.1,15.8,25.6,15.8,40.6v442c0,26.601,32.1,40.101,51.1,21.4l123.3-141.3\r\n\t\tc16.5-19.8,25.6-29.601,25.6-49.2V500c0-15,5.7-29.5,15.8-40.601L955.615,75.5c26.5-28.8,6.101-75.5-33.1-75.5h-873\r\n\t\tc-39.2,0-59.7,46.6-33.1,75.5L370.216,459.3z\"/>\r\n</g>\r\n</svg>\r\n"),
|
||||||
|
}
|
||||||
var ResLICENSE = &fyne.StaticResource{
|
var ResLICENSE = &fyne.StaticResource{
|
||||||
StaticName: "LICENSE",
|
StaticName: "LICENSE",
|
||||||
StaticContent: []byte(
|
StaticContent: []byte(
|
||||||
|
|||||||
@@ -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/star-filled.svg >> bundled.go
|
||||||
fyne bundle -append -prefix Res icons/publicdomain/grid.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/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 ../LICENSE >> bundled.go
|
||||||
fyne bundle -append -prefix Res licenses/BSDLICENSE >> bundled.go
|
fyne bundle -append -prefix Res licenses/BSDLICENSE >> bundled.go
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 971.986 971.986"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<path d="M370.216,459.3c10.2,11.1,15.8,25.6,15.8,40.6v442c0,26.601,32.1,40.101,51.1,21.4l123.3-141.3
|
||||||
|
c16.5-19.8,25.6-29.601,25.6-49.2V500c0-15,5.7-29.5,15.8-40.601L955.615,75.5c26.5-28.8,6.101-75.5-33.1-75.5h-873
|
||||||
|
c-39.2,0-59.7,46.6-33.1,75.5L370.216,459.3z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
@@ -17,6 +17,9 @@ func SliceContains[T comparable](ts []T, t T) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func FilterSlice[T any](ss []T, test func(T) bool) []T {
|
func FilterSlice[T any](ss []T, test func(T) bool) []T {
|
||||||
|
if ss == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
result := make([]T, 0)
|
result := make([]T, 0)
|
||||||
for _, s := range ss {
|
for _, s := range ss {
|
||||||
if test(s) {
|
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 {
|
func MapSlice[T any, U any](ts []T, f func(T) U) []U {
|
||||||
|
if ts == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
result := make([]U, len(ts))
|
result := make([]U, len(ts))
|
||||||
for i, t := range ts {
|
for i, t := range ts {
|
||||||
result[i] = f(t)
|
result[i] = f(t)
|
||||||
|
|||||||
+26
-16
@@ -27,7 +27,9 @@ type AlbumsPage struct {
|
|||||||
grid *widgets.GridView
|
grid *widgets.GridView
|
||||||
searchGrid *widgets.GridView
|
searchGrid *widgets.GridView
|
||||||
searcher *widgets.SearchEntry
|
searcher *widgets.SearchEntry
|
||||||
|
filterBtn *widgets.AlbumFilterButton
|
||||||
searchText string
|
searchText string
|
||||||
|
filter backend.AlbumFilter
|
||||||
titleDisp *widget.RichText
|
titleDisp *widget.RichText
|
||||||
sortOrder *selectWidget
|
sortOrder *selectWidget
|
||||||
container *fyne.Container
|
container *fyne.Container
|
||||||
@@ -71,16 +73,23 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
|
|||||||
cfg.SortOrder = string(backend.AlbumSortRecentlyAdded)
|
cfg.SortOrder = string(backend.AlbumSortRecentlyAdded)
|
||||||
}
|
}
|
||||||
a.sortOrder.Selected = cfg.SortOrder
|
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)
|
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
|
||||||
contr.ConnectAlbumGridActions(a.grid)
|
contr.ConnectAlbumGridActions(a.grid)
|
||||||
a.searcher = widgets.NewSearchEntry()
|
a.createSearchAndFilter()
|
||||||
a.searcher.OnSearched = a.OnSearched
|
|
||||||
a.createContainer(false)
|
a.createContainer(false)
|
||||||
|
|
||||||
return a
|
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) {
|
func (a *AlbumsPage) createContainer(searchgrid bool) {
|
||||||
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
||||||
sortVbox := container.NewVBox(layout.NewSpacer(), a.sortOrder, layout.NewSpacer())
|
sortVbox := container.NewVBox(layout.NewSpacer(), a.sortOrder, layout.NewSpacer())
|
||||||
@@ -89,7 +98,7 @@ func (a *AlbumsPage) createContainer(searchgrid bool) {
|
|||||||
g = a.searchGrid
|
g = a.searchGrid
|
||||||
}
|
}
|
||||||
a.container = container.NewBorder(
|
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,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
@@ -99,11 +108,13 @@ func (a *AlbumsPage) createContainer(searchgrid bool) {
|
|||||||
|
|
||||||
func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
|
func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
|
||||||
a := &AlbumsPage{
|
a := &AlbumsPage{
|
||||||
cfg: saved.cfg,
|
cfg: saved.cfg,
|
||||||
contr: saved.contr,
|
contr: saved.contr,
|
||||||
pm: saved.pm,
|
pm: saved.pm,
|
||||||
lm: saved.lm,
|
lm: saved.lm,
|
||||||
im: saved.im,
|
im: saved.im,
|
||||||
|
searchText: saved.searchText,
|
||||||
|
filter: saved.filter,
|
||||||
}
|
}
|
||||||
a.ExtendBaseWidget(a)
|
a.ExtendBaseWidget(a)
|
||||||
|
|
||||||
@@ -115,13 +126,10 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
|
|||||||
a.sortOrder.Selected = saved.sortOrder
|
a.sortOrder.Selected = saved.sortOrder
|
||||||
a.sortOrder.OnChanged = a.onSortOrderChanged
|
a.sortOrder.OnChanged = a.onSortOrderChanged
|
||||||
a.grid = widgets.NewGridViewFromState(saved.gridState)
|
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 != "" {
|
if a.searchText != "" {
|
||||||
a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
|
a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
|
||||||
}
|
}
|
||||||
|
a.createSearchAndFilter()
|
||||||
a.createContainer(saved.searchText != "")
|
a.createContainer(saved.searchText != "")
|
||||||
|
|
||||||
return a
|
return a
|
||||||
@@ -154,7 +162,7 @@ func (a *AlbumsPage) Reload() {
|
|||||||
if a.searchText != "" {
|
if a.searchText != "" {
|
||||||
a.doSearch(a.searchText)
|
a.doSearch(a.searchText)
|
||||||
} else {
|
} 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.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||||
a.grid.Refresh()
|
a.grid.Refresh()
|
||||||
}
|
}
|
||||||
@@ -168,6 +176,7 @@ func (a *AlbumsPage) Save() SavedPage {
|
|||||||
lm: a.lm,
|
lm: a.lm,
|
||||||
im: a.im,
|
im: a.im,
|
||||||
searchText: a.searchText,
|
searchText: a.searchText,
|
||||||
|
filter: a.filter,
|
||||||
sortOrder: a.sortOrder.Selected,
|
sortOrder: a.sortOrder.Selected,
|
||||||
gridState: a.grid.SaveToState(),
|
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.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)), a.im)
|
||||||
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
||||||
} else {
|
} 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.container.Objects[0] = a.searchGrid
|
||||||
a.Refresh()
|
a.Refresh()
|
||||||
@@ -190,7 +199,7 @@ func (a *AlbumsPage) doSearch(query string) {
|
|||||||
|
|
||||||
func (a *AlbumsPage) onSortOrderChanged(order string) {
|
func (a *AlbumsPage) onSortOrderChanged(order string) {
|
||||||
a.cfg.SortOrder = a.sortOrder.Selected
|
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))
|
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||||
if a.searchText == "" {
|
if a.searchText == "" {
|
||||||
a.container.Objects[0] = a.grid
|
a.container.Objects[0] = a.grid
|
||||||
@@ -205,6 +214,7 @@ func (a *AlbumsPage) CreateRenderer() fyne.WidgetRenderer {
|
|||||||
|
|
||||||
type savedAlbumsPage struct {
|
type savedAlbumsPage struct {
|
||||||
searchText string
|
searchText string
|
||||||
|
filter backend.AlbumFilter
|
||||||
cfg *backend.AlbumsPageConfig
|
cfg *backend.AlbumsPageConfig
|
||||||
contr *controller.Controller
|
contr *controller.Controller
|
||||||
pm *backend.PlaybackManager
|
pm *backend.PlaybackManager
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package browsing
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/dweymouth/supersonic/backend"
|
"github.com/dweymouth/supersonic/backend"
|
||||||
"github.com/dweymouth/supersonic/ui/controller"
|
"github.com/dweymouth/supersonic/ui/controller"
|
||||||
@@ -30,6 +29,7 @@ type FavoritesPage struct {
|
|||||||
sm *backend.ServerManager
|
sm *backend.ServerManager
|
||||||
lm *backend.LibraryManager
|
lm *backend.LibraryManager
|
||||||
|
|
||||||
|
filter backend.AlbumFilter
|
||||||
searchText string
|
searchText string
|
||||||
nowPlayingID string
|
nowPlayingID string
|
||||||
pendingViewSwitch bool
|
pendingViewSwitch bool
|
||||||
@@ -39,6 +39,7 @@ type FavoritesPage struct {
|
|||||||
artistListCtr *fyne.Container
|
artistListCtr *fyne.Container
|
||||||
tracklistCtr *fyne.Container
|
tracklistCtr *fyne.Container
|
||||||
searcher *widgets.SearchEntry
|
searcher *widgets.SearchEntry
|
||||||
|
filterBtn *widgets.AlbumFilterButton
|
||||||
titleDisp *widget.RichText
|
titleDisp *widget.RichText
|
||||||
toggleBtns *widgets.ToggleButtonGroup
|
toggleBtns *widgets.ToggleButtonGroup
|
||||||
container *fyne.Container
|
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 {
|
func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Controller, sm *backend.ServerManager, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *FavoritesPage {
|
||||||
a := &FavoritesPage{
|
a := &FavoritesPage{
|
||||||
cfg: cfg,
|
filter: backend.AlbumFilter{ExcludeUnfavorited: true},
|
||||||
contr: contr,
|
cfg: cfg,
|
||||||
pm: pm,
|
contr: contr,
|
||||||
lm: lm,
|
pm: pm,
|
||||||
sm: sm,
|
lm: lm,
|
||||||
im: im,
|
sm: sm,
|
||||||
|
im: im,
|
||||||
}
|
}
|
||||||
a.ExtendBaseWidget(a)
|
a.ExtendBaseWidget(a)
|
||||||
a.createHeader(0, "")
|
a.createHeader(0)
|
||||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(lm.StarredIter()), a.im)
|
iter := lm.StarredIter(a.filter)
|
||||||
|
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
||||||
a.contr.ConnectAlbumGridActions(a.grid)
|
a.contr.ConnectAlbumGridActions(a.grid)
|
||||||
if cfg.InitialView == "Artists" {
|
if cfg.InitialView == "Artists" {
|
||||||
a.toggleBtns.SetActivatedButton(1)
|
a.toggleBtns.SetActivatedButton(1)
|
||||||
@@ -69,7 +72,7 @@ func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Contro
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
|
func (a *FavoritesPage) createHeader(activeBtnIdx int) {
|
||||||
a.titleDisp = widget.NewRichTextWithText("Favorites")
|
a.titleDisp = widget.NewRichTextWithText("Favorites")
|
||||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
||||||
SizeName: theme.SizeNameHeadingText,
|
SizeName: theme.SizeNameHeadingText,
|
||||||
@@ -80,27 +83,32 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
|
|||||||
widget.NewButtonWithIcon("", myTheme.TracksIcon, a.onShowFavoriteSongs))
|
widget.NewButtonWithIcon("", myTheme.TracksIcon, a.onShowFavoriteSongs))
|
||||||
a.searcher = widgets.NewSearchEntry()
|
a.searcher = widgets.NewSearchEntry()
|
||||||
a.searcher.OnSearched = a.OnSearched
|
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) {
|
func (a *FavoritesPage) createContainer(initialView fyne.CanvasObject) {
|
||||||
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
||||||
a.container = container.NewBorder(
|
a.container = container.NewBorder(container.NewHBox(util.NewHSpace(9),
|
||||||
container.NewHBox(util.NewHSpace(9), a.titleDisp, container.NewCenter(a.toggleBtns), layout.NewSpacer(), searchVbox, util.NewHSpace(15)),
|
a.titleDisp, container.NewCenter(a.toggleBtns), layout.NewSpacer(), container.NewCenter(a.filterBtn), searchVbox, util.NewHSpace(15)),
|
||||||
nil, nil, nil, initialView)
|
nil, nil, nil, initialView)
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
|
func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
|
||||||
a := &FavoritesPage{
|
a := &FavoritesPage{
|
||||||
cfg: saved.cfg,
|
cfg: saved.cfg,
|
||||||
contr: saved.contr,
|
contr: saved.contr,
|
||||||
pm: saved.pm,
|
pm: saved.pm,
|
||||||
lm: saved.lm,
|
lm: saved.lm,
|
||||||
sm: saved.sm,
|
sm: saved.sm,
|
||||||
im: saved.im,
|
im: saved.im,
|
||||||
|
searchText: saved.searchText,
|
||||||
|
filter: saved.filter,
|
||||||
}
|
}
|
||||||
a.ExtendBaseWidget(a)
|
a.ExtendBaseWidget(a)
|
||||||
a.createHeader(saved.activeToggleBtn, saved.searchText)
|
a.createHeader(saved.activeToggleBtn)
|
||||||
a.grid = widgets.NewGridViewFromState(saved.gridState)
|
a.grid = widgets.NewGridViewFromState(saved.gridState)
|
||||||
|
|
||||||
if saved.searchText != "" {
|
if saved.searchText != "" {
|
||||||
@@ -129,7 +137,8 @@ func (a *FavoritesPage) Reload() {
|
|||||||
if a.searchText != "" {
|
if a.searchText != "" {
|
||||||
a.doSearchAlbums(a.searchText)
|
a.doSearchAlbums(a.searchText)
|
||||||
} else {
|
} 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 {
|
if a.tracklistCtr != nil || a.artistListCtr != nil {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -169,6 +178,7 @@ func (a *FavoritesPage) Save() SavedPage {
|
|||||||
sm: a.sm,
|
sm: a.sm,
|
||||||
im: a.im,
|
im: a.im,
|
||||||
lm: a.lm,
|
lm: a.lm,
|
||||||
|
filter: a.filter,
|
||||||
searchText: a.searchText,
|
searchText: a.searchText,
|
||||||
gridState: a.grid.SaveToState(),
|
gridState: a.grid.SaveToState(),
|
||||||
activeToggleBtn: a.toggleBtns.ActivatedButtonIndex(),
|
activeToggleBtn: a.toggleBtns.ActivatedButtonIndex(),
|
||||||
@@ -219,9 +229,7 @@ func (a *FavoritesPage) SelectAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *FavoritesPage) doSearchAlbums(query string) {
|
func (a *FavoritesPage) doSearchAlbums(query string) {
|
||||||
iter := a.lm.SearchIterWithFilter(query, func(al *subsonic.AlbumID3) bool {
|
iter := a.lm.SearchIterWithFilter(query, a.filter)
|
||||||
return al.Starred.After(time.Time{})
|
|
||||||
})
|
|
||||||
if a.searchGrid == nil {
|
if a.searchGrid == nil {
|
||||||
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
||||||
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
||||||
@@ -235,6 +243,7 @@ func (a *FavoritesPage) doSearchAlbums(query string) {
|
|||||||
func (a *FavoritesPage) onShowFavoriteAlbums() {
|
func (a *FavoritesPage) onShowFavoriteAlbums() {
|
||||||
a.cfg.InitialView = "Albums" // save setting
|
a.cfg.InitialView = "Albums" // save setting
|
||||||
a.searcher.Entry.Show()
|
a.searcher.Entry.Show()
|
||||||
|
a.filterBtn.Show()
|
||||||
if a.searchText == "" {
|
if a.searchText == "" {
|
||||||
a.container.Objects[0] = a.grid
|
a.container.Objects[0] = a.grid
|
||||||
} else {
|
} else {
|
||||||
@@ -246,6 +255,7 @@ func (a *FavoritesPage) onShowFavoriteAlbums() {
|
|||||||
func (a *FavoritesPage) onShowFavoriteArtists() {
|
func (a *FavoritesPage) onShowFavoriteArtists() {
|
||||||
a.cfg.InitialView = "Artists" // save setting
|
a.cfg.InitialView = "Artists" // save setting
|
||||||
a.searcher.Entry.Hide() // disable search on artists for now
|
a.searcher.Entry.Hide() // disable search on artists for now
|
||||||
|
a.filterBtn.Hide()
|
||||||
if a.artistListCtr == nil {
|
if a.artistListCtr == nil {
|
||||||
if a.pendingViewSwitch {
|
if a.pendingViewSwitch {
|
||||||
return
|
return
|
||||||
@@ -294,6 +304,7 @@ func buildArtistListModel(artists []*subsonic.ArtistID3) []widgets.ArtistGenreLi
|
|||||||
func (a *FavoritesPage) onShowFavoriteSongs() {
|
func (a *FavoritesPage) onShowFavoriteSongs() {
|
||||||
a.cfg.InitialView = "Songs" // save setting
|
a.cfg.InitialView = "Songs" // save setting
|
||||||
a.searcher.Entry.Hide() // disable search on songs for now
|
a.searcher.Entry.Hide() // disable search on songs for now
|
||||||
|
a.filterBtn.Hide()
|
||||||
if a.tracklistCtr == nil {
|
if a.tracklistCtr == nil {
|
||||||
if a.pendingViewSwitch {
|
if a.pendingViewSwitch {
|
||||||
return
|
return
|
||||||
@@ -343,6 +354,7 @@ type savedFavoritesPage struct {
|
|||||||
lm *backend.LibraryManager
|
lm *backend.LibraryManager
|
||||||
gridState widgets.GridViewState
|
gridState widgets.GridViewState
|
||||||
searchGridState widgets.GridViewState
|
searchGridState widgets.GridViewState
|
||||||
|
filter backend.AlbumFilter
|
||||||
searchText string
|
searchText string
|
||||||
activeToggleBtn int
|
activeToggleBtn int
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-28
@@ -12,8 +12,6 @@ import (
|
|||||||
"fyne.io/fyne/v2/layout"
|
"fyne.io/fyne/v2/layout"
|
||||||
"fyne.io/fyne/v2/theme"
|
"fyne.io/fyne/v2/theme"
|
||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: there is a lot of code duplication between this and albumspage. Refactor?
|
// TODO: there is a lot of code duplication between this and albumspage. Refactor?
|
||||||
@@ -29,6 +27,8 @@ type GenrePage struct {
|
|||||||
searchGrid *widgets.GridView
|
searchGrid *widgets.GridView
|
||||||
searcher *widgets.SearchEntry
|
searcher *widgets.SearchEntry
|
||||||
searchText string
|
searchText string
|
||||||
|
filter backend.AlbumFilter
|
||||||
|
filterBtn *widgets.AlbumFilterButton
|
||||||
titleDisp *widget.RichText
|
titleDisp *widget.RichText
|
||||||
playRandom *widget.Button
|
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 {
|
func NewGenrePage(genre string, contr *controller.Controller, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *GenrePage {
|
||||||
g := &GenrePage{
|
g := &GenrePage{
|
||||||
genre: genre,
|
genre: genre,
|
||||||
contr: contr,
|
filter: backend.AlbumFilter{Genres: []string{genre}},
|
||||||
pm: pm,
|
contr: contr,
|
||||||
lm: lm,
|
pm: pm,
|
||||||
im: im,
|
lm: lm,
|
||||||
|
im: im,
|
||||||
}
|
}
|
||||||
g.ExtendBaseWidget(g)
|
g.ExtendBaseWidget(g)
|
||||||
|
|
||||||
@@ -52,16 +53,24 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba
|
|||||||
SizeName: theme.SizeNameHeadingText,
|
SizeName: theme.SizeNameHeadingText,
|
||||||
}
|
}
|
||||||
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
|
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.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
||||||
g.contr.ConnectAlbumGridActions(g.grid)
|
g.contr.ConnectAlbumGridActions(g.grid)
|
||||||
g.searcher = widgets.NewSearchEntry()
|
g.createSearchAndFilter()
|
||||||
g.searcher.OnSearched = g.OnSearched
|
|
||||||
g.createContainer(false)
|
g.createContainer(false)
|
||||||
|
|
||||||
return g
|
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) {
|
func (g *GenrePage) createContainer(searchGrid bool) {
|
||||||
searchVbox := container.NewVBox(layout.NewSpacer(), g.searcher, layout.NewSpacer())
|
searchVbox := container.NewVBox(layout.NewSpacer(), g.searcher, layout.NewSpacer())
|
||||||
gr := g.grid
|
gr := g.grid
|
||||||
@@ -70,21 +79,21 @@ func (g *GenrePage) createContainer(searchGrid bool) {
|
|||||||
}
|
}
|
||||||
playRandomVbox := container.NewVBox(layout.NewSpacer(), g.playRandom, layout.NewSpacer())
|
playRandomVbox := container.NewVBox(layout.NewSpacer(), g.playRandom, layout.NewSpacer())
|
||||||
g.container = container.NewBorder(
|
g.container = container.NewBorder(
|
||||||
container.NewHBox(util.NewHSpace(6), g.titleDisp, playRandomVbox, layout.NewSpacer(), searchVbox, util.NewHSpace(15)),
|
container.NewHBox(util.NewHSpace(6),
|
||||||
nil,
|
g.titleDisp, playRandomVbox, layout.NewSpacer(), container.NewCenter(g.filterBtn), searchVbox, util.NewHSpace(15)),
|
||||||
nil,
|
nil, nil, nil, gr,
|
||||||
nil,
|
|
||||||
gr,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func restoreGenrePage(saved *savedGenrePage) *GenrePage {
|
func restoreGenrePage(saved *savedGenrePage) *GenrePage {
|
||||||
g := &GenrePage{
|
g := &GenrePage{
|
||||||
genre: saved.genre,
|
genre: saved.genre,
|
||||||
contr: saved.contr,
|
contr: saved.contr,
|
||||||
pm: saved.pm,
|
pm: saved.pm,
|
||||||
lm: saved.lm,
|
lm: saved.lm,
|
||||||
im: saved.im,
|
im: saved.im,
|
||||||
|
searchText: saved.searchText,
|
||||||
|
filter: saved.filter,
|
||||||
}
|
}
|
||||||
g.ExtendBaseWidget(g)
|
g.ExtendBaseWidget(g)
|
||||||
|
|
||||||
@@ -94,10 +103,7 @@ func restoreGenrePage(saved *savedGenrePage) *GenrePage {
|
|||||||
}
|
}
|
||||||
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
|
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
|
||||||
g.grid = widgets.NewGridViewFromState(saved.gridState)
|
g.grid = widgets.NewGridViewFromState(saved.gridState)
|
||||||
g.searcher = widgets.NewSearchEntry()
|
g.createSearchAndFilter()
|
||||||
g.searcher.OnSearched = g.OnSearched
|
|
||||||
g.searcher.Entry.Text = saved.searchText
|
|
||||||
g.searchText = saved.searchText
|
|
||||||
if g.searchText != "" {
|
if g.searchText != "" {
|
||||||
g.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
|
g.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
|
||||||
}
|
}
|
||||||
@@ -118,7 +124,8 @@ func (g *GenrePage) Reload() {
|
|||||||
if g.searchText != "" {
|
if g.searchText != "" {
|
||||||
g.doSearch(g.searchText)
|
g.doSearch(g.searchText)
|
||||||
} else {
|
} 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()
|
g.grid.Refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +133,7 @@ func (g *GenrePage) Reload() {
|
|||||||
func (g *GenrePage) Save() SavedPage {
|
func (g *GenrePage) Save() SavedPage {
|
||||||
sg := &savedGenrePage{
|
sg := &savedGenrePage{
|
||||||
genre: g.genre,
|
genre: g.genre,
|
||||||
|
filter: g.filter,
|
||||||
searchText: g.searchText,
|
searchText: g.searchText,
|
||||||
contr: g.contr,
|
contr: g.contr,
|
||||||
pm: g.pm,
|
pm: g.pm,
|
||||||
@@ -159,9 +167,7 @@ func (g *GenrePage) OnSearched(query string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *GenrePage) doSearch(query string) {
|
func (g *GenrePage) doSearch(query string) {
|
||||||
iter := g.lm.SearchIterWithFilter(query, func(al *subsonic.AlbumID3) bool {
|
iter := g.lm.SearchIterWithFilter(query, g.filter)
|
||||||
return al.Genre == g.genre
|
|
||||||
})
|
|
||||||
if g.searchGrid == nil {
|
if g.searchGrid == nil {
|
||||||
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
||||||
g.contr.ConnectAlbumGridActions(g.searchGrid)
|
g.contr.ConnectAlbumGridActions(g.searchGrid)
|
||||||
@@ -179,6 +185,7 @@ func (g *GenrePage) playRandomSongs() {
|
|||||||
type savedGenrePage struct {
|
type savedGenrePage struct {
|
||||||
genre string
|
genre string
|
||||||
searchText string
|
searchText string
|
||||||
|
filter backend.AlbumFilter
|
||||||
contr *controller.Controller
|
contr *controller.Controller
|
||||||
pm *backend.PlaybackManager
|
pm *backend.PlaybackManager
|
||||||
lm *backend.LibraryManager
|
lm *backend.LibraryManager
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ func (s *SettingsDialog) createGeneralTab() *container.TabItem {
|
|||||||
|
|
||||||
// Scrobble settings
|
// Scrobble settings
|
||||||
|
|
||||||
twoDigitValidator := func(text string, r rune) bool {
|
twoDigitValidator := func(text, selText string, r rune) bool {
|
||||||
return unicode.IsDigit(r) && len(text) < 2
|
return unicode.IsDigit(r) && len(text)-len(selText) < 2
|
||||||
}
|
}
|
||||||
|
|
||||||
percentEntry := widgets.NewTextRestrictedEntry(twoDigitValidator)
|
percentEntry := widgets.NewTextRestrictedEntry(twoDigitValidator)
|
||||||
@@ -247,7 +247,7 @@ func (s *SettingsDialog) createPlaybackTab() *container.TabItem {
|
|||||||
replayGainSelect.SetSelectedIndex(0)
|
replayGainSelect.SetSelectedIndex(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
preampGain := widgets.NewTextRestrictedEntry(func(curText string, r rune) bool {
|
preampGain := widgets.NewTextRestrictedEntry(func(curText, _ string, r rune) bool {
|
||||||
return (curText == "" && r == '-') ||
|
return (curText == "" && r == '-') ||
|
||||||
(curText == "" && unicode.IsDigit(r)) ||
|
(curText == "" && unicode.IsDigit(r)) ||
|
||||||
((curText == "-" || curText == "0") && unicode.IsDigit(r))
|
((curText == "-" || curText == "0") && unicode.IsDigit(r))
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ var (
|
|||||||
PlaylistIcon fyne.Resource
|
PlaylistIcon fyne.Resource
|
||||||
ShuffleIcon fyne.Resource
|
ShuffleIcon fyne.Resource
|
||||||
TracksIcon fyne.Resource
|
TracksIcon fyne.Resource
|
||||||
|
FilterIcon fyne.Resource = theme.NewThemedResource(res.ResFilterSvg)
|
||||||
)
|
)
|
||||||
|
|
||||||
// MUST be called at startup!
|
// MUST be called at startup!
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+31
-21
@@ -129,11 +129,11 @@ func (g *GridView) Clear() {
|
|||||||
func (g *GridView) Reset(iter GridViewIterator) {
|
func (g *GridView) Reset(iter GridViewIterator) {
|
||||||
g.itemsMutex.Lock()
|
g.itemsMutex.Lock()
|
||||||
g.items = nil
|
g.items = nil
|
||||||
g.itemsMutex.Unlock()
|
|
||||||
g.fetching = false
|
g.fetching = false
|
||||||
g.done = false
|
g.done = false
|
||||||
g.highestShown = 0
|
g.highestShown = 0
|
||||||
g.iter = iter
|
g.iter = iter
|
||||||
|
g.itemsMutex.Unlock()
|
||||||
g.fetchMoreItems(36)
|
g.fetchMoreItems(36)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,8 +194,13 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
|||||||
if itemIdx > g.highestShown {
|
if itemIdx > g.highestShown {
|
||||||
g.highestShown = itemIdx
|
g.highestShown = itemIdx
|
||||||
}
|
}
|
||||||
|
var item GridViewItemModel
|
||||||
g.itemsMutex.RLock()
|
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()
|
g.itemsMutex.RUnlock()
|
||||||
if card.PrevID == item.ID {
|
if card.PrevID == item.ID {
|
||||||
// nothing to do
|
// nothing to do
|
||||||
@@ -208,26 +213,31 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
|||||||
card.ImgLoadCancel()
|
card.ImgLoadCancel()
|
||||||
card.ImgLoadCancel = nil
|
card.ImgLoadCancel = nil
|
||||||
}
|
}
|
||||||
if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
|
if item.CoverArtID != "" {
|
||||||
card.Cover.SetImage(img)
|
if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
|
||||||
} else {
|
card.Cover.SetImage(img)
|
||||||
card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
|
} else {
|
||||||
// asynchronously fetch cover image
|
card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
// asynchronously fetch cover image
|
||||||
card.ImgLoadCancel = cancel
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
go func(ctx context.Context) {
|
card.ImgLoadCancel = cancel
|
||||||
i, err := g.imageFetcher.GetCoverThumbnail(item.CoverArtID)
|
go func(ctx context.Context) {
|
||||||
select {
|
i, err := g.imageFetcher.GetCoverThumbnail(item.CoverArtID)
|
||||||
case <-ctx.Done():
|
select {
|
||||||
return
|
case <-ctx.Done():
|
||||||
default:
|
return
|
||||||
if err == nil {
|
default:
|
||||||
card.Cover.SetImage(i)
|
if err == nil {
|
||||||
} else {
|
card.Cover.SetImage(i)
|
||||||
log.Printf("error fetching image: %s", err.Error())
|
} 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
|
// if user has scrolled near the bottom, fetch more
|
||||||
|
|||||||
@@ -13,19 +13,21 @@ import (
|
|||||||
type TextRestrictedEntry struct {
|
type TextRestrictedEntry struct {
|
||||||
widget.Entry
|
widget.Entry
|
||||||
|
|
||||||
charAllowed func(string, rune) bool
|
charAllowed CharAllowedFunc
|
||||||
|
|
||||||
minWidth float32
|
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 := &TextRestrictedEntry{charAllowed: charAllowed}
|
||||||
e.ExtendBaseWidget(e)
|
e.ExtendBaseWidget(e)
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *TextRestrictedEntry) TypedRune(r rune) {
|
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)
|
e.Entry.TypedRune(r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user