From 51efab082e951276609053fabfe5db7da9bcdbb0 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 18 Mar 2023 15:29:11 -0700 Subject: [PATCH 01/12] add trackiterator --- backend/librarymanager.go | 4 ++++ backend/trackiterator.go | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 backend/trackiterator.go diff --git a/backend/librarymanager.go b/backend/librarymanager.go index f33bae4..69b1092 100644 --- a/backend/librarymanager.go +++ b/backend/librarymanager.go @@ -8,6 +8,10 @@ type AlbumIterator interface { Next() *subsonic.AlbumID3 } +type TrackIterator interface { + Next() *subsonic.Child +} + type LibraryManager struct { PreCacheCoverFn func(coverID string) diff --git a/backend/trackiterator.go b/backend/trackiterator.go new file mode 100644 index 0000000..11372f7 --- /dev/null +++ b/backend/trackiterator.go @@ -0,0 +1,42 @@ +package backend + +import "github.com/dweymouth/go-subsonic/subsonic" + +type allTracksIterator struct { + albumIter AlbumIterator + curAlbum *subsonic.AlbumID3 + curTrackIdx int + done bool +} + +func (l *LibraryManager) AllTracksIterator() TrackIterator { + return &allTracksIterator{ + albumIter: l.AlbumsIter(AlbumSortRecentlyAdded), + } +} + +func (a *allTracksIterator) Next() *subsonic.Child { + if a.done { + return nil + } + + // fetch next album + if a.curAlbum == nil || a.curTrackIdx >= len(a.curAlbum.Song) { + a.curAlbum = a.albumIter.Next() + if a.curAlbum == nil { + a.done = true + return nil + } + a.curTrackIdx = 0 + + if len(a.curAlbum.Song) == 0 { + // in the unlikely case of an album with zero tracks, + // just call recursively to move to next album + return a.Next() + } + } + + tr := a.curAlbum.Song[a.curTrackIdx] + a.curTrackIdx += 1 + return tr +} From ddc4d5272b0a5e967f14bf9a70db30ba8086d574 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 25 Mar 2023 09:19:11 -0700 Subject: [PATCH 02/12] add thread-safety to tracklist --- ui/widgets/tracklist.go | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index f47d693..388784b 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -9,6 +9,7 @@ import ( "supersonic/ui/layouts" "supersonic/ui/os" "supersonic/ui/util" + "sync" "time" "fyne.io/fyne/v2" @@ -35,6 +36,8 @@ type Tracklist struct { widget.BaseWidget // Tracks is the set of tracks displayed by the widget. + // Direct access to this is not thread-safe but OK for + // views that only load tracks into the widget once at page load. Tracks []*subsonic.Child // AutoNumber sets whether to auto-number the tracks 1..N in display order, @@ -67,6 +70,7 @@ type Tracklist struct { visibleColumns []bool + tracksMutex sync.RWMutex selectionMgr util.ListSelectionManager nowPlayingIdx int colLayout *layouts.ColumnsLayout @@ -80,7 +84,7 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { t := &Tracklist{Tracks: tracks, nowPlayingIdx: -1, visibleColumns: make([]bool, 11)} t.ExtendBaseWidget(t) - t.selectionMgr = util.NewListSelectionManager(func() int { return len(t.Tracks) }) + t.selectionMgr = util.NewListSelectionManager(t.lenTracks) // #, Title, Artist, Album, Time, Year, Favorite, Plays, Bitrate, Size, Path t.colLayout = layouts.NewColumnsLayout([]float32{40, -1, -1, -1, 60, 60, 47, 65, 75, 70, -1}) t.buildHeader() @@ -92,7 +96,7 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { } playingIcon := container.NewCenter(container.NewHBox(util.NewHSpace(2), widget.NewIcon(theme.MediaPlayIcon()))) t.list = widget.NewList( - func() int { return len(t.Tracks) }, + t.lenTracks, func() fyne.CanvasObject { tr := NewTrackRow(t, playingIcon) tr.OnTapped = func() { t.onSelectTrack(tr.trackIdx) } @@ -108,7 +112,7 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { if t.AutoNumber { i = itemID + 1 } - tr.Update(t.Tracks[itemID], itemID == t.nowPlayingIdx, i) + tr.Update(t.TrackAt(itemID), itemID == t.nowPlayingIdx, i) }) t.container = container.NewBorder(t.hdr, nil, nil, nil, t.list) return t @@ -130,6 +134,16 @@ func (t *Tracklist) buildHeader() { t.colLayout) } +// Gets the track at the given index. Thread-safe. +func (t *Tracklist) TrackAt(idx int) *subsonic.Child { + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() + if idx >= len(t.Tracks) { + return nil + } + return t.Tracks[idx] +} + func (t *Tracklist) SetVisibleColumns(cols []string) { t.visibleColumns[0] = true t.visibleColumns[1] = true @@ -168,17 +182,22 @@ func (t *Tracklist) setColumnVisible(colNum int, vis bool) { func (t *Tracklist) SetNowPlaying(trackID string) { t.nowPlayingIdx = -1 + t.tracksMutex.RLock() for i, tr := range t.Tracks { if tr.ID == trackID { t.nowPlayingIdx = i break } } + t.tracksMutex.RUnlock() t.list.Refresh() } func (t *Tracklist) IncrementPlayCount(trackID string) { - if tr := sharedutil.FindTrackByID(trackID, t.Tracks); tr != nil { + t.tracksMutex.RLock() + tr := sharedutil.FindTrackByID(trackID, t.Tracks) + t.tracksMutex.RUnlock() + if tr != nil { tr.PlayCount += 1 t.list.Refresh() } @@ -255,7 +274,9 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { func (t *Tracklist) onSetFavorite(trackID string, fav bool) { // update our own track model + t.tracksMutex.RLock() tr := sharedutil.FindTrackByID(trackID, t.Tracks) + t.tracksMutex.RUnlock() if fav { tr.Starred = time.Now() } else { @@ -282,6 +303,8 @@ func (t *Tracklist) onAlbumTapped(albumID string) { func (t *Tracklist) selectedTracks() []*subsonic.Child { sel := t.selectionMgr.GetSelection() tracks := make([]*subsonic.Child, 0, len(sel)) + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() for _, idx := range sel { tracks = append(tracks, t.Tracks[idx]) } @@ -291,6 +314,8 @@ func (t *Tracklist) selectedTracks() []*subsonic.Child { func (t *Tracklist) selectedTrackIDs() []string { sel := t.selectionMgr.GetSelection() tracks := make([]string, 0, len(sel)) + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() for _, idx := range sel { tracks = append(tracks, t.Tracks[idx].ID) } @@ -301,6 +326,12 @@ func (t *Tracklist) SelectedTrackIndexes() []int { return t.selectionMgr.GetSelection() } +func (t *Tracklist) lenTracks() int { + t.tracksMutex.RLock() + defer t.tracksMutex.RUnlock() + return len(t.Tracks) +} + func ColNumber(colName string) int { // built-in columns # and Title are always visible switch colName { From 88d4322ecdb6a3a133d822f31f0f7d876137ab84 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 17:12:43 -0700 Subject: [PATCH 03/12] trackiterator properly loads album tracklist --- backend/trackiterator.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/backend/trackiterator.go b/backend/trackiterator.go index 11372f7..f11113f 100644 --- a/backend/trackiterator.go +++ b/backend/trackiterator.go @@ -1,8 +1,13 @@ package backend -import "github.com/dweymouth/go-subsonic/subsonic" +import ( + "log" + + "github.com/dweymouth/go-subsonic/subsonic" +) type allTracksIterator struct { + l *LibraryManager albumIter AlbumIterator curAlbum *subsonic.AlbumID3 curTrackIdx int @@ -11,7 +16,8 @@ type allTracksIterator struct { func (l *LibraryManager) AllTracksIterator() TrackIterator { return &allTracksIterator{ - albumIter: l.AlbumsIter(AlbumSortRecentlyAdded), + l: l, + albumIter: l.AlbumsIter(AlbumSortArtistAZ), } } @@ -22,18 +28,22 @@ func (a *allTracksIterator) Next() *subsonic.Child { // fetch next album if a.curAlbum == nil || a.curTrackIdx >= len(a.curAlbum.Song) { - a.curAlbum = a.albumIter.Next() - if a.curAlbum == nil { + al := a.albumIter.Next() + if al == nil { a.done = true return nil } - a.curTrackIdx = 0 - - if len(a.curAlbum.Song) == 0 { + al, err := a.l.s.Server.GetAlbum(al.ID) + if err != nil { + log.Printf("error fetching album: %s", err.Error()) + } + if len(al.Song) == 0 { // in the unlikely case of an album with zero tracks, // just call recursively to move to next album return a.Next() } + a.curAlbum = al + a.curTrackIdx = 0 } tr := a.curAlbum.Song[a.curTrackIdx] From b21b1ce6ba0142d73ef733bedc667cedb2f3349e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 17:13:24 -0700 Subject: [PATCH 04/12] add TracklistLoader for infinite scrolling tracklist --- ui/widgets/tracklist.go | 18 ++++++++++ ui/widgets/tracklistloader.go | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 ui/widgets/tracklistloader.go diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 388784b..2868cbc 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -67,6 +67,7 @@ type Tracklist struct { OnShowAlbumPage func(albumID string) OnColumnVisibilityMenuShown func(*widget.PopUp) + OnTrackShown func(tracknum int) visibleColumns []bool @@ -113,6 +114,9 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { i = itemID + 1 } tr.Update(t.TrackAt(itemID), itemID == t.nowPlayingIdx, i) + if t.OnTrackShown != nil { + t.OnTrackShown(itemID) + } }) t.container = container.NewBorder(t.hdr, nil, nil, nil, t.list) return t @@ -203,6 +207,20 @@ func (t *Tracklist) IncrementPlayCount(trackID string) { } } +// Remove all tracks from the tracklist. Thread-safe. +func (t *Tracklist) Clear() { + t.tracksMutex.Lock() + defer t.tracksMutex.Unlock() + t.Tracks = nil +} + +// Append more tracks to the tracklist. Thread-safe. +func (t *Tracklist) AppendTracks(trs []*subsonic.Child) { + t.tracksMutex.Lock() + defer t.tracksMutex.Unlock() + t.Tracks = append(t.Tracks, trs...) +} + func (t *Tracklist) SelectAll() { t.selectionMgr.SelectAll() t.list.Refresh() diff --git a/ui/widgets/tracklistloader.go b/ui/widgets/tracklistloader.go new file mode 100644 index 0000000..bbaa5eb --- /dev/null +++ b/ui/widgets/tracklistloader.go @@ -0,0 +1,67 @@ +package widgets + +import ( + "log" + "supersonic/backend" + + "github.com/dweymouth/go-subsonic/subsonic" +) + +// Component that manages lazily loading more tracks into a Tracklist +// as the user scrolls near the bottom. +type TracklistLoader struct { + tracklist *Tracklist + iter backend.TrackIterator + + trackBuffer []*subsonic.Child + fetching bool + done bool + len int + highestShown int +} + +func NewTracklistLoader(tracklist *Tracklist, iter backend.TrackIterator) TracklistLoader { + t := TracklistLoader{ + tracklist: tracklist, + iter: iter, + } + t.tracklist.OnTrackShown = t.onTrackShown + t.fetching = true + go t.loadMoreTracks(25) + return t +} + +func (t *TracklistLoader) onTrackShown(tracknum int) { + if tracknum > t.highestShown { + t.highestShown = tracknum + } + if t.highestShown >= t.len-25 && !t.fetching && !t.done { + t.fetching = true + go t.loadMoreTracks(25) + } +} + +func (t *TracklistLoader) loadMoreTracks(num int) { + // repeat fetch task as long as user has scrolled near bottom + for !t.done && t.highestShown >= t.len-25 { + log.Println("fetching more tracks") + if t.trackBuffer == nil { + t.trackBuffer = make([]*subsonic.Child, 0, num) + } + t.trackBuffer = t.trackBuffer[:0] + for i := 0; i < num; i++ { + tr := t.iter.Next() + if tr == nil { + t.done = true + t.trackBuffer = nil + break + } + t.trackBuffer = append(t.trackBuffer, tr) + } + t.tracklist.AppendTracks(t.trackBuffer) + t.tracklist.Refresh() + log.Printf("appended %d tracks", len(t.trackBuffer)) + t.len += len(t.trackBuffer) + } + t.fetching = false +} From 5e8c450d769fce5a07a057dff49edeb5de99e9b2 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 17:25:59 -0700 Subject: [PATCH 05/12] add TracksPage --- backend/config.go | 8 ++++ ui/browsing/router.go | 2 + ui/browsing/trackspage.go | 82 +++++++++++++++++++++++++++++++++++++++ ui/controller/routes.go | 5 +++ ui/mainwindow.go | 6 ++- 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 ui/browsing/trackspage.go diff --git a/backend/config.go b/backend/config.go index 727e601..b925ef4 100644 --- a/backend/config.go +++ b/backend/config.go @@ -55,6 +55,10 @@ type PlaylistPageConfig struct { TracklistColumns []string } +type TracksPageConfig struct { + TracklistColumns []string +} + type LocalPlaybackConfig struct { AudioDeviceName string AudioExclusive bool @@ -83,6 +87,7 @@ type Config struct { FavoritesPage FavoritesPageConfig NowPlayingPage NowPlayingPageConfig PlaylistPage PlaylistPageConfig + TracksPage TracksPageConfig LocalPlayback LocalPlaybackConfig Scrobbling ScrobbleConfig ReplayGain ReplayGainConfig @@ -117,6 +122,9 @@ func DefaultConfig(appVersionTag string) *Config { PlaylistPage: PlaylistPageConfig{ TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, }, + TracksPage: TracksPageConfig{ + TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, + }, LocalPlayback: LocalPlaybackConfig{ // "auto" is the name to pass to MPV for autoselecting the output device AudioDeviceName: "auto", diff --git a/ui/browsing/router.go b/ui/browsing/router.go index 70a34f6..4a603b0 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -46,6 +46,8 @@ func (r Router) CreatePage(rte controller.Route) Page { return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) case controller.Playlists: return NewPlaylistsPage(r.Controller, r.App.ServerManager) + case controller.Tracks: + return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.App.LibraryManager) } return nil } diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go new file mode 100644 index 0000000..3403902 --- /dev/null +++ b/ui/browsing/trackspage.go @@ -0,0 +1,82 @@ +package browsing + +import ( + "supersonic/backend" + "supersonic/res" + "supersonic/ui/controller" + "supersonic/ui/layouts" + "supersonic/ui/widgets" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" + "fyne.io/fyne/v2/widget" +) + +type TracksPage struct { + widget.BaseWidget + + tracksPageState + + title *widget.RichText + tracklist *widgets.Tracklist + loader widgets.TracklistLoader + playRandom *widget.Button + container *fyne.Container +} + +type tracksPageState struct { + contr *controller.Controller + conf *backend.TracksPageConfig + lm *backend.LibraryManager +} + +func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, lm *backend.LibraryManager) *TracksPage { + t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, lm: lm}} + t.ExtendBaseWidget(t) + t.tracklist = widgets.NewTracklist(nil) + t.tracklist.AutoNumber = true + t.tracklist.SetVisibleColumns(conf.TracklistColumns) + contr.ConnectTracklistActions(t.tracklist) + t.title = widget.NewRichTextWithText("All Tracks") + t.title.Segments[0].(*widget.TextSegment).Style.SizeName = widget.RichTextStyleHeading.SizeName + t.playRandom = widget.NewButtonWithIcon("Play random", res.ResShuffleInvertSvg, t.playRandomSongs) + t.createContainer() + t.Reload() + return t +} + +func (t *TracksPage) createContainer() { + playRandomVbox := container.NewVBox(layout.NewSpacer(), t.playRandom, layout.NewSpacer()) + topRow := container.NewHBox(t.title, playRandomVbox, layout.NewSpacer()) //searchVbox, util.NewHSpace(15)) + t.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, + container.NewBorder(topRow, nil, nil, nil, t.tracklist)) +} + +func (t *TracksPage) Route() controller.Route { + return controller.TracksRoute() +} + +func (t *TracksPage) Reload() { + t.tracklist.Clear() + iter := t.lm.AllTracksIterator() + // loads asynchronously + t.loader = widgets.NewTracklistLoader(t.tracklist, iter) +} + +func (t *TracksPage) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(t.container) +} + +func (t *TracksPage) Save() SavedPage { + state := t.tracksPageState + return &state +} + +func (s *tracksPageState) Restore() Page { + return NewTracksPage(s.contr, s.conf, s.lm) +} + +func (t *TracksPage) playRandomSongs() { + t.contr.App.PlaybackManager.PlayRandomSongs("") +} diff --git a/ui/controller/routes.go b/ui/controller/routes.go index d50a4df..62bfea3 100644 --- a/ui/controller/routes.go +++ b/ui/controller/routes.go @@ -14,6 +14,7 @@ const ( NowPlaying Playlist Playlists + Tracks ) type Route struct { @@ -52,6 +53,10 @@ func PlaylistsRoute() Route { return Route{Page: Playlists} } +func TracksRoute() Route { + return Route{Page: Tracks} +} + func ArtistsRoute() Route { return Route{Page: Artists} } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index ae9e333..1ad6f4e 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -26,9 +26,10 @@ var ( ShortcutNavFour = desktop.CustomShortcut{KeyName: fyne.Key4, Modifier: os.ControlModifier} ShortcutNavFive = desktop.CustomShortcut{KeyName: fyne.Key5, Modifier: os.ControlModifier} ShortcutNavSix = desktop.CustomShortcut{KeyName: fyne.Key6, Modifier: os.ControlModifier} + ShortcutNavSeven = desktop.CustomShortcut{KeyName: fyne.Key7, Modifier: os.ControlModifier} NavShortcuts = []desktop.CustomShortcut{ShortcutNavOne, ShortcutNavTwo, ShortcutNavThree, - ShortcutNavFour, ShortcutNavFive, ShortcutNavSix} + ShortcutNavFour, ShortcutNavFive, ShortcutNavSix, ShortcutNavSeven} ) type MainWindow struct { @@ -187,6 +188,9 @@ func (m *MainWindow) addNavigationButtons() { m.BrowsingPane.AddNavigationButton(res.ResPlaylistInvertPng, func() { m.Router.NavigateTo(controller.PlaylistsRoute()) }) + m.BrowsingPane.AddNavigationButton(res.ResMusicnotesInvertPng, func() { + m.Router.NavigateTo(controller.TracksRoute()) + }) } func (m *MainWindow) addShortcuts() { From 6d8fd75767e1b0dfd729ae6203c5971b7452cd75 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 18:07:01 -0700 Subject: [PATCH 06/12] add SearchTracksIterator --- backend/albumiterator.go | 26 ++++------ backend/searchiterbase.go | 33 +++++++++++++ backend/trackiterator.go | 99 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 133 insertions(+), 25 deletions(-) create mode 100644 backend/searchiterbase.go diff --git a/backend/albumiterator.go b/backend/albumiterator.go index 697c449..b2ef1a1 100644 --- a/backend/albumiterator.go +++ b/backend/albumiterator.go @@ -142,12 +142,9 @@ func (r *baseIter) Next() *subsonic.AlbumID3 { } type searchIter struct { - query string - artistOffset int - albumOffset int - songOffset int + searchIterBase + l *LibraryManager - s *subsonic.Client filter func(*subsonic.AlbumID3) bool prefetched []*subsonic.AlbumID3 prefetchedPos int @@ -157,9 +154,11 @@ type searchIter struct { func (l *LibraryManager) newSearchIter(query string, filter func(*subsonic.AlbumID3) bool) *searchIter { return &searchIter{ - query: query, + searchIterBase: searchIterBase{ + query: query, + s: l.s.Server, + }, l: l, - s: l.s.Server, filter: filter, albumIDset: make(map[string]bool), } @@ -172,17 +171,8 @@ func (s *searchIter) Next() *subsonic.AlbumID3 { // prefetch more search results from server if s.prefetched == nil { - searchOpts := map[string]string{ - "artistOffset": strconv.Itoa(s.artistOffset), - "albumOffset": strconv.Itoa(s.albumOffset), - "songOffset": strconv.Itoa(s.songOffset), - } - results, err := s.s.Search3(s.query, searchOpts) - if err != nil { - log.Println(err) - results = nil - } - if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 { + results := s.searchIterBase.fetchResults() + if results == nil { s.done = true s.albumIDset = nil return nil diff --git a/backend/searchiterbase.go b/backend/searchiterbase.go new file mode 100644 index 0000000..4fa880f --- /dev/null +++ b/backend/searchiterbase.go @@ -0,0 +1,33 @@ +package backend + +import ( + "log" + "strconv" + + "github.com/dweymouth/go-subsonic/subsonic" +) + +type searchIterBase struct { + query string + artistOffset int + albumOffset int + songOffset int + s *subsonic.Client +} + +func (s *searchIterBase) fetchResults() *subsonic.SearchResult3 { + searchOpts := map[string]string{ + "artistOffset": strconv.Itoa(s.artistOffset), + "albumOffset": strconv.Itoa(s.albumOffset), + "songOffset": strconv.Itoa(s.songOffset), + } + results, err := s.s.Search3(s.query, searchOpts) + if err != nil { + log.Println(err) + results = nil + } + if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 { + return nil + } + return results +} diff --git a/backend/trackiterator.go b/backend/trackiterator.go index f11113f..60e9937 100644 --- a/backend/trackiterator.go +++ b/backend/trackiterator.go @@ -6,6 +6,20 @@ import ( "github.com/dweymouth/go-subsonic/subsonic" ) +func (l *LibraryManager) AllTracksIterator() TrackIterator { + return &allTracksIterator{ + l: l, + albumIter: l.AlbumsIter(AlbumSortArtistAZ), + } +} + +func (l *LibraryManager) SearchTracksIterator(query string) TrackIterator { + return &searchTracksIterator{searchIterBase: searchIterBase{ + s: l.s.Server, + query: query, + }} +} + type allTracksIterator struct { l *LibraryManager albumIter AlbumIterator @@ -14,13 +28,6 @@ type allTracksIterator struct { done bool } -func (l *LibraryManager) AllTracksIterator() TrackIterator { - return &allTracksIterator{ - l: l, - albumIter: l.AlbumsIter(AlbumSortArtistAZ), - } -} - func (a *allTracksIterator) Next() *subsonic.Child { if a.done { return nil @@ -50,3 +57,81 @@ func (a *allTracksIterator) Next() *subsonic.Child { a.curTrackIdx += 1 return tr } + +type searchTracksIterator struct { + searchIterBase + + prefetched []*subsonic.Child + prefetchedPos int + trackIDset map[string]bool + done bool +} + +func (s *searchTracksIterator) Next() *subsonic.Child { + if s.done { + return nil + } + + // prefetch more search results from server + if s.prefetched == nil { + results := s.searchIterBase.fetchResults() + if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 { + s.done = true + s.trackIDset = nil + return nil + } + + // add results from songs search + s.addNewTracks(results.Song) + s.songOffset += len(results.Song) + + // add results from artists search + for _, artist := range results.Artist { + artist, err := s.s.GetArtist(artist.ID) + if err != nil { + log.Printf("error fetching artist: %s", err.Error()) + } else { + s.addNewTracksFromAlbums(artist.Album) + } + } + s.artistOffset += len(results.Artist) + + // add results from albums search + s.addNewTracksFromAlbums(results.Album) + s.albumOffset += len(results.Album) + } + + // return from prefetched results + if len(s.prefetched) > 0 { + tr := s.prefetched[s.prefetchedPos] + s.prefetchedPos++ + if s.prefetchedPos == len(s.prefetched) { + s.prefetched = nil + s.prefetchedPos = 0 + } + + return tr + } + + return nil +} + +func (s *searchTracksIterator) addNewTracks(tracks []*subsonic.Child) { + for _, tr := range tracks { + if _, have := s.trackIDset[tr.ID]; have { + continue + } + s.prefetched = append(s.prefetched, tr) + s.trackIDset[tr.ID] = true + } +} + +func (s *searchTracksIterator) addNewTracksFromAlbums(albums []*subsonic.AlbumID3) { + for _, al := range albums { + if album, err := s.s.GetAlbum(al.ID); err != nil { + log.Printf("error fetching album: %s", err.Error()) + } else { + s.addNewTracks(album.Song) + } + } +} From 7a04d502febab5952c51a2f3dde05b6c5708c9ba Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 18:23:03 -0700 Subject: [PATCH 07/12] add search to tracks page --- backend/trackiterator.go | 11 ++++--- ui/browsing/trackspage.go | 62 ++++++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/backend/trackiterator.go b/backend/trackiterator.go index 60e9937..de21a71 100644 --- a/backend/trackiterator.go +++ b/backend/trackiterator.go @@ -14,10 +14,13 @@ func (l *LibraryManager) AllTracksIterator() TrackIterator { } func (l *LibraryManager) SearchTracksIterator(query string) TrackIterator { - return &searchTracksIterator{searchIterBase: searchIterBase{ - s: l.s.Server, - query: query, - }} + return &searchTracksIterator{ + searchIterBase: searchIterBase{ + s: l.s.Server, + query: query, + }, + trackIDset: make(map[string]bool), + } } type allTracksIterator struct { diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index 3403902..46df479 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -5,6 +5,7 @@ import ( "supersonic/res" "supersonic/ui/controller" "supersonic/ui/layouts" + "supersonic/ui/util" "supersonic/ui/widgets" "fyne.io/fyne/v2" @@ -18,17 +19,21 @@ type TracksPage struct { tracksPageState - title *widget.RichText - tracklist *widgets.Tracklist - loader widgets.TracklistLoader - playRandom *widget.Button - container *fyne.Container + title *widget.RichText + searcher *widgets.Searcher + tracklist *widgets.Tracklist + loader widgets.TracklistLoader + searchTracklist *widgets.Tracklist + searchLoader widgets.TracklistLoader + playRandom *widget.Button + container *fyne.Container } type tracksPageState struct { - contr *controller.Controller - conf *backend.TracksPageConfig - lm *backend.LibraryManager + searchText string + contr *controller.Controller + conf *backend.TracksPageConfig + lm *backend.LibraryManager } func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, lm *backend.LibraryManager) *TracksPage { @@ -41,6 +46,8 @@ func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, t.title = widget.NewRichTextWithText("All Tracks") t.title.Segments[0].(*widget.TextSegment).Style.SizeName = widget.RichTextStyleHeading.SizeName t.playRandom = widget.NewButtonWithIcon("Play random", res.ResShuffleInvertSvg, t.playRandomSongs) + t.searcher = widgets.NewSearcher() + t.searcher.OnSearched = t.OnSearched t.createContainer() t.Reload() return t @@ -48,7 +55,8 @@ func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, func (t *TracksPage) createContainer() { playRandomVbox := container.NewVBox(layout.NewSpacer(), t.playRandom, layout.NewSpacer()) - topRow := container.NewHBox(t.title, playRandomVbox, layout.NewSpacer()) //searchVbox, util.NewHSpace(15)) + searchVbox := container.NewVBox(layout.NewSpacer(), t.searcher.Entry, layout.NewSpacer()) + topRow := container.NewHBox(t.title, playRandomVbox, layout.NewSpacer(), searchVbox, util.NewHSpace(5)) t.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, container.NewBorder(topRow, nil, nil, nil, t.tracklist)) } @@ -64,6 +72,34 @@ func (t *TracksPage) Reload() { t.loader = widgets.NewTracklistLoader(t.tracklist, iter) } +func (t *TracksPage) OnSearched(query string) { + t.searchText = query + if query == "" { + t.container.Objects[0].(*fyne.Container).Objects[0] = t.tracklist + if t.searchTracklist != nil { + t.searchTracklist.Clear() + } + t.Refresh() + return + } + t.doSearch(query) +} + +func (t *TracksPage) doSearch(query string) { + if t.searchTracklist == nil { + t.searchTracklist = widgets.NewTracklist(nil) + t.searchTracklist.AutoNumber = true + t.searchTracklist.SetVisibleColumns(t.conf.TracklistColumns) + t.contr.ConnectTracklistActions(t.searchTracklist) + } else { + t.searchTracklist.Clear() + } + iter := t.lm.SearchTracksIterator(query) + t.searchLoader = widgets.NewTracklistLoader(t.searchTracklist, iter) + t.container.Objects[0].(*fyne.Container).Objects[0] = t.searchTracklist + t.Refresh() +} + func (t *TracksPage) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(t.container) } @@ -74,7 +110,13 @@ func (t *TracksPage) Save() SavedPage { } func (s *tracksPageState) Restore() Page { - return NewTracksPage(s.contr, s.conf, s.lm) + t := NewTracksPage(s.contr, s.conf, s.lm) + t.searchText = s.searchText + if t.searchText != "" { + t.searcher.Entry.Text = t.searchText + t.doSearch(t.searchText) + } + return t } func (t *TracksPage) playRandomSongs() { From 3521f62f28c5e1d309253050569dfab533e0fcba Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 18:34:06 -0700 Subject: [PATCH 08/12] bug fix --- backend/trackiterator.go | 2 +- ui/widgets/tracklistloader.go | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/trackiterator.go b/backend/trackiterator.go index de21a71..6462eb4 100644 --- a/backend/trackiterator.go +++ b/backend/trackiterator.go @@ -78,7 +78,7 @@ func (s *searchTracksIterator) Next() *subsonic.Child { // prefetch more search results from server if s.prefetched == nil { results := s.searchIterBase.fetchResults() - if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 { + if results == nil { s.done = true s.trackIDset = nil return nil diff --git a/ui/widgets/tracklistloader.go b/ui/widgets/tracklistloader.go index bbaa5eb..9393f9a 100644 --- a/ui/widgets/tracklistloader.go +++ b/ui/widgets/tracklistloader.go @@ -1,7 +1,6 @@ package widgets import ( - "log" "supersonic/backend" "github.com/dweymouth/go-subsonic/subsonic" @@ -44,7 +43,6 @@ func (t *TracklistLoader) onTrackShown(tracknum int) { func (t *TracklistLoader) loadMoreTracks(num int) { // repeat fetch task as long as user has scrolled near bottom for !t.done && t.highestShown >= t.len-25 { - log.Println("fetching more tracks") if t.trackBuffer == nil { t.trackBuffer = make([]*subsonic.Child, 0, num) } @@ -53,15 +51,16 @@ func (t *TracklistLoader) loadMoreTracks(num int) { tr := t.iter.Next() if tr == nil { t.done = true - t.trackBuffer = nil break } t.trackBuffer = append(t.trackBuffer, tr) } t.tracklist.AppendTracks(t.trackBuffer) t.tracklist.Refresh() - log.Printf("appended %d tracks", len(t.trackBuffer)) t.len += len(t.trackBuffer) } + if t.done { + t.trackBuffer = nil + } t.fetching = false } From ff1094c58865acbab134ba7e5e24a280e862e16a Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 19:53:08 -0700 Subject: [PATCH 09/12] memory allocation optimization --- backend/trackiterator.go | 46 ++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/trackiterator.go b/backend/trackiterator.go index 6462eb4..02ccefa 100644 --- a/backend/trackiterator.go +++ b/backend/trackiterator.go @@ -76,32 +76,29 @@ func (s *searchTracksIterator) Next() *subsonic.Child { } // prefetch more search results from server - if s.prefetched == nil { + if len(s.prefetched) == 0 { results := s.searchIterBase.fetchResults() - if results == nil { - s.done = true - s.trackIDset = nil - return nil - } - // add results from songs search - s.addNewTracks(results.Song) - s.songOffset += len(results.Song) + if results != nil { + // add results from songs search + s.addNewTracks(results.Song) + s.songOffset += len(results.Song) - // add results from artists search - for _, artist := range results.Artist { - artist, err := s.s.GetArtist(artist.ID) - if err != nil { - log.Printf("error fetching artist: %s", err.Error()) - } else { - s.addNewTracksFromAlbums(artist.Album) + // add results from artists search + for _, artist := range results.Artist { + artist, err := s.s.GetArtist(artist.ID) + if err != nil { + log.Printf("error fetching artist: %s", err.Error()) + } else { + s.addNewTracksFromAlbums(artist.Album) + } } - } - s.artistOffset += len(results.Artist) + s.artistOffset += len(results.Artist) - // add results from albums search - s.addNewTracksFromAlbums(results.Album) - s.albumOffset += len(results.Album) + // add results from albums search + s.addNewTracksFromAlbums(results.Album) + s.albumOffset += len(results.Album) + } } // return from prefetched results @@ -109,13 +106,16 @@ func (s *searchTracksIterator) Next() *subsonic.Child { tr := s.prefetched[s.prefetchedPos] s.prefetchedPos++ if s.prefetchedPos == len(s.prefetched) { - s.prefetched = nil + s.prefetched = s.prefetched[:0] s.prefetchedPos = 0 } - return tr } + // no more results + s.done = true + s.prefetched = nil + s.trackIDset = nil return nil } From 6b57df78a9fd332763b1daeb198d202aa04601c5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 19:56:29 -0700 Subject: [PATCH 10/12] adjust position of tracks page search bar --- ui/browsing/trackspage.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index 46df479..e0d2edb 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -5,7 +5,6 @@ import ( "supersonic/res" "supersonic/ui/controller" "supersonic/ui/layouts" - "supersonic/ui/util" "supersonic/ui/widgets" "fyne.io/fyne/v2" @@ -56,7 +55,7 @@ func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, func (t *TracksPage) createContainer() { playRandomVbox := container.NewVBox(layout.NewSpacer(), t.playRandom, layout.NewSpacer()) searchVbox := container.NewVBox(layout.NewSpacer(), t.searcher.Entry, layout.NewSpacer()) - topRow := container.NewHBox(t.title, playRandomVbox, layout.NewSpacer(), searchVbox, util.NewHSpace(5)) + topRow := container.NewHBox(t.title, playRandomVbox, layout.NewSpacer(), searchVbox) t.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15}, container.NewBorder(topRow, nil, nil, nil, t.tracklist)) } From 78ed013466dc5c9bbcc5755f27329bef64421b3e Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 21:07:31 -0700 Subject: [PATCH 11/12] make tracks page show now playing + tracklist now playing refactor --- ui/browsing/trackspage.go | 18 ++++++++++++++++++ ui/widgets/tracklist.go | 34 +++++++++++++--------------------- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/ui/browsing/trackspage.go b/ui/browsing/trackspage.go index e0d2edb..dd19b53 100644 --- a/ui/browsing/trackspage.go +++ b/ui/browsing/trackspage.go @@ -3,6 +3,7 @@ package browsing import ( "supersonic/backend" "supersonic/res" + "supersonic/sharedutil" "supersonic/ui/controller" "supersonic/ui/layouts" "supersonic/ui/widgets" @@ -11,6 +12,7 @@ import ( "fyne.io/fyne/v2/container" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/widget" + "github.com/dweymouth/go-subsonic/subsonic" ) type TracksPage struct { @@ -18,6 +20,8 @@ type TracksPage struct { tracksPageState + nowPlayingID string + title *widget.RichText searcher *widgets.Searcher tracklist *widgets.Tracklist @@ -71,6 +75,19 @@ func (t *TracksPage) Reload() { t.loader = widgets.NewTracklistLoader(t.tracklist, iter) } +func (t *TracksPage) OnSongChange(track *subsonic.Child, lastScrobbledIfAny *subsonic.Child) { + t.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) + t.tracklist.SetNowPlaying(t.nowPlayingID) + if t.searchTracklist != nil { + t.searchTracklist.SetNowPlaying(t.nowPlayingID) + } + playedID := sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny) + t.tracklist.IncrementPlayCount(playedID) + if t.searchTracklist != nil { + t.searchTracklist.IncrementPlayCount(playedID) + } +} + func (t *TracksPage) OnSearched(query string) { t.searchText = query if query == "" { @@ -89,6 +106,7 @@ func (t *TracksPage) doSearch(query string) { t.searchTracklist = widgets.NewTracklist(nil) t.searchTracklist.AutoNumber = true t.searchTracklist.SetVisibleColumns(t.conf.TracklistColumns) + t.searchTracklist.SetNowPlaying(t.nowPlayingID) t.contr.ConnectTracklistActions(t.searchTracklist) } else { t.searchTracklist.Clear() diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 2868cbc..1ab2f48 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -71,18 +71,18 @@ type Tracklist struct { visibleColumns []bool - tracksMutex sync.RWMutex - selectionMgr util.ListSelectionManager - nowPlayingIdx int - colLayout *layouts.ColumnsLayout - hdr *ListHeader - list *widget.List - ctxMenu *fyne.Menu - container *fyne.Container + tracksMutex sync.RWMutex + selectionMgr util.ListSelectionManager + nowPlayingID string + colLayout *layouts.ColumnsLayout + hdr *ListHeader + list *widget.List + ctxMenu *fyne.Menu + container *fyne.Container } func NewTracklist(tracks []*subsonic.Child) *Tracklist { - t := &Tracklist{Tracks: tracks, nowPlayingIdx: -1, visibleColumns: make([]bool, 11)} + t := &Tracklist{Tracks: tracks, visibleColumns: make([]bool, 11)} t.ExtendBaseWidget(t) t.selectionMgr = util.NewListSelectionManager(t.lenTracks) @@ -113,7 +113,7 @@ func NewTracklist(tracks []*subsonic.Child) *Tracklist { if t.AutoNumber { i = itemID + 1 } - tr.Update(t.TrackAt(itemID), itemID == t.nowPlayingIdx, i) + tr.Update(t.TrackAt(itemID), i) if t.OnTrackShown != nil { t.OnTrackShown(itemID) } @@ -185,15 +185,7 @@ func (t *Tracklist) setColumnVisible(colNum int, vis bool) { } func (t *Tracklist) SetNowPlaying(trackID string) { - t.nowPlayingIdx = -1 - t.tracksMutex.RLock() - for i, tr := range t.Tracks { - if tr.ID == trackID { - t.nowPlayingIdx = i - break - } - } - t.tracksMutex.RUnlock() + t.nowPlayingID = trackID t.list.Refresh() } @@ -466,7 +458,7 @@ func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow return t } -func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { +func (t *TrackRow) Update(tr *subsonic.Child, rowNum int) { // Update info that can change if this row is bound to // a new track (*subsonic.Child) if tr.ID != t.trackID { @@ -516,7 +508,7 @@ func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { } // Render whether track is playing or not - if isPlaying != t.isPlaying { + if isPlaying := t.tracklist.nowPlayingID == tr.ID; isPlaying != t.isPlaying { t.isPlaying = isPlaying t.name.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying t.dur.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying From 7e92407651bba64e9785fe2c76890d60933f9e1f Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Thu, 20 Apr 2023 21:15:01 -0700 Subject: [PATCH 12/12] update CHANGELOG with all tracks page --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d39303..39b2f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - [#115](https://github.com/dweymouth/supersonic/issues/115) Add search bar to artist, genres, and playlists pages - [#104](https://github.com/dweymouth/supersonic/issues/104) Add alternate (e.g. external) hostname to server connection config - [#136](https://github.com/dweymouth/supersonic/issues/136) Add "..." button to album page with menu to add album to queue or playlist +- [#70](https://github.com/dweymouth/supersonic/issues/70) Add searchable "All Tracks" page, with button to play random tracks ### Fixes - **todo-commithash** Don't show update available prompt if the found version is the same as the running app version