Merge pull request #272 from dweymouth/feature/quick-search

Add "quick search" dialog to search entire library from any page
This commit is contained in:
Drew Weymouth
2023-11-05 15:21:14 -08:00
committed by GitHub
21 changed files with 731 additions and 45 deletions
+4
View File
@@ -37,6 +37,8 @@ type Favorites struct {
type MediaProvider interface {
SetPrefetchCoverCallback(cb func(coverArtID string))
GetTrack(trackID string) (*Track, error)
GetAlbum(albumID string) (*AlbumWithTracks, error)
GetAlbumInfo(albumID string) (*AlbumInfo, error)
@@ -57,6 +59,8 @@ type MediaProvider interface {
SearchAlbums(searchQuery string, filter AlbumFilter) AlbumIterator
SearchAll(searchQuery string, maxResults int) ([]*SearchResult, error)
GetRandomTracks(genre string, count int) ([]*Track, error)
GetSimilarTracks(artistID string, count int) ([]*Track, error)
+42
View File
@@ -87,3 +87,45 @@ type PlaylistWithTracks struct {
Playlist
Tracks []*Track
}
type ContentType int
const (
ContentTypeAlbum ContentType = iota
ContentTypeArtist
ContentTypePlaylist
ContentTypeTrack
ContentTypeGenre
)
func (c ContentType) String() string {
switch c {
case ContentTypeAlbum:
return "Album"
case ContentTypeArtist:
return "Artist"
case ContentTypeTrack:
return "Track"
case ContentTypePlaylist:
return "Playlist"
case ContentTypeGenre:
return "Genre"
default:
return "Unknown"
}
}
type SearchResult struct {
Name string
ID string
CoverID string
Type ContentType
// for Album / Playlist: track count
// Artist / Genre: album count
// Track: length (seconds)
Size int
// Unset for ContentTypes Artist, Playlist, and Genre
ArtistName string
}
+204
View File
@@ -0,0 +1,204 @@
package subsonic
import (
"sort"
"strconv"
"strings"
"sync"
"github.com/deluan/sanitize"
"github.com/dweymouth/go-subsonic/subsonic"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
)
func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
var wg sync.WaitGroup
var err error // only set by Search3
var result *subsonic.SearchResult3
var playlists []*subsonic.Playlist
var genres []*subsonic.Genre
wg.Add(1)
go func() {
count := strconv.Itoa(maxResults / 3)
res, e := s.client.Search3(searchQuery, map[string]string{
"artistCount": count,
"albumCount": count,
"songCount": count,
})
if e != nil {
err = e
} else {
result = res
}
wg.Done()
}()
querySanitized := strings.ToLower(sanitize.Accents(searchQuery))
queryLowerWords := strings.Fields(querySanitized)
wg.Add(1)
go func() {
p, e := s.client.GetPlaylists(nil)
if e == nil {
playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool {
return allTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
})
}
wg.Done()
}()
wg.Add(1)
go func() {
g, e := s.client.GetGenres()
if e == nil {
genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool {
return allTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
})
}
wg.Done()
}()
wg.Wait()
if err != nil {
return nil, err
}
results := mergeResults(result, playlists, genres)
rankResults(results, querySanitized, queryLowerWords)
if len(results) > maxResults {
results = results[:maxResults]
}
return results, nil
}
// name and terms should be pre-converted to the same case
func allTermsMatch(name string, terms []string) bool {
for _, t := range terms {
if !strings.Contains(name, t) {
return false
}
}
return true
}
func mergeResults(
searchResult *subsonic.SearchResult3,
matchingPlaylists []*subsonic.Playlist,
matchingGenres []*subsonic.Genre,
) []*mediaprovider.SearchResult {
var results []*mediaprovider.SearchResult
for _, al := range searchResult.Album {
results = append(results, &mediaprovider.SearchResult{
Type: mediaprovider.ContentTypeAlbum,
ID: al.ID,
CoverID: al.CoverArt,
Name: al.Name,
ArtistName: getNameString(al.Artist, al.Artists),
Size: al.SongCount,
})
}
for _, ar := range searchResult.Artist {
results = append(results, &mediaprovider.SearchResult{
Type: mediaprovider.ContentTypeArtist,
ID: ar.ID,
CoverID: ar.CoverArt,
Name: ar.Name,
Size: ar.AlbumCount,
})
}
for _, tr := range searchResult.Song {
results = append(results, &mediaprovider.SearchResult{
Type: mediaprovider.ContentTypeTrack,
ID: tr.ID,
CoverID: tr.CoverArt,
Name: tr.Title,
ArtistName: getNameString(tr.Artist, tr.Artists),
Size: tr.Duration,
})
}
for _, pl := range matchingPlaylists {
results = append(results, &mediaprovider.SearchResult{
Type: mediaprovider.ContentTypePlaylist,
ID: pl.ID,
CoverID: pl.CoverArt,
Name: pl.Name,
Size: pl.SongCount,
})
}
for _, g := range matchingGenres {
results = append(results, &mediaprovider.SearchResult{
Type: mediaprovider.ContentTypeGenre,
ID: g.Name,
Name: g.Name,
Size: g.AlbumCount,
})
}
return results
}
func rankResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) {
if len(queryTerms) == 0 || len(results) < 2 {
return
}
sanitizeMemo := make(map[string]string, len(results))
sanitized := func(s string) string {
if x, ok := sanitizeMemo[s]; ok {
return x
}
x := strings.ToLower(sanitize.Accents(s))
sanitizeMemo[s] = x
return x
}
sort.Slice(results, func(i, j int) bool {
a, b := results[i], results[j]
aName := sanitized(a.Name)
bName := sanitized(b.Name)
// Compare by entire query
matchesA, matchesB := strings.Contains(aName, fullQuery), strings.Contains(bName, fullQuery)
if matchesA && !matchesB {
return true // item A has a direct match with the full query and B does not
} else if matchesB && !matchesA {
return false // item B matches but not A
}
// Compare by search query terms
for _, term := range queryTerms {
firstTermIdxA, firstTermIdxB := strings.Index(aName, term), strings.Index(bName, term)
if firstTermIdxA >= 0 && firstTermIdxB < 0 {
return true // item A has a direct match with the query term and B does not
} else if firstTermIdxB >= 0 && firstTermIdxA < 0 {
return false // item B matches but not A
}
if firstTermIdxA < firstTermIdxB {
return true // item A matches the query term starting at an earlier position
} else if firstTermIdxB < firstTermIdxA {
return false // item B matches first
}
}
// Defer to item type for priority order
return a.Type < b.Type
})
}
// select Subsonic single-valued name or join OpenSubsonic multi-valued names
func getNameString(singleName string, idNames []subsonic.IDName) string {
if len(idNames) == 0 {
return singleName
}
names := sharedutil.MapSlice(idNames, func(a subsonic.IDName) string {
return a.Name
})
return strings.Join(names, ", ")
}
@@ -14,9 +14,17 @@ import (
"github.com/dweymouth/supersonic/sharedutil"
)
const cacheValidDurationSeconds = 60
type subsonicMediaProvider struct {
client *subsonic.Client
prefetchCoverCB func(coverArtID string)
genresCached []*mediaprovider.Genre
genresCachedAt int64 // unix
playlistsCached []*mediaprovider.Playlist
playlistsCachedAt int64 // unix
}
func SubsonicMediaProvider(subsonicClient *subsonic.Client) mediaprovider.MediaProvider {
@@ -47,6 +55,14 @@ func (s *subsonicMediaProvider) EditPlaylistTracks(id string, trackIDsToAdd []st
return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, trackIndexesToRemove)
}
func (s *subsonicMediaProvider) GetTrack(trackID string) (*mediaprovider.Track, error) {
tr, err := s.client.GetSong(trackID)
if err != nil {
return nil, err
}
return toTrack(tr), nil
}
func (s *subsonicMediaProvider) GetAlbum(albumID string) (*mediaprovider.AlbumWithTracks, error) {
al, err := s.client.GetAlbum(albumID)
if err != nil {
@@ -139,17 +155,23 @@ func (s *subsonicMediaProvider) GetFavorites() (mediaprovider.Favorites, error)
}
func (s *subsonicMediaProvider) GetGenres() ([]*mediaprovider.Genre, error) {
if s.genresCached != nil && time.Now().Unix()-s.genresCachedAt < cacheValidDurationSeconds {
return s.genresCached, nil
}
g, err := s.client.GetGenres()
if err != nil {
return nil, err
}
return sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre {
s.genresCached = sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre {
return &mediaprovider.Genre{
Name: g.Name,
AlbumCount: g.AlbumCount,
TrackCount: g.SongCount,
}
}), nil
})
s.genresCachedAt = time.Now().Unix()
return s.genresCached, nil
}
func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.PlaylistWithTracks, error) {
@@ -165,11 +187,17 @@ func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.P
}
func (s *subsonicMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) {
if s.playlistsCached != nil && time.Now().Unix()-s.playlistsCachedAt < cacheValidDurationSeconds {
return s.playlistsCached, nil
}
pl, err := s.client.GetPlaylists(map[string]string{})
if err != nil {
return nil, err
}
return sharedutil.MapSlice(pl, toPlaylist), nil
s.playlistsCached = sharedutil.MapSlice(pl, toPlaylist)
s.playlistsCachedAt = time.Now().Unix()
return s.playlistsCached, nil
}
func (s *subsonicMediaProvider) GetRandomTracks(genreName string, count int) ([]*mediaprovider.Track, error) {
+9
View File
@@ -214,6 +214,15 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl
return p.player.PlayTrackAt(firstTrack)
}
func (p *PlaybackManager) PlayTrack(trackID string) error {
tr, err := p.sm.Server.GetTrack(trackID)
if err != nil {
return err
}
p.LoadTracks([]*mediaprovider.Track{tr}, false, false)
return p.PlayFromBeginning()
}
func (p *PlaybackManager) PlayFromBeginning() error {
return p.player.PlayFromBeginning()
}
+1
View File
@@ -5,6 +5,7 @@ go 1.19
require (
fyne.io/fyne/v2 v2.4.1
github.com/20after4/configdir v0.1.1
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363
github.com/fsnotify/fsnotify v1.6.0
+2
View File
@@ -67,6 +67,8 @@ github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7h
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+dbf7JA6CAaM2UH/AGP1KX4DsJmTI=
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231104060932-f124004dd651 h1:szaOWq1a8gthqA55qq1egRj660DQM3Pp0nkrmjUwR48=
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231104060932-f124004dd651/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro=
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY=
+1
View File
@@ -76,6 +76,7 @@ func newArtistsPage(
SizeName: theme.SizeNameHeadingText,
}
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = "Search page"
a.searcher.OnSearched = func(query string) { a.onSearched(query, false /*firstLoad*/) }
a.searcher.Entry.Text = searchText
if g := pool.Obtain(util.WidgetTypeGridView); g != nil {
+3 -2
View File
@@ -64,7 +64,7 @@ type BrowsingPane struct {
container *fyne.Container
}
func NewBrowsingPane(app *backend.App) *BrowsingPane {
func NewBrowsingPane(app *backend.App, controller *controller.Controller) *BrowsingPane {
b := &BrowsingPane{app: app}
b.ExtendBaseWidget(b)
b.home = widget.NewButtonWithIcon("", theme.HomeIcon(), b.GoHome)
@@ -81,13 +81,14 @@ func NewBrowsingPane(app *backend.App) *BrowsingPane {
p.ShowAtPosition(fyne.NewPos(b.Size().Width-p.MinSize().Width+4,
b.navBtnsContainer.MinSize().Height+theme.Padding()))
})
quickSearchBtn := widget.NewButtonWithIcon("", theme.SearchIcon(), controller.ShowQuickSearch)
b.settingsMenu = fyne.NewMenu("")
b.navBtnsContainer = container.NewHBox()
b.container = container.NewBorder(container.New(
&layouts.MaxPadLayout{PadLeft: -5, PadRight: -5},
container.New(layouts.NewLeftMiddleRightLayout(0),
container.NewHBox(b.home, b.back, b.forward, b.reload), b.navBtnsContainer,
container.NewHBox(layout.NewSpacer(), b.settingsBtn))),
container.NewHBox(layout.NewSpacer(), quickSearchBtn, b.settingsBtn))),
nil, nil, nil, b.pageContainer)
b.updateHistoryButtons()
return b
+1
View File
@@ -92,6 +92,7 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int) {
widget.NewButtonWithIcon("", myTheme.ArtistIcon, a.onShowFavoriteArtists),
widget.NewButtonWithIcon("", myTheme.TracksIcon, a.onShowFavoriteSongs))
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = "Search page"
a.searcher.OnSearched = a.OnSearched
a.searcher.Entry.Text = a.searchText
a.filterBtn = widgets.NewAlbumFilterButton(&a.filter, a.mp.GetGenres)
+1
View File
@@ -49,6 +49,7 @@ func newGenresPage(contr *controller.Controller, mp mediaprovider.MediaProvider,
a.list = NewGenreList(sorting)
a.list.OnNavTo = func(id string) { a.contr.NavigateTo(controller.GenreRoute(id)) }
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = "Search page"
a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText
a.buildContainer()
+1
View File
@@ -140,6 +140,7 @@ func (g *GridViewPage) createTitleAndSort() {
func (g *GridViewPage) createSearchAndFilter() {
g.searcher = widgets.NewSearchEntry()
g.searcher.PlaceHolder = "Search page"
g.searcher.Text = g.searchText
g.searcher.OnSearched = g.OnSearched
if g.filter != nil {
+1
View File
@@ -63,6 +63,7 @@ func newPlaylistsPage(contr *controller.Controller, pool *util.WidgetPool, cfg *
a.ExtendBaseWidget(a)
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
a.searcher = widgets.NewSearchEntry()
a.searcher.PlaceHolder = "Search page"
a.searcher.OnSearched = a.onSearched
a.searcher.Entry.Text = searchText
a.viewToggle = widgets.NewToggleButtonGroup(0,
+1
View File
@@ -68,6 +68,7 @@ func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig,
t.title.Segments[0].(*widget.TextSegment).Style.SizeName = widget.RichTextStyleHeading.SizeName
t.playRandom = widget.NewButtonWithIcon("Play random", theme.ShuffleIcon, t.playRandomSongs)
t.searcher = widgets.NewSearchEntry()
t.searcher.PlaceHolder = "Search page"
t.searcher.OnSearched = t.OnSearched
t.createContainer()
t.Reload()
+33
View File
@@ -71,6 +71,10 @@ func (m *Controller) QueueShowModalFunc(f func()) {
}
}
func (m *Controller) HaveModal() bool {
return m.haveModal
}
func (m *Controller) ShowPopUpImage(img image.Image) {
im := canvas.NewImageFromImage(img)
im.FillMode = canvas.ImageFillContain
@@ -483,6 +487,35 @@ func (c *Controller) ShowSettingsDialog(themeUpdateCallbk func(), themeFiles map
pop.Show()
}
func (c *Controller) ShowQuickSearch() {
qs := dialogs.NewQuickSearch(c.App.ServerManager.Server, c.App.ImageManager)
pop := widget.NewModalPopUp(qs, c.MainWindow.Canvas())
qs.OnDismiss = func() {
pop.Hide()
c.doModalClosed()
}
qs.OnNavigateTo = func(contentType mediaprovider.ContentType, id string) {
pop.Hide()
c.doModalClosed()
switch contentType {
case mediaprovider.ContentTypeAlbum:
c.NavigateTo(AlbumRoute(id))
case mediaprovider.ContentTypeArtist:
c.NavigateTo(ArtistRoute(id))
case mediaprovider.ContentTypeTrack:
go c.App.PlaybackManager.PlayTrack(id)
case mediaprovider.ContentTypePlaylist:
c.NavigateTo(PlaylistRoute(id))
case mediaprovider.ContentTypeGenre:
c.NavigateTo(GenreRoute(id))
}
}
c.ClosePopUpOnEscape(pop)
c.haveModal = true
pop.Show()
c.MainWindow.Canvas().Focus(qs.SearchEntry)
}
func (c *Controller) trySetPasswordAndConnectToServer(server *backend.ServerConfig, password string) error {
if err := c.App.ServerManager.SetServerPassword(server, password); err != nil {
log.Printf("error setting keyring credentials: %v", err)
+303
View File
@@ -0,0 +1,303 @@
package dialogs
import (
"fmt"
"image"
"log"
"sync"
"fyne.io/fyne/v2"
"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/mediaprovider"
"github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util"
"github.com/dweymouth/supersonic/ui/widgets"
)
type QuickSearch struct {
widget.BaseWidget
OnDismiss func()
OnNavigateTo func(mediaprovider.ContentType, string)
SearchEntry fyne.Focusable // exported so it can be focused by the Controller
mp mediaprovider.MediaProvider
imgSource util.ImageFetcher
resultsMutex sync.RWMutex
searchResults []*mediaprovider.SearchResult
list *widget.List
selectedIndex int
content *fyne.Container
}
func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch {
q := &QuickSearch{
mp: mp,
imgSource: im,
}
q.ExtendBaseWidget(q)
se := newQuickSearchEntry()
se.OnSearched = q.onSearched
se.OnSubmitted = func(_ string) {
q.onSelected(q.selectedIndex)
}
se.OnTypedDown = q.moveSelectionDown
se.OnTypedUp = q.moveSelectionUp
se.OnTypedEscape = q.onDismiss
q.SearchEntry = se
q.list = widget.NewList(
func() int {
q.resultsMutex.RLock()
defer q.resultsMutex.RUnlock()
return len(q.searchResults)
},
func() fyne.CanvasObject { return newQuickSearchResult(q) },
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()
qs := co.(*quickSearchResult)
qs.index = lii
qs.Update(result)
},
)
dismissBtn := widget.NewButton("Close", q.onDismiss)
title := widget.NewRichText(&widget.TextSegment{Text: "Quick Search", Style: boldStyle})
title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter
q.content = container.NewBorder(
container.NewVBox(title, se),
container.NewVBox(widget.NewSeparator(), container.NewHBox(layout.NewSpacer(), dismissBtn)),
nil, nil, q.list)
return q
}
func (q *QuickSearch) onDismiss() {
if q.OnDismiss != nil {
q.OnDismiss()
}
}
func (q *QuickSearch) onSelected(idx int) {
if q.OnNavigateTo == nil {
return
}
q.resultsMutex.RLock()
if len(q.searchResults) <= idx {
q.resultsMutex.RUnlock()
return
}
id := q.searchResults[idx].ID
typ := q.searchResults[idx].Type
q.resultsMutex.RUnlock()
q.OnNavigateTo(typ, id)
}
func (q *QuickSearch) moveSelectionDown() {
q.resultsMutex.RLock()
if q.selectedIndex < len(q.searchResults)-1 {
q.selectedIndex++
}
q.resultsMutex.RUnlock()
q.list.Select(q.selectedIndex)
}
func (q *QuickSearch) moveSelectionUp() {
q.resultsMutex.RLock()
if q.selectedIndex > 0 {
q.selectedIndex--
}
q.resultsMutex.RUnlock()
q.list.Select(q.selectedIndex)
}
func (q *QuickSearch) onSearched(query string) {
var results []*mediaprovider.SearchResult
if query != "" {
if res, err := q.mp.SearchAll(query, 20); err != nil {
log.Printf("Error searching: %s", err.Error())
} else {
results = res
}
}
q.resultsMutex.Lock()
q.searchResults = results
q.resultsMutex.Unlock()
q.list.Refresh()
q.list.ScrollToTop()
q.selectedIndex = 0
q.list.Select(0)
}
func (q *QuickSearch) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(q.content)
}
func (q *QuickSearch) MinSize() fyne.Size {
return fyne.NewSize(400, 350)
}
type quickSearchResult struct {
widget.BaseWidget
parent *QuickSearch
id string
index int
contentType mediaprovider.ContentType
imageLoader util.ThumbnailLoader
image *widgets.ImagePlaceholder
title *widget.Label
secondary *widget.RichText
content *fyne.Container
}
func newQuickSearchResult(parent *QuickSearch) *quickSearchResult {
qs := &quickSearchResult{
parent: parent,
image: widgets.NewImagePlaceholder(myTheme.AlbumIcon, 50),
title: widget.NewLabel(""),
secondary: widget.NewRichText(),
}
qs.title.Wrapping = fyne.TextTruncate
qs.secondary.Wrapping = fyne.TextTruncate
qs.ExtendBaseWidget(qs)
qs.imageLoader = util.NewThumbnailLoader(parent.imgSource, 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
}
if q.contentType == result.Type && q.id == result.ID {
return // nothing to do
}
q.id = result.ID
q.contentType = result.Type
q.image.CenterIcon = placeholderIconForContentType(result.Type)
q.imageLoader.Load(result.CoverID)
q.title.SetText(result.Name)
maybePluralize := func(s string, size int) string {
if size != 1 {
return s + "s"
}
return s
}
var secondaryText string
switch result.Type {
case mediaprovider.ContentTypeAlbum:
secondaryText = result.ArtistName
case mediaprovider.ContentTypeArtist:
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
case mediaprovider.ContentTypeTrack:
secondaryText = result.ArtistName
case mediaprovider.ContentTypePlaylist:
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
case mediaprovider.ContentTypeGenre:
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
}
q.secondary.Segments = []widget.RichTextSegment{
&widget.TextSegment{
Text: result.Type.String(),
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
},
&widget.TextSegment{
Text: " · ",
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
},
&widget.TextSegment{
Text: secondaryText,
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
},
}
q.secondary.Refresh()
}
func (q *quickSearchResult) Tapped(_ *fyne.PointEvent) {
q.parent.onSelected(q.index)
}
func (q *quickSearchResult) CreateRenderer() fyne.WidgetRenderer {
if q.content == nil {
q.content = container.NewBorder(nil, nil, container.NewCenter(q.image), nil,
container.New(&layouts.VboxCustomPadding{ExtraPad: -15},
q.title,
q.secondary,
))
}
return widget.NewSimpleRenderer(q.content)
}
func (q *quickSearchResult) Refresh() {
q.BaseWidget.Refresh()
}
type quickSearchEntry struct {
widgets.SearchEntry
OnTypedUp func()
OnTypedDown func()
OnTypedEscape func()
}
func newQuickSearchEntry() *quickSearchEntry {
q := &quickSearchEntry{}
q.ExtendBaseWidget(q)
q.SearchEntry.Init()
return q
}
func (q *quickSearchEntry) TypedKey(e *fyne.KeyEvent) {
switch {
case e.Name == fyne.KeyUp && q.OnTypedUp != nil:
q.OnTypedUp()
case e.Name == fyne.KeyDown && q.OnTypedDown != nil:
q.OnTypedDown()
case e.Name == fyne.KeyEscape && q.OnTypedEscape != nil:
q.OnTypedEscape()
default:
q.SearchEntry.TypedKey(e)
}
}
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
}
}
+14 -4
View File
@@ -23,6 +23,7 @@ import (
var (
ShortcutReload = desktop.CustomShortcut{KeyName: fyne.KeyR, Modifier: os.ControlModifier}
ShortcutSearch = desktop.CustomShortcut{KeyName: fyne.KeyF, Modifier: os.ControlModifier}
ShortcutQuickSearch = desktop.CustomShortcut{KeyName: fyne.KeyG, Modifier: os.ControlModifier}
ShortcutCloseWindow = desktop.CustomShortcut{KeyName: fyne.KeyW, Modifier: os.ControlModifier}
ShortcutNavOne = desktop.CustomShortcut{KeyName: fyne.Key1, Modifier: os.ControlModifier}
@@ -53,10 +54,9 @@ type MainWindow struct {
func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, app *backend.App, size fyne.Size) MainWindow {
m := MainWindow{
App: app,
Window: fyneApp.NewWindow(displayAppName),
BrowsingPane: browsing.NewBrowsingPane(app),
theme: theme.NewMyTheme(&app.Config.Theme, configdir.LocalConfig(appName, "themes")),
App: app,
Window: fyneApp.NewWindow(displayAppName),
theme: theme.NewMyTheme(&app.Config.Theme, configdir.LocalConfig(appName, "themes")),
}
m.theme.NormalFont = app.Config.Application.FontNormalTTF
@@ -71,6 +71,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string,
MainWindow: m.Window,
App: app,
}
m.BrowsingPane = browsing.NewBrowsingPane(app, m.Controller)
m.Router = browsing.NewRouter(app, m.Controller, m.BrowsingPane)
// inject controller dependencies
m.Controller.NavHandler = m.Router.NavigateTo
@@ -249,10 +250,19 @@ func (m *MainWindow) addShortcuts() {
m.BrowsingPane.Reload()
})
m.Canvas().AddShortcut(&ShortcutSearch, func(_ fyne.Shortcut) {
if m.Controller.HaveModal() {
// Do not focus search widget behind modal dialog
return
}
if s := m.BrowsingPane.GetSearchBarIfAny(); s != nil {
m.Window.Canvas().Focus(s)
}
})
m.Canvas().AddShortcut(&ShortcutQuickSearch, func(_ fyne.Shortcut) {
if !m.Controller.HaveModal() {
m.Controller.ShowQuickSearch()
}
})
m.Canvas().AddShortcut(&fyne.ShortcutSelectAll{}, func(_ fyne.Shortcut) {
m.BrowsingPane.SelectAll()
})
+63
View File
@@ -0,0 +1,63 @@
package util
import (
"context"
"image"
"log"
)
// 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 ImageFetcher
OnBeforeLoad func()
OnLoaded func(image.Image)
}
// Image backend interface for the ThumbnailLoader
// impl: backend.ImageManager
type ImageFetcher interface {
GetCoverThumbnailFromCache(string) (image.Image, bool)
GetCoverThumbnailAsync(string, func(image.Image, error)) context.CancelFunc
}
func NewThumbnailLoader(im ImageFetcher, onLoaded func(image.Image)) ThumbnailLoader {
return ThumbnailLoader{im: im, 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)
}
}
+7 -32
View File
@@ -2,12 +2,11 @@ package widgets
import (
"context"
"image"
"log"
"sync"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/util"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget"
@@ -37,11 +36,6 @@ 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 GridViewIterator interface {
NextN(int) []GridViewItemModel
}
@@ -82,7 +76,7 @@ type GridView struct {
type GridViewState struct {
items []GridViewItemModel
iter GridViewIterator
imageFetcher ImageFetcher
imageFetcher util.ImageFetcher
Placeholder fyne.Resource
highestShown int
done bool
@@ -99,7 +93,7 @@ type GridViewState struct {
var _ fyne.Widget = (*GridView)(nil)
func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
func NewFixedGridView(items []GridViewItemModel, fetch util.ImageFetcher, placeholder fyne.Resource) *GridView {
g := &GridView{
GridViewState: GridViewState{
items: items,
@@ -113,7 +107,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 util.ImageFetcher, placeholder fyne.Resource) *GridView {
g := &GridView{
GridViewState: GridViewState{
iter: iter,
@@ -203,6 +197,8 @@ func (g *GridView) createGridWrap() {
// create func
func() fyne.CanvasObject {
card := NewGridViewItem(g.Placeholder)
card.ImgLoader = util.NewThumbnailLoader(g.imageFetcher, 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 +241,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 {
+3 -3
View File
@@ -1,12 +1,12 @@
package widgets
import (
"context"
"image"
"github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/layouts"
"github.com/dweymouth/supersonic/ui/util"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
@@ -140,8 +140,8 @@ type GridViewItem struct {
container *fyne.Container
// updated by GridView
Cover *coverImage
ImgLoadCancel context.CancelFunc
Cover *coverImage
ImgLoader util.ThumbnailLoader
OnPlay func()
OnShowContextMenu func(fyne.Position)
+6 -1
View File
@@ -21,6 +21,12 @@ type SearchEntry struct {
func NewSearchEntry() *SearchEntry {
sf := &SearchEntry{}
sf.ExtendBaseWidget(sf)
sf.Init()
return sf
}
// For use only by extending widgets
func (sf *SearchEntry) Init() {
sf.PlaceHolder = "Search"
sf.ActionItem = NewClearTextButton(func() {
sf.SetText("")
@@ -34,7 +40,6 @@ func NewSearchEntry() *SearchEntry {
}
debounceFunc()
}
return sf
}
func (s *SearchEntry) TypedKey(e *fyne.KeyEvent) {