Merge pull request #6 from dweymouth/develop

Add sort selection to albums page
This commit is contained in:
Drew Weymouth
2022-12-28 18:44:44 -08:00
committed by GitHub
6 changed files with 316 additions and 132 deletions
+2 -3
View File
@@ -1,8 +1,7 @@
# supersonic
A lightweight desktop client for Subsonic music servers. This is in REALLY early development and if you decide to use it now, expect to find bugs!
A lightweight desktop client for Subsonic music servers. This is in early development and if you decide to use it now, expect to find some bugs!
On first startup, the app will prompt you for your Subsonic server connection. The app currently displays a searchable album grid view sorting by
recently added, and double-clicking an album plays that album.
On first startup, the app will prompt you for your Subsonic server connection. The app currently displays a searchable album grid view that is also sortable by the standard Subsonic API sort orders. Double-clicking an album plays that album.
## Build (Ubuntu)
+139 -9
View File
@@ -26,22 +26,52 @@ func NewLibraryManager(s *ServerManager) *LibraryManager {
}
}
func (l *LibraryManager) RecentlyAddedIter() AlbumIterator {
return l.newBaseIter("newest")
}
type AlbumSortOrder string
func (l *LibraryManager) RecentlyPlayedIter() AlbumIterator {
return l.newBaseIter("recent")
const (
AlbumSortRecentlyAdded AlbumSortOrder = "Recently Added"
AlbumSortRecentlyPlayed AlbumSortOrder = "Recently Played"
AlbumSortFrequentlyPlayed AlbumSortOrder = "Frequently Played"
AlbumSortRandom AlbumSortOrder = "Random"
AlbumSortTitleAZ AlbumSortOrder = "Title (A-Z)"
AlbumSortArtistAZ AlbumSortOrder = "Artist (A-Z)"
)
var (
AlbumSortOrders []string = []string{
string(AlbumSortRecentlyAdded),
string(AlbumSortRecentlyPlayed),
string(AlbumSortFrequentlyPlayed),
string(AlbumSortRandom),
string(AlbumSortTitleAZ),
string(AlbumSortArtistAZ),
}
)
func (l *LibraryManager) AlbumsIter(sort AlbumSortOrder) AlbumIterator {
switch sort {
case AlbumSortRecentlyAdded:
return l.newBaseIter("newest")
case AlbumSortRecentlyPlayed:
return l.newBaseIter("recent")
case AlbumSortFrequentlyPlayed:
return l.newBaseIter("frequent")
case AlbumSortRandom:
return l.newRandomIter()
case AlbumSortTitleAZ:
return l.newBaseIter("alphabeticalByName")
case AlbumSortArtistAZ:
return l.newBaseIter("alphabeticalByArtist")
default:
log.Printf("Undefined album sort order: %s", sort)
return nil
}
}
func (l *LibraryManager) StarredIter() AlbumIterator {
return l.newBaseIter("starred")
}
func (l *LibraryManager) FrequentlyPlayedIter() AlbumIterator {
return l.newBaseIter("frequent")
}
func (l *LibraryManager) SearchIter(query string) AlbumIterator {
return l.newSearchIter(query)
}
@@ -237,3 +267,103 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) {
s.albumIDset[album.ID] = true
}
}
type randomIter struct {
albumIDSet map[string]bool
l *LibraryManager
s *subsonic.Client
prefetched []*subsonic.AlbumID3
prefetchedPos int
// Random iter works in two phases - phase 1 by requesting random
// albums from the server. Since the Subsonic API provides no way
// of paginating a single random sort, we may get albums back twice.
// We use albumIDSet to keep track of which albums have already been returned.
// Once we start getting back too many already-returned albums,
// switch to requesting more albums from a deterministic sort order.
phaseTwo bool
offset int
done bool
}
func (l *LibraryManager) newRandomIter() *randomIter {
return &randomIter{
l: l,
s: l.s.Server,
albumIDSet: make(map[string]bool),
}
}
func (r *randomIter) Next() *subsonic.AlbumID3 {
if r.done {
return nil
}
if r.prefetched == nil {
if r.phaseTwo {
for len(r.prefetched) == 0 {
albums, err := r.s.GetAlbumList2("newest", map[string]string{"size": "20", "offset": strconv.Itoa(r.offset)})
if err != nil {
log.Println(err)
albums = nil
}
if len(albums) == 0 {
r.done = true
return nil
}
r.offset += len(albums)
for _, album := range albums {
if _, ok := r.albumIDSet[album.ID]; !ok {
r.prefetched = append(r.prefetched, album)
r.albumIDSet[album.ID] = true
}
}
}
r.prefetchedPos = 0
} else {
albums, err := r.s.GetAlbumList2("random", map[string]string{"size": "25"})
if err != nil {
log.Println(err)
r.done = true
return nil
}
var hitCount int
for _, album := range albums {
if _, ok := r.albumIDSet[album.ID]; !ok {
hitCount++
r.prefetched = append(r.prefetched, album)
r.albumIDSet[album.ID] = true
}
}
if successRatio := float64(hitCount) / float64(25); successRatio < 0.4 {
r.phaseTwo = true
}
}
}
// return from prefetched results
if len(r.prefetched) > 0 {
a := r.prefetched[r.prefetchedPos]
r.prefetchedPos++
if r.prefetchedPos == len(r.prefetched) {
r.prefetched = nil
r.prefetchedPos = 0
}
r.l.CacheAlbum(a)
return a
}
return nil
}
func (r *randomIter) NextN(n int, cb func(*subsonic.AlbumID3)) {
go func() {
for i := 0; i < n; i++ {
a := r.Next()
cb(a)
if a == nil {
break
}
}
}()
}
+63 -11
View File
@@ -2,9 +2,12 @@ package ui
import (
"supersonic/backend"
"supersonic/ui/widgets"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -13,12 +16,36 @@ var _ fyne.Widget = (*AlbumsPage)(nil)
type AlbumsPage struct {
widget.BaseWidget
im *backend.ImageManager
lm *backend.LibraryManager
grid *AlbumGrid
searchGrid *AlbumGrid
gridContainer *fyne.Container
OnPlayAlbum func(string)
im *backend.ImageManager
lm *backend.LibraryManager
grid *AlbumGrid
searchGrid *AlbumGrid
searchText string
titleDisp *widget.RichText
sortOrder *selectWidget
container *fyne.Container
OnPlayAlbum func(string)
}
type selectWidget struct {
widget.Select
height float32
}
func NewSelect(options []string, onChanged func(string)) *selectWidget {
s := &selectWidget{
Select: widget.Select{
Options: options,
OnChanged: onChanged,
},
}
s.height = widget.NewSelect(nil, nil).MinSize().Height
s.ExtendBaseWidget(s)
return s
}
func (s *selectWidget) MinSize() fyne.Size {
return fyne.NewSize(170, s.height)
}
func NewAlbumsPage(title string, lm *backend.LibraryManager, im *backend.ImageManager) *AlbumsPage {
@@ -27,22 +54,38 @@ func NewAlbumsPage(title string, lm *backend.LibraryManager, im *backend.ImageMa
im: im,
}
a.ExtendBaseWidget(a)
a.grid = NewAlbumGrid(lm.RecentlyAddedIter(), im.GetAlbumThumbnail)
a.titleDisp = widget.NewRichTextWithText(title)
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
SizeName: theme.SizeNameHeadingText,
}
a.sortOrder = NewSelect(backend.AlbumSortOrders, nil)
a.sortOrder.Selected = a.sortOrder.Options[0]
a.sortOrder.OnChanged = a.onSortOrderChanged
sortVbox := container.NewVBox(layout.NewSpacer(), a.sortOrder, layout.NewSpacer())
a.grid = NewAlbumGrid(lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected)), im.GetAlbumThumbnail)
a.grid.OnPlayAlbum = a.onPlayAlbum
a.gridContainer = container.NewMax(a.grid)
a.container = container.NewBorder(
container.NewHBox(widgets.NewHSpace(15), a.titleDisp, sortVbox),
nil,
nil,
nil,
a.grid,
)
return a
}
func (a *AlbumsPage) OnSearched(query string) {
a.searchText = query
if query == "" {
a.gridContainer.Objects[0] = a.grid
a.container.Objects[0] = a.grid
a.searchGrid = nil
a.Refresh()
return
}
a.searchGrid = NewAlbumGrid(a.lm.SearchIter(query), a.im.GetAlbumThumbnail)
a.searchGrid.OnPlayAlbum = a.onPlayAlbum
a.gridContainer.Objects[0] = a.searchGrid
a.container.Objects[0] = a.searchGrid
a.Refresh()
}
@@ -52,7 +95,16 @@ func (a *AlbumsPage) onPlayAlbum(albumID string) {
}
}
func (a *AlbumsPage) onSortOrderChanged(order string) {
a.grid = NewAlbumGrid(a.lm.AlbumsIter(backend.AlbumSortOrder(order)), a.im.GetAlbumThumbnail)
a.grid.OnPlayAlbum = a.onPlayAlbum
if a.searchText == "" {
a.container.Objects[0] = a.grid
a.Refresh()
}
}
func (a *AlbumsPage) CreateRenderer() fyne.WidgetRenderer {
a.ExtendBaseWidget(a)
return widget.NewSimpleRenderer(a.gridContainer)
return widget.NewSimpleRenderer(a.container)
}
+22 -109
View File
@@ -1,14 +1,12 @@
package ui
import (
"context"
"supersonic/ui/widgets"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
@@ -23,117 +21,34 @@ type Searchable interface {
type BrowsingPane struct {
widget.BaseWidget
searchBar *searchField
pendingSearchLock sync.Mutex
pendingSearch bool
searchGoroutine bool
cancelPendingSearch context.CancelFunc
curPage Page
searchBar *widgets.SearchEntry
pendingSearchLock sync.Mutex
pendingSearch bool
searchGoroutine bool
curPage Page
container *fyne.Container
pageContaner *fyne.Container
container *fyne.Container
}
type blankPage struct {
widget.Separator
}
type searchField struct {
widget.Entry
height float32
OnTextChanged func(string)
}
var _ fyne.Tappable = (*clearTextButton)(nil)
type clearTextButton struct {
widget.Icon
OnTapped func()
}
type hspace struct {
widget.BaseWidget
Width float32
}
func newHSpace(w float32) *hspace {
h := &hspace{Width: w}
h.ExtendBaseWidget(h)
return h
}
func (h *hspace) MinSize() fyne.Size {
return fyne.NewSize(h.Width, 0)
}
func (h *hspace) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(layout.NewSpacer())
}
func NewClearTextButton() *clearTextButton {
c := &clearTextButton{}
c.ExtendBaseWidget(c)
c.Resource = theme.SearchIcon()
return c
}
func (c *clearTextButton) Tapped(*fyne.PointEvent) {
if c.OnTapped != nil {
c.OnTapped()
}
}
func NewSearchField() *searchField {
sf := &searchField{}
sf.ExtendBaseWidget(sf)
// this is a bit hacky
sf.height = widget.NewEntry().MinSize().Height
sf.PlaceHolder = "Search"
c := NewClearTextButton()
c.OnTapped = func() {
sf.SetText("")
}
sf.ActionItem = c
sf.OnChanged = func(s string) {
if s == "" {
c.Resource = theme.SearchIcon()
} else {
c.Resource = theme.ContentClearIcon()
}
c.Refresh()
if sf.OnTextChanged != nil {
sf.OnTextChanged(s)
}
}
return sf
}
func (s *searchField) MinSize() fyne.Size {
return fyne.NewSize(200, s.height)
}
func NewBrowsingPane() *BrowsingPane {
b := &BrowsingPane{}
b.ExtendBaseWidget(b)
b.searchBar = NewSearchField()
b.searchBar = widgets.NewSearchEntry()
b.searchBar.OnTextChanged = b.onSearchTextChanged
b.curPage = &blankPage{}
b.pageContaner = container.NewMax(b.curPage)
b.container = container.NewBorder(
container.NewHBox(newHSpace(15), b.searchBar),
nil, nil, nil, b.pageContaner)
container.NewHBox(widgets.NewHSpace(15), b.searchBar),
nil, nil, nil, b.curPage)
return b
}
func (b *BrowsingPane) SetPage(p Page) {
if b.cancelPendingSearch != nil {
b.cancelPendingSearch()
b.cancelPendingSearch = nil
}
b.curPage = p
b.pageContaner.Objects[0] = p
b.container.Objects[0] = p
b.Refresh()
}
@@ -156,20 +71,18 @@ func (b *BrowsingPane) waitAndSearch() {
var getReadyToSearch bool
var done bool
for !done {
select {
case <-t.C:
b.pendingSearchLock.Lock()
if b.pendingSearch {
getReadyToSearch = true
b.pendingSearch = false
} else if getReadyToSearch {
b.sendSearch(b.searchBar.Text)
t.Stop()
b.searchGoroutine = false
done = true
}
b.pendingSearchLock.Unlock()
<-t.C
b.pendingSearchLock.Lock()
if b.pendingSearch {
getReadyToSearch = true
b.pendingSearch = false
} else if getReadyToSearch {
b.sendSearch(b.searchBar.Text)
t.Stop()
b.searchGoroutine = false
done = true
}
b.pendingSearchLock.Unlock()
}
}
+27
View File
@@ -0,0 +1,27 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
)
type HSpace struct {
widget.BaseWidget
Width float32
}
func NewHSpace(w float32) *HSpace {
h := &HSpace{Width: w}
h.ExtendBaseWidget(h)
return h
}
func (h *HSpace) MinSize() fyne.Size {
return fyne.NewSize(h.Width, 0)
}
func (h *HSpace) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(layout.NewSpacer())
}
+63
View File
@@ -0,0 +1,63 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
type SearchEntry struct {
widget.Entry
height float32
OnTextChanged func(string)
}
func NewSearchEntry() *SearchEntry {
sf := &SearchEntry{}
sf.ExtendBaseWidget(sf)
// this is a bit hacky
sf.height = widget.NewEntry().MinSize().Height
sf.PlaceHolder = "Search"
c := NewClearTextButton()
c.OnTapped = func() {
sf.SetText("")
}
sf.ActionItem = c
sf.OnChanged = func(s string) {
if s == "" {
c.Resource = theme.SearchIcon()
} else {
c.Resource = theme.ContentClearIcon()
}
c.Refresh()
if sf.OnTextChanged != nil {
sf.OnTextChanged(s)
}
}
return sf
}
func (s *SearchEntry) MinSize() fyne.Size {
return fyne.NewSize(200, s.height)
}
var _ fyne.Tappable = (*clearTextButton)(nil)
type clearTextButton struct {
widget.Icon
OnTapped func()
}
func NewClearTextButton() *clearTextButton {
c := &clearTextButton{}
c.ExtendBaseWidget(c)
c.Resource = theme.SearchIcon()
return c
}
func (c *clearTextButton) Tapped(*fyne.PointEvent) {
if c.OnTapped != nil {
c.OnTapped()
}
}