From ae5b7111bc09a42cca7c7e91d60940490e786a60 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 28 Dec 2022 10:20:14 -0800 Subject: [PATCH 1/5] move search entry to its own file --- ui/browsingpane.go | 108 ++++++++------------------------------ ui/widgets/searchentry.go | 63 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 87 deletions(-) create mode 100644 ui/widgets/searchentry.go diff --git a/ui/browsingpane.go b/ui/browsingpane.go index f4998e2..764b339 100644 --- a/ui/browsingpane.go +++ b/ui/browsingpane.go @@ -1,14 +1,13 @@ 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,35 +22,19 @@ 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 @@ -72,68 +55,21 @@ 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) + 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 +92,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() } } diff --git a/ui/widgets/searchentry.go b/ui/widgets/searchentry.go new file mode 100644 index 0000000..4e0c54b --- /dev/null +++ b/ui/widgets/searchentry.go @@ -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() + } +} From 105adf4e08623f1e90489faecbc68389120ce7c8 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 28 Dec 2022 17:48:28 -0800 Subject: [PATCH 2/5] Add sort order selector to albums page --- backend/albumiterator.go | 44 +++++++++++++++++++----- ui/albumspage.go | 73 ++++++++++++++++++++++++++++++++++------ ui/browsingpane.go | 23 +------------ ui/widgets/hspace.go | 27 +++++++++++++++ 4 files changed, 125 insertions(+), 42 deletions(-) create mode 100644 ui/widgets/hspace.go diff --git a/backend/albumiterator.go b/backend/albumiterator.go index 3443d23..a5d6ace 100644 --- a/backend/albumiterator.go +++ b/backend/albumiterator.go @@ -26,22 +26,48 @@ 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 ( + RecentlyAdded AlbumSortOrder = "Recently Added" + RecentlyPlayed AlbumSortOrder = "Recently Played" + FrequentlyPlayed AlbumSortOrder = "Frequently Played" + TitleAZ AlbumSortOrder = "Title (A-Z)" + ArtistAZ AlbumSortOrder = "Artist (A-Z)" +) + +var ( + AlbumSortOrders []string = []string{ + string(RecentlyAdded), + string(RecentlyPlayed), + string(FrequentlyPlayed), + string(TitleAZ), + string(ArtistAZ), + } +) + +func (l *LibraryManager) AlbumsIter(sort AlbumSortOrder) AlbumIterator { + switch sort { + case RecentlyAdded: + return l.newBaseIter("newest") + case RecentlyPlayed: + return l.newBaseIter("recent") + case FrequentlyPlayed: + return l.newBaseIter("frequent") + case TitleAZ: + return l.newBaseIter("alphabeticalByName") + case ArtistAZ: + 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) } diff --git a/ui/albumspage.go b/ui/albumspage.go index 8777529..221bcb1 100644 --- a/ui/albumspage.go +++ b/ui/albumspage.go @@ -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,15 @@ func (a *AlbumsPage) onPlayAlbum(albumID string) { } } +func (a *AlbumsPage) onSortOrderChanged(order string) { + a.grid = NewAlbumGrid(a.lm.AlbumsIter(backend.AlbumSortOrder(order)), a.im.GetAlbumThumbnail) + 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) } diff --git a/ui/browsingpane.go b/ui/browsingpane.go index 764b339..110a97e 100644 --- a/ui/browsingpane.go +++ b/ui/browsingpane.go @@ -7,7 +7,6 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" ) @@ -35,26 +34,6 @@ type blankPage struct { widget.Separator } -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 NewBrowsingPane() *BrowsingPane { b := &BrowsingPane{} b.ExtendBaseWidget(b) @@ -62,7 +41,7 @@ func NewBrowsingPane() *BrowsingPane { b.searchBar.OnTextChanged = b.onSearchTextChanged b.curPage = &blankPage{} b.container = container.NewBorder( - container.NewHBox(newHSpace(15), b.searchBar), + container.NewHBox(widgets.NewHSpace(15), b.searchBar), nil, nil, nil, b.curPage) return b } diff --git a/ui/widgets/hspace.go b/ui/widgets/hspace.go new file mode 100644 index 0000000..8ea39c3 --- /dev/null +++ b/ui/widgets/hspace.go @@ -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()) +} From 73017c285a96d8418c4f2d24eefcbd54fdb87334 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 28 Dec 2022 18:16:13 -0800 Subject: [PATCH 3/5] add random album sort --- backend/albumiterator.go | 134 ++++++++++++++++++++++++++++++++++----- 1 file changed, 119 insertions(+), 15 deletions(-) diff --git a/backend/albumiterator.go b/backend/albumiterator.go index a5d6ace..0d3bc66 100644 --- a/backend/albumiterator.go +++ b/backend/albumiterator.go @@ -29,34 +29,38 @@ func NewLibraryManager(s *ServerManager) *LibraryManager { type AlbumSortOrder string const ( - RecentlyAdded AlbumSortOrder = "Recently Added" - RecentlyPlayed AlbumSortOrder = "Recently Played" - FrequentlyPlayed AlbumSortOrder = "Frequently Played" - TitleAZ AlbumSortOrder = "Title (A-Z)" - ArtistAZ AlbumSortOrder = "Artist (A-Z)" + 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(RecentlyAdded), - string(RecentlyPlayed), - string(FrequentlyPlayed), - string(TitleAZ), - string(ArtistAZ), + string(AlbumSortRecentlyAdded), + string(AlbumSortRecentlyPlayed), + string(AlbumSortFrequentlyPlayed), + string(AlbumSortRandom), + string(AlbumSortTitleAZ), + string(AlbumSortArtistAZ), } ) func (l *LibraryManager) AlbumsIter(sort AlbumSortOrder) AlbumIterator { switch sort { - case RecentlyAdded: + case AlbumSortRecentlyAdded: return l.newBaseIter("newest") - case RecentlyPlayed: + case AlbumSortRecentlyPlayed: return l.newBaseIter("recent") - case FrequentlyPlayed: + case AlbumSortFrequentlyPlayed: return l.newBaseIter("frequent") - case TitleAZ: + case AlbumSortRandom: + return l.newRandomIter() + case AlbumSortTitleAZ: return l.newBaseIter("alphabeticalByName") - case ArtistAZ: + case AlbumSortArtistAZ: return l.newBaseIter("alphabeticalByArtist") default: log.Printf("Undefined album sort order: %s", sort) @@ -263,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 + } + } + }() +} From 4d332be7ec41d488ee744499457de0901f1356e6 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 28 Dec 2022 18:39:41 -0800 Subject: [PATCH 4/5] forgot to connect playAlbum action --- ui/albumspage.go | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/albumspage.go b/ui/albumspage.go index 221bcb1..b02c3d1 100644 --- a/ui/albumspage.go +++ b/ui/albumspage.go @@ -97,6 +97,7 @@ 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() From 1452017b68f26b070e333d6fc41d94d6ed3a0d3e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Wed, 28 Dec 2022 18:41:38 -0800 Subject: [PATCH 5/5] update readme for sort orders --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 32793f8..315fd7e 100644 --- a/README.md +++ b/README.md @@ -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)