From 0439ec695be0670939e4aee8c2dfb4b39f16de5a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Mon, 30 Oct 2023 18:18:59 -0700 Subject: [PATCH 01/13] add SearchAll function to MediaProvider --- backend/mediaprovider/mediaprovider.go | 2 + backend/mediaprovider/model.go | 42 ++++++ backend/mediaprovider/subsonic/searchall.go | 136 ++++++++++++++++++ .../subsonic/subsonicmediaprovider.go | 26 +++- 4 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 backend/mediaprovider/subsonic/searchall.go diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 6529074..db30fdb 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -57,6 +57,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) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 91e5837..bc1ae7d 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -87,3 +87,45 @@ type PlaylistWithTracks struct { Playlist Tracks []*Track } + +type ContentType int + +const ( + ContentTypeAlbum ContentType = iota + ContentTypeArtist + ContentTypeTrack + ContentTypePlaylist + 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 +} diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go new file mode 100644 index 0000000..edcbc55 --- /dev/null +++ b/backend/mediaprovider/subsonic/searchall.go @@ -0,0 +1,136 @@ +package subsonic + +import ( + "sort" + "strconv" + "strings" + "sync" + + "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() + }() + + queryLowerWords := strings.Fields(strings.ToLower(searchQuery)) + + 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(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(g.Name), queryLowerWords) + }) + } + wg.Done() + }() + + wg.Wait() + if err != nil { + return nil, err + } + + results := mergeResults(result, playlists, genres) + //rankResults(results, queryLowerWords) // TODO + 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, + 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, + Name: ar.Name, + Size: ar.AlbumCount, + }) + } + + for _, tr := range searchResult.Song { + results = append(results, &mediaprovider.SearchResult{ + Type: mediaprovider.ContentTypeTrack, + Name: tr.Title, + ArtistName: getNameString(tr.Artist, tr.Artists), + Size: tr.Duration, + }) + } + + return results +} + +func rankResults(results []*mediaprovider.SearchResult, queryTerms []string) { + // TODO + sort.Slice(results, func(a, b int) bool { + return false + }) +} + +// 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, ", ") +} diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 989d159..e5f52b5 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -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 { @@ -139,17 +147,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 +179,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) { From e10b751a4ff0e362e1629220aec7fb862669db90 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 31 Oct 2023 09:11:11 -0700 Subject: [PATCH 02/13] some work on the quick search dialog + image load refactoring --- backend/imagemanager.go | 49 ++++++++++++ ui/dialogs/quicksearch.go | 154 +++++++++++++++++++++++++++++++++++++ ui/widgets/gridview.go | 38 +++------ ui/widgets/gridviewitem.go | 6 +- 4 files changed, 215 insertions(+), 32 deletions(-) create mode 100644 ui/dialogs/quicksearch.go 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) From 38a399222a48d0c1676e759dff9669b8bd3c2166 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 31 Oct 2023 09:34:40 -0700 Subject: [PATCH 03/13] simplify --- ui/dialogs/quicksearch.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 998c555..d15034f 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -7,7 +7,6 @@ import ( "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" @@ -56,11 +55,10 @@ func NewQuickSearch(mp *mediaprovider.MediaProvider, im *backend.ImageManager) * co.(*quickSearchResult).Update(result) }, ) + title := widget.NewRichText(&widget.TextSegment{Text: "Quick Search", Style: boldStyle}) + title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter q.content = container.NewVBox( - container.NewHBox( - layout.NewSpacer(), - widget.NewRichText(&widget.TextSegment{Text: "Quick Search", Style: boldStyle}), - layout.NewSpacer()), + title, container.NewBorder(searchEntry, nil, nil, nil, q.list), ) return q From 17df4d780e5df9f4fd0284075562797e4b20726e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 31 Oct 2023 18:11:23 -0700 Subject: [PATCH 04/13] more WIP --- backend/mediaprovider/subsonic/searchall.go | 12 +++++-- ui/browsing/browsingpane.go | 5 +-- ui/controller/controller.go | 26 ++++++++++++++ ui/dialogs/quicksearch.go | 40 +++++++++++++++++---- ui/mainwindow.go | 8 ++--- 5 files changed, 75 insertions(+), 16 deletions(-) diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index edcbc55..69534ea 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -91,6 +91,8 @@ func mergeResults( 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, @@ -99,15 +101,19 @@ func mergeResults( for _, ar := range searchResult.Artist { results = append(results, &mediaprovider.SearchResult{ - Type: mediaprovider.ContentTypeArtist, - Name: ar.Name, - Size: ar.AlbumCount, + 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, diff --git a/ui/browsing/browsingpane.go b/ui/browsing/browsingpane.go index d551dec..21559fa 100644 --- a/ui/browsing/browsingpane.go +++ b/ui/browsing/browsingpane.go @@ -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 diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 541eb7a..3e2cc67 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -483,6 +483,32 @@ 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: + // TODO + case mediaprovider.ContentTypePlaylist: + c.NavigateTo(PlaylistRoute(id)) + case mediaprovider.ContentTypeGenre: + c.NavigateTo(GenreRoute(id)) + } + } + pop.Show() +} + 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) diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index d15034f..67399b0 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -2,6 +2,7 @@ package dialogs import ( "image" + "log" "sync" "fyne.io/fyne/v2" @@ -18,7 +19,10 @@ import ( type QuickSearch struct { widget.BaseWidget - mp *mediaprovider.MediaProvider + OnDismiss func() + OnNavigateTo func(mediaprovider.ContentType, string) + + mp mediaprovider.MediaProvider im *backend.ImageManager resultsMutex sync.RWMutex @@ -29,7 +33,7 @@ type QuickSearch struct { content *fyne.Container } -func NewQuickSearch(mp *mediaprovider.MediaProvider, im *backend.ImageManager) *QuickSearch { +func NewQuickSearch(mp mediaprovider.MediaProvider, im *backend.ImageManager) *QuickSearch { q := &QuickSearch{ mp: mp, im: im, @@ -57,25 +61,42 @@ func NewQuickSearch(mp *mediaprovider.MediaProvider, im *backend.ImageManager) * ) title := widget.NewRichText(&widget.TextSegment{Text: "Quick Search", Style: boldStyle}) title.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignCenter - q.content = container.NewVBox( - title, - container.NewBorder(searchEntry, nil, nil, nil, q.list), - ) + q.content = container.NewBorder( + container.NewVBox(title, searchEntry), nil, nil, nil, q.list) return q } 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() } func (q *QuickSearch) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(q.content) } +func (q *QuickSearch) MinSize() fyne.Size { + return fyne.NewSize(500, 350) +} + type quickSearchResult struct { widget.BaseWidget // parent *QuickSearch + id string + contentType mediaprovider.ContentType + imageLoader backend.ThumbnailLoader image *widgets.ImagePlaceholder @@ -88,7 +109,7 @@ type quickSearchResult struct { func newQuickSearchResult(im *backend.ImageManager) *quickSearchResult { qs := &quickSearchResult{ - image: widgets.NewImagePlaceholder(myTheme.AlbumIcon, 64), + image: widgets.NewImagePlaceholder(myTheme.AlbumIcon, 50), title: widget.NewLabel(""), secondary: widget.NewRichText(), } @@ -107,6 +128,11 @@ 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) diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 765cbd4..3a2842c 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -53,10 +53,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 +70,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 From 155ee877e3ec98867ade58667ac5ddbb2dcbb6ae Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 31 Oct 2023 20:16:22 -0700 Subject: [PATCH 05/13] mostly working now, except for track result action, and results ranking --- ui/controller/controller.go | 3 + ui/dialogs/quicksearch.go | 130 +++++++++++++++++++++++++++++------- ui/widgets/searchentry.go | 7 +- 3 files changed, 116 insertions(+), 24 deletions(-) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 3e2cc67..ff08312 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -506,7 +506,10 @@ func (c *Controller) ShowQuickSearch() { 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 { diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index 67399b0..defecc0 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -6,12 +6,13 @@ import ( "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" + "github.com/dweymouth/supersonic/ui/layouts" myTheme "github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/widgets" ) @@ -22,6 +23,8 @@ type QuickSearch struct { OnDismiss func() OnNavigateTo func(mediaprovider.ContentType, string) + SearchEntry fyne.Focusable // exported so it can be focused by the Controller + mp mediaprovider.MediaProvider im *backend.ImageManager @@ -40,15 +43,36 @@ func NewQuickSearch(mp mediaprovider.MediaProvider, im *backend.ImageManager) *Q } q.ExtendBaseWidget(q) - searchEntry := widgets.NewSearchEntry() - searchEntry.OnChanged = q.onSearched + se := newQuickSearchEntry() + se.OnSearched = q.onSearched + se.OnSubmitted = func(_ string) { + q.onSelected(q.selectedIndex) + } + se.OnTypedDown = func() { + q.resultsMutex.RLock() + if q.selectedIndex < len(q.searchResults)-1 { + q.selectedIndex++ + } + q.resultsMutex.RUnlock() + q.list.Select(q.selectedIndex) + } + se.OnTypedUp = func() { + q.resultsMutex.RLock() + if q.selectedIndex > 0 { + q.selectedIndex-- + } + q.resultsMutex.RUnlock() + q.list.Select(q.selectedIndex) + } + 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(im) }, + func() fyne.CanvasObject { return newQuickSearchResult(q) }, func(lii widget.ListItemID, co fyne.CanvasObject) { var result *mediaprovider.SearchResult q.resultsMutex.RLock() @@ -56,16 +80,43 @@ func NewQuickSearch(mp mediaprovider.MediaProvider, im *backend.ImageManager) *Q result = q.searchResults[lii] } q.resultsMutex.RUnlock() - co.(*quickSearchResult).Update(result) + 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, searchEntry), nil, nil, nil, q.list) + 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) onSearched(query string) { var results []*mediaprovider.SearchResult if query != "" { @@ -79,6 +130,8 @@ func (q *QuickSearch) onSearched(query string) { q.searchResults = results q.resultsMutex.Unlock() q.list.Refresh() + q.list.ScrollToTop() + q.list.Select(0) } func (q *QuickSearch) CreateRenderer() fyne.WidgetRenderer { @@ -86,15 +139,16 @@ func (q *QuickSearch) CreateRenderer() fyne.WidgetRenderer { } func (q *QuickSearch) MinSize() fyne.Size { - return fyne.NewSize(500, 350) + return fyne.NewSize(400, 350) } type quickSearchResult struct { widget.BaseWidget - // parent *QuickSearch + parent *QuickSearch id string + index int contentType mediaprovider.ContentType imageLoader backend.ThumbnailLoader @@ -102,19 +156,21 @@ type quickSearchResult struct { image *widgets.ImagePlaceholder title *widget.Label secondary *widget.RichText - selection *canvas.Rectangle content *fyne.Container } -func newQuickSearchResult(im *backend.ImageManager) *quickSearchResult { +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 = im.NewThumbnailLoader(func(im image.Image) { + qs.imageLoader = parent.im.NewThumbnailLoader(func(im image.Image) { qs.image.SetImage(im, false) }) qs.imageLoader.OnBeforeLoad = func() { @@ -136,22 +192,21 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { q.image.CenterIcon = placeholderIconForContentType(result.Type) q.imageLoader.Load(result.CoverID) q.title.SetText(result.Name) - //TODO: q.secondary.Segments = + q.secondary.Segments = []widget.RichTextSegment{&widget.TextSegment{Text: result.Type.String()}} + q.secondary.Refresh() +} + +func (q *quickSearchResult) Tapped(_ *fyne.PointEvent) { + q.parent.onSelected(q.index) } 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, - )), - ) + 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) } @@ -160,6 +215,35 @@ 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: diff --git a/ui/widgets/searchentry.go b/ui/widgets/searchentry.go index 41d932e..d5ed450 100644 --- a/ui/widgets/searchentry.go +++ b/ui/widgets/searchentry.go @@ -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) { From 7aa94dd357125fce4271b3409011bd88d847db12 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 31 Oct 2023 22:11:06 -0700 Subject: [PATCH 06/13] add Ctrl+G shortcut --- ui/controller/controller.go | 4 ++++ ui/mainwindow.go | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/ui/controller/controller.go b/ui/controller/controller.go index ff08312..de826bb 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -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 diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 3a2842c..65ffd26 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -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} @@ -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() }) From 3238aad65a6abc512669aa7a045e9871e2ac013e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 1 Nov 2023 16:03:29 -0700 Subject: [PATCH 07/13] include playlists and genres in results; UI improvement --- backend/mediaprovider/subsonic/searchall.go | 23 ++++++- ui/dialogs/quicksearch.go | 74 ++++++++++++++++----- 2 files changed, 78 insertions(+), 19 deletions(-) diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index 69534ea..f2e38b0 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -39,7 +39,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { p, e := s.client.GetPlaylists(nil) - if e != nil { + if e == nil { playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool { return allTermsMatch(strings.ToLower(p.Name), queryLowerWords) }) @@ -50,7 +50,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Add(1) go func() { g, e := s.client.GetGenres() - if e != nil { + if e == nil { genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool { return allTermsMatch(strings.ToLower(g.Name), queryLowerWords) }) @@ -120,6 +120,25 @@ func mergeResults( }) } + 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 } diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index defecc0..d1e8884 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -1,6 +1,7 @@ package dialogs import ( + "fmt" "image" "log" "sync" @@ -48,22 +49,8 @@ func NewQuickSearch(mp mediaprovider.MediaProvider, im *backend.ImageManager) *Q se.OnSubmitted = func(_ string) { q.onSelected(q.selectedIndex) } - se.OnTypedDown = func() { - q.resultsMutex.RLock() - if q.selectedIndex < len(q.searchResults)-1 { - q.selectedIndex++ - } - q.resultsMutex.RUnlock() - q.list.Select(q.selectedIndex) - } - se.OnTypedUp = func() { - q.resultsMutex.RLock() - if q.selectedIndex > 0 { - q.selectedIndex-- - } - q.resultsMutex.RUnlock() - q.list.Select(q.selectedIndex) - } + se.OnTypedDown = q.moveSelectionDown + se.OnTypedUp = q.moveSelectionUp se.OnTypedEscape = q.onDismiss q.SearchEntry = se q.list = widget.NewList( @@ -117,6 +104,24 @@ func (q *QuickSearch) onSelected(idx int) { 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 != "" { @@ -192,7 +197,42 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) { q.image.CenterIcon = placeholderIconForContentType(result.Type) q.imageLoader.Load(result.CoverID) q.title.SetText(result.Name) - q.secondary.Segments = []widget.RichTextSegment{&widget.TextSegment{Text: result.Type.String()}} + + 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() } From 5fd3f96129cd004665eca9a7155625528c7f27a7 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 2 Nov 2023 17:40:17 -0700 Subject: [PATCH 08/13] WIP ranking of search results --- backend/mediaprovider/subsonic/searchall.go | 48 ++++++++++++++++++--- go.mod | 1 + go.sum | 2 + ui/dialogs/quicksearch.go | 1 + 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index f2e38b0..6abd9b8 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "github.com/deluan/sanitize" "github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/supersonic/backend/mediaprovider" "github.com/dweymouth/supersonic/sharedutil" @@ -34,14 +35,14 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Done() }() - queryLowerWords := strings.Fields(strings.ToLower(searchQuery)) + queryLowerWords := strings.Fields(strings.ToLower(sanitize.Accents(searchQuery))) 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(p.Name), queryLowerWords) + return allTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords) }) } wg.Done() @@ -52,7 +53,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ g, e := s.client.GetGenres() if e == nil { genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool { - return allTermsMatch(strings.ToLower(g.Name), queryLowerWords) + return allTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords) }) } wg.Done() @@ -64,7 +65,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ } results := mergeResults(result, playlists, genres) - //rankResults(results, queryLowerWords) // TODO + rankResults(results, queryLowerWords) if len(results) > maxResults { results = results[:maxResults] } @@ -143,9 +144,42 @@ func mergeResults( } func rankResults(results []*mediaprovider.SearchResult, queryTerms []string) { - // TODO - sort.Slice(results, func(a, b int) bool { - return false + if len(queryTerms) == 0 || len(results) < 2 { + return + } + + sanitizeMemo := make([]string, len(results)) + sanitized := func(s string, i int) string { + if x := sanitizeMemo[i]; x != "" { + return x + } + x := strings.ToLower(sanitize.Accents(s)) + sanitizeMemo[i] = x + return x + } + + sort.Slice(results, func(i, j int) bool { + // Compare by search query terms + a, b := results[i], results[j] + aName := sanitized(a.Name, i) + bName := sanitized(b.Name, j) + + 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 }) } diff --git a/go.mod b/go.mod index 9a43360..1726f11 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 9ec00f2..6b0f3f1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go index d1e8884..2698aaf 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -136,6 +136,7 @@ func (q *QuickSearch) onSearched(query string) { q.resultsMutex.Unlock() q.list.Refresh() q.list.ScrollToTop() + q.selectedIndex = 0 q.list.Select(0) } From 7d7d315d74a7191f27f07dc8b5ebb0b69a0df0a5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 5 Nov 2023 14:30:57 -0800 Subject: [PATCH 09/13] refactor: move ImageLoader to ui/util --- backend/imagemanager.go | 49 ----------------------------- ui/dialogs/quicksearch.go | 16 +++++----- ui/util/thumbnailloader.go | 63 ++++++++++++++++++++++++++++++++++++++ ui/widgets/gridview.go | 15 +++------ ui/widgets/gridviewitem.go | 4 +-- 5 files changed, 78 insertions(+), 69 deletions(-) create mode 100644 ui/util/thumbnailloader.go diff --git a/backend/imagemanager.go b/backend/imagemanager.go index aa7bf6b..96da5bf 100644 --- a/backend/imagemanager.go +++ b/backend/imagemanager.go @@ -188,55 +188,6 @@ 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 index 2698aaf..52ba02d 100644 --- a/ui/dialogs/quicksearch.go +++ b/ui/dialogs/quicksearch.go @@ -11,10 +11,10 @@ import ( "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" "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" ) @@ -26,8 +26,8 @@ type QuickSearch struct { SearchEntry fyne.Focusable // exported so it can be focused by the Controller - mp mediaprovider.MediaProvider - im *backend.ImageManager + mp mediaprovider.MediaProvider + imgSource util.ImageFetcher resultsMutex sync.RWMutex searchResults []*mediaprovider.SearchResult @@ -37,10 +37,10 @@ type QuickSearch struct { content *fyne.Container } -func NewQuickSearch(mp mediaprovider.MediaProvider, im *backend.ImageManager) *QuickSearch { +func NewQuickSearch(mp mediaprovider.MediaProvider, im util.ImageFetcher) *QuickSearch { q := &QuickSearch{ - mp: mp, - im: im, + mp: mp, + imgSource: im, } q.ExtendBaseWidget(q) @@ -157,7 +157,7 @@ type quickSearchResult struct { index int contentType mediaprovider.ContentType - imageLoader backend.ThumbnailLoader + imageLoader util.ThumbnailLoader image *widgets.ImagePlaceholder title *widget.Label @@ -176,7 +176,7 @@ func newQuickSearchResult(parent *QuickSearch) *quickSearchResult { qs.title.Wrapping = fyne.TextTruncate qs.secondary.Wrapping = fyne.TextTruncate qs.ExtendBaseWidget(qs) - qs.imageLoader = parent.im.NewThumbnailLoader(func(im image.Image) { + qs.imageLoader = util.NewThumbnailLoader(parent.imgSource, func(im image.Image) { qs.image.SetImage(im, false) }) qs.imageLoader.OnBeforeLoad = func() { diff --git a/ui/util/thumbnailloader.go b/ui/util/thumbnailloader.go new file mode 100644 index 0000000..f1fc063 --- /dev/null +++ b/ui/util/thumbnailloader.go @@ -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) + } +} diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index 95da758..01f1f6f 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -2,12 +2,11 @@ package widgets import ( "context" - "image" "sync" - "github.com/dweymouth/supersonic/backend" "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,10 +36,6 @@ func (b *BatchingIterator) NextN(n int) []*mediaprovider.Album { return results } -type ThumbnailLoaderFactory interface { - NewThumbnailLoader(func(image.Image)) backend.ThumbnailLoader -} - type GridViewIterator interface { NextN(int) []GridViewItemModel } @@ -81,7 +76,7 @@ type GridView struct { type GridViewState struct { items []GridViewItemModel iter GridViewIterator - imageFetcher ThumbnailLoaderFactory + imageFetcher util.ImageFetcher Placeholder fyne.Resource highestShown int done bool @@ -98,7 +93,7 @@ type GridViewState struct { var _ fyne.Widget = (*GridView)(nil) -func NewFixedGridView(items []GridViewItemModel, fetch ThumbnailLoaderFactory, placeholder fyne.Resource) *GridView { +func NewFixedGridView(items []GridViewItemModel, fetch util.ImageFetcher, placeholder fyne.Resource) *GridView { g := &GridView{ GridViewState: GridViewState{ items: items, @@ -112,7 +107,7 @@ func NewFixedGridView(items []GridViewItemModel, fetch ThumbnailLoaderFactory, p return g } -func NewGridView(iter GridViewIterator, fetch ThumbnailLoaderFactory, placeholder fyne.Resource) *GridView { +func NewGridView(iter GridViewIterator, fetch util.ImageFetcher, placeholder fyne.Resource) *GridView { g := &GridView{ GridViewState: GridViewState{ iter: iter, @@ -202,7 +197,7 @@ func (g *GridView) createGridWrap() { // create func func() fyne.CanvasObject { card := NewGridViewItem(g.Placeholder) - card.ImgLoader = g.imageFetcher.NewThumbnailLoader(card.Cover.SetImage) + 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) { diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index 5581b48..d928550 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -3,10 +3,10 @@ package widgets import ( "image" - "github.com/dweymouth/supersonic/backend" "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" @@ -141,7 +141,7 @@ type GridViewItem struct { // updated by GridView Cover *coverImage - ImgLoader backend.ThumbnailLoader + ImgLoader util.ThumbnailLoader OnPlay func() OnShowContextMenu func(fyne.Position) From de8539df0a55b8fc828f3f2a51c2852643759a0d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 5 Nov 2023 14:51:59 -0800 Subject: [PATCH 10/13] attach play action to track results in quick search --- backend/mediaprovider/mediaprovider.go | 2 ++ backend/mediaprovider/subsonic/subsonicmediaprovider.go | 8 ++++++++ backend/playbackmanager.go | 9 +++++++++ ui/controller/controller.go | 2 +- 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index db30fdb..837afb4 100644 --- a/backend/mediaprovider/mediaprovider.go +++ b/backend/mediaprovider/mediaprovider.go @@ -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) diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index e5f52b5..b546e67 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -55,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 { diff --git a/backend/playbackmanager.go b/backend/playbackmanager.go index 44857f0..27fac46 100644 --- a/backend/playbackmanager.go +++ b/backend/playbackmanager.go @@ -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() } diff --git a/ui/controller/controller.go b/ui/controller/controller.go index de826bb..25cbb64 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -503,7 +503,7 @@ func (c *Controller) ShowQuickSearch() { case mediaprovider.ContentTypeArtist: c.NavigateTo(ArtistRoute(id)) case mediaprovider.ContentTypeTrack: - // TODO + go c.App.PlaybackManager.PlayTrack(id) case mediaprovider.ContentTypePlaylist: c.NavigateTo(PlaylistRoute(id)) case mediaprovider.ContentTypeGenre: From fd9ae477b67285f7b73a0823850fc95507020040 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 5 Nov 2023 15:05:05 -0800 Subject: [PATCH 11/13] improve result ranking --- backend/mediaprovider/subsonic/searchall.go | 29 ++++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go index 6abd9b8..79433c2 100644 --- a/backend/mediaprovider/subsonic/searchall.go +++ b/backend/mediaprovider/subsonic/searchall.go @@ -35,7 +35,8 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ wg.Done() }() - queryLowerWords := strings.Fields(strings.ToLower(sanitize.Accents(searchQuery))) + querySanitized := strings.ToLower(sanitize.Accents(searchQuery)) + queryLowerWords := strings.Fields(querySanitized) wg.Add(1) go func() { @@ -65,7 +66,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([ } results := mergeResults(result, playlists, genres) - rankResults(results, queryLowerWords) + rankResults(results, querySanitized, queryLowerWords) if len(results) > maxResults { results = results[:maxResults] } @@ -143,27 +144,35 @@ func mergeResults( return results } -func rankResults(results []*mediaprovider.SearchResult, queryTerms []string) { +func rankResults(results []*mediaprovider.SearchResult, fullQuery string, queryTerms []string) { if len(queryTerms) == 0 || len(results) < 2 { return } - sanitizeMemo := make([]string, len(results)) - sanitized := func(s string, i int) string { - if x := sanitizeMemo[i]; x != "" { + 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[i] = x + sanitizeMemo[s] = x return x } sort.Slice(results, func(i, j int) bool { - // Compare by search query terms a, b := results[i], results[j] - aName := sanitized(a.Name, i) - bName := sanitized(b.Name, 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 { From 6acf65312bdf4bf3a485fc4752a137bbae8dd58d Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 5 Nov 2023 15:16:20 -0800 Subject: [PATCH 12/13] change placeholder text in page-specific search fields --- ui/browsing/artistspage.go | 1 + ui/browsing/favoritespage.go | 1 + ui/browsing/genrespage.go | 1 + ui/browsing/gridviewpage.go | 1 + ui/browsing/playlistspage.go | 1 + ui/browsing/trackspage.go | 1 + 6 files changed, 6 insertions(+) diff --git a/ui/browsing/artistspage.go b/ui/browsing/artistspage.go index 1d039d0..62c9a1b 100644 --- a/ui/browsing/artistspage.go +++ b/ui/browsing/artistspage.go @@ -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 { diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index a4406fe..c7c3714 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -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) diff --git a/ui/browsing/genrespage.go b/ui/browsing/genrespage.go index c985400..dbc1702 100644 --- a/ui/browsing/genrespage.go +++ b/ui/browsing/genrespage.go @@ -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() diff --git a/ui/browsing/gridviewpage.go b/ui/browsing/gridviewpage.go index 64dee60..03a6c2b 100644 --- a/ui/browsing/gridviewpage.go +++ b/ui/browsing/gridviewpage.go @@ -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 { diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index 464d937..d952e37 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -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, diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index ce689e6..f046ed8 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -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() From 2b78b7a70d0b476e1296bdd0cb8fcd4607f7a145 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 5 Nov 2023 15:18:03 -0800 Subject: [PATCH 13/13] switch rank order between playlists and tracks --- backend/mediaprovider/model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index bc1ae7d..9e10253 100644 --- a/backend/mediaprovider/model.go +++ b/backend/mediaprovider/model.go @@ -93,8 +93,8 @@ type ContentType int const ( ContentTypeAlbum ContentType = iota ContentTypeArtist - ContentTypeTrack ContentTypePlaylist + ContentTypeTrack ContentTypeGenre )