Merge pull request #139 from dweymouth/feature/alltracksview

Add searchable "All Tracks" page
This commit is contained in:
Drew Weymouth
2023-04-20 21:19:16 -07:00
committed by GitHub
12 changed files with 475 additions and 40 deletions
+1
View File
@@ -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
+8 -18
View File
@@ -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
+8
View File
@@ -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",
+4
View File
@@ -8,6 +8,10 @@ type AlbumIterator interface {
Next() *subsonic.AlbumID3
}
type TrackIterator interface {
Next() *subsonic.Child
}
type LibraryManager struct {
PreCacheCoverFn func(coverID string)
+33
View File
@@ -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
}
+140
View File
@@ -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)
}
}
}
+2
View File
@@ -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
}
+141
View File
@@ -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("")
}
+5
View File
@@ -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}
}
+5 -1
View File
@@ -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() {
+62 -21
View File
@@ -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
+66
View File
@@ -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
}