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