diff --git a/backend/imagemanager.go b/backend/imagemanager.go index 96da5bf..aa7bf6b 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -188,6 +188,55 @@ func (i *ImageManager) RefreshCachedArtistImageIfExpired(artistID string, imgURL return err } +// ThumbnailLoader is a utility type that exposes a single API to load +// a cover thumbnail by ID. If the image is immediately available in +// the cache, OnLoaded will be called immediately. If it is not, +// OnBeforeLoad will be called first, then OnLoaded will be called async +// once the image is available. +// Any subsequent calls to Load will cancel the previous load if not yet completed. +type ThumbnailLoader struct { + prevLoadCancel context.CancelFunc + im *ImageManager + + OnBeforeLoad func() + OnLoaded func(image.Image) +} + +func (i *ImageManager) NewThumbnailLoader(onLoaded func(image.Image)) ThumbnailLoader { + return ThumbnailLoader{im: i, OnLoaded: onLoaded} +} + +func (i *ThumbnailLoader) Load(coverID string) { + if i.prevLoadCancel != nil { + i.prevLoadCancel() + } + if coverID == "" { + i.callOnLoaded(nil) + return + } + if img, ok := i.im.GetCoverThumbnailFromCache(coverID); ok { + i.callOnLoaded(img) + return + } + if i.OnBeforeLoad != nil { + i.OnBeforeLoad() + } + i.prevLoadCancel = i.im.GetCoverThumbnailAsync(coverID, func(img image.Image, err error) { + if err != nil { + log.Printf("Error loading cover image: %s", err.Error()) + } else { + i.callOnLoaded(img) + } + i.prevLoadCancel() // Done. Release resources associated with un-cancelled ctx + }) +} + +func (i *ThumbnailLoader) callOnLoaded(im image.Image) { + if i.OnLoaded != nil { + i.OnLoaded(im) + } +} + func (i *ImageManager) ensureCoverCacheDir() string { // if user logged out with pending fetches in progress, // make sure we don't write to nil (00000000-*0) cache directory diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go new file mode 100644 index 0000000..998c555 --- /dev/null +++ b/ui/dialogs/quicksearch.go @@ -0,0 +1,154 @@ +package dialogs + +import ( + "image" + "sync" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + "github.com/dweymouth/supersonic/backend" + "github.com/dweymouth/supersonic/backend/mediaprovider" + myTheme "github.com/dweymouth/supersonic/ui/theme" + "github.com/dweymouth/supersonic/ui/widgets" +) + +type QuickSearch struct { + widget.BaseWidget + + mp *mediaprovider.MediaProvider + im *backend.ImageManager + + resultsMutex sync.RWMutex + searchResults []*mediaprovider.SearchResult + list *widget.List + selectedIndex int + + content *fyne.Container +} + +func NewQuickSearch(mp *mediaprovider.MediaProvider, im *backend.ImageManager) *QuickSearch { + q := &QuickSearch{ + mp: mp, + im: im, + } + q.ExtendBaseWidget(q) + + searchEntry := widgets.NewSearchEntry() + searchEntry.OnChanged = q.onSearched + q.list = widget.NewList( + func() int { + q.resultsMutex.RLock() + defer q.resultsMutex.RUnlock() + return len(q.searchResults) + }, + func() fyne.CanvasObject { return newQuickSearchResult(im) }, + func(lii widget.ListItemID, co fyne.CanvasObject) { + var result *mediaprovider.SearchResult + q.resultsMutex.RLock() + if len(q.searchResults) > lii { + result = q.searchResults[lii] + } + q.resultsMutex.RUnlock() + co.(*quickSearchResult).Update(result) + }, + ) + q.content = container.NewVBox( + container.NewHBox( + layout.NewSpacer(), + widget.NewRichText(&widget.TextSegment{Text: "Quick Search", Style: boldStyle}), + layout.NewSpacer()), + container.NewBorder(searchEntry, nil, nil, nil, q.list), + ) + return q +} + +func (q *QuickSearch) onSearched(query string) { +} + +func (q *QuickSearch) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(q.content) +} + +type quickSearchResult struct { + widget.BaseWidget + + // parent *QuickSearch + + imageLoader backend.ThumbnailLoader + + image *widgets.ImagePlaceholder + title *widget.Label + secondary *widget.RichText + selection *canvas.Rectangle + + content *fyne.Container +} + +func newQuickSearchResult(im *backend.ImageManager) *quickSearchResult { + qs := &quickSearchResult{ + image: widgets.NewImagePlaceholder(myTheme.AlbumIcon, 64), + title: widget.NewLabel(""), + secondary: widget.NewRichText(), + } + qs.ExtendBaseWidget(qs) + qs.imageLoader = im.NewThumbnailLoader(func(im image.Image) { + qs.image.SetImage(im, false) + }) + qs.imageLoader.OnBeforeLoad = func() { + qs.image.SetImage(nil, false) + } + + return qs +} + +func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { + if result == nil { + return + } + q.image.CenterIcon = placeholderIconForContentType(result.Type) + q.imageLoader.Load(result.CoverID) + q.title.SetText(result.Name) + //TODO: q.secondary.Segments = +} + +func (q *quickSearchResult) CreateRenderer() fyne.WidgetRenderer { + if q.selection == nil { + q.selection = canvas.NewRectangle(theme.SelectionColor()) + } + if q.content == nil { + q.content = container.NewMax( + q.selection, + container.NewBorder(nil, nil, q.image, nil, + container.NewVBox( + q.title, + q.secondary, + )), + ) + } + return widget.NewSimpleRenderer(q.content) +} + +func (q *quickSearchResult) Refresh() { + q.BaseWidget.Refresh() +} + +func placeholderIconForContentType(c mediaprovider.ContentType) fyne.Resource { + switch c { + case mediaprovider.ContentTypeAlbum: + return myTheme.AlbumIcon + case mediaprovider.ContentTypeArtist: + return myTheme.ArtistIcon + case mediaprovider.ContentTypeTrack: + return myTheme.TracksIcon + case mediaprovider.ContentTypeGenre: + return myTheme.GenreIcon + case mediaprovider.ContentTypePlaylist: + return myTheme.PlaylistIcon + default: + return theme.WarningIcon() // unreached + } +} diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index 21ed4ba..95da758 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -3,9 +3,9 @@ package widgets import ( "context" "image" - "log" "sync" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" @@ -37,9 +37,8 @@ func (b *BatchingIterator) NextN(n int) []*mediaprovider.Album { return results } -type ImageFetcher interface { - GetCoverThumbnailFromCache(string) (image.Image, bool) - GetCoverThumbnailAsync(string, func(image.Image, error)) context.CancelFunc +type ThumbnailLoaderFactory interface { + NewThumbnailLoader(func(image.Image)) backend.ThumbnailLoader } type GridViewIterator interface { @@ -82,7 +81,7 @@ type GridView struct { type GridViewState struct { items []GridViewItemModel iter GridViewIterator - imageFetcher ImageFetcher + imageFetcher ThumbnailLoaderFactory Placeholder fyne.Resource highestShown int done bool @@ -99,7 +98,7 @@ type GridViewState struct { var _ fyne.Widget = (*GridView)(nil) -func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder fyne.Resource) *GridView { +func NewFixedGridView(items []GridViewItemModel, fetch ThumbnailLoaderFactory, placeholder fyne.Resource) *GridView { g := &GridView{ GridViewState: GridViewState{ items: items, @@ -113,7 +112,7 @@ func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder return g } -func NewGridView(iter GridViewIterator, fetch ImageFetcher, placeholder fyne.Resource) *GridView { +func NewGridView(iter GridViewIterator, fetch ThumbnailLoaderFactory, placeholder fyne.Resource) *GridView { g := &GridView{ GridViewState: GridViewState{ iter: iter, @@ -203,6 +202,8 @@ func (g *GridView) createGridWrap() { // create func func() fyne.CanvasObject { card := NewGridViewItem(g.Placeholder) + card.ImgLoader = g.imageFetcher.NewThumbnailLoader(card.Cover.SetImage) + card.ImgLoader.OnBeforeLoad = func() { card.Cover.SetImage(nil) } card.OnPlay = func() { g.onPlay(card.ItemID(), false) } card.OnShowSecondaryPage = func(id string) { if g.OnShowSecondaryPage != nil { @@ -245,28 +246,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { 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) - } + card.ImgLoader.Load(item.CoverArtID) // if user has scrolled near the bottom, fetch more if itemIdx > g.lenItems()-10 { diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index af987a1..5581b48 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -1,9 +1,9 @@ package widgets import ( - "context" "image" + "github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/layouts" @@ -140,8 +140,8 @@ type GridViewItem struct { container *fyne.Container // updated by GridView - Cover *coverImage - ImgLoadCancel context.CancelFunc + Cover *coverImage + ImgLoader backend.ThumbnailLoader OnPlay func() OnShowContextMenu func(fyne.Position)