Merge pull request #150 from dweymouth/feature/playlistgrid

Add grid view option to playlists page
This commit is contained in:
Drew Weymouth
2023-04-28 20:25:32 -07:00
committed by GitHub
22 changed files with 695 additions and 514 deletions
+1
View File
@@ -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
+1 -1
View File
@@ -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
+8
View File
@@ -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"},
},
+5 -5
View File
@@ -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
}
+10
View File
@@ -130,6 +130,16 @@ var ResStarFilledSvg = &fyne.StaticResource{
StaticContent: []byte(
"<?xml version=\"1.0\" encoding=\"utf-8\"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->\r\n<svg width=\"800px\" height=\"800px\" viewBox=\"2 2 22 22\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\r\n<path d=\"M21.12 9.88005C21.0781 9.74719 20.9996 9.62884 20.8935 9.53862C20.7873 9.4484 20.6579 9.38997 20.52 9.37005L15.1 8.58005L12.67 3.67005C12.6008 3.55403 12.5027 3.45795 12.3853 3.39123C12.2678 3.32451 12.1351 3.28943 12 3.28943C11.8649 3.28943 11.7322 3.32451 11.6147 3.39123C11.4973 3.45795 11.3991 3.55403 11.33 3.67005L8.89999 8.58005L3.47999 9.37005C3.34211 9.38997 3.21266 9.4484 3.10652 9.53862C3.00038 9.62884 2.92186 9.74719 2.87999 9.88005C2.83529 10.0124 2.82846 10.1547 2.86027 10.2907C2.89207 10.4268 2.96124 10.5512 3.05999 10.6501L6.99999 14.4701L6.06999 19.8701C6.04642 20.0091 6.06199 20.1519 6.11497 20.2826C6.16796 20.4133 6.25625 20.5267 6.36999 20.6101C6.48391 20.6912 6.61825 20.7389 6.75785 20.7478C6.89746 20.7566 7.03675 20.7262 7.15999 20.6601L12 18.1101L16.85 20.6601C16.9573 20.7189 17.0776 20.7499 17.2 20.7501C17.3573 20.7482 17.5105 20.6995 17.64 20.6101C17.7537 20.5267 17.842 20.4133 17.895 20.2826C17.948 20.1519 17.9636 20.0091 17.94 19.8701L17 14.4701L20.93 10.6501C21.0305 10.5523 21.1015 10.4283 21.1351 10.2922C21.1687 10.1561 21.1634 10.0133 21.12 9.88005Z\" fill=\"#000000\"/>\r\n</svg>"),
}
var ResGridSvg = &fyne.StaticResource{
StaticName: "grid.svg",
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=\"4\" width=\"18\" height=\"18\"/>\r\n\t<rect x=\"26\" y=\"4\" width=\"18\" height=\"18\"/>\r\n\t<rect x=\"4\" y=\"26\" width=\"18\" height=\"18\"/>\r\n\t<rect x=\"26\" y=\"26\" width=\"18\" height=\"18\"/>\r\n</g>\r\n</svg>"),
}
var ResListSvg = &fyne.StaticResource{
StaticName: "list.svg",
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>"),
}
var ResLICENSE = &fyne.StaticResource{
StaticName: "LICENSE",
StaticContent: []byte(
+2
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" ><g id="grid">
<rect x="4" y="4" width="18" height="18"/>
<rect x="26" y="4" width="18" height="18"/>
<rect x="4" y="26" width="18" height="18"/>
<rect x="26" y="26" width="18" height="18"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?><svg width="800px" height="800px" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" ><g id="grid">
<rect x="4" y="6" width="40" height="8"/>
<rect x="4" y="20" width="40" height="8"/>
<rect x="4" y="34" width="40" height="8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 291 B

+8
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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
+19 -17
View File
@@ -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 {
+13 -4
View File
@@ -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()
+16 -16
View File
@@ -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
}
+16 -16
View File
@@ -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 {
+2 -2
View File
@@ -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)
}
}
+125 -26
View File
@@ -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 {
+1 -1
View File
@@ -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)
}
-193
View File
@@ -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)
}
-230
View File
@@ -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)
}
+263
View File
@@ -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)
}
+189
View File
@@ -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)
}