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 - [#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 - [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 - [#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 ### Fixed
- [#148](https://github.com/dweymouth/supersonic/issues/148) Fix potential crash when searching for albums - [#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.LibraryManager = NewLibraryManager(a.ServerManager)
a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName)) a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName))
a.LibraryManager.PreCacheCoverFn = func(coverID string) { a.LibraryManager.PreCacheCoverFn = func(coverID string) {
_, _ = a.ImageManager.GetAlbumThumbnail(coverID) _, _ = a.ImageManager.GetCoverThumbnail(coverID)
} }
return a, nil return a, nil
+8
View File
@@ -59,6 +59,10 @@ type PlaylistPageConfig struct {
TracklistColumns []string TracklistColumns []string
} }
type PlaylistsPageConfig struct {
InitialView string
}
type TracksPageConfig struct { type TracksPageConfig struct {
TracklistColumns []string TracklistColumns []string
} }
@@ -91,6 +95,7 @@ type Config struct {
FavoritesPage FavoritesPageConfig FavoritesPage FavoritesPageConfig
NowPlayingPage NowPlayingPageConfig NowPlayingPage NowPlayingPageConfig
PlaylistPage PlaylistPageConfig PlaylistPage PlaylistPageConfig
PlaylistsPage PlaylistsPageConfig
TracksPage TracksPageConfig TracksPage TracksPageConfig
LocalPlayback LocalPlaybackConfig LocalPlayback LocalPlaybackConfig
Scrobbling ScrobbleConfig Scrobbling ScrobbleConfig
@@ -126,6 +131,9 @@ func DefaultConfig(appVersionTag string) *Config {
PlaylistPage: PlaylistPageConfig{ PlaylistPage: PlaylistPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
}, },
PlaylistsPage: PlaylistsPageConfig{
InitialView: "List",
},
TracksPage: TracksPageConfig{ TracksPage: TracksPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, 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 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) img, err := i.thumbnailCache.GetExtendTTL(coverID, i.thumbnailCache.DefaultTTL)
if err == nil && img != nil { if err == nil && img != nil {
return img, true return img, true
@@ -53,14 +53,14 @@ func (i *ImageManager) GetAlbumThumbnailFromCache(coverID string) (image.Image,
return nil, false return nil, false
} }
func (i *ImageManager) GetAlbumThumbnail(coverID string) (image.Image, error) { func (i *ImageManager) GetCoverThumbnail(coverID string) (image.Image, error) {
if im, ok := i.GetAlbumThumbnailFromCache(coverID); ok { if im, ok := i.GetCoverThumbnailFromCache(coverID); ok {
return im, nil return im, nil
} }
return i.fetchAndCacheCoverFromDiskOrServer(coverID, i.thumbnailCache.DefaultTTL) 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 // in-memory cache
if img, err := i.thumbnailCache.GetWithNewTTL(coverID, ttl); err == nil { if img, err := i.thumbnailCache.GetWithNewTTL(coverID, ttl); err == nil {
return img, nil return img, nil
@@ -68,7 +68,7 @@ func (i *ImageManager) GetAlbumThumbnailWithTTL(coverID string, ttl time.Duratio
return i.fetchAndCacheCoverFromDiskOrServer(coverID, ttl) 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 { if i.cachedFullSizeCoverID == coverID {
return i.cachedFullSizeCover, nil return i.cachedFullSizeCover, nil
} }
+10
View File
@@ -130,6 +130,16 @@ var ResStarFilledSvg = &fyne.StaticResource{
StaticContent: []byte( 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>"), "<?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{ var ResLICENSE = &fyne.StaticResource{
StaticName: "LICENSE", StaticName: "LICENSE",
StaticContent: []byte( 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/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-outline.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/star-filled.svg >> bundled.go fyne bundle -append -prefix Res icons/publicdomain/star-filled.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/grid.svg >> bundled.go
fyne bundle -append -prefix Res icons/publicdomain/list.svg >> bundled.go
fyne bundle -append -prefix Res ../LICENSE >> bundled.go fyne bundle -append -prefix Res ../LICENSE >> bundled.go
fyne bundle -append -prefix Res licenses/BSDLICENSE >> bundled.go fyne bundle -append -prefix Res licenses/BSDLICENSE >> bundled.go
+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 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 { func FindTrackByID(id string, tracks []*subsonic.Child) *subsonic.Child {
for _, tr := range tracks { for _, tr := range tracks {
if id == tr.ID { 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 // 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 // if the user navigates to the album page for the track
imgTTLSec := song.Duration + 30 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) 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() a.Refresh()
go func() { 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.Image.Image = cover
a.cover.Refresh() a.cover.Refresh()
} else { } else {
@@ -244,7 +244,7 @@ func (a *AlbumPageHeader) toggleFavorited() {
} }
func (a *AlbumPageHeader) showPopUpCover() { func (a *AlbumPageHeader) showPopUpCover() {
cover, err := a.page.im.GetFullSizeAlbumCover(a.coverID) cover, err := a.page.im.GetFullSizeCoverArt(a.coverID)
if err != nil { if err != nil {
log.Printf("error getting full size album cover: %s", err.Error()) log.Printf("error getting full size album cover: %s", err.Error())
return return
+19 -17
View File
@@ -24,8 +24,8 @@ type AlbumsPage struct {
pm *backend.PlaybackManager pm *backend.PlaybackManager
im *backend.ImageManager im *backend.ImageManager
lm *backend.LibraryManager lm *backend.LibraryManager
grid *widgets.AlbumGrid grid *widgets.GridView
searchGrid *widgets.AlbumGrid searchGrid *widgets.GridView
searcher *widgets.Searcher searcher *widgets.Searcher
searchText string searchText string
titleDisp *widget.RichText titleDisp *widget.RichText
@@ -72,10 +72,10 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
} }
a.sortOrder.Selected = cfg.SortOrder a.sortOrder.Selected = cfg.SortOrder
iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected)) iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected))
a.grid = widgets.NewAlbumGrid(iter, im, false /*showYear*/) a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
a.grid.OnPlayAlbum = a.onPlayAlbum a.grid.OnPlay = a.onPlayAlbum
a.grid.OnShowArtistPage = a.onShowArtistPage a.grid.OnShowSecondaryPage = a.onShowArtistPage
a.grid.OnShowAlbumPage = a.onShowAlbumPage a.grid.OnShowItemPage = a.onShowAlbumPage
a.searcher = widgets.NewSearcher() a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched a.searcher.OnSearched = a.OnSearched
a.createContainer(false) a.createContainer(false)
@@ -116,13 +116,13 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
a.sortOrder = NewSelect(backend.AlbumSortOrders, nil) a.sortOrder = NewSelect(backend.AlbumSortOrders, nil)
a.sortOrder.Selected = saved.sortOrder a.sortOrder.Selected = saved.sortOrder
a.sortOrder.OnChanged = a.onSortOrderChanged a.sortOrder.OnChanged = a.onSortOrderChanged
a.grid = widgets.NewAlbumGridFromState(saved.gridState) a.grid = widgets.NewGridViewFromState(saved.gridState)
a.searcher = widgets.NewSearcher() a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched a.searcher.OnSearched = a.OnSearched
a.searcher.Entry.Text = saved.searchText a.searcher.Entry.Text = saved.searchText
a.searchText = saved.searchText a.searchText = saved.searchText
if a.searchText != "" { if a.searchText != "" {
a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState) a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
} }
a.createContainer(saved.searchText != "") a.createContainer(saved.searchText != "")
@@ -156,7 +156,8 @@ func (a *AlbumsPage) Reload() {
if a.searchText != "" { if a.searchText != "" {
a.doSearch(a.searchText) a.doSearch(a.searchText)
} else { } 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() a.grid.Refresh()
} }
} }
@@ -180,12 +181,12 @@ func (a *AlbumsPage) Save() SavedPage {
func (a *AlbumsPage) doSearch(query string) { func (a *AlbumsPage) doSearch(query string) {
if a.searchGrid == nil { if a.searchGrid == nil {
a.searchGrid = widgets.NewAlbumGrid(a.lm.SearchIter(query), a.im, false /*showYear*/) a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)), a.im)
a.searchGrid.OnPlayAlbum = a.onPlayAlbum a.searchGrid.OnPlay = a.onPlayAlbum
a.searchGrid.OnShowAlbumPage = a.onShowAlbumPage a.searchGrid.OnShowItemPage = a.onShowAlbumPage
a.searchGrid.OnShowArtistPage = a.onShowArtistPage a.searchGrid.OnShowSecondaryPage = a.onShowArtistPage
} else { } else {
a.searchGrid.Reset(a.lm.SearchIter(query)) a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)))
} }
a.container.Objects[0] = a.searchGrid a.container.Objects[0] = a.searchGrid
a.Refresh() a.Refresh()
@@ -205,7 +206,8 @@ func (a *AlbumsPage) onShowAlbumPage(albumID string) {
func (a *AlbumsPage) onSortOrderChanged(order string) { func (a *AlbumsPage) onSortOrderChanged(order string) {
a.cfg.SortOrder = a.sortOrder.Selected a.cfg.SortOrder = a.sortOrder.Selected
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 == "" { if a.searchText == "" {
a.container.Objects[0] = a.grid a.container.Objects[0] = a.grid
a.Refresh() a.Refresh()
@@ -225,8 +227,8 @@ type savedAlbumsPage struct {
lm *backend.LibraryManager lm *backend.LibraryManager
im *backend.ImageManager im *backend.ImageManager
sortOrder string sortOrder string
gridState widgets.AlbumGridState gridState widgets.GridViewState
searchGridState widgets.AlbumGridState searchGridState widgets.GridViewState
} }
func (s *savedAlbumsPage) Restore() Page { func (s *savedAlbumsPage) Restore() Page {
+13 -4
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"log" "log"
"strconv"
"strings" "strings"
"supersonic/backend" "supersonic/backend"
"supersonic/res" "supersonic/res"
@@ -39,7 +40,7 @@ type ArtistPage struct {
artistInfo *subsonic.ArtistID3 artistInfo *subsonic.ArtistID3
albumGrid *widgets.AlbumGrid albumGrid *widgets.GridView
tracklistCtr *fyne.Container tracklistCtr *fyne.Container
nowPlayingID string nowPlayingID string
header *ArtistPageHeader header *ArtistPageHeader
@@ -172,9 +173,17 @@ func (a *ArtistPage) showAlbumGrid() {
a.activeView = 0 // if page still loading, will show discography view first a.activeView = 0 // if page still loading, will show discography view first
return return
} }
a.albumGrid = widgets.NewFixedAlbumGrid(a.artistInfo.Album, a.im, true /*showYear*/) model := sharedutil.MapSlice(a.artistInfo.Album, func(al *subsonic.AlbumID3) widgets.GridViewItemModel {
a.albumGrid.OnPlayAlbum = a.onPlayAlbum return widgets.GridViewItemModel{
a.albumGrid.OnShowAlbumPage = a.onShowAlbumPage 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].(*fyne.Container).Objects[0] = a.albumGrid
a.container.Objects[0].Refresh() a.container.Objects[0].Refresh()
+16 -16
View File
@@ -32,8 +32,8 @@ type FavoritesPage struct {
nowPlayingID string nowPlayingID string
pendingViewSwitch bool pendingViewSwitch bool
grid *widgets.AlbumGrid grid *widgets.GridView
searchGrid *widgets.AlbumGrid searchGrid *widgets.GridView
artistListCtr *fyne.Container artistListCtr *fyne.Container
tracklistCtr *fyne.Container tracklistCtr *fyne.Container
searcher *widgets.Searcher searcher *widgets.Searcher
@@ -53,7 +53,7 @@ func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Contro
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.createHeader(0, "") 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() a.connectGridActions()
if cfg.InitialView == "Artists" { if cfg.InitialView == "Artists" {
a.toggleBtns.SetActivatedButton(1) a.toggleBtns.SetActivatedButton(1)
@@ -82,9 +82,9 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
} }
func (a *FavoritesPage) connectGridActions() { func (a *FavoritesPage) connectGridActions() {
a.grid.OnPlayAlbum = a.onPlayAlbum a.grid.OnPlay = a.onPlayAlbum
a.grid.OnShowAlbumPage = a.onShowAlbumPage a.grid.OnShowItemPage = a.onShowAlbumPage
a.grid.OnShowArtistPage = a.onShowArtistPage a.grid.OnShowSecondaryPage = a.onShowArtistPage
} }
func (a *FavoritesPage) createContainer(initialView fyne.CanvasObject) { func (a *FavoritesPage) createContainer(initialView fyne.CanvasObject) {
@@ -105,11 +105,11 @@ func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.createHeader(saved.activeToggleBtn, saved.searchText) a.createHeader(saved.activeToggleBtn, saved.searchText)
a.grid = widgets.NewAlbumGridFromState(saved.gridState) a.grid = widgets.NewGridViewFromState(saved.gridState)
a.connectGridActions() a.connectGridActions()
if saved.searchText != "" { if saved.searchText != "" {
a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState) a.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
} }
if saved.activeToggleBtn == 1 { if saved.activeToggleBtn == 1 {
@@ -134,7 +134,7 @@ func (a *FavoritesPage) Reload() {
if a.searchText != "" { if a.searchText != "" {
a.doSearchAlbums(a.searchText) a.doSearchAlbums(a.searchText)
} else { } else {
a.grid.Reset(a.lm.StarredIter()) a.grid.Reset(widgets.NewGridViewAlbumIterator(a.lm.StarredIter()))
} }
if a.tracklistCtr != nil || a.artistListCtr != nil { if a.tracklistCtr != nil || a.artistListCtr != nil {
go func() { go func() {
@@ -220,12 +220,12 @@ func (a *FavoritesPage) doSearchAlbums(query string) {
return al.Starred.After(time.Time{}) return al.Starred.After(time.Time{})
}) })
if a.searchGrid == nil { if a.searchGrid == nil {
a.searchGrid = widgets.NewAlbumGrid(iter, a.im, false /*showYear*/) a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
a.searchGrid.OnPlayAlbum = a.onPlayAlbum a.searchGrid.OnPlay = a.onPlayAlbum
a.searchGrid.OnShowAlbumPage = a.onShowAlbumPage a.searchGrid.OnShowItemPage = a.onShowAlbumPage
a.searchGrid.OnShowArtistPage = a.onShowArtistPage a.searchGrid.OnShowSecondaryPage = a.onShowArtistPage
} else { } else {
a.searchGrid.Reset(iter) a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter))
} }
a.container.Objects[0] = a.searchGrid a.container.Objects[0] = a.searchGrid
a.Refresh() a.Refresh()
@@ -352,8 +352,8 @@ type savedFavoritesPage struct {
sm *backend.ServerManager sm *backend.ServerManager
im *backend.ImageManager im *backend.ImageManager
lm *backend.LibraryManager lm *backend.LibraryManager
gridState widgets.AlbumGridState gridState widgets.GridViewState
searchGridState widgets.AlbumGridState searchGridState widgets.GridViewState
searchText string searchText string
activeToggleBtn int activeToggleBtn int
} }
+16 -16
View File
@@ -24,8 +24,8 @@ type GenrePage struct {
im *backend.ImageManager im *backend.ImageManager
pm *backend.PlaybackManager pm *backend.PlaybackManager
lm *backend.LibraryManager lm *backend.LibraryManager
grid *widgets.AlbumGrid grid *widgets.GridView
searchGrid *widgets.AlbumGrid searchGrid *widgets.GridView
searcher *widgets.Searcher searcher *widgets.Searcher
searchText string searchText string
titleDisp *widget.RichText 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) g.playRandom = widget.NewButtonWithIcon("Play random", res.ResShuffleInvertSvg, g.playRandomSongs)
iter := g.lm.GenreIter(g.genre) iter := g.lm.GenreIter(g.genre)
g.grid = widgets.NewAlbumGrid(iter, g.im, false) g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
g.grid.OnPlayAlbum = g.onPlayAlbum g.grid.OnPlay = g.onPlayAlbum
g.grid.OnShowArtistPage = g.onShowArtistPage g.grid.OnShowSecondaryPage = g.onShowArtistPage
g.grid.OnShowAlbumPage = g.onShowAlbumPage g.grid.OnShowItemPage = g.onShowAlbumPage
g.searcher = widgets.NewSearcher() g.searcher = widgets.NewSearcher()
g.searcher.OnSearched = g.OnSearched g.searcher.OnSearched = g.OnSearched
g.createContainer(false) g.createContainer(false)
@@ -94,13 +94,13 @@ func restoreGenrePage(saved *savedGenrePage) *GenrePage {
SizeName: theme.SizeNameHeadingText, SizeName: theme.SizeNameHeadingText,
} }
g.playRandom = widget.NewButtonWithIcon("Play random", res.ResShuffleInvertSvg, g.playRandomSongs) 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 = widgets.NewSearcher()
g.searcher.OnSearched = g.OnSearched g.searcher.OnSearched = g.OnSearched
g.searcher.Entry.Text = saved.searchText g.searcher.Entry.Text = saved.searchText
g.searchText = saved.searchText g.searchText = saved.searchText
if g.searchText != "" { if g.searchText != "" {
g.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState) g.searchGrid = widgets.NewGridViewFromState(saved.searchGridState)
} }
g.createContainer(saved.searchText != "") g.createContainer(saved.searchText != "")
@@ -119,7 +119,7 @@ func (g *GenrePage) Reload() {
if g.searchText != "" { if g.searchText != "" {
g.doSearch(g.searchText) g.doSearch(g.searchText)
} else { } else {
g.grid.Reset(g.lm.GenreIter(g.genre)) g.grid.Reset(widgets.NewGridViewAlbumIterator(g.lm.GenreIter(g.genre)))
g.grid.Refresh() g.grid.Refresh()
} }
} }
@@ -176,12 +176,12 @@ func (g *GenrePage) doSearch(query string) {
return al.Genre == g.genre return al.Genre == g.genre
}) })
if g.searchGrid == nil { if g.searchGrid == nil {
g.searchGrid = widgets.NewAlbumGrid(iter, g.im, false /*showYear*/) g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
g.searchGrid.OnPlayAlbum = g.onPlayAlbum g.searchGrid.OnPlay = g.onPlayAlbum
g.searchGrid.OnShowAlbumPage = g.onShowAlbumPage g.searchGrid.OnShowItemPage = g.onShowAlbumPage
g.searchGrid.OnShowArtistPage = g.onShowArtistPage g.searchGrid.OnShowSecondaryPage = g.onShowArtistPage
} else { } else {
g.searchGrid.Reset(iter) g.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter))
} }
g.container.Objects[0] = g.searchGrid g.container.Objects[0] = g.searchGrid
g.Refresh() g.Refresh()
@@ -198,8 +198,8 @@ type savedGenrePage struct {
pm *backend.PlaybackManager pm *backend.PlaybackManager
lm *backend.LibraryManager lm *backend.LibraryManager
im *backend.ImageManager im *backend.ImageManager
gridState widgets.AlbumGridState gridState widgets.GridViewState
searchGridState widgets.AlbumGridState searchGridState widgets.GridViewState
} }
func (s *savedGenrePage) Restore() Page { func (s *savedGenrePage) Restore() Page {
+2 -2
View File
@@ -233,13 +233,13 @@ func (a *PlaylistPageHeader) Update(playlist *subsonic.Playlist) {
var haveCover bool var haveCover bool
if playlist.CoverArt != "" { 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*/) a.image.SetImage(im, false /*tappable*/)
haveCover = true haveCover = true
} }
} }
if !haveCover { 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) a.image.SetImage(im, false)
} }
} }
+125 -26
View File
@@ -1,10 +1,12 @@
package browsing package browsing
import ( import (
"fmt"
"log" "log"
"strconv" "strconv"
"strings" "strings"
"supersonic/backend" "supersonic/backend"
"supersonic/res"
"supersonic/sharedutil" "supersonic/sharedutil"
"supersonic/ui/controller" "supersonic/ui/controller"
"supersonic/ui/layouts" "supersonic/ui/layouts"
@@ -21,36 +23,52 @@ import (
type PlaylistsPage struct { type PlaylistsPage struct {
widget.BaseWidget widget.BaseWidget
contr *controller.Controller cfg *backend.PlaylistsPageConfig
sm *backend.ServerManager contr *controller.Controller
playlists []*subsonic.Playlist sm *backend.ServerManager
playlists []*subsonic.Playlist
searchedPlaylists []*subsonic.Playlist
searcher *widgets.Searcher viewToggle *widgets.ToggleButtonGroup
titleDisp *widget.RichText searcher *widgets.Searcher
container *fyne.Container titleDisp *widget.RichText
list *PlaylistList container *fyne.Container
listView *PlaylistList
gridView *widgets.GridView
} }
func NewPlaylistsPage(contr *controller.Controller, sm *backend.ServerManager) *PlaylistsPage { func NewPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, sm *backend.ServerManager) *PlaylistsPage {
return newPlaylistsPage(contr, sm, "") 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{ a := &PlaylistsPage{
cfg: cfg,
sm: sm, sm: sm,
contr: contr, contr: contr,
titleDisp: widget.NewRichTextWithText("Playlists"), titleDisp: widget.NewRichTextWithText("Playlists"),
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText 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 = widgets.NewSearcher()
a.searcher.OnSearched = a.onSearched a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText 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 != "") go a.load(searchText != "")
return a return a
} }
@@ -64,26 +82,103 @@ func (a *PlaylistsPage) load(searchOnLoad bool) {
if searchOnLoad { if searchOnLoad {
a.onSearched(a.searcher.Entry.Text) a.onSearched(a.searcher.Entry.Text)
} else { } else {
a.list.Playlists = playlists a.refreshView(playlists)
a.list.Refresh()
} }
} }
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) { func (a *PlaylistsPage) onSearched(query string) {
// since the playlist list is returned in full non-paginated, we will do our own // 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 // simple search based on the name, description, and owner, rather than calling a server API
var playlists []*subsonic.Playlist
if query == "" { if query == "" {
a.list.Playlists = a.playlists a.searchedPlaylists = nil
playlists = a.playlists
} else { } 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) qLower := strings.ToLower(query)
return strings.Contains(strings.ToLower(p.Name), qLower) || return strings.Contains(strings.ToLower(p.Name), qLower) ||
strings.Contains(strings.ToLower(p.Comment), qLower) || strings.Contains(strings.ToLower(p.Comment), qLower) ||
strings.Contains(strings.ToLower(p.Owner), 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) var _ Searchable = (*PlaylistsPage)(nil)
@@ -97,33 +192,37 @@ func (a *PlaylistsPage) Route() controller.Route {
} }
func (a *PlaylistsPage) Reload() { func (a *PlaylistsPage) Reload() {
go a.load(false) go a.load(a.searcher.Entry.Text != "")
} }
func (a *PlaylistsPage) Save() SavedPage { func (a *PlaylistsPage) Save() SavedPage {
return &savedPlaylistsPage{ return &savedPlaylistsPage{
contr: a.contr, contr: a.contr,
cfg: a.cfg,
sm: a.sm, sm: a.sm,
searchText: a.searcher.Entry.Text, searchText: a.searcher.Entry.Text,
activeView: a.viewToggle.ActivatedButtonIndex(),
} }
} }
type savedPlaylistsPage struct { type savedPlaylistsPage struct {
contr *controller.Controller contr *controller.Controller
cfg *backend.PlaylistsPageConfig
sm *backend.ServerManager sm *backend.ServerManager
searchText string searchText string
activeView int
} }
func (s *savedPlaylistsPage) Restore() Page { 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()) searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher.Entry, layout.NewSpacer())
a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
container.NewBorder( container.NewBorder(
container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox), container.NewHBox(a.titleDisp, container.NewCenter(a.viewToggle), layout.NewSpacer(), searchVbox),
nil, nil, nil, a.list)) nil, nil, nil, initialView))
} }
func (a *PlaylistsPage) CreateRenderer() fyne.WidgetRenderer { 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: case controller.Playlist:
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
case controller.Playlists: case controller.Playlists:
return NewPlaylistsPage(r.Controller, r.App.ServerManager) return NewPlaylistsPage(r.Controller, &r.App.Config.PlaylistsPage, r.App.ServerManager)
case controller.Tracks: case controller.Tracks:
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.App.LibraryManager) 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)
}