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/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/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..9cba0a2 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 @@ -73,9 +73,9 @@ 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.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 != "") @@ -181,9 +181,9 @@ 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.OnPlay = a.onPlayAlbum + a.searchGrid.OnShowItemPage = a.onShowAlbumPage + a.searchGrid.OnShowSecondaryPage = a.onShowArtistPage } else { a.searchGrid.Reset(a.lm.SearchIter(query)) } @@ -225,8 +225,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..99503ad 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -39,7 +39,7 @@ type ArtistPage struct { artistInfo *subsonic.ArtistID3 - albumGrid *widgets.AlbumGrid + albumGrid *widgets.GridView tracklistCtr *fyne.Container nowPlayingID string header *ArtistPageHeader @@ -173,8 +173,8 @@ func (a *ArtistPage) showAlbumGrid() { return } a.albumGrid = widgets.NewFixedAlbumGrid(a.artistInfo.Album, a.im, true /*showYear*/) - a.albumGrid.OnPlayAlbum = a.onPlayAlbum - a.albumGrid.OnShowAlbumPage = a.onShowAlbumPage + 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..7dd8556 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 @@ -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 { @@ -221,9 +221,9 @@ func (a *FavoritesPage) doSearchAlbums(query string) { }) 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.OnPlay = a.onPlayAlbum + a.searchGrid.OnShowItemPage = a.onShowAlbumPage + a.searchGrid.OnShowSecondaryPage = a.onShowArtistPage } else { a.searchGrid.Reset(iter) } @@ -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..6b3534c 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 @@ -53,9 +53,9 @@ 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.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 != "") @@ -177,9 +177,9 @@ func (g *GenrePage) doSearch(query string) { }) 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.OnPlay = g.onPlayAlbum + g.searchGrid.OnShowItemPage = g.onShowAlbumPage + g.searchGrid.OnShowSecondaryPage = g.onShowArtistPage } else { g.searchGrid.Reset(iter) } @@ -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/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..e52ecb0 --- /dev/null +++ b/ui/widgets/gridview.go @@ -0,0 +1,229 @@ +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 { + GetCoverThumbnailFromCache(string) (image.Image, bool) + GetCoverThumbnail(string) (image.Image, error) +} + +type GridView struct { + widget.BaseWidget + + GridViewState + + grid *widget.GridWrapList +} + +type GridViewState struct { + items []*subsonic.AlbumID3 + itemsMutex sync.RWMutex + iter *backend.BatchingIterator + highestShown int + fetching bool + done bool + showYear bool + + imageFetcher ImageFetcher + OnPlay func(string) + OnShowItemPage func(string) + OnShowSecondaryPage func(string) + + scrollPos float32 +} + +var _ fyne.Widget = (*GridView)(nil) + +func NewFixedAlbumGrid(albums []*subsonic.AlbumID3, fetch ImageFetcher, showYear bool) *GridView { + g := &GridView{ + GridViewState: GridViewState{ + items: albums, + done: true, + imageFetcher: fetch, + showYear: showYear, + }, + } + g.ExtendBaseWidget(g) + g.createGridWrapList() + return g +} + +func NewAlbumGrid(iter backend.AlbumIterator, fetch ImageFetcher, showYear bool) *GridView { + g := &GridView{ + GridViewState: GridViewState{ + iter: backend.NewBatchingIterator(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 backend.AlbumIterator) { + g.itemsMutex.Lock() + g.items = nil + g.itemsMutex.Unlock() + g.fetching = false + g.done = false + g.highestShown = 0 + g.iter = backend.NewBatchingIterator(iter) + g.fetchMoreItems(36) +} + +func (g *GridView) createGridWrapList() { + g.grid = widget.NewGridWrapList( + func() int { + return g.lenItems() + }, + // create func + func() fyne.CanvasObject { + card := NewGridViewCard(g.showYear) + 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.(*GridViewCard) + g.doUpdateItemCard(itemID, ac) + }, + ) +} + +func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewCard) { + if itemIdx > g.highestShown { + g.highestShown = itemIdx + } + g.itemsMutex.RLock() + album := g.items[itemIdx] + g.itemsMutex.RUnlock() + if card.PrevID == album.ID { + // nothing to do + return + } + card.Update(album) + card.PrevID = album.ID + // cancel any previous image fetch + if card.ImgLoadCancel != nil { + card.ImgLoadCancel() + card.ImgLoadCancel = nil + } + if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(album.CoverArt); 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(album.CoverArt) + 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 { + albums := g.iter.NextN(albumFetchBatchSize) + g.itemsMutex.Lock() + g.items = append(g.items, albums...) + g.itemsMutex.Unlock() + if len(albums) < albumFetchBatchSize { + g.done = true + } + n += len(albums) + if len(albums) > 0 { + g.Refresh() + } + } + } + g.fetching = false + }() +} + +func (g *GridView) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(g.grid) +} diff --git a/ui/widgets/gridviewcard.go b/ui/widgets/gridviewcard.go new file mode 100644 index 0000000..853fd18 --- /dev/null +++ b/ui/widgets/gridviewcard.go @@ -0,0 +1,200 @@ +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 = (*GridViewCard)(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 GridViewCardModel struct { + Name string + ID string + CoverArtID string + Secondary string + SecondaryID string +} + +type GridViewCard struct { + widget.BaseWidget + + albumID string + artistID string + title *CustomHyperlink + artist *CustomHyperlink + year *widget.Label + container *fyne.Container + + showYear bool + + // 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 NewGridViewCard(showYear bool) *GridViewCard { + g := &GridViewCard{ + title: NewCustomHyperlink(), + artist: NewCustomHyperlink(), + year: widget.NewLabel(""), + Cover: newCoverImage(), + showYear: showYear, + } + 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.title.OnTapped = showItemFn + g.artist.OnTapped = func() { + if g.OnShowSecondaryPage != nil { + g.OnShowSecondaryPage() + } + } + + g.createContainer() + return g +} + +func (g *GridViewCard) createContainer() { + var secondLabel fyne.Widget = g.artist + if g.showYear { + secondLabel = g.year + } + info := container.New(&layouts.VboxCustomPadding{ExtraPad: -16}, g.title, secondLabel) + 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 *GridViewCard) Update(al *subsonic.AlbumID3) { + g.title.SetText(al.Name) + g.artist.SetText(al.Artist) + g.year.SetText(strconv.Itoa(al.Year)) + g.albumID = al.ID + g.artistID = al.ArtistID + g.Cover.playbtn.Hidden = true +} + +func (g *GridViewCard) ItemID() string { + return g.albumID +} + +func (g *GridViewCard) SecondaryID() string { + return g.artistID +} + +func (g *GridViewCard) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(g.container) +}