implement Quick Search for Jellyfin
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/deluan/sanitize"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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 RankSearchResults(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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,11 +1,140 @@
|
|||||||
package jellyfin
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/deluan/sanitize"
|
||||||
|
"github.com/dweymouth/go-jellyfin"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||||
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
|
func (s *jellyfinMediaProvider) SearchAll(searchQuery string, maxResults int) ([]*mediaprovider.SearchResult, error) {
|
||||||
return nil, errors.New("unimplemented")
|
limit := maxResults / 3
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var albums []*jellyfin.Album
|
||||||
|
var artists []*jellyfin.Artist
|
||||||
|
var songs []*jellyfin.Song
|
||||||
|
var genres []jellyfin.NameID
|
||||||
|
var playlists []*jellyfin.Playlist
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
albumResult, _ := s.client.Search(searchQuery, jellyfin.TypeAlbum, jellyfin.Paging{Limit: limit})
|
||||||
|
albums = albumResult.Albums
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
artistResult, _ := s.client.Search(searchQuery, jellyfin.TypeArtist, jellyfin.Paging{Limit: limit})
|
||||||
|
artists = artistResult.Artists
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
songResult, _ := s.client.Search(searchQuery, jellyfin.TypeSong, jellyfin.Paging{Limit: limit})
|
||||||
|
songs = songResult.Songs
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
querySanitized := strings.ToLower(sanitize.Accents(searchQuery))
|
||||||
|
queryLowerWords := strings.Fields(querySanitized)
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
p, e := s.client.GetPlaylists()
|
||||||
|
if e == nil {
|
||||||
|
playlists = sharedutil.FilterSlice(p, func(p *jellyfin.Playlist) bool {
|
||||||
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
g, e := s.client.GetGenres(jellyfin.Paging{})
|
||||||
|
if e == nil {
|
||||||
|
genres = sharedutil.FilterSlice(g, func(g jellyfin.NameID) bool {
|
||||||
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
results := mergeResults(albums, artists, songs, playlists, genres)
|
||||||
|
helpers.RankSearchResults(results, searchQuery, queryLowerWords)
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeResults(
|
||||||
|
albums []*jellyfin.Album,
|
||||||
|
artists []*jellyfin.Artist,
|
||||||
|
songs []*jellyfin.Song,
|
||||||
|
matchingPlaylists []*jellyfin.Playlist,
|
||||||
|
matchingGenres []jellyfin.NameID,
|
||||||
|
) []*mediaprovider.SearchResult {
|
||||||
|
var results []*mediaprovider.SearchResult
|
||||||
|
|
||||||
|
getArtistNames := func(artist jellyfin.NameID) string {
|
||||||
|
return artist.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, al := range albums {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeAlbum,
|
||||||
|
ID: al.ID,
|
||||||
|
CoverID: al.ID,
|
||||||
|
Name: al.Name,
|
||||||
|
ArtistName: strings.Join(sharedutil.MapSlice(al.Artists, getArtistNames), ","),
|
||||||
|
Size: al.ChildCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ar := range artists {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeArtist,
|
||||||
|
ID: ar.ID,
|
||||||
|
CoverID: ar.ID,
|
||||||
|
Name: ar.Name,
|
||||||
|
Size: ar.AlbumCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tr := range songs {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypeTrack,
|
||||||
|
ID: tr.Id,
|
||||||
|
CoverID: tr.Id,
|
||||||
|
Name: tr.Name,
|
||||||
|
ArtistName: strings.Join(sharedutil.MapSlice(tr.Artists, getArtistNames), ","),
|
||||||
|
Size: int(tr.RunTimeTicks / 10_000_000),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pl := range matchingPlaylists {
|
||||||
|
results = append(results, &mediaprovider.SearchResult{
|
||||||
|
Type: mediaprovider.ContentTypePlaylist,
|
||||||
|
ID: pl.ID,
|
||||||
|
CoverID: pl.ID,
|
||||||
|
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: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package subsonic
|
package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -9,6 +8,7 @@ import (
|
|||||||
"github.com/deluan/sanitize"
|
"github.com/deluan/sanitize"
|
||||||
"github.com/dweymouth/go-subsonic/subsonic"
|
"github.com/dweymouth/go-subsonic/subsonic"
|
||||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||||
|
"github.com/dweymouth/supersonic/backend/mediaprovider/helpers"
|
||||||
"github.com/dweymouth/supersonic/sharedutil"
|
"github.com/dweymouth/supersonic/sharedutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
|||||||
p, e := s.client.GetPlaylists(nil)
|
p, e := s.client.GetPlaylists(nil)
|
||||||
if e == nil {
|
if e == nil {
|
||||||
playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool {
|
playlists = sharedutil.FilterSlice(p, func(p *subsonic.Playlist) bool {
|
||||||
return allTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(p.Name)), queryLowerWords)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
wg.Done()
|
wg.Done()
|
||||||
@@ -54,7 +54,7 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
|||||||
g, e := s.client.GetGenres()
|
g, e := s.client.GetGenres()
|
||||||
if e == nil {
|
if e == nil {
|
||||||
genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool {
|
genres = sharedutil.FilterSlice(g, func(g *subsonic.Genre) bool {
|
||||||
return allTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
return helpers.AllTermsMatch(strings.ToLower(sanitize.Accents(g.Name)), queryLowerWords)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
wg.Done()
|
wg.Done()
|
||||||
@@ -66,23 +66,13 @@ func (s *subsonicMediaProvider) SearchAll(searchQuery string, maxResults int) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
results := mergeResults(result, playlists, genres)
|
results := mergeResults(result, playlists, genres)
|
||||||
rankResults(results, querySanitized, queryLowerWords)
|
helpers.RankSearchResults(results, querySanitized, queryLowerWords)
|
||||||
if len(results) > maxResults {
|
if len(results) > maxResults {
|
||||||
results = results[:maxResults]
|
results = results[:maxResults]
|
||||||
}
|
}
|
||||||
return results, nil
|
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(
|
func mergeResults(
|
||||||
searchResult *subsonic.SearchResult3,
|
searchResult *subsonic.SearchResult3,
|
||||||
matchingPlaylists []*subsonic.Playlist,
|
matchingPlaylists []*subsonic.Playlist,
|
||||||
@@ -144,54 +134,6 @@ func mergeResults(
|
|||||||
return results
|
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
|
// select Subsonic single-valued name or join OpenSubsonic multi-valued names
|
||||||
func getNameString(singleName string, idNames []subsonic.IDName) string {
|
func getNameString(singleName string, idNames []subsonic.IDName) string {
|
||||||
if len(idNames) == 0 {
|
if len(idNames) == 0 {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1
|
||||||
github.com/dweymouth/go-jellyfin v0.0.0-20231112233951-e56feff35fc6
|
github.com/dweymouth/go-jellyfin v0.0.0-20231113004204-96b092385986
|
||||||
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
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1 h1:mGvOb3zxl4vCLv+
|
|||||||
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
github.com/deluan/sanitize v0.0.0-20230310221930-6e18967d9fc1/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E=
|
||||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555 h1:8S+d0LuwdTUEipEzFeXp8rNwTQD47dBdDOg9+FI1+Vw=
|
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555 h1:8S+d0LuwdTUEipEzFeXp8rNwTQD47dBdDOg9+FI1+Vw=
|
||||||
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro=
|
github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231110162149-a0e470497555/go.mod h1:AWM1iPM2YfliduZ4u/kQzP9E6ARIWm0gg+57GpYzWro=
|
||||||
github.com/dweymouth/go-jellyfin v0.0.0-20231112233951-e56feff35fc6 h1:lM/wEw/WL9PQHRvBF5FLNNDkbwIU8txThl9Y+Jxv9s4=
|
github.com/dweymouth/go-jellyfin v0.0.0-20231113004204-96b092385986 h1:5yZgruDoqH2Z09ZTHJckF7qHCGW2JrgutwyWpsIdpWI=
|
||||||
github.com/dweymouth/go-jellyfin v0.0.0-20231112233951-e56feff35fc6/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU=
|
github.com/dweymouth/go-jellyfin v0.0.0-20231113004204-96b092385986/go.mod h1:BMwS4vdjEYf1gmjPGSKCzWP/I6YlI6fkefJ9nsjBjaU=
|
||||||
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=
|
||||||
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0=
|
||||||
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363 h1:MIH7MAWWPPVRKEKxz+RJubn+ycyQPimHn1Zvoxs1KRI=
|
github.com/dweymouth/go-subsonic v0.0.0-20231105161622-54b5aec28363 h1:MIH7MAWWPPVRKEKxz+RJubn+ycyQPimHn1Zvoxs1KRI=
|
||||||
|
|||||||
@@ -217,21 +217,29 @@ func (q *quickSearchResult) Update(result *mediaprovider.SearchResult) {
|
|||||||
case mediaprovider.ContentTypePlaylist:
|
case mediaprovider.ContentTypePlaylist:
|
||||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
|
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("track", result.Size))
|
||||||
case mediaprovider.ContentTypeGenre:
|
case mediaprovider.ContentTypeGenre:
|
||||||
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
|
if result.Size > 0 {
|
||||||
|
secondaryText = fmt.Sprintf("%d %s", result.Size, maybePluralize("album", result.Size))
|
||||||
|
} else {
|
||||||
|
secondaryText = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
q.secondary.Segments = []widget.RichTextSegment{
|
q.secondary.Segments = []widget.RichTextSegment{
|
||||||
&widget.TextSegment{
|
&widget.TextSegment{
|
||||||
Text: result.Type.String(),
|
Text: result.Type.String(),
|
||||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
|
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, TextStyle: fyne.TextStyle{Bold: true}, Inline: true},
|
||||||
},
|
},
|
||||||
&widget.TextSegment{
|
}
|
||||||
Text: " · ",
|
if secondaryText != "" {
|
||||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
q.secondary.Segments = append(q.secondary.Segments,
|
||||||
},
|
&widget.TextSegment{
|
||||||
&widget.TextSegment{
|
Text: " · ",
|
||||||
Text: secondaryText,
|
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||||
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
},
|
||||||
},
|
&widget.TextSegment{
|
||||||
|
Text: secondaryText,
|
||||||
|
Style: widget.RichTextStyle{SizeName: theme.SizeNameCaptionText, Inline: true},
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
q.secondary.Refresh()
|
q.secondary.Refresh()
|
||||||
|
|||||||
Reference in New Issue
Block a user