372 lines
8.6 KiB
Go
372 lines
8.6 KiB
Go
package widgets
|
|
|
|
import (
|
|
"context"
|
|
"image"
|
|
"log"
|
|
"sync"
|
|
|
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
|
"github.com/dweymouth/supersonic/sharedutil"
|
|
|
|
"fyne.io/fyne/v2"
|
|
"fyne.io/fyne/v2/widget"
|
|
xwidget "fyne.io/x/fyne/widget"
|
|
)
|
|
|
|
const batchFetchSize = 6
|
|
|
|
type BatchingIterator struct {
|
|
iter mediaprovider.AlbumIterator
|
|
}
|
|
|
|
func NewBatchingIterator(iter mediaprovider.AlbumIterator) BatchingIterator {
|
|
return BatchingIterator{iter}
|
|
}
|
|
|
|
func (b *BatchingIterator) NextN(n int) []*mediaprovider.Album {
|
|
results := make([]*mediaprovider.Album, 0, n)
|
|
i := 0
|
|
for i < n {
|
|
album := b.iter.Next()
|
|
if album == nil {
|
|
break
|
|
}
|
|
results = append(results, album)
|
|
i++
|
|
}
|
|
return results
|
|
}
|
|
|
|
type ImageFetcher interface {
|
|
GetCoverThumbnailFromCache(string) (image.Image, bool)
|
|
GetCoverThumbnailAsync(string, func(image.Image, error)) context.CancelFunc
|
|
}
|
|
|
|
type GridViewIterator interface {
|
|
NextN(int) []GridViewItemModel
|
|
}
|
|
|
|
type gridViewAlbumIterator struct {
|
|
iter BatchingIterator
|
|
}
|
|
|
|
func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel {
|
|
albums := g.iter.NextN(n)
|
|
return sharedutil.MapSlice(albums, func(al *mediaprovider.Album) GridViewItemModel {
|
|
return GridViewItemModel{
|
|
Name: al.Name,
|
|
ID: al.ID,
|
|
CoverArtID: al.CoverArtID,
|
|
Secondary: al.ArtistNames,
|
|
SecondaryIDs: al.ArtistIDs,
|
|
}
|
|
})
|
|
}
|
|
|
|
func NewGridViewAlbumIterator(iter mediaprovider.AlbumIterator) GridViewIterator {
|
|
return gridViewAlbumIterator{iter: NewBatchingIterator(iter)}
|
|
}
|
|
|
|
type GridView struct {
|
|
widget.BaseWidget
|
|
|
|
stateMutex sync.RWMutex
|
|
fetchCancel context.CancelFunc
|
|
GridViewState
|
|
|
|
grid *xwidget.GridWrap
|
|
menu *widget.PopUpMenu
|
|
menuGridViewItemId string
|
|
}
|
|
|
|
type GridViewState struct {
|
|
items []GridViewItemModel
|
|
iter GridViewIterator
|
|
imageFetcher ImageFetcher
|
|
Placeholder fyne.Resource
|
|
highestShown int
|
|
done bool
|
|
|
|
OnPlay func(id string, shuffle bool)
|
|
OnAddToQueue func(id string)
|
|
OnAddToPlaylist func(id string)
|
|
OnDownload func(id string)
|
|
OnShowItemPage func(id string)
|
|
OnShowSecondaryPage func(id string)
|
|
|
|
scrollPos float32
|
|
}
|
|
|
|
var _ fyne.Widget = (*GridView)(nil)
|
|
|
|
func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
|
|
g := &GridView{
|
|
GridViewState: GridViewState{
|
|
items: items,
|
|
done: true,
|
|
imageFetcher: fetch,
|
|
Placeholder: placeholder,
|
|
},
|
|
}
|
|
g.ExtendBaseWidget(g)
|
|
g.createGridWrap()
|
|
return g
|
|
}
|
|
|
|
func NewGridView(iter GridViewIterator, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
|
|
g := &GridView{
|
|
GridViewState: GridViewState{
|
|
iter: iter,
|
|
imageFetcher: fetch,
|
|
Placeholder: placeholder,
|
|
},
|
|
}
|
|
g.ExtendBaseWidget(g)
|
|
g.createGridWrap()
|
|
|
|
// fetch initial items
|
|
g.checkFetchMoreItems(36)
|
|
return g
|
|
}
|
|
|
|
func (g *GridView) SaveToState() *GridViewState {
|
|
g.stateMutex.RLock()
|
|
s := g.GridViewState
|
|
g.stateMutex.RUnlock()
|
|
s.scrollPos = g.grid.GetScrollOffset()
|
|
return &s
|
|
}
|
|
|
|
func NewGridViewFromState(state *GridViewState) *GridView {
|
|
g := &GridView{GridViewState: *state}
|
|
g.ExtendBaseWidget(g)
|
|
g.createGridWrap()
|
|
g.Refresh() // needed to initialize the widget
|
|
g.grid.ScrollToOffset(state.scrollPos)
|
|
return g
|
|
}
|
|
|
|
func (g *GridView) Clear() {
|
|
g.stateMutex.Lock()
|
|
defer g.stateMutex.Unlock()
|
|
g.cancelFetch()
|
|
g.items = nil
|
|
g.done = true
|
|
}
|
|
|
|
func (g *GridView) Reset(iter GridViewIterator) {
|
|
g.stateMutex.Lock()
|
|
g.cancelFetch()
|
|
g.items = nil
|
|
g.done = false
|
|
g.highestShown = 0
|
|
g.iter = iter
|
|
g.stateMutex.Unlock()
|
|
g.checkFetchMoreItems(36)
|
|
g.Refresh()
|
|
}
|
|
|
|
func (g *GridView) ResetFromState(state *GridViewState) {
|
|
g.stateMutex.Lock()
|
|
g.cancelFetch()
|
|
g.GridViewState = *state
|
|
g.stateMutex.Unlock()
|
|
g.grid.Refresh()
|
|
g.grid.ScrollToOffset(state.scrollPos)
|
|
g.grid.Refresh()
|
|
}
|
|
|
|
func (g *GridView) ResetFixed(items []GridViewItemModel) {
|
|
g.stateMutex.Lock()
|
|
g.cancelFetch()
|
|
g.items = items
|
|
g.done = true
|
|
g.highestShown = 0
|
|
g.iter = nil
|
|
g.stateMutex.Unlock()
|
|
g.Refresh()
|
|
}
|
|
|
|
func (g *GridView) GetScrollOffset() float32 {
|
|
return g.grid.GetScrollOffset()
|
|
}
|
|
|
|
func (g *GridView) ScrollToOffset(offs float32) {
|
|
g.grid.ScrollToOffset(offs)
|
|
}
|
|
|
|
func (g *GridView) createGridWrap() {
|
|
g.grid = xwidget.NewGridWrap(
|
|
func() int {
|
|
return g.lenItems()
|
|
},
|
|
// create func
|
|
func() fyne.CanvasObject {
|
|
card := NewGridViewItem(g.Placeholder)
|
|
card.OnPlay = func() { g.onPlay(card.ItemID(), false) }
|
|
card.OnShowSecondaryPage = func(id string) {
|
|
if g.OnShowSecondaryPage != nil {
|
|
g.OnShowSecondaryPage(id)
|
|
}
|
|
}
|
|
card.OnShowItemPage = func() {
|
|
if g.OnShowItemPage != nil {
|
|
g.OnShowItemPage(card.ItemID())
|
|
}
|
|
}
|
|
card.OnShowContextMenu = func(p fyne.Position) {
|
|
g.showContextMenu(card, p)
|
|
}
|
|
return card
|
|
},
|
|
// update func
|
|
func(itemID xwidget.GridWrapItemID, obj fyne.CanvasObject) {
|
|
ac := obj.(*GridViewItem)
|
|
g.doUpdateItemCard(int(itemID), ac)
|
|
},
|
|
)
|
|
}
|
|
|
|
func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
|
if itemIdx > g.highestShown {
|
|
g.highestShown = itemIdx
|
|
}
|
|
var item GridViewItemModel
|
|
g.stateMutex.RLock()
|
|
// itemIdx can rarely be out of range if the data is being updated
|
|
// as the view is requested to refresh
|
|
if itemIdx < len(g.items) {
|
|
item = g.items[itemIdx]
|
|
}
|
|
g.stateMutex.RUnlock()
|
|
card.Cover.Im.CenterIcon = g.Placeholder
|
|
if !card.NeedsUpdate(item) {
|
|
// nothing to do
|
|
return
|
|
}
|
|
card.Update(item)
|
|
// cancel any previous image fetch (no issues with possible double-invocations)
|
|
if card.ImgLoadCancel != nil {
|
|
card.ImgLoadCancel()
|
|
}
|
|
if item.CoverArtID != "" {
|
|
if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
|
|
card.Cover.SetImage(img)
|
|
} else {
|
|
card.Cover.SetImage(nil)
|
|
card.ImgLoadCancel = g.imageFetcher.GetCoverThumbnailAsync(item.CoverArtID, func(i image.Image, err error) {
|
|
if err == nil {
|
|
card.Cover.SetImage(i)
|
|
} else {
|
|
log.Printf("error fetching image: %s", err.Error())
|
|
}
|
|
card.ImgLoadCancel() // done. release resources associated with cancel channel
|
|
})
|
|
}
|
|
} else {
|
|
// use the placeholder for an item that has no cover art ID
|
|
card.Cover.SetImage(nil)
|
|
}
|
|
|
|
// if user has scrolled near the bottom, fetch more
|
|
if itemIdx > g.lenItems()-10 {
|
|
g.checkFetchMoreItems(20)
|
|
}
|
|
}
|
|
|
|
func (g *GridView) lenItems() int {
|
|
g.stateMutex.RLock()
|
|
defer g.stateMutex.RUnlock()
|
|
return len(g.items)
|
|
}
|
|
|
|
// fetches at least count more items if fetch not in progress and not done
|
|
// acquires stateMutex for atomicity
|
|
func (g *GridView) checkFetchMoreItems(count int) {
|
|
g.stateMutex.Lock()
|
|
defer g.stateMutex.Unlock()
|
|
if g.done || g.fetchCancel != nil {
|
|
return // done, or fetch already in progress
|
|
}
|
|
if g.iter == nil {
|
|
g.done = true
|
|
return
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
g.fetchCancel = cancel
|
|
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)
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
g.stateMutex.Lock()
|
|
g.items = append(g.items, items...)
|
|
g.stateMutex.Unlock()
|
|
if len(items) < batchFetchSize {
|
|
g.done = true
|
|
}
|
|
n += len(items)
|
|
if len(items) > 0 {
|
|
g.grid.Refresh()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// call cancelfunc to release Context resources
|
|
g.stateMutex.Lock()
|
|
g.cancelFetch()
|
|
g.stateMutex.Unlock()
|
|
}()
|
|
}
|
|
|
|
// must be called with stateMutex locked for writing
|
|
func (g *GridView) cancelFetch() {
|
|
if g.fetchCancel != nil {
|
|
g.fetchCancel()
|
|
g.fetchCancel = nil
|
|
}
|
|
}
|
|
|
|
func (g *GridView) showContextMenu(card *GridViewItem, pos fyne.Position) {
|
|
g.menuGridViewItemId = card.ItemID()
|
|
if g.menu == nil {
|
|
g.menu = widget.NewPopUpMenu(fyne.NewMenu("",
|
|
fyne.NewMenuItem("Play", func() { g.onPlay(g.menuGridViewItemId, false) }),
|
|
fyne.NewMenuItem("Shuffle", func() { g.onPlay(g.menuGridViewItemId, true) }),
|
|
fyne.NewMenuItem("Add to queue", func() {
|
|
if g.OnAddToQueue != nil {
|
|
g.OnAddToQueue(g.menuGridViewItemId)
|
|
}
|
|
}),
|
|
fyne.NewMenuItem("Add to playlist...", func() {
|
|
if g.OnAddToPlaylist != nil {
|
|
g.OnAddToPlaylist(g.menuGridViewItemId)
|
|
}
|
|
}),
|
|
fyne.NewMenuItem("Download...", func() {
|
|
if g.OnDownload != nil {
|
|
g.OnDownload(g.menuGridViewItemId)
|
|
}
|
|
})),
|
|
fyne.CurrentApp().Driver().CanvasForObject(g))
|
|
}
|
|
g.menu.ShowAtPosition(pos)
|
|
}
|
|
|
|
func (g *GridView) onPlay(itemID string, shuffle bool) {
|
|
if g.OnPlay != nil {
|
|
g.OnPlay(itemID, shuffle)
|
|
}
|
|
}
|
|
|
|
func (g *GridView) CreateRenderer() fyne.WidgetRenderer {
|
|
return widget.NewSimpleRenderer(g.grid)
|
|
}
|