diff --git a/backend/mediaprovider/mediaprovider.go b/backend/mediaprovider/mediaprovider.go index 6529074..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) @@ -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) diff --git a/backend/mediaprovider/model.go b/backend/mediaprovider/model.go index 91e5837..9e10253 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 + 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 +} diff --git a/backend/mediaprovider/subsonic/searchall.go b/backend/mediaprovider/subsonic/searchall.go new file mode 100644 index 0000000..79433c2 --- /dev/null +++ b/backend/mediaprovider/subsonic/searchall.go @@ -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, ", ") +} diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index 989d159..b546e67 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 { @@ -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) { 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/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/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/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/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() diff --git a/ui/controller/controller.go b/ui/controller/controller.go index 541eb7a..25cbb64 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 @@ -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) diff --git a/ui/dialogs/quicksearch.go b/ui/dialogs/quicksearch.go new file mode 100644 index 0000000..52ba02d --- /dev/null +++ b/ui/dialogs/quicksearch.go @@ -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 + } +} diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 765cbd4..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} @@ -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() }) 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 21ed4ba..01f1f6f 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -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 { diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index af987a1..d928550 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -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) 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) {