diff --git a/CHANGELOG.md b/CHANGELOG.md
index aac7c7c..eba9eda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
- [#145](https://github.com/dweymouth/supersonic/issues/145) Add rating column to tracklist with 5-star rating widget
- [b017995](https://github.com/dweymouth/supersonic/commit/b01799550ded0c6a8f33913827df23818f9a7353) Add Ctrl+W (Cmd+W on Mac) shortcut to close to tray if enabled
- [#95](https://github.com/dweymouth/supersonic/issues/95) Enable click-to-seek behavior in seek bar
+- [#40](https://github.com/dweymouth/supersonic/issues/40) Add grid view option to playlists page
### Fixed
- [#148](https://github.com/dweymouth/supersonic/issues/148) Fix potential crash when searching for albums
diff --git a/backend/app.go b/backend/app.go
index a8d81b0..653b8db 100644
--- a/backend/app.go
+++ b/backend/app.go
@@ -65,7 +65,7 @@ func StartupApp(appName, appVersionTag, configFile, latestReleaseURL string) (*A
a.LibraryManager = NewLibraryManager(a.ServerManager)
a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName))
a.LibraryManager.PreCacheCoverFn = func(coverID string) {
- _, _ = a.ImageManager.GetAlbumThumbnail(coverID)
+ _, _ = a.ImageManager.GetCoverThumbnail(coverID)
}
return a, nil
diff --git a/backend/config.go b/backend/config.go
index 4e30009..0f1e685 100644
--- a/backend/config.go
+++ b/backend/config.go
@@ -59,6 +59,10 @@ type PlaylistPageConfig struct {
TracklistColumns []string
}
+type PlaylistsPageConfig struct {
+ InitialView string
+}
+
type TracksPageConfig struct {
TracklistColumns []string
}
@@ -91,6 +95,7 @@ type Config struct {
FavoritesPage FavoritesPageConfig
NowPlayingPage NowPlayingPageConfig
PlaylistPage PlaylistPageConfig
+ PlaylistsPage PlaylistsPageConfig
TracksPage TracksPageConfig
LocalPlayback LocalPlaybackConfig
Scrobbling ScrobbleConfig
@@ -126,6 +131,9 @@ func DefaultConfig(appVersionTag string) *Config {
PlaylistPage: PlaylistPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
},
+ PlaylistsPage: PlaylistsPageConfig{
+ InitialView: "List",
+ },
TracksPage: TracksPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
},
diff --git a/backend/imagemanager.go b/backend/imagemanager.go
index 6dce73f..a330b2d 100644
--- a/backend/imagemanager.go
+++ b/backend/imagemanager.go
@@ -45,7 +45,7 @@ func NewImageManager(ctx context.Context, s *ServerManager, baseCacheDir string)
return i
}
-func (i *ImageManager) GetAlbumThumbnailFromCache(coverID string) (image.Image, bool) {
+func (i *ImageManager) GetCoverThumbnailFromCache(coverID string) (image.Image, bool) {
img, err := i.thumbnailCache.GetExtendTTL(coverID, i.thumbnailCache.DefaultTTL)
if err == nil && img != nil {
return img, true
@@ -53,14 +53,14 @@ func (i *ImageManager) GetAlbumThumbnailFromCache(coverID string) (image.Image,
return nil, false
}
-func (i *ImageManager) GetAlbumThumbnail(coverID string) (image.Image, error) {
- if im, ok := i.GetAlbumThumbnailFromCache(coverID); ok {
+func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) {
+ if im, ok := i.GetCoverThumbnailFromCache(coverID); ok {
return im, nil
}
return i.fetchAndCacheCoverFromDiskOrServer(coverID, i.thumbnailCache.DefaultTTL)
}
-func (i *ImageManager) GetAlbumThumbnailWithTTL(coverID string, ttl time.Duration) (image.Image, error) {
+func (i *ImageManager) GetCoverThumbnailWithTTL(coverID string, ttl time.Duration) (image.Image, error) {
// in-memory cache
if img, err := i.thumbnailCache.GetWithNewTTL(coverID, ttl); err == nil {
return img, nil
@@ -68,7 +68,7 @@ func (i *ImageManager) GetAlbumThumbnailWithTTL(coverID string, ttl time.Duratio
return i.fetchAndCacheCoverFromDiskOrServer(coverID, ttl)
}
-func (i *ImageManager) GetFullSizeAlbumCover(coverID string) (image.Image, error) {
+func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error) {
if i.cachedFullSizeCoverID == coverID {
return i.cachedFullSizeCover, nil
}
diff --git a/res/bundled.go b/res/bundled.go
index d02d826..4bf4716 100644
--- a/res/bundled.go
+++ b/res/bundled.go
@@ -130,6 +130,16 @@ var ResStarFilledSvg = &fyne.StaticResource{
StaticContent: []byte(
"\r\n"),
}
+var ResGridSvg = &fyne.StaticResource{
+ StaticName: "grid.svg",
+ StaticContent: []byte(
+ "\r"),
+}
+var ResListSvg = &fyne.StaticResource{
+ StaticName: "list.svg",
+ StaticContent: []byte(
+ "\r"),
+}
var ResLICENSE = &fyne.StaticResource{
StaticName: "LICENSE",
StaticContent: []byte(
diff --git a/res/bundled_gen.sh b/res/bundled_gen.sh
index f21f5fe..3bb5b19 100755
--- a/res/bundled_gen.sh
+++ b/res/bundled_gen.sh
@@ -25,6 +25,8 @@ fyne bundle -append -prefix Res icons/publicdomain/shuffle.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/shuffle-invert.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/star-outline.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/list.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/grid.svg b/res/icons/publicdomain/grid.svg
new file mode 100644
index 0000000..a4476a9
--- /dev/null
+++ b/res/icons/publicdomain/grid.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/res/icons/publicdomain/list.svg b/res/icons/publicdomain/list.svg
new file mode 100644
index 0000000..c19cdc1
--- /dev/null
+++ b/res/icons/publicdomain/list.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go
index e62703f..1729f9c 100644
--- a/sharedutil/sharedutil.go
+++ b/sharedutil/sharedutil.go
@@ -35,6 +35,14 @@ func FilterSlice[T any](ss []T, test func(T) bool) []T {
return result
}
+func MapSlice[T any, U any](ts []T, f func(T) U) []U {
+ result := make([]U, len(ts))
+ for i, t := range ts {
+ result[i] = f(t)
+ }
+ return result
+}
+
func FindTrackByID(id string, tracks []*subsonic.Child) *subsonic.Child {
for _, tr := range tracks {
if id == tr.ID {
diff --git a/ui/bottompanel.go b/ui/bottompanel.go
index 4c78f16..8ff0638 100644
--- a/ui/bottompanel.go
+++ b/ui/bottompanel.go
@@ -95,7 +95,7 @@ func (bp *BottomPanel) onSongChange(song *subsonic.Child, _ *subsonic.Child) {
// be in cache for the next song if it's from the same album, or
// if the user navigates to the album page for the track
imgTTLSec := song.Duration + 30
- im, _ = bp.ImageManager.GetAlbumThumbnailWithTTL(song.CoverArt, time.Duration(imgTTLSec)*time.Second)
+ im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArt, time.Duration(imgTTLSec)*time.Second)
}
bp.NowPlaying.Update(song.Title, song.Artist, song.Album, im)
}
diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go
index 4499b65..9f483a9 100644
--- a/ui/browsing/albumpage.go
+++ b/ui/browsing/albumpage.go
@@ -226,7 +226,7 @@ func (a *AlbumPageHeader) Update(album *subsonic.AlbumID3, im *backend.ImageMana
a.Refresh()
go func() {
- if cover, err := im.GetAlbumThumbnail(album.CoverArt); err == nil {
+ if cover, err := im.GetCoverThumbnail(album.CoverArt); err == nil {
a.cover.Image.Image = cover
a.cover.Refresh()
} else {
@@ -244,7 +244,7 @@ func (a *AlbumPageHeader) toggleFavorited() {
}
func (a *AlbumPageHeader) showPopUpCover() {
- cover, err := a.page.im.GetFullSizeAlbumCover(a.coverID)
+ cover, err := a.page.im.GetFullSizeCoverArt(a.coverID)
if err != nil {
log.Printf("error getting full size album cover: %s", err.Error())
return
diff --git a/ui/browsing/albumspage.go b/ui/browsing/albumspage.go
index ecfcf1a..8c2ed16 100644
--- a/ui/browsing/albumspage.go
+++ b/ui/browsing/albumspage.go
@@ -24,8 +24,8 @@ type AlbumsPage struct {
pm *backend.PlaybackManager
im *backend.ImageManager
lm *backend.LibraryManager
- grid *widgets.AlbumGrid
- searchGrid *widgets.AlbumGrid
+ grid *widgets.GridView
+ searchGrid *widgets.GridView
searcher *widgets.Searcher
searchText string
titleDisp *widget.RichText
@@ -72,10 +72,10 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
}
a.sortOrder.Selected = cfg.SortOrder
iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected))
- a.grid = widgets.NewAlbumGrid(iter, im, false /*showYear*/)
- a.grid.OnPlayAlbum = a.onPlayAlbum
- a.grid.OnShowArtistPage = a.onShowArtistPage
- a.grid.OnShowAlbumPage = a.onShowAlbumPage
+ a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
+ a.grid.OnPlay = a.onPlayAlbum
+ a.grid.OnShowSecondaryPage = a.onShowArtistPage
+ a.grid.OnShowItemPage = a.onShowAlbumPage
a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched
a.createContainer(false)
@@ -116,13 +116,13 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
a.sortOrder = NewSelect(backend.AlbumSortOrders, nil)
a.sortOrder.Selected = saved.sortOrder
a.sortOrder.OnChanged = a.onSortOrderChanged
- a.grid = widgets.NewAlbumGridFromState(saved.gridState)
+ a.grid = widgets.NewGridViewFromState(saved.gridState)
a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched
a.searcher.Entry.Text = saved.searchText
a.searchText = saved.searchText
if a.searchText != "" {
- a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState)
+ a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
}
a.createContainer(saved.searchText != "")
@@ -156,7 +156,8 @@ func (a *AlbumsPage) Reload() {
if a.searchText != "" {
a.doSearch(a.searchText)
} else {
- a.grid.Reset(a.lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected)))
+ iter := a.lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected))
+ a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
a.grid.Refresh()
}
}
@@ -180,12 +181,12 @@ func (a *AlbumsPage) Save() SavedPage {
func (a *AlbumsPage) doSearch(query string) {
if a.searchGrid == nil {
- a.searchGrid = widgets.NewAlbumGrid(a.lm.SearchIter(query), a.im, false /*showYear*/)
- a.searchGrid.OnPlayAlbum = a.onPlayAlbum
- a.searchGrid.OnShowAlbumPage = a.onShowAlbumPage
- a.searchGrid.OnShowArtistPage = a.onShowArtistPage
+ a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)), a.im)
+ a.searchGrid.OnPlay = a.onPlayAlbum
+ a.searchGrid.OnShowItemPage = a.onShowAlbumPage
+ a.searchGrid.OnShowSecondaryPage = a.onShowArtistPage
} else {
- a.searchGrid.Reset(a.lm.SearchIter(query))
+ a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)))
}
a.container.Objects[0] = a.searchGrid
a.Refresh()
@@ -205,7 +206,8 @@ func (a *AlbumsPage) onShowAlbumPage(albumID string) {
func (a *AlbumsPage) onSortOrderChanged(order string) {
a.cfg.SortOrder = a.sortOrder.Selected
- a.grid.Reset(a.lm.AlbumsIter(backend.AlbumSortOrder(order)))
+ iter := a.lm.AlbumsIter(backend.AlbumSortOrder(order))
+ a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
if a.searchText == "" {
a.container.Objects[0] = a.grid
a.Refresh()
@@ -225,8 +227,8 @@ type savedAlbumsPage struct {
lm *backend.LibraryManager
im *backend.ImageManager
sortOrder string
- gridState widgets.AlbumGridState
- searchGridState widgets.AlbumGridState
+ gridState widgets.GridViewState
+ searchGridState widgets.GridViewState
}
func (s *savedAlbumsPage) Restore() Page {
diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go
index b584817..7eac001 100644
--- a/ui/browsing/artistpage.go
+++ b/ui/browsing/artistpage.go
@@ -2,6 +2,7 @@ package browsing
import (
"log"
+ "strconv"
"strings"
"supersonic/backend"
"supersonic/res"
@@ -39,7 +40,7 @@ type ArtistPage struct {
artistInfo *subsonic.ArtistID3
- albumGrid *widgets.AlbumGrid
+ albumGrid *widgets.GridView
tracklistCtr *fyne.Container
nowPlayingID string
header *ArtistPageHeader
@@ -172,9 +173,17 @@ func (a *ArtistPage) showAlbumGrid() {
a.activeView = 0 // if page still loading, will show discography view first
return
}
- a.albumGrid = widgets.NewFixedAlbumGrid(a.artistInfo.Album, a.im, true /*showYear*/)
- a.albumGrid.OnPlayAlbum = a.onPlayAlbum
- a.albumGrid.OnShowAlbumPage = a.onShowAlbumPage
+ model := sharedutil.MapSlice(a.artistInfo.Album, func(al *subsonic.AlbumID3) widgets.GridViewItemModel {
+ return widgets.GridViewItemModel{
+ Name: al.Name,
+ ID: al.ID,
+ CoverArtID: al.CoverArt,
+ Secondary: strconv.Itoa(al.Year),
+ }
+ })
+ a.albumGrid = widgets.NewFixedGridView(model, a.im)
+ a.albumGrid.OnPlay = a.onPlayAlbum
+ a.albumGrid.OnShowItemPage = a.onShowAlbumPage
}
a.container.Objects[0].(*fyne.Container).Objects[0] = a.albumGrid
a.container.Objects[0].Refresh()
diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go
index 49c4533..389682c 100644
--- a/ui/browsing/favoritespage.go
+++ b/ui/browsing/favoritespage.go
@@ -32,8 +32,8 @@ type FavoritesPage struct {
nowPlayingID string
pendingViewSwitch bool
- grid *widgets.AlbumGrid
- searchGrid *widgets.AlbumGrid
+ grid *widgets.GridView
+ searchGrid *widgets.GridView
artistListCtr *fyne.Container
tracklistCtr *fyne.Container
searcher *widgets.Searcher
@@ -53,7 +53,7 @@ func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Contro
}
a.ExtendBaseWidget(a)
a.createHeader(0, "")
- a.grid = widgets.NewAlbumGrid(a.lm.StarredIter(), a.im, false)
+ a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(lm.StarredIter()), a.im)
a.connectGridActions()
if cfg.InitialView == "Artists" {
a.toggleBtns.SetActivatedButton(1)
@@ -82,9 +82,9 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
}
func (a *FavoritesPage) connectGridActions() {
- a.grid.OnPlayAlbum = a.onPlayAlbum
- a.grid.OnShowAlbumPage = a.onShowAlbumPage
- a.grid.OnShowArtistPage = a.onShowArtistPage
+ a.grid.OnPlay = a.onPlayAlbum
+ a.grid.OnShowItemPage = a.onShowAlbumPage
+ a.grid.OnShowSecondaryPage = a.onShowArtistPage
}
func (a *FavoritesPage) createContainer(initialView fyne.CanvasObject) {
@@ -105,11 +105,11 @@ func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
}
a.ExtendBaseWidget(a)
a.createHeader(saved.activeToggleBtn, saved.searchText)
- a.grid = widgets.NewAlbumGridFromState(saved.gridState)
+ a.grid = widgets.NewGridViewFromState(saved.gridState)
a.connectGridActions()
if saved.searchText != "" {
- a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState)
+ a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
}
if saved.activeToggleBtn == 1 {
@@ -134,7 +134,7 @@ func (a *FavoritesPage) Reload() {
if a.searchText != "" {
a.doSearchAlbums(a.searchText)
} else {
- a.grid.Reset(a.lm.StarredIter())
+ a.grid.Reset(widgets.NewGridViewAlbumIterator(a.lm.StarredIter()))
}
if a.tracklistCtr != nil || a.artistListCtr != nil {
go func() {
@@ -220,12 +220,12 @@ func (a *FavoritesPage) doSearchAlbums(query string) {
return al.Starred.After(time.Time{})
})
if a.searchGrid == nil {
- a.searchGrid = widgets.NewAlbumGrid(iter, a.im, false /*showYear*/)
- a.searchGrid.OnPlayAlbum = a.onPlayAlbum
- a.searchGrid.OnShowAlbumPage = a.onShowAlbumPage
- a.searchGrid.OnShowArtistPage = a.onShowArtistPage
+ a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
+ a.searchGrid.OnPlay = a.onPlayAlbum
+ a.searchGrid.OnShowItemPage = a.onShowAlbumPage
+ a.searchGrid.OnShowSecondaryPage = a.onShowArtistPage
} else {
- a.searchGrid.Reset(iter)
+ a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter))
}
a.container.Objects[0] = a.searchGrid
a.Refresh()
@@ -352,8 +352,8 @@ type savedFavoritesPage struct {
sm *backend.ServerManager
im *backend.ImageManager
lm *backend.LibraryManager
- gridState widgets.AlbumGridState
- searchGridState widgets.AlbumGridState
+ gridState widgets.GridViewState
+ searchGridState widgets.GridViewState
searchText string
activeToggleBtn int
}
diff --git a/ui/browsing/genrepage.go b/ui/browsing/genrepage.go
index f71bfcb..70d2730 100644
--- a/ui/browsing/genrepage.go
+++ b/ui/browsing/genrepage.go
@@ -24,8 +24,8 @@ type GenrePage struct {
im *backend.ImageManager
pm *backend.PlaybackManager
lm *backend.LibraryManager
- grid *widgets.AlbumGrid
- searchGrid *widgets.AlbumGrid
+ grid *widgets.GridView
+ searchGrid *widgets.GridView
searcher *widgets.Searcher
searchText string
titleDisp *widget.RichText
@@ -52,10 +52,10 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba
}
g.playRandom = widget.NewButtonWithIcon("Play random", res.ResShuffleInvertSvg, g.playRandomSongs)
iter := g.lm.GenreIter(g.genre)
- g.grid = widgets.NewAlbumGrid(iter, g.im, false)
- g.grid.OnPlayAlbum = g.onPlayAlbum
- g.grid.OnShowArtistPage = g.onShowArtistPage
- g.grid.OnShowAlbumPage = g.onShowAlbumPage
+ g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
+ g.grid.OnPlay = g.onPlayAlbum
+ g.grid.OnShowSecondaryPage = g.onShowArtistPage
+ g.grid.OnShowItemPage = g.onShowAlbumPage
g.searcher = widgets.NewSearcher()
g.searcher.OnSearched = g.OnSearched
g.createContainer(false)
@@ -94,13 +94,13 @@ func restoreGenrePage(saved *savedGenrePage) *GenrePage {
SizeName: theme.SizeNameHeadingText,
}
g.playRandom = widget.NewButtonWithIcon("Play random", res.ResShuffleInvertSvg, g.playRandomSongs)
- g.grid = widgets.NewAlbumGridFromState(saved.gridState)
+ g.grid = widgets.NewGridViewFromState(saved.gridState)
g.searcher = widgets.NewSearcher()
g.searcher.OnSearched = g.OnSearched
g.searcher.Entry.Text = saved.searchText
g.searchText = saved.searchText
if g.searchText != "" {
- g.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState)
+ g.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
}
g.createContainer(saved.searchText != "")
@@ -119,7 +119,7 @@ func (g *GenrePage) Reload() {
if g.searchText != "" {
g.doSearch(g.searchText)
} else {
- g.grid.Reset(g.lm.GenreIter(g.genre))
+ g.grid.Reset(widgets.NewGridViewAlbumIterator(g.lm.GenreIter(g.genre)))
g.grid.Refresh()
}
}
@@ -176,12 +176,12 @@ func (g *GenrePage) doSearch(query string) {
return al.Genre == g.genre
})
if g.searchGrid == nil {
- g.searchGrid = widgets.NewAlbumGrid(iter, g.im, false /*showYear*/)
- g.searchGrid.OnPlayAlbum = g.onPlayAlbum
- g.searchGrid.OnShowAlbumPage = g.onShowAlbumPage
- g.searchGrid.OnShowArtistPage = g.onShowArtistPage
+ g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
+ g.searchGrid.OnPlay = g.onPlayAlbum
+ g.searchGrid.OnShowItemPage = g.onShowAlbumPage
+ g.searchGrid.OnShowSecondaryPage = g.onShowArtistPage
} else {
- g.searchGrid.Reset(iter)
+ g.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter))
}
g.container.Objects[0] = g.searchGrid
g.Refresh()
@@ -198,8 +198,8 @@ type savedGenrePage struct {
pm *backend.PlaybackManager
lm *backend.LibraryManager
im *backend.ImageManager
- gridState widgets.AlbumGridState
- searchGridState widgets.AlbumGridState
+ gridState widgets.GridViewState
+ searchGridState widgets.GridViewState
}
func (s *savedGenrePage) Restore() Page {
diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go
index 014d42f..00a8c62 100644
--- a/ui/browsing/playlistpage.go
+++ b/ui/browsing/playlistpage.go
@@ -233,13 +233,13 @@ func (a *PlaylistPageHeader) Update(playlist *subsonic.Playlist) {
var haveCover bool
if playlist.CoverArt != "" {
- if im, err := a.page.im.GetAlbumThumbnail(playlist.CoverArt); err == nil && im != nil {
+ if im, err := a.page.im.GetCoverThumbnail(playlist.CoverArt); err == nil && im != nil {
a.image.SetImage(im, false /*tappable*/)
haveCover = true
}
}
if !haveCover {
- if im, err := a.page.im.GetAlbumThumbnail(playlist.ID); err == nil && im != nil {
+ if im, err := a.page.im.GetCoverThumbnail(playlist.ID); err == nil && im != nil {
a.image.SetImage(im, false)
}
}
diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go
index 0a7b189..de198bc 100644
--- a/ui/browsing/playlistspage.go
+++ b/ui/browsing/playlistspage.go
@@ -1,10 +1,12 @@
package browsing
import (
+ "fmt"
"log"
"strconv"
"strings"
"supersonic/backend"
+ "supersonic/res"
"supersonic/sharedutil"
"supersonic/ui/controller"
"supersonic/ui/layouts"
@@ -21,36 +23,52 @@ import (
type PlaylistsPage struct {
widget.BaseWidget
- contr *controller.Controller
- sm *backend.ServerManager
- playlists []*subsonic.Playlist
+ cfg *backend.PlaylistsPageConfig
+ contr *controller.Controller
+ sm *backend.ServerManager
+ playlists []*subsonic.Playlist
+ searchedPlaylists []*subsonic.Playlist
- searcher *widgets.Searcher
- titleDisp *widget.RichText
- container *fyne.Container
- list *PlaylistList
+ viewToggle *widgets.ToggleButtonGroup
+ searcher *widgets.Searcher
+ titleDisp *widget.RichText
+ container *fyne.Container
+ listView *PlaylistList
+ gridView *widgets.GridView
}
-func NewPlaylistsPage(contr *controller.Controller, sm *backend.ServerManager) *PlaylistsPage {
- return newPlaylistsPage(contr, sm, "")
+func NewPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, sm *backend.ServerManager) *PlaylistsPage {
+ activeView := 0
+ if cfg.InitialView == "Grid" {
+ activeView = 1
+ }
+ return newPlaylistsPage(contr, cfg, sm, "", activeView)
}
-func newPlaylistsPage(contr *controller.Controller, sm *backend.ServerManager, searchText string) *PlaylistsPage {
+func newPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, sm *backend.ServerManager, searchText string, activeView int) *PlaylistsPage {
a := &PlaylistsPage{
+ cfg: cfg,
sm: sm,
contr: contr,
titleDisp: widget.NewRichTextWithText("Playlists"),
}
a.ExtendBaseWidget(a)
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
- a.list = NewPlaylistList()
- a.list.OnNavTo = func(id string) {
- a.contr.NavigateTo(controller.PlaylistRoute(id))
- }
a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText
- a.buildContainer()
+ a.viewToggle = widgets.NewToggleButtonGroup(0,
+ widget.NewButtonWithIcon("", theme.NewThemedResource(res.ResListSvg), a.showListView),
+ widget.NewButtonWithIcon("", theme.NewThemedResource(res.ResGridSvg), a.showGridView))
+ a.viewToggle.SetActivatedButton(activeView)
+ if activeView == 0 {
+ a.createListView()
+ a.buildContainer(a.listView)
+ } else {
+ a.createGridView(nil)
+ a.buildContainer(a.gridView)
+ }
+
go a.load(searchText != "")
return a
}
@@ -64,26 +82,103 @@ func (a *PlaylistsPage) load(searchOnLoad bool) {
if searchOnLoad {
a.onSearched(a.searcher.Entry.Text)
} else {
- a.list.Playlists = playlists
- a.list.Refresh()
+ a.refreshView(playlists)
}
}
+func (a *PlaylistsPage) createListView() {
+ a.listView = NewPlaylistList()
+ a.listView.OnNavTo = a.showPlaylistPage
+}
+
+func (a *PlaylistsPage) createGridView(playlists []*subsonic.Playlist) {
+ model := createPlaylistGridViewModel(playlists)
+ a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager)
+ a.gridView.OnPlay = func(id string) {
+ a.contr.App.PlaybackManager.PlayPlaylist(id, 0)
+ }
+ a.gridView.OnShowItemPage = a.showPlaylistPage
+}
+
+func (a *PlaylistsPage) showListView() {
+ a.cfg.InitialView = "List" // save setting
+ if a.listView == nil {
+ a.createListView()
+ if a.searcher.Entry.Text != "" {
+ a.listView.Playlists = a.searchedPlaylists
+ } else {
+ a.listView.Playlists = a.playlists
+ }
+ }
+ a.container.Objects[0].(*fyne.Container).Objects[0] = a.listView
+ a.container.Objects[0].Refresh()
+}
+
+func (a *PlaylistsPage) showGridView() {
+ a.cfg.InitialView = "Grid" // save setting
+ if a.gridView == nil {
+ playlists := a.playlists
+ if a.searcher.Entry.Text != "" {
+ playlists = a.searchedPlaylists
+ }
+ a.createGridView(playlists)
+ }
+ a.container.Objects[0].(*fyne.Container).Objects[0] = a.gridView
+ a.container.Objects[0].Refresh()
+}
+
+func createPlaylistGridViewModel(playlists []*subsonic.Playlist) []widgets.GridViewItemModel {
+ return sharedutil.MapSlice(playlists, func(pl *subsonic.Playlist) widgets.GridViewItemModel {
+ tracks := "tracks"
+ if pl.SongCount == 1 {
+ tracks = "track"
+ }
+ return widgets.GridViewItemModel{
+ Name: pl.Name,
+ ID: pl.ID,
+ CoverArtID: pl.CoverArt,
+ Secondary: fmt.Sprintf("%d %s", pl.SongCount, tracks),
+ }
+ })
+}
+
+func (a *PlaylistsPage) showPlaylistPage(id string) {
+ a.contr.NavigateTo(controller.PlaylistRoute(id))
+}
+
func (a *PlaylistsPage) onSearched(query string) {
// since the playlist list is returned in full non-paginated, we will do our own
// simple search based on the name, description, and owner, rather than calling a server API
+ var playlists []*subsonic.Playlist
if query == "" {
- a.list.Playlists = a.playlists
+ a.searchedPlaylists = nil
+ playlists = a.playlists
} else {
- result := sharedutil.FilterSlice(a.playlists, func(p *subsonic.Playlist) bool {
+ a.searchedPlaylists = sharedutil.FilterSlice(a.playlists, func(p *subsonic.Playlist) bool {
qLower := strings.ToLower(query)
return strings.Contains(strings.ToLower(p.Name), qLower) ||
strings.Contains(strings.ToLower(p.Comment), qLower) ||
strings.Contains(strings.ToLower(p.Owner), qLower)
})
- a.list.Playlists = result
+ playlists = a.searchedPlaylists
+ }
+ a.refreshView(playlists)
+}
+
+// update the model for both views if initialized,
+// refresh the active view
+func (a *PlaylistsPage) refreshView(playlists []*subsonic.Playlist) {
+ if a.listView != nil {
+ a.listView.Playlists = playlists
+ }
+ if a.gridView != nil {
+ a.gridView.ResetFixed(createPlaylistGridViewModel(playlists))
+ }
+ if a.viewToggle.ActivatedButtonIndex() == 0 {
+ a.listView.Refresh()
+ } else {
+ a.gridView.Refresh()
}
- a.list.Refresh()
}
var _ Searchable = (*PlaylistsPage)(nil)
@@ -97,33 +192,37 @@ func (a *PlaylistsPage) Route() controller.Route {
}
func (a *PlaylistsPage) Reload() {
- go a.load(false)
+ go a.load(a.searcher.Entry.Text != "")
}
func (a *PlaylistsPage) Save() SavedPage {
return &savedPlaylistsPage{
contr: a.contr,
+ cfg: a.cfg,
sm: a.sm,
searchText: a.searcher.Entry.Text,
+ activeView: a.viewToggle.ActivatedButtonIndex(),
}
}
type savedPlaylistsPage struct {
contr *controller.Controller
+ cfg *backend.PlaylistsPageConfig
sm *backend.ServerManager
searchText string
+ activeView int
}
func (s *savedPlaylistsPage) Restore() Page {
- return newPlaylistsPage(s.contr, s.sm, s.searchText)
+ return newPlaylistsPage(s.contr, s.cfg, s.sm, s.searchText, s.activeView)
}
-func (a *PlaylistsPage) buildContainer() {
+func (a *PlaylistsPage) buildContainer(initialView fyne.CanvasObject) {
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher.Entry, layout.NewSpacer())
a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
container.NewBorder(
- container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox),
- nil, nil, nil, a.list))
+ container.NewHBox(a.titleDisp, container.NewCenter(a.viewToggle), layout.NewSpacer(), searchVbox),
+ nil, nil, nil, initialView))
}
func (a *PlaylistsPage) CreateRenderer() fyne.WidgetRenderer {
diff --git a/ui/browsing/router.go b/ui/browsing/router.go
index 4a603b0..9facac0 100644
--- a/ui/browsing/router.go
+++ b/ui/browsing/router.go
@@ -45,7 +45,7 @@ func (r Router) CreatePage(rte controller.Route) Page {
case controller.Playlist:
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
case controller.Playlists:
- return NewPlaylistsPage(r.Controller, r.App.ServerManager)
+ return NewPlaylistsPage(r.Controller, &r.App.Config.PlaylistsPage, r.App.ServerManager)
case controller.Tracks:
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.App.LibraryManager)
}
diff --git a/ui/widgets/albumcard.go b/ui/widgets/albumcard.go
deleted file mode 100644
index 4f68e2b..0000000
--- a/ui/widgets/albumcard.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package widgets
-
-import (
- "context"
- "image"
- "strconv"
-
- "supersonic/res"
- "supersonic/ui/layouts"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/canvas"
- "fyne.io/fyne/v2/container"
- "fyne.io/fyne/v2/driver/desktop"
- "fyne.io/fyne/v2/widget"
-
- "github.com/dweymouth/go-subsonic/subsonic"
-)
-
-var _ fyne.Widget = (*AlbumCard)(nil)
-
-var _ fyne.Widget = (*albumCover)(nil)
-var _ fyne.Tappable = (*albumCover)(nil)
-
-type albumCover struct {
- widget.BaseWidget
-
- Im *canvas.Image
- playbtn *canvas.Image
- OnDoubleTapped func()
- OnTapped func()
-}
-
-func newAlbumCover() *albumCover {
- a := &albumCover{}
- a.ExtendBaseWidget(a)
- a.Im = &canvas.Image{FillMode: canvas.ImageFillContain, ScaleMode: canvas.ImageScaleFastest}
- a.Im.SetMinSize(fyne.NewSize(200, 200))
- a.playbtn = &canvas.Image{FillMode: canvas.ImageFillContain, Resource: res.ResPlaybuttonPng}
- a.playbtn.SetMinSize(fyne.NewSize(60, 60))
- a.playbtn.Hidden = true
- return a
-}
-
-func (a *albumCover) CreateRenderer() fyne.WidgetRenderer {
- return widget.NewSimpleRenderer(
- container.NewMax(a.Im, container.NewCenter(a.playbtn)),
- )
-}
-
-func (a *albumCover) Cursor() desktop.Cursor {
- return desktop.PointerCursor
-}
-
-func (a *albumCover) Tapped(e *fyne.PointEvent) {
- if isInside(a.center(), a.playbtn.Size().Height/2, e.Position) {
- if a.OnDoubleTapped != nil {
- a.OnDoubleTapped()
- }
- return
- }
- if a.OnTapped != nil {
- a.OnTapped()
- }
-}
-
-func (a *albumCover) MouseIn(*desktop.MouseEvent) {
- a.playbtn.Hidden = false
- a.Refresh()
-}
-
-func (a *albumCover) MouseOut() {
- a.playbtn.Hidden = true
- a.Refresh()
-}
-
-// TODO: figure out why circle around play button isn't being displayed
-func (a *albumCover) MouseMoved(e *desktop.MouseEvent) {
- if isInside(a.center(), a.playbtn.MinSize().Height/2, e.Position) {
- a.playbtn.SetMinSize(fyne.NewSize(65, 65))
- } else {
- a.playbtn.SetMinSize(fyne.NewSize(60, 60))
- }
- a.Refresh()
-}
-
-func (a *albumCover) center() fyne.Position {
- return fyne.NewPos(a.Size().Width/2, a.Size().Height/2)
-}
-
-func (a *albumCover) SetImage(im image.Image) {
- a.Im.Resource = nil
- a.Im.Image = im
- a.Refresh()
-}
-
-func (a *albumCover) SetImageResource(res *fyne.StaticResource) {
- a.Im.Image = nil
- a.Im.Resource = res
- a.Refresh()
-}
-
-func isInside(origin fyne.Position, radius float32, point fyne.Position) bool {
- x, y := (point.X - origin.X), (point.Y - origin.Y)
- return x*x+y*y <= radius*radius
-}
-
-type AlbumCard struct {
- widget.BaseWidget
-
- albumID string
- artistID string
- title *CustomHyperlink
- artist *CustomHyperlink
- year *widget.Label
- container *fyne.Container
-
- showYear bool
-
- // updated by AlbumGrid
- Cover *albumCover
-
- // these fields are used by AlbumGrid to track async update tasks
- PrevAlbumID string
- ImgLoadCancel context.CancelFunc
-
- OnPlay func()
- OnShowAlbumPage func()
- OnShowArtistPage func()
-}
-
-func NewAlbumCard(showYear bool) *AlbumCard {
- a := &AlbumCard{
- title: NewCustomHyperlink(),
- artist: NewCustomHyperlink(),
- year: widget.NewLabel(""),
- Cover: newAlbumCover(),
- showYear: showYear,
- }
- a.ExtendBaseWidget(a)
- a.Cover.OnDoubleTapped = func() {
- if a.OnPlay != nil {
- a.OnPlay()
- }
- }
- showAlbumFn := func() {
- if a.OnShowAlbumPage != nil {
- a.OnShowAlbumPage()
- }
- }
- a.Cover.OnTapped = showAlbumFn
- a.title.OnTapped = showAlbumFn
- a.artist.OnTapped = func() {
- if a.OnShowArtistPage != nil {
- a.OnShowArtistPage()
- }
- }
-
- a.createContainer()
- return a
-}
-
-func (a *AlbumCard) createContainer() {
- var secondLabel fyne.Widget = a.artist
- if a.showYear {
- secondLabel = a.year
- }
- info := container.New(&layouts.VboxCustomPadding{ExtraPad: -16}, a.title, secondLabel)
- c := container.New(&layouts.VboxCustomPadding{ExtraPad: -5}, a.Cover, info)
- pad := &layouts.CenterPadLayout{PadLeftRight: 20, PadTopBottom: 10}
- a.container = container.New(pad, c)
-}
-
-func (a *AlbumCard) Update(al *subsonic.AlbumID3) {
- a.title.SetText(al.Name)
- a.artist.SetText(al.Artist)
- a.year.SetText(strconv.Itoa(al.Year))
- a.albumID = al.ID
- a.artistID = al.ArtistID
- a.Cover.playbtn.Hidden = true
-}
-
-func (a *AlbumCard) AlbumID() string {
- return a.albumID
-}
-
-func (a *AlbumCard) ArtistID() string {
- return a.artistID
-}
-
-func (a *AlbumCard) CreateRenderer() fyne.WidgetRenderer {
- return widget.NewSimpleRenderer(a.container)
-}
diff --git a/ui/widgets/albumgrid.go b/ui/widgets/albumgrid.go
deleted file mode 100644
index 9e58b01..0000000
--- a/ui/widgets/albumgrid.go
+++ /dev/null
@@ -1,230 +0,0 @@
-package widgets
-
-import (
- "context"
- "image"
- "log"
- "supersonic/backend"
- "supersonic/res"
- "sync"
-
- "fyne.io/fyne/v2"
- "fyne.io/fyne/v2/widget"
- "github.com/dweymouth/go-subsonic/subsonic"
-)
-
-const albumFetchBatchSize = 6
-
-type ImageFetcher interface {
- GetAlbumThumbnailFromCache(string) (image.Image, bool)
- GetAlbumThumbnail(string) (image.Image, error)
-}
-
-type AlbumGrid struct {
- widget.BaseWidget
-
- AlbumGridState
-
- grid *widget.GridWrapList
-}
-
-type AlbumGridState struct {
- albums []*subsonic.AlbumID3
- albumsMutex sync.RWMutex
- iter *backend.BatchingIterator
- highestShown int
- fetching bool
- done bool
- showYear bool
-
- imageFetcher ImageFetcher
- OnPlayAlbum func(string)
- OnShowAlbumPage func(string)
- OnShowArtistPage func(string)
-
- scrollPos float32
-}
-
-var _ fyne.Widget = (*AlbumGrid)(nil)
-
-func NewFixedAlbumGrid(albums []*subsonic.AlbumID3, fetch ImageFetcher, showYear bool) *AlbumGrid {
- ag := &AlbumGrid{
- AlbumGridState: AlbumGridState{
- albums: albums,
- done: true,
- imageFetcher: fetch,
- showYear: showYear,
- },
- }
- ag.ExtendBaseWidget(ag)
- ag.createGridWrapList()
- return ag
-}
-
-func NewAlbumGrid(iter backend.AlbumIterator, fetch ImageFetcher, showYear bool) *AlbumGrid {
- ag := &AlbumGrid{
- AlbumGridState: AlbumGridState{
- iter: backend.NewBatchingIterator(iter),
- imageFetcher: fetch,
- },
- }
- ag.ExtendBaseWidget(ag)
-
- ag.createGridWrapList()
-
- // fetch initial albums
- ag.fetchMoreAlbums(36)
- return ag
-}
-
-func (ag *AlbumGrid) SaveToState() AlbumGridState {
- s := ag.AlbumGridState
- s.scrollPos = ag.grid.GetScrollOffset()
- return s
-}
-
-func NewAlbumGridFromState(state AlbumGridState) *AlbumGrid {
- ag := &AlbumGrid{AlbumGridState: state}
- ag.ExtendBaseWidget(ag)
- ag.createGridWrapList()
- ag.Refresh() // needed to initialize the widget
- ag.grid.ScrollToOffset(state.scrollPos)
- return ag
-}
-
-func (ag *AlbumGrid) Clear() {
- ag.albumsMutex.Lock()
- defer ag.albumsMutex.Unlock()
- ag.albums = nil
- ag.done = true
-}
-
-func (ag *AlbumGrid) Reset(iter backend.AlbumIterator) {
- ag.albumsMutex.Lock()
- ag.albums = nil
- ag.albumsMutex.Unlock()
- ag.fetching = false
- ag.done = false
- ag.highestShown = 0
- ag.iter = backend.NewBatchingIterator(iter)
- ag.fetchMoreAlbums(36)
-}
-
-func (ag *AlbumGrid) createGridWrapList() {
- g := widget.NewGridWrapList(
- func() int {
- return ag.lenAlbums()
- },
- // create func
- func() fyne.CanvasObject {
- ac := NewAlbumCard(ag.showYear)
- ac.OnPlay = func() {
- if ag.OnPlayAlbum != nil {
- ag.OnPlayAlbum(ac.AlbumID())
- }
- }
- ac.OnShowArtistPage = func() {
- if ag.OnShowArtistPage != nil {
- ag.OnShowArtistPage(ac.ArtistID())
- }
- }
- ac.OnShowAlbumPage = func() {
- if ag.OnShowAlbumPage != nil {
- ag.OnShowAlbumPage(ac.AlbumID())
- }
- }
- return ac
- },
- // update func
- func(itemID int, obj fyne.CanvasObject) {
- ac := obj.(*AlbumCard)
- ag.doUpdateAlbumCard(itemID, ac)
- },
- )
- ag.grid = g
-}
-
-func (ag *AlbumGrid) doUpdateAlbumCard(albumIdx int, ac *AlbumCard) {
- if albumIdx > ag.highestShown {
- ag.highestShown = albumIdx
- }
- ag.albumsMutex.RLock()
- album := ag.albums[albumIdx]
- ag.albumsMutex.RUnlock()
- if ac.PrevAlbumID == album.ID {
- // nothing to do
- return
- }
- ac.Update(album)
- ac.PrevAlbumID = album.ID
- // cancel any previous image fetch
- if ac.ImgLoadCancel != nil {
- ac.ImgLoadCancel()
- ac.ImgLoadCancel = nil
- }
- if img, ok := ag.imageFetcher.GetAlbumThumbnailFromCache(album.CoverArt); ok {
- ac.Cover.SetImage(img)
- } else {
- ac.Cover.SetImageResource(res.ResAlbumplaceholderPng)
- // asynchronously fetch cover image
- ctx, cancel := context.WithCancel(context.Background())
- ac.ImgLoadCancel = cancel
- go func(ctx context.Context) {
- i, err := ag.imageFetcher.GetAlbumThumbnail(album.CoverArt)
- select {
- case <-ctx.Done():
- return
- default:
- if err == nil {
- ac.Cover.SetImage(i)
- } else {
- log.Printf("error fetching image: %s", err.Error())
- }
- }
- }(ctx)
- }
-
- // if user has scrolled near the bottom, fetch more
- if !ag.done && !ag.fetching && albumIdx > ag.lenAlbums()-10 {
- ag.fetchMoreAlbums(20)
- }
-}
-
-func (a *AlbumGrid) lenAlbums() int {
- a.albumsMutex.RLock()
- defer a.albumsMutex.RUnlock()
- return len(a.albums)
-}
-
-// fetches at least count more albums
-func (a *AlbumGrid) fetchMoreAlbums(count int) {
- if a.iter == nil {
- a.done = true
- }
- a.fetching = true
- go func() {
- // keep repeating the fetch task as long as the user
- // has scrolled near the bottom
- for !a.done && a.highestShown >= a.lenAlbums()-10 {
- n := 0
- for !a.done && n < count {
- albums := a.iter.NextN(albumFetchBatchSize)
- a.albumsMutex.Lock()
- a.albums = append(a.albums, albums...)
- a.albumsMutex.Unlock()
- if len(albums) < albumFetchBatchSize {
- a.done = true
- }
- n += len(albums)
- if len(albums) > 0 {
- a.Refresh()
- }
- }
- }
- a.fetching = false
- }()
-}
-
-func (a *AlbumGrid) CreateRenderer() fyne.WidgetRenderer {
- return widget.NewSimpleRenderer(a.grid)
-}
diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go
new file mode 100644
index 0000000..3c4969a
--- /dev/null
+++ b/ui/widgets/gridview.go
@@ -0,0 +1,263 @@
+package widgets
+
+import (
+ "context"
+ "image"
+ "log"
+ "supersonic/backend"
+ "supersonic/res"
+ "supersonic/sharedutil"
+ "sync"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/widget"
+ "github.com/dweymouth/go-subsonic/subsonic"
+)
+
+const batchFetchSize = 6
+
+type ImageFetcher interface {
+ GetCoverThumbnailFromCache(string) (image.Image, bool)
+ GetCoverThumbnail(string) (image.Image, error)
+}
+
+type GridViewIterator interface {
+ NextN(int) []GridViewItemModel
+}
+
+type gridViewAlbumIterator struct {
+ iter *backend.BatchingIterator
+}
+
+func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel {
+ albums := g.iter.NextN(n)
+ return sharedutil.MapSlice(albums, func(al *subsonic.AlbumID3) GridViewItemModel {
+ return GridViewItemModel{
+ Name: al.Name,
+ ID: al.ID,
+ CoverArtID: al.CoverArt,
+ Secondary: al.Artist,
+ SecondaryID: al.ArtistID,
+ }
+ })
+}
+
+func NewGridViewAlbumIterator(iter backend.AlbumIterator) GridViewIterator {
+ return gridViewAlbumIterator{iter: backend.NewBatchingIterator(iter)}
+}
+
+type GridView struct {
+ widget.BaseWidget
+
+ GridViewState
+
+ grid *widget.GridWrapList
+}
+
+type GridViewState struct {
+ items []GridViewItemModel
+ itemsMutex sync.RWMutex
+ iter GridViewIterator
+ highestShown int
+ fetching bool
+ done bool
+
+ imageFetcher ImageFetcher
+ OnPlay func(string)
+ OnShowItemPage func(string)
+ OnShowSecondaryPage func(string)
+
+ scrollPos float32
+}
+
+var _ fyne.Widget = (*GridView)(nil)
+
+func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher) *GridView {
+ g := &GridView{
+ GridViewState: GridViewState{
+ items: items,
+ done: true,
+ imageFetcher: fetch,
+ },
+ }
+ g.ExtendBaseWidget(g)
+ g.createGridWrapList()
+ return g
+}
+
+func NewGridView(iter GridViewIterator, fetch ImageFetcher) *GridView {
+ g := &GridView{
+ GridViewState: GridViewState{
+ iter: iter,
+ imageFetcher: fetch,
+ },
+ }
+ g.ExtendBaseWidget(g)
+
+ g.createGridWrapList()
+
+ // fetch initial items
+ g.fetchMoreItems(36)
+ return g
+}
+
+func (g *GridView) SaveToState() GridViewState {
+ s := g.GridViewState
+ s.scrollPos = g.grid.GetScrollOffset()
+ return s
+}
+
+func NewGridViewFromState(state GridViewState) *GridView {
+ g := &GridView{GridViewState: state}
+ g.ExtendBaseWidget(g)
+ g.createGridWrapList()
+ g.Refresh() // needed to initialize the widget
+ g.grid.ScrollToOffset(state.scrollPos)
+ return g
+}
+
+func (g *GridView) Clear() {
+ g.itemsMutex.Lock()
+ defer g.itemsMutex.Unlock()
+ g.items = nil
+ g.done = true
+}
+
+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.fetchMoreItems(36)
+}
+
+func (g *GridView) ResetFixed(items []GridViewItemModel) {
+ g.itemsMutex.Lock()
+ g.items = items
+ g.itemsMutex.Unlock()
+ g.fetching = false
+ g.done = true
+ g.highestShown = 0
+ g.iter = nil
+}
+
+func (g *GridView) createGridWrapList() {
+ g.grid = widget.NewGridWrapList(
+ func() int {
+ return g.lenItems()
+ },
+ // create func
+ func() fyne.CanvasObject {
+ card := NewGridViewItem()
+ card.OnPlay = func() {
+ if g.OnPlay != nil {
+ g.OnPlay(card.ItemID())
+ }
+ }
+ card.OnShowSecondaryPage = func() {
+ if g.OnShowSecondaryPage != nil {
+ g.OnShowSecondaryPage(card.SecondaryID())
+ }
+ }
+ card.OnShowItemPage = func() {
+ if g.OnShowItemPage != nil {
+ g.OnShowItemPage(card.ItemID())
+ }
+ }
+ return card
+ },
+ // update func
+ func(itemID int, obj fyne.CanvasObject) {
+ ac := obj.(*GridViewItem)
+ g.doUpdateItemCard(itemID, ac)
+ },
+ )
+}
+
+func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
+ if itemIdx > g.highestShown {
+ g.highestShown = itemIdx
+ }
+ g.itemsMutex.RLock()
+ item := g.items[itemIdx]
+ g.itemsMutex.RUnlock()
+ if card.PrevID == item.ID {
+ // nothing to do
+ return
+ }
+ card.Update(item)
+ card.PrevID = item.ID
+ // cancel any previous image fetch
+ if card.ImgLoadCancel != nil {
+ 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())
+ }
+ }
+ }(ctx)
+ }
+
+ // if user has scrolled near the bottom, fetch more
+ if !g.done && !g.fetching && itemIdx > g.lenItems()-10 {
+ g.fetchMoreItems(20)
+ }
+}
+
+func (g *GridView) lenItems() int {
+ g.itemsMutex.RLock()
+ defer g.itemsMutex.RUnlock()
+ return len(g.items)
+}
+
+// fetches at least count more items
+func (g *GridView) fetchMoreItems(count int) {
+ if g.iter == nil {
+ g.done = true
+ }
+ g.fetching = true
+ go func() {
+ // keep repeating the fetch task as long as the user
+ // has scrolled near the bottom
+ for !g.done && g.highestShown >= g.lenItems()-10 {
+ n := 0
+ for !g.done && n < count {
+ items := g.iter.NextN(batchFetchSize)
+ g.itemsMutex.Lock()
+ g.items = append(g.items, items...)
+ g.itemsMutex.Unlock()
+ if len(items) < batchFetchSize {
+ g.done = true
+ }
+ n += len(items)
+ if len(items) > 0 {
+ g.Refresh()
+ }
+ }
+ }
+ g.fetching = false
+ }()
+}
+
+func (g *GridView) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(g.grid)
+}
diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go
new file mode 100644
index 0000000..232cfdc
--- /dev/null
+++ b/ui/widgets/gridviewitem.go
@@ -0,0 +1,189 @@
+package widgets
+
+import (
+ "context"
+ "image"
+
+ "supersonic/res"
+ "supersonic/ui/layouts"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/canvas"
+ "fyne.io/fyne/v2/container"
+ "fyne.io/fyne/v2/driver/desktop"
+ "fyne.io/fyne/v2/widget"
+)
+
+var _ fyne.Widget = (*GridViewItem)(nil)
+
+var _ fyne.Widget = (*coverImage)(nil)
+var _ fyne.Tappable = (*coverImage)(nil)
+
+type coverImage struct {
+ widget.BaseWidget
+
+ Im *canvas.Image
+ playbtn *canvas.Image
+ OnDoubleTapped func()
+ OnTapped func()
+}
+
+func newCoverImage() *coverImage {
+ c := &coverImage{}
+ c.ExtendBaseWidget(c)
+ c.Im = &canvas.Image{FillMode: canvas.ImageFillContain, ScaleMode: canvas.ImageScaleFastest}
+ c.Im.SetMinSize(fyne.NewSize(200, 200))
+ c.playbtn = &canvas.Image{FillMode: canvas.ImageFillContain, Resource: res.ResPlaybuttonPng}
+ c.playbtn.SetMinSize(fyne.NewSize(60, 60))
+ c.playbtn.Hidden = true
+ return c
+}
+
+func (c *coverImage) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(
+ container.NewMax(c.Im, container.NewCenter(c.playbtn)),
+ )
+}
+
+func (c *coverImage) Cursor() desktop.Cursor {
+ return desktop.PointerCursor
+}
+
+func (c *coverImage) Tapped(e *fyne.PointEvent) {
+ if isInside(c.center(), c.playbtn.Size().Height/2, e.Position) {
+ if c.OnDoubleTapped != nil {
+ c.OnDoubleTapped()
+ }
+ return
+ }
+ if c.OnTapped != nil {
+ c.OnTapped()
+ }
+}
+
+func (a *coverImage) MouseIn(*desktop.MouseEvent) {
+ a.playbtn.Hidden = false
+ a.Refresh()
+}
+
+func (a *coverImage) MouseOut() {
+ a.playbtn.Hidden = true
+ a.Refresh()
+}
+
+func (a *coverImage) MouseMoved(e *desktop.MouseEvent) {
+ if isInside(a.center(), a.playbtn.MinSize().Height/2, e.Position) {
+ a.playbtn.SetMinSize(fyne.NewSize(65, 65))
+ } else {
+ a.playbtn.SetMinSize(fyne.NewSize(60, 60))
+ }
+ a.Refresh()
+}
+
+func (a *coverImage) center() fyne.Position {
+ return fyne.NewPos(a.Size().Width/2, a.Size().Height/2)
+}
+
+func (a *coverImage) SetImage(im image.Image) {
+ a.Im.Resource = nil
+ a.Im.Image = im
+ a.Refresh()
+}
+
+func (a *coverImage) SetImageResource(res *fyne.StaticResource) {
+ a.Im.Image = nil
+ a.Im.Resource = res
+ a.Refresh()
+}
+
+func isInside(origin fyne.Position, radius float32, point fyne.Position) bool {
+ x, y := (point.X - origin.X), (point.Y - origin.Y)
+ return x*x+y*y <= radius*radius
+}
+
+type GridViewItemModel struct {
+ Name string
+ ID string
+ CoverArtID string
+ Secondary string
+ SecondaryID string
+}
+
+type GridViewItem struct {
+ widget.BaseWidget
+
+ itemID string
+ secondaryID string
+ primaryText *CustomHyperlink
+ secondaryText *CustomHyperlink
+ container *fyne.Container
+
+ // updated by GridView
+ Cover *coverImage
+
+ // these fields are used by GridView to track async update tasks
+ PrevID string
+ ImgLoadCancel context.CancelFunc
+
+ OnPlay func()
+ OnShowItemPage func()
+ OnShowSecondaryPage func()
+}
+
+func NewGridViewItem() *GridViewItem {
+ g := &GridViewItem{
+ primaryText: NewCustomHyperlink(),
+ secondaryText: NewCustomHyperlink(),
+ Cover: newCoverImage(),
+ }
+ g.ExtendBaseWidget(g)
+ g.Cover.OnDoubleTapped = func() {
+ if g.OnPlay != nil {
+ g.OnPlay()
+ }
+ }
+ showItemFn := func() {
+ if g.OnShowItemPage != nil {
+ g.OnShowItemPage()
+ }
+ }
+ g.Cover.OnTapped = showItemFn
+ g.primaryText.OnTapped = showItemFn
+ g.secondaryText.OnTapped = func() {
+ if g.OnShowSecondaryPage != nil {
+ g.OnShowSecondaryPage()
+ }
+ }
+
+ g.createContainer()
+ return g
+}
+
+func (g *GridViewItem) createContainer() {
+ info := container.New(&layouts.VboxCustomPadding{ExtraPad: -16}, g.primaryText, g.secondaryText)
+ c := container.New(&layouts.VboxCustomPadding{ExtraPad: -5}, g.Cover, info)
+ pad := &layouts.CenterPadLayout{PadLeftRight: 20, PadTopBottom: 10}
+ g.container = container.New(pad, c)
+}
+
+func (g *GridViewItem) Update(model GridViewItemModel) {
+ g.itemID = model.ID
+ g.secondaryID = model.SecondaryID
+ g.primaryText.SetText(model.Name)
+ g.secondaryText.SetText(model.Secondary)
+ g.secondaryText.Disabled = model.SecondaryID == ""
+ g.secondaryText.Refresh()
+ g.Cover.playbtn.Hidden = true
+}
+
+func (g *GridViewItem) ItemID() string {
+ return g.itemID
+}
+
+func (g *GridViewItem) SecondaryID() string {
+ return g.secondaryID
+}
+
+func (g *GridViewItem) CreateRenderer() fyne.WidgetRenderer {
+ return widget.NewSimpleRenderer(g.container)
+}