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