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 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/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/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/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 new file mode 100644 index 0000000..02ccefa --- /dev/null +++ b/backend/trackiterator.go @@ -0,0 +1,140 @@ +package backend + +import ( + "log" + + "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, + }, + trackIDset: make(map[string]bool), + } +} + +type allTracksIterator struct { + l *LibraryManager + albumIter AlbumIterator + curAlbum *subsonic.AlbumID3 + curTrackIdx int + done bool +} + +func (a *allTracksIterator) Next() *subsonic.Child { + if a.done { + return nil + } + + // fetch next album + if a.curAlbum == nil || a.curTrackIdx >= len(a.curAlbum.Song) { + al := a.albumIter.Next() + if al == nil { + a.done = true + return nil + } + 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] + 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 len(s.prefetched) == 0 { + results := s.searchIterBase.fetchResults() + + 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) + } + } + 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 = s.prefetched[:0] + s.prefetchedPos = 0 + } + return tr + } + + // no more results + s.done = true + s.prefetched = nil + s.trackIDset = nil + 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) + } + } +} 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..dd19b53 --- /dev/null +++ b/ui/browsing/trackspage.go @@ -0,0 +1,141 @@ +package browsing + +import ( + "supersonic/backend" + "supersonic/res" + "supersonic/sharedutil" + "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" + "github.com/dweymouth/go-subsonic/subsonic" +) + +type TracksPage struct { + widget.BaseWidget + + tracksPageState + + nowPlayingID string + + 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 { + searchText string + 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.searcher = widgets.NewSearcher() + t.searcher.OnSearched = t.OnSearched + t.createContainer() + t.Reload() + return t +} + +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) + 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) 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 == "" { + 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.searchTracklist.SetNowPlaying(t.nowPlayingID) + 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) +} + +func (t *TracksPage) Save() SavedPage { + state := t.tracksPageState + return &state +} + +func (s *tracksPageState) Restore() Page { + 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() { + 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() { diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index f47d693..1ab2f48 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, @@ -64,23 +67,25 @@ type Tracklist struct { OnShowAlbumPage func(albumID string) OnColumnVisibilityMenuShown func(*widget.PopUp) + OnTrackShown func(tracknum int) visibleColumns []bool - 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(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 +97,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 +113,10 @@ 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), i) + if t.OnTrackShown != nil { + t.OnTrackShown(itemID) + } }) t.container = container.NewBorder(t.hdr, nil, nil, nil, t.list) return t @@ -130,6 +138,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 @@ -167,23 +185,34 @@ func (t *Tracklist) setColumnVisible(colNum int, vis bool) { } func (t *Tracklist) SetNowPlaying(trackID string) { - t.nowPlayingIdx = -1 - for i, tr := range t.Tracks { - if tr.ID == trackID { - t.nowPlayingIdx = i - break - } - } + t.nowPlayingID = trackID 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() } } +// 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() @@ -255,7 +284,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 +313,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 +324,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 +336,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 { @@ -417,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 { @@ -467,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 diff --git a/ui/widgets/tracklistloader.go b/ui/widgets/tracklistloader.go new file mode 100644 index 0000000..9393f9a --- /dev/null +++ b/ui/widgets/tracklistloader.go @@ -0,0 +1,66 @@ +package widgets + +import ( + "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 { + 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 + break + } + t.trackBuffer = append(t.trackBuffer, tr) + } + t.tracklist.AppendTracks(t.trackBuffer) + t.tracklist.Refresh() + t.len += len(t.trackBuffer) + } + if t.done { + t.trackBuffer = nil + } + t.fetching = false +}