Merge pull request #53 from dweymouth/feature/favorite-artists-songs

Feature/favorite artists songs
This commit is contained in:
Drew Weymouth
2023-02-22 21:19:02 -08:00
committed by GitHub
16 changed files with 611 additions and 161 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ Slightly outdated screenshots of Supersonic running against the Navidrome <a hre
* [x] Artist view with biography, image, similar artists, and discography * [x] Artist view with biography, image, similar artists, and discography
* [x] Create, play, and update playlists * [x] Create, play, and update playlists
* [x] Configure visible tracklist columns * [x] Configure visible tracklist columns
* [x] Set/unset favorite and browse by favorites (albums only; artists+songs coming soon) * [x] Set/unset favorite and browse by favorite albums, artists, and songs
* [x] View and edit play queue (add and remove tracks; reorder support coming soon) * [x] View and edit play queue (add and remove tracks; reorder support coming soon)
* [ ] Shuffle and repeat playback modes (planned) * [ ] Shuffle and repeat playback modes (planned)
* [ ] Set and view five-star rating (planned) * [ ] Set and view five-star rating (planned)
+12 -2
View File
@@ -24,6 +24,11 @@ type AlbumPageConfig struct {
TracklistColumns []string TracklistColumns []string
} }
type FavoritesPageConfig struct {
InitialView string
TracklistColumns []string
}
type NowPlayingPageConfig struct { type NowPlayingPageConfig struct {
TracklistColumns []string TracklistColumns []string
} }
@@ -40,6 +45,7 @@ type Config struct {
Application AppConfig Application AppConfig
Servers []*ServerConfig Servers []*ServerConfig
AlbumPage AlbumPageConfig AlbumPage AlbumPageConfig
FavoritesPage FavoritesPageConfig
NowPlayingPage NowPlayingPageConfig NowPlayingPage NowPlayingPageConfig
PlaylistPage PlaylistPageConfig PlaylistPage PlaylistPageConfig
LocalPlayback LocalPlaybackConfig LocalPlayback LocalPlaybackConfig
@@ -52,10 +58,14 @@ func DefaultConfig() *Config {
WindowHeight: 800, WindowHeight: 800,
}, },
AlbumPage: AlbumPageConfig{ AlbumPage: AlbumPageConfig{
TracklistColumns: []string{"Artist", "Time", "Plays"}, TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite"},
},
FavoritesPage: FavoritesPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
InitialView: "Albums",
}, },
NowPlayingPage: NowPlayingPageConfig{ NowPlayingPage: NowPlayingPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time"}, TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
}, },
PlaylistPage: PlaylistPageConfig{ PlaylistPage: PlaylistPageConfig{
TracklistColumns: []string{"Artist", "Album", "Time", "Plays"}, TracklistColumns: []string{"Artist", "Album", "Time", "Plays"},
+23 -2
View File
@@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"supersonic/backend/util" "supersonic/backend/util"
"supersonic/player" "supersonic/player"
"supersonic/sharedutil"
"time" "time"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/go-subsonic/subsonic"
@@ -141,7 +142,11 @@ func (p *PlaybackManager) LoadTracks(tracks []*subsonic.Child, appendToQueue, sh
return err return err
} }
p.player.AppendFile(url.String()) p.player.AppendFile(url.String())
p.playQueue = append(p.playQueue, tracks[i]) // ensure a deep copy of the track info so that we can maintain our own state
// (tracking play count increases, favorite, and rating) without messing up
// other views' track models
tr := *tracks[i]
p.playQueue = append(p.playQueue, &tr)
} }
return nil return nil
} }
@@ -176,10 +181,25 @@ func (p *PlaybackManager) PlayTrackAt(idx int) error {
func (p *PlaybackManager) GetPlayQueue() []*subsonic.Child { func (p *PlaybackManager) GetPlayQueue() []*subsonic.Child {
pq := make([]*subsonic.Child, len(p.playQueue)) pq := make([]*subsonic.Child, len(p.playQueue))
copy(pq, p.playQueue) for i, tr := range p.playQueue {
copy := *tr
pq[i] = &copy
}
return pq return pq
} }
// Any time the user changes the favorite status of a track elsewhere in the app,
// this should be called to ensure the in-memory track model is updated.
func (p *PlaybackManager) OnTrackFavoriteStatusChanged(id string, fav bool) {
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil {
if fav {
tr.Starred = time.Now()
} else {
tr.Starred = time.Time{}
}
}
}
// trackIdxs must be sorted // trackIdxs must be sorted
func (p *PlaybackManager) RemoveTracksFromQueue(trackIdxs []int) { func (p *PlaybackManager) RemoveTracksFromQueue(trackIdxs []int) {
newQueue := make([]*subsonic.Child, 0, len(p.playQueue)-len(trackIdxs)) newQueue := make([]*subsonic.Child, 0, len(p.playQueue)-len(trackIdxs))
@@ -228,6 +248,7 @@ func (p *PlaybackManager) checkScrobble(playDur time.Duration) {
song := p.playQueue[p.nowPlayingIdx] song := p.playQueue[p.nowPlayingIdx]
if playDur.Seconds()/p.curTrackTime > ScrobbleThreshold { if playDur.Seconds()/p.curTrackTime > ScrobbleThreshold {
log.Printf("Scrobbling %q", song.Title) log.Printf("Scrobbling %q", song.Title)
song.PlayCount += 1
p.lastScrobbled = song p.lastScrobbled = song
p.sm.Server.Scrobble(song.ID, map[string]string{"time": strconv.FormatInt(time.Now().Unix()*1000, 10)}) p.sm.Server.Scrobble(song.ID, map[string]string{"time": strconv.FormatInt(time.Now().Unix()*1000, 10)})
} }
+19
View File
@@ -0,0 +1,19 @@
package sharedutil
import "github.com/dweymouth/go-subsonic/subsonic"
func FindTrackByID(id string, tracks []*subsonic.Child) *subsonic.Child {
for _, tr := range tracks {
if id == tr.ID {
return tr
}
}
return nil
}
func TrackIDOrEmptyStr(track *subsonic.Child) string {
if track == nil {
return ""
}
return track.ID
}
+4 -15
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"supersonic/backend" "supersonic/backend"
"supersonic/res" "supersonic/res"
"supersonic/sharedutil"
"supersonic/ui/controller" "supersonic/ui/controller"
"supersonic/ui/layouts" "supersonic/ui/layouts"
"supersonic/ui/util" "supersonic/ui/util"
@@ -66,14 +67,7 @@ func NewAlbumPage(
a.header = NewAlbumPageHeader(a) a.header = NewAlbumPageHeader(a)
a.tracklist = widgets.NewTracklist(nil) a.tracklist = widgets.NewTracklist(nil)
a.tracklist.SetVisibleColumns(a.cfg.TracklistColumns) a.tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
// connect tracklist actions a.contr.ConnectTracklistActions(a.tracklist)
a.tracklist.OnPlayTrackAt = a.onPlayTrackAt
a.tracklist.OnAddToQueue = func(tracks []*subsonic.Child) { a.pm.LoadTracks(tracks, true, false) }
a.tracklist.OnPlaySelection = func(tracks []*subsonic.Child) {
a.pm.LoadTracks(tracks, false, false)
a.pm.PlayFromBeginning()
}
a.tracklist.OnAddToPlaylist = a.contr.DoAddTracksToPlaylistWorkflow
a.container = container.NewBorder( a.container = container.NewBorder(
container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 15, PadBottom: 10}, a.header), container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 15, PadBottom: 10}, a.header),
@@ -107,7 +101,7 @@ func (a *AlbumPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny *subso
a.nowPlayingID = song.ID a.nowPlayingID = song.ID
} }
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.IncrementPlayCount(lastScrobbledIfAny) a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
} }
func (a *AlbumPage) Reload() { func (a *AlbumPage) Reload() {
@@ -122,11 +116,6 @@ func (a *AlbumPage) SelectAll() {
a.tracklist.SelectAll() a.tracklist.SelectAll()
} }
func (a *AlbumPage) onPlayTrackAt(tracknum int) {
a.pm.LoadTracks(a.tracklist.Tracks, false, false)
a.pm.PlayTrackAt(tracknum)
}
// should be called asynchronously // should be called asynchronously
func (a *AlbumPage) load() { func (a *AlbumPage) load() {
album, err := a.lm.GetAlbum(a.albumID) album, err := a.lm.GetAlbum(a.albumID)
@@ -185,7 +174,7 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader {
} }
a.miscLabel = widget.NewLabel("") a.miscLabel = widget.NewLabel("")
playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() { playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() {
page.onPlayTrackAt(0) go page.pm.PlayAlbum(page.albumID, 0)
}) })
shuffleBtn := widget.NewButtonWithIcon(" Shuffle", res.ResShuffleInvertSvg, func() { shuffleBtn := widget.NewButtonWithIcon(" Shuffle", res.ResShuffleInvertSvg, func() {
page.pm.LoadTracks(page.tracklist.Tracks, false, true) page.pm.LoadTracks(page.tracklist.Tracks, false, true)
+15 -1
View File
@@ -124,6 +124,7 @@ type ArtistPageHeader struct {
titleDisp *widget.RichText titleDisp *widget.RichText
biographyDisp *widget.RichText biographyDisp *widget.RichText
similarArtists *fyne.Container similarArtists *fyne.Container
favoriteBtn *widgets.FavoriteButton
container *fyne.Container container *fyne.Container
} }
@@ -139,6 +140,7 @@ func NewArtistPageHeader(page *ArtistPage, nav func(Route)) *ArtistPageHeader {
SizeName: theme.SizeNameHeadingText, SizeName: theme.SizeNameHeadingText,
} }
a.artistImage = widgets.NewImagePlaceholder(res.ResPeopleInvertPng, 225) a.artistImage = widgets.NewImagePlaceholder(res.ResPeopleInvertPng, 225)
a.favoriteBtn = widgets.NewFavoriteButton(func() { go a.toggleFavorited() })
a.biographyDisp.Wrapping = fyne.TextWrapWord a.biographyDisp.Wrapping = fyne.TextWrapWord
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.createContainer() a.createContainer()
@@ -149,6 +151,7 @@ func (a *ArtistPageHeader) Update(artist *subsonic.ArtistID3) {
if artist == nil { if artist == nil {
return return
} }
a.favoriteBtn.IsFavorited = !artist.Starred.IsZero()
a.artistID = artist.ID a.artistID = artist.ID
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
a.titleDisp.Refresh() a.titleDisp.Refresh()
@@ -197,9 +200,20 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
} }
} }
func (a *ArtistPageHeader) toggleFavorited() {
if a.favoriteBtn.IsFavorited {
a.artistPage.sm.Server.Star(subsonic.StarParameters{ArtistIDs: []string{a.artistID}})
} else {
a.artistPage.sm.Server.Unstar(subsonic.StarParameters{ArtistIDs: []string{a.artistID}})
}
}
func (a *ArtistPageHeader) createContainer() { func (a *ArtistPageHeader) createContainer() {
a.container = container.NewBorder(nil, nil, a.artistImage, nil, a.container = container.NewBorder(nil, nil, a.artistImage, nil,
container.NewBorder(a.titleDisp, nil, nil, nil, container.NewVBox(a.biographyDisp, a.similarArtists))) container.NewVBox(
container.New(&layouts.VboxCustomPadding{ExtraPad: -10},
a.titleDisp, a.biographyDisp, a.similarArtists),
container.NewHBox(widgets.NewHSpace(2), a.favoriteBtn)))
} }
func (a *ArtistPageHeader) CreateRenderer() fyne.WidgetRenderer { func (a *ArtistPageHeader) CreateRenderer() fyne.WidgetRenderer {
+221 -49
View File
@@ -1,7 +1,11 @@
package browsing package browsing
import ( import (
"log"
"supersonic/backend" "supersonic/backend"
"supersonic/res"
"supersonic/ui/controller"
"supersonic/ui/layouts"
"supersonic/ui/widgets" "supersonic/ui/widgets"
"time" "time"
@@ -16,45 +20,71 @@ import (
type FavoritesPage struct { type FavoritesPage struct {
widget.BaseWidget widget.BaseWidget
pm *backend.PlaybackManager cfg *backend.FavoritesPageConfig
im *backend.ImageManager contr controller.Controller
sm *backend.ServerManager pm *backend.PlaybackManager
lm *backend.LibraryManager im *backend.ImageManager
nav func(Route) sm *backend.ServerManager
grid *widgets.AlbumGrid lm *backend.LibraryManager
searchGrid *widgets.AlbumGrid nav func(Route)
searcher *widgets.Searcher
searchText string searchText string
titleDisp *widget.RichText nowPlayingID string
container *fyne.Container pendingViewSwitch bool
grid *widgets.AlbumGrid
searchGrid *widgets.AlbumGrid
artistListCtr *fyne.Container
tracklistCtr *fyne.Container
searcher *widgets.Searcher
titleDisp *widget.RichText
toggleBtns *widgets.ToggleButtonGroup
container *fyne.Container
} }
func NewFavoritesPage(sm *backend.ServerManager, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager, nav func(Route)) *FavoritesPage { func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr controller.Controller, sm *backend.ServerManager, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager, nav func(Route)) *FavoritesPage {
a := &FavoritesPage{ a := &FavoritesPage{
pm: pm, cfg: cfg,
lm: lm, contr: contr,
sm: sm, pm: pm,
im: im, lm: lm,
nav: nav, sm: sm,
im: im,
nav: nav,
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.createTitle() a.createHeader(0, "")
iter := lm.StarredIter() a.grid = widgets.NewAlbumGrid(a.lm.StarredIter(), a.im, false)
a.grid = widgets.NewAlbumGrid(iter, im, false) a.connectGridActions()
a.grid.OnPlayAlbum = a.onPlayAlbum
a.grid.OnShowAlbumPage = a.onShowAlbumPage
a.grid.OnShowArtistPage = a.onShowArtistPage
a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched
a.createContainer(false) a.createContainer(false)
if cfg.InitialView == "Artists" {
a.toggleBtns.SetActivatedButton(1)
a.onShowFavoriteArtists()
} else if cfg.InitialView == "Songs" {
a.toggleBtns.SetActivatedButton(2)
a.onShowFavoriteSongs()
}
return a return a
} }
func (a *FavoritesPage) createTitle() { func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) {
a.titleDisp = widget.NewRichTextWithText("Favorites") a.titleDisp = widget.NewRichTextWithText("Favorites")
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
SizeName: theme.SizeNameHeadingText, SizeName: theme.SizeNameHeadingText,
} }
a.toggleBtns = widgets.NewToggleButtonGroup(activeBtnIdx,
widget.NewButtonWithIcon("", res.ResDiscInvertPng, a.onShowFavoriteAlbums),
widget.NewButtonWithIcon("", res.ResPeopleInvertPng, a.onShowFavoriteArtists),
widget.NewButtonWithIcon("", res.ResMusicnotesInvertPng, a.onShowFavoriteSongs))
a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched
a.searcher.Entry.Text = searchText
}
func (a *FavoritesPage) connectGridActions() {
a.grid.OnPlayAlbum = a.onPlayAlbum
a.grid.OnShowAlbumPage = a.onShowAlbumPage
a.grid.OnShowArtistPage = a.onShowArtistPage
} }
func (a *FavoritesPage) createContainer(searchGrid bool) { func (a *FavoritesPage) createContainer(searchGrid bool) {
@@ -64,31 +94,34 @@ func (a *FavoritesPage) createContainer(searchGrid bool) {
gr = a.searchGrid gr = a.searchGrid
} }
a.container = container.NewBorder( a.container = container.NewBorder(
container.NewHBox(widgets.NewHSpace(9), a.titleDisp, layout.NewSpacer(), searchVbox, widgets.NewHSpace(15)), container.NewHBox(widgets.NewHSpace(9), a.titleDisp, container.NewCenter(a.toggleBtns), layout.NewSpacer(), searchVbox, widgets.NewHSpace(15)),
nil, nil, nil, gr) nil, nil, nil, gr)
} }
func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage { func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
a := &FavoritesPage{ a := &FavoritesPage{
pm: saved.pm, cfg: saved.cfg,
lm: saved.lm, contr: saved.contr,
sm: saved.sm, pm: saved.pm,
im: saved.im, lm: saved.lm,
nav: saved.nav, sm: saved.sm,
im: saved.im,
nav: saved.nav,
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.createTitle() a.createHeader(saved.activeToggleBtn, saved.searchText)
a.grid = widgets.NewAlbumGridFromState(saved.gridState) a.grid = widgets.NewAlbumGridFromState(saved.gridState)
a.grid.OnPlayAlbum = a.onPlayAlbum a.connectGridActions()
a.grid.OnShowAlbumPage = a.onShowAlbumPage
a.grid.OnShowArtistPage = a.onShowArtistPage
a.searcher = widgets.NewSearcher()
a.searcher.OnSearched = a.OnSearched
a.searcher.Entry.Text = saved.searchText
if saved.searchText != "" { if saved.searchText != "" {
a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState) a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState)
} }
a.createContainer(saved.searchText != "") a.createContainer(saved.searchText != "")
if saved.activeToggleBtn == 1 {
a.onShowFavoriteArtists()
} else if saved.activeToggleBtn == 2 {
a.onShowFavoriteSongs()
}
return a return a
} }
@@ -98,22 +131,58 @@ func (a *FavoritesPage) Route() Route {
} }
func (a *FavoritesPage) Reload() { func (a *FavoritesPage) Reload() {
// reload favorite albums view
if a.searchText != "" { if a.searchText != "" {
a.doSearch(a.searchText) a.doSearchAlbums(a.searchText)
} else { } else {
a.grid.Reset(a.lm.StarredIter()) a.grid.Reset(a.lm.StarredIter())
} }
if a.tracklistCtr != nil || a.artistListCtr != nil {
go func() {
// re-fetch starred info from server
starred, err := a.sm.Server.GetStarred2(nil)
if err != nil {
log.Printf("error getting starred items: %s", err.Error())
return
}
if a.tracklistCtr != nil {
// refresh favorite songs view
tr := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
tr.Tracks = starred.Song
if a.toggleBtns.ActivatedButtonIndex() == 2 {
// favorite songs view is visible
tr.Refresh()
}
}
if a.artistListCtr != nil {
// refresh favorite artists view
al := a.artistListCtr.Objects[0].(*widgets.ArtistGenrePlaylist)
al.Items = buildArtistListModel(starred.Artist)
if a.toggleBtns.ActivatedButtonIndex() == 1 {
// favorite artists view is visible
al.Refresh()
}
}
}()
}
} }
func (a *FavoritesPage) Save() SavedPage { func (a *FavoritesPage) Save() SavedPage {
if a.tracklistCtr != nil {
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
a.cfg.TracklistColumns = tl.VisibleColumns()
}
sf := &savedFavoritesPage{ sf := &savedFavoritesPage{
pm: a.pm, cfg: a.cfg,
sm: a.sm, contr: a.contr,
im: a.im, pm: a.pm,
lm: a.lm, sm: a.sm,
nav: a.nav, im: a.im,
searchText: a.searchText, lm: a.lm,
gridState: a.grid.SaveToState(), nav: a.nav,
searchText: a.searchText,
gridState: a.grid.SaveToState(),
activeToggleBtn: a.toggleBtns.ActivatedButtonIndex(),
} }
if a.searchGrid != nil { if a.searchGrid != nil {
sf.searchGridState = a.searchGrid.SaveToState() sf.searchGridState = a.searchGrid.SaveToState()
@@ -137,10 +206,22 @@ func (a *FavoritesPage) OnSearched(query string) {
a.Refresh() a.Refresh()
return return
} }
a.doSearch(query) a.doSearchAlbums(query)
} }
func (a *FavoritesPage) doSearch(query string) { var _ CanShowNowPlaying = (*FavoritesPage)(nil)
func (a *FavoritesPage) OnSongChange(song *subsonic.Child, _ *subsonic.Child) {
a.nowPlayingID = ""
if song != nil {
a.nowPlayingID = song.ID
}
if a.tracklistCtr != nil {
a.tracklistCtr.Objects[0].(*widgets.Tracklist).SetNowPlaying(a.nowPlayingID)
}
}
func (a *FavoritesPage) doSearchAlbums(query string) {
iter := a.lm.SearchIterWithFilter(query, func(al *subsonic.AlbumID3) bool { iter := a.lm.SearchIterWithFilter(query, func(al *subsonic.AlbumID3) bool {
return al.Starred.After(time.Time{}) return al.Starred.After(time.Time{})
}) })
@@ -156,6 +237,94 @@ func (a *FavoritesPage) doSearch(query string) {
a.Refresh() a.Refresh()
} }
func (a *FavoritesPage) onShowFavoriteAlbums() {
a.cfg.InitialView = "Albums" // save setting
a.searcher.Entry.Show()
if a.searchText == "" {
a.container.Objects[0] = a.grid
} else {
a.container.Objects[0] = a.searchGrid
}
a.Refresh()
}
func (a *FavoritesPage) onShowFavoriteArtists() {
a.cfg.InitialView = "Artists" // save setting
a.searcher.Entry.Hide() // disable search on artists for now
if a.artistListCtr == nil {
if a.pendingViewSwitch {
return
}
a.pendingViewSwitch = true
go func() {
s, err := a.sm.Server.GetStarred2(nil)
if err != nil {
log.Printf("error getting starred items: %s", err.Error())
return
}
model := buildArtistListModel(s.Artist)
artistList := widgets.NewArtistGenrePlaylist(model)
artistList.ShowAlbumCount = true
artistList.OnNavTo = func(artistID string) {
a.nav(ArtistRoute(artistID))
}
a.artistListCtr = container.New(
&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
artistList)
a.container.Objects[0] = a.artistListCtr
a.Refresh()
a.pendingViewSwitch = false
}()
} else {
a.container.Objects[0] = a.artistListCtr
a.Refresh()
}
}
func buildArtistListModel(artists []*subsonic.ArtistID3) []widgets.ArtistGenrePlaylistItemModel {
model := make([]widgets.ArtistGenrePlaylistItemModel, 0)
for _, ar := range artists {
model = append(model, widgets.ArtistGenrePlaylistItemModel{
ID: ar.ID,
Name: ar.Name,
AlbumCount: ar.AlbumCount,
})
}
return model
}
func (a *FavoritesPage) onShowFavoriteSongs() {
a.cfg.InitialView = "Songs" // save setting
a.searcher.Entry.Hide() // disable search on songs for now
if a.tracklistCtr == nil {
if a.pendingViewSwitch {
return
}
a.pendingViewSwitch = true
go func() {
s, err := a.sm.Server.GetStarred2(nil)
if err != nil {
log.Printf("error getting starred items: %s", err.Error())
return
}
tracklist := widgets.NewTracklist(s.Song)
tracklist.AutoNumber = true
tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
tracklist.SetNowPlaying(a.nowPlayingID)
a.contr.ConnectTracklistActions(tracklist)
a.tracklistCtr = container.New(
&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
tracklist)
a.container.Objects[0] = a.tracklistCtr
a.Refresh()
a.pendingViewSwitch = false
}()
} else {
a.container.Objects[0] = a.tracklistCtr
a.Refresh()
}
}
func (a *FavoritesPage) onPlayAlbum(albumID string) { func (a *FavoritesPage) onPlayAlbum(albumID string) {
go a.pm.PlayAlbum(albumID, 0) go a.pm.PlayAlbum(albumID, 0)
} }
@@ -174,6 +343,8 @@ func (a *FavoritesPage) CreateRenderer() fyne.WidgetRenderer {
} }
type savedFavoritesPage struct { type savedFavoritesPage struct {
cfg *backend.FavoritesPageConfig
contr controller.Controller
pm *backend.PlaybackManager pm *backend.PlaybackManager
sm *backend.ServerManager sm *backend.ServerManager
im *backend.ImageManager im *backend.ImageManager
@@ -181,6 +352,7 @@ type savedFavoritesPage struct {
gridState widgets.AlbumGridState gridState widgets.AlbumGridState
searchGridState widgets.AlbumGridState searchGridState widgets.AlbumGridState
searchText string searchText string
activeToggleBtn int
nav func(Route) nav func(Route)
} }
+4 -2
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"supersonic/backend" "supersonic/backend"
"supersonic/sharedutil"
"supersonic/ui/controller" "supersonic/ui/controller"
"supersonic/ui/layouts" "supersonic/ui/layouts"
"supersonic/ui/widgets" "supersonic/ui/widgets"
@@ -44,8 +45,9 @@ func NewNowPlayingPage(
a.tracklist.SetVisibleColumns(conf.TracklistColumns) a.tracklist.SetVisibleColumns(conf.TracklistColumns)
a.tracklist.AutoNumber = true a.tracklist.AutoNumber = true
a.tracklist.DisablePlaybackMenu = true a.tracklist.DisablePlaybackMenu = true
contr.ConnectTracklistActions(a.tracklist)
// override the default OnPlayTrackAt handler b/c we don't need to re-load the tracks into the queue
a.tracklist.OnPlayTrackAt = a.onPlayTrackAt a.tracklist.OnPlayTrackAt = a.onPlayTrackAt
a.tracklist.OnAddToPlaylist = a.contr.DoAddTracksToPlaylistWorkflow
a.tracklist.AuxiliaryMenuItems = []*fyne.MenuItem{ a.tracklist.AuxiliaryMenuItems = []*fyne.MenuItem{
fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue), fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue),
} }
@@ -86,7 +88,7 @@ func (a *NowPlayingPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny *
a.nowPlayingID = song.ID a.nowPlayingID = song.ID
} }
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.IncrementPlayCount(lastScrobbledIfAny) a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
} }
func (a *NowPlayingPage) Reload() { func (a *NowPlayingPage) Reload() {
+5 -14
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"supersonic/backend" "supersonic/backend"
"supersonic/res" "supersonic/res"
"supersonic/sharedutil"
"supersonic/ui/controller" "supersonic/ui/controller"
"supersonic/ui/layouts" "supersonic/ui/layouts"
"supersonic/ui/util" "supersonic/ui/util"
@@ -57,13 +58,7 @@ func NewPlaylistPage(
fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist), fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist),
} }
// connect tracklist actions // connect tracklist actions
a.tracklist.OnPlayTrackAt = a.onPlayTrackAt a.contr.ConnectTracklistActions(a.tracklist)
a.tracklist.OnAddToQueue = func(tracks []*subsonic.Child) { a.pm.LoadTracks(tracks, true, false) }
a.tracklist.OnPlaySelection = func(tracks []*subsonic.Child) {
a.pm.LoadTracks(tracks, false, false)
a.pm.PlayFromBeginning()
}
a.tracklist.OnAddToPlaylist = a.contr.DoAddTracksToPlaylistWorkflow
a.container = container.NewBorder( a.container = container.NewBorder(
container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 15, PadBottom: 10}, a.header), container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 15, PadBottom: 10}, a.header),
@@ -93,7 +88,7 @@ func (a *PlaylistPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny *su
a.nowPlayingID = song.ID a.nowPlayingID = song.ID
} }
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.IncrementPlayCount(lastScrobbledIfAny) a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
} }
func (a *PlaylistPage) Reload() { func (a *PlaylistPage) Reload() {
@@ -108,11 +103,6 @@ func (a *PlaylistPage) SelectAll() {
a.tracklist.SelectAll() a.tracklist.SelectAll()
} }
func (a *PlaylistPage) onPlayTrackAt(tracknum int) {
a.pm.LoadTracks(a.tracklist.Tracks, false, false)
a.pm.PlayTrackAt(tracknum)
}
// should be called asynchronously // should be called asynchronously
func (a *PlaylistPage) load() { func (a *PlaylistPage) load() {
playlist, err := a.sm.Server.GetPlaylist(a.playlistID) playlist, err := a.sm.Server.GetPlaylist(a.playlistID)
@@ -163,7 +153,8 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
a.createdAtLabel = widget.NewLabel("") a.createdAtLabel = widget.NewLabel("")
a.trackTimeLabel = widget.NewLabel("") a.trackTimeLabel = widget.NewLabel("")
playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() { playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() {
page.onPlayTrackAt(0) page.pm.LoadTracks(page.tracklist.Tracks, false, false)
page.pm.PlayFromBeginning()
}) })
// TODO: find way to pad shuffle svg rather than using a space in the label string // TODO: find way to pad shuffle svg rather than using a space in the label string
shuffleBtn := widget.NewButtonWithIcon(" Shuffle", res.ResShuffleInvertSvg, func() { shuffleBtn := widget.NewButtonWithIcon(" Shuffle", res.ResShuffleInvertSvg, func() {
+1 -1
View File
@@ -95,7 +95,7 @@ func (r Router) CreatePage(rte Route) Page {
case Artists: case Artists:
return NewArtistsGenresPage(false, r.App.ServerManager, r.OpenRoute) return NewArtistsGenresPage(false, r.App.ServerManager, r.OpenRoute)
case Favorites: case Favorites:
return NewFavoritesPage(r.App.ServerManager, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute) return NewFavoritesPage(&r.App.Config.FavoritesPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute)
case Genre: case Genre:
return NewGenrePage(rte.Arg, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute) return NewGenrePage(rte.Arg, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute)
case Genres: case Genres:
+28
View File
@@ -6,12 +6,14 @@ import (
"supersonic/backend" "supersonic/backend"
"supersonic/ui/dialogs" "supersonic/ui/dialogs"
"supersonic/ui/util" "supersonic/ui/util"
"supersonic/ui/widgets"
"time" "time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type Controller struct { type Controller struct {
@@ -40,6 +42,32 @@ func (m Controller) ShowPopUpImage(img image.Image) {
)) ))
} }
func (m Controller) ConnectTracklistActions(tracklist *widgets.Tracklist) {
tracklist.OnAddToPlaylist = m.DoAddTracksToPlaylistWorkflow
tracklist.OnAddToQueue = func(tracks []*subsonic.Child) {
m.App.PlaybackManager.LoadTracks(tracks, true, false)
}
tracklist.OnPlayTrackAt = func(idx int) {
m.App.PlaybackManager.LoadTracks(tracklist.Tracks, false, false)
m.App.PlaybackManager.PlayTrackAt(idx)
}
tracklist.OnPlaySelection = func(tracks []*subsonic.Child) {
m.App.PlaybackManager.LoadTracks(tracks, false, false)
m.App.PlaybackManager.PlayFromBeginning()
}
tracklist.OnSetFavorite = func(trackIDs []string, fav bool) {
s := m.App.ServerManager.Server
if fav {
go s.Star(subsonic.StarParameters{SongIDs: trackIDs})
} else {
go s.Unstar(subsonic.StarParameters{SongIDs: trackIDs})
}
for _, id := range trackIDs {
m.App.PlaybackManager.OnTrackFavoriteStatusChanged(id, fav)
}
}
}
func (m Controller) PromptForFirstServer() { func (m Controller) PromptForFirstServer() {
d := dialogs.NewAddEditServerDialog("Connect to Server", nil) d := dialogs.NewAddEditServerDialog("Connect to Server", nil)
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
+58
View File
@@ -0,0 +1,58 @@
package layouts
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
)
var _ fyne.Layout = (*VboxCustomPadding)(nil)
type HboxCustomPadding struct {
ExtraPad float32
DisableThemePad bool
}
func (v *HboxCustomPadding) MinSize(objects []fyne.CanvasObject) fyne.Size {
minSize := fyne.NewSize(0, 0)
for _, child := range objects {
if !child.Visible() {
continue
}
minSize.Height = fyne.Max(child.MinSize().Height, minSize.Height)
minSize.Width += child.MinSize().Width
}
minSize.Width += (v.themePad() + v.ExtraPad) * float32(len(objects)-1)
return minSize
}
func (v *HboxCustomPadding) Layout(objects []fyne.CanvasObject, size fyne.Size) {
total := float32(0)
for _, child := range objects {
if !child.Visible() {
continue
}
total += child.MinSize().Width
}
x, y := float32(0), float32(0)
extra := float32(0)
for _, child := range objects {
if !child.Visible() {
continue
}
width := child.MinSize().Width
child.Move(fyne.NewPos(x+extra, y))
x += width
child.Resize(fyne.NewSize(width, size.Height))
extra += (v.themePad() + v.ExtraPad)
}
}
func (v *HboxCustomPadding) themePad() float32 {
if v.DisableThemePad {
return 0
}
return theme.Padding()
}
+2 -30
View File
@@ -3,7 +3,6 @@ package widgets
import ( import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
@@ -56,37 +55,10 @@ func (v *volumeSlider) MinSize() fyne.Size {
return fyne.NewSize(v.Width, h) return fyne.NewSize(v.Width, h)
} }
type tappableIcon struct {
widget.Icon
OnTapped func()
}
func newTappableIcon(res fyne.Resource) *tappableIcon {
icon := &tappableIcon{}
icon.ExtendBaseWidget(icon)
icon.SetResource(res)
return icon
}
func (t *tappableIcon) Tapped(_ *fyne.PointEvent) {
if t.OnTapped != nil {
t.OnTapped()
}
}
func (t *tappableIcon) TappedSecondary(_ *fyne.PointEvent) {
}
func (t *tappableIcon) Cursor() desktop.Cursor {
return desktop.PointerCursor
}
type VolumeControl struct { type VolumeControl struct {
widget.BaseWidget widget.BaseWidget
icon *tappableIcon icon *TappableIcon
slider *volumeSlider slider *volumeSlider
OnVolumeChanged func(int) OnVolumeChanged func(int)
@@ -100,7 +72,7 @@ type VolumeControl struct {
func NewVolumeControl(initialVol int) *VolumeControl { func NewVolumeControl(initialVol int) *VolumeControl {
v := &VolumeControl{} v := &VolumeControl{}
v.ExtendBaseWidget(v) v.ExtendBaseWidget(v)
v.icon = newTappableIcon(theme.VolumeUpIcon()) v.icon = NewTappbaleIcon(theme.VolumeUpIcon())
v.icon.OnTapped = v.toggleMute v.icon.OnTapped = v.toggleMute
v.slider = NewVolumeSlider(100) v.slider = NewVolumeSlider(100)
v.lastVol = initialVol v.lastVol = initialVol
+34
View File
@@ -0,0 +1,34 @@
package widgets
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
)
type TappableIcon struct {
widget.Icon
OnTapped func()
}
func NewTappbaleIcon(res fyne.Resource) *TappableIcon {
icon := &TappableIcon{}
icon.ExtendBaseWidget(icon)
icon.SetResource(res)
return icon
}
func (t *TappableIcon) Tapped(_ *fyne.PointEvent) {
if t.OnTapped != nil {
t.OnTapped()
}
}
func (t *TappableIcon) TappedSecondary(_ *fyne.PointEvent) {
}
func (t *TappableIcon) Cursor() desktop.Cursor {
return desktop.PointerCursor
}
+69
View File
@@ -0,0 +1,69 @@
package widgets
import (
"supersonic/ui/layouts"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
type ToggleButtonGroup struct {
widget.BaseWidget
buttonContainer *fyne.Container
activeBtnIdx int
}
func NewToggleButtonGroup(activatedBtnIdx int, buttons ...*widget.Button) *ToggleButtonGroup {
t := &ToggleButtonGroup{}
t.ExtendBaseWidget(t)
t.buttonContainer = container.New(&layouts.HboxCustomPadding{DisableThemePad: true})
for i, b := range buttons {
b.Importance = widget.MediumImportance
t.buttonContainer.Add(b)
prevOnTapped := b.OnTapped
b.OnTapped = func(i int) func() {
return func() {
if t.onTapped(i) && prevOnTapped != nil {
prevOnTapped()
}
}
}(i)
}
if activatedBtnIdx >= 0 && activatedBtnIdx <= len(buttons) {
buttons[activatedBtnIdx].Importance = widget.HighImportance
}
return t
}
func (t *ToggleButtonGroup) ActivatedButtonIndex() int {
return t.activeBtnIdx
}
func (t *ToggleButtonGroup) SetActivatedButton(idx int) {
changed := t.activeBtnIdx != idx
t.activeBtnIdx = idx
for i, b := range t.buttonContainer.Objects {
if i == idx {
b.(*widget.Button).Importance = widget.HighImportance
} else {
b.(*widget.Button).Importance = widget.MediumImportance
}
}
if changed {
t.Refresh()
}
}
func (t *ToggleButtonGroup) onTapped(btnIdx int) bool {
changed := t.activeBtnIdx != btnIdx
t.SetActivatedButton(btnIdx)
return changed
}
func (t *ToggleButtonGroup) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(t.buttonContainer)
}
+115 -44
View File
@@ -4,6 +4,8 @@ import (
"log" "log"
"runtime" "runtime"
"strconv" "strconv"
"supersonic/res"
"supersonic/sharedutil"
"supersonic/ui/layouts" "supersonic/ui/layouts"
"supersonic/ui/os" "supersonic/ui/os"
"supersonic/ui/util" "supersonic/ui/util"
@@ -19,12 +21,13 @@ import (
) )
const ( const (
ColumnArtist = "Artist" ColumnArtist = "Artist"
ColumnAlbum = "Album" ColumnAlbum = "Album"
ColumnTime = "Time" ColumnTime = "Time"
ColumnYear = "Year" ColumnYear = "Year"
ColumnPlays = "Plays" ColumnFavorite = "Favorite"
ColumnBitrate = "Bitrate" ColumnPlays = "Plays"
ColumnBitrate = "Bitrate"
) )
type Tracklist struct { type Tracklist struct {
@@ -41,6 +44,7 @@ type Tracklist struct {
OnPlaySelection func(tracks []*subsonic.Child) OnPlaySelection func(tracks []*subsonic.Child)
OnAddToQueue func(trackIDs []*subsonic.Child) OnAddToQueue func(trackIDs []*subsonic.Child)
OnAddToPlaylist func(trackIDs []string) OnAddToPlaylist func(trackIDs []string)
OnSetFavorite func(trackIDs []string, fav bool)
visibleColumns []bool visibleColumns []bool
@@ -54,11 +58,12 @@ type Tracklist struct {
} }
func NewTracklist(tracks []*subsonic.Child) *Tracklist { func NewTracklist(tracks []*subsonic.Child) *Tracklist {
t := &Tracklist{Tracks: tracks, nowPlayingIdx: -1, visibleColumns: make([]bool, 8)} t := &Tracklist{Tracks: tracks, nowPlayingIdx: -1, visibleColumns: make([]bool, 9)}
t.ExtendBaseWidget(t) t.ExtendBaseWidget(t)
t.selectionMgr = util.NewListSelectionManager(func() int { return len(t.Tracks) }) t.selectionMgr = util.NewListSelectionManager(func() int { return len(t.Tracks) })
t.colLayout = layouts.NewColumnsLayout([]float32{35, -1, -1, -1, 60, 60, 65, 75}) // #, Title, Artist, Album, Time, Year, Favorite, Plays, Bitrate
t.colLayout = layouts.NewColumnsLayout([]float32{35, -1, -1, -1, 60, 60, 47, 65, 75})
t.buildHeader() t.buildHeader()
t.hdr.OnColumnVisibilityChanged = t.setColumnVisible t.hdr.OnColumnVisibilityChanged = t.setColumnVisible
playingIcon := container.NewCenter(container.NewHBox(NewHSpace(2), widget.NewIcon(theme.MediaPlayIcon()))) playingIcon := container.NewCenter(container.NewHBox(NewHSpace(2), widget.NewIcon(theme.MediaPlayIcon())))
@@ -93,6 +98,7 @@ func (t *Tracklist) buildHeader() {
{Text: "Album", AlignTrailing: false, CanToggleVisible: true}, {Text: "Album", AlignTrailing: false, CanToggleVisible: true},
{Text: "Time", AlignTrailing: true, CanToggleVisible: true}, {Text: "Time", AlignTrailing: true, CanToggleVisible: true},
{Text: "Year", AlignTrailing: true, CanToggleVisible: true}, {Text: "Year", AlignTrailing: true, CanToggleVisible: true},
{Text: "Fav.", AlignTrailing: false, CanToggleVisible: true},
{Text: "Plays", AlignTrailing: true, CanToggleVisible: true}, {Text: "Plays", AlignTrailing: true, CanToggleVisible: true},
{Text: "Bitrate", AlignTrailing: true, CanToggleVisible: true}}, {Text: "Bitrate", AlignTrailing: true, CanToggleVisible: true}},
t.colLayout) t.colLayout)
@@ -142,19 +148,13 @@ func (t *Tracklist) SetNowPlaying(trackID string) {
break break
} }
} }
t.list.Refresh() t.Refresh()
} }
func (t *Tracklist) IncrementPlayCount(track *subsonic.Child) { func (t *Tracklist) IncrementPlayCount(trackID string) {
if track == nil { if tr := sharedutil.FindTrackByID(trackID, t.Tracks); tr != nil {
return tr.PlayCount += 1
} t.Refresh()
for _, tr := range t.Tracks {
if tr.ID == track.ID {
tr.PlayCount += 1
t.Refresh()
return
}
} }
} }
@@ -239,6 +239,20 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) {
widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition) widget.ShowPopUpMenuAtPosition(t.ctxMenu, fyne.CurrentApp().Driver().CanvasForObject(t), e.AbsolutePosition)
} }
func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
// update our own track model
tr := sharedutil.FindTrackByID(trackID, t.Tracks)
if fav {
tr.Starred = time.Now()
} else {
tr.Starred = time.Time{}
}
// notify listener
if t.OnSetFavorite != nil {
t.OnSetFavorite([]string{trackID}, fav)
}
}
func (t *Tracklist) selectedTracks() []*subsonic.Child { func (t *Tracklist) selectedTracks() []*subsonic.Child {
sel := t.selectionMgr.GetSelection() sel := t.selectionMgr.GetSelection()
tracks := make([]*subsonic.Child, 0, len(sel)) tracks := make([]*subsonic.Child, 0, len(sel))
@@ -272,10 +286,12 @@ func ColNumber(colName string) int {
return 4 return 4
case ColumnYear: case ColumnYear:
return 5 return 5
case ColumnPlays: case ColumnFavorite:
return 6 return 6
case ColumnBitrate: case ColumnPlays:
return 7 return 7
case ColumnBitrate:
return 8
default: default:
log.Printf("error: Tracklist: invalid column name %s", colName) log.Printf("error: Tracklist: invalid column name %s", colName)
return -100 return -100
@@ -294,8 +310,10 @@ func colName(i int) string {
case 5: case 5:
return ColumnYear return ColumnYear
case 6: case 6:
return ColumnPlays return ColumnFavorite
case 7: case 7:
return ColumnPlays
case 8:
return ColumnBitrate return ColumnBitrate
default: default:
return "" return ""
@@ -306,21 +324,24 @@ type TrackRow struct {
widget.BaseWidget widget.BaseWidget
// internal state // internal state
tracklist *Tracklist tracklist *Tracklist
trackIdx int trackIdx int
trackID string trackNum int
isPlaying bool trackID string
playCount int64 isPlaying bool
tappedAt int64 // unixMillis isFavorite bool
playCount int64
tappedAt int64 // unixMillis
num *widget.RichText num *widget.RichText
name *widget.RichText name *widget.RichText
artist *widget.RichText artist *widget.RichText
album *widget.RichText album *widget.RichText
dur *widget.RichText dur *widget.RichText
year *widget.RichText year *widget.RichText
bitrate *widget.RichText favorite *fyne.Container
plays *widget.RichText bitrate *widget.RichText
plays *widget.RichText
OnTapped func() OnTapped func()
OnDoubleTapped func() OnDoubleTapped func()
@@ -346,6 +367,9 @@ func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow
t.dur.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing t.dur.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing
t.year = widget.NewRichTextWithText("") t.year = widget.NewRichTextWithText("")
t.year.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing t.year.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing
favorite := NewTappbaleIcon(res.ResHeartOutlineInvertPng)
favorite.OnTapped = t.toggleFavorited
t.favorite = container.NewCenter(favorite)
t.plays = widget.NewRichTextWithText("") t.plays = widget.NewRichTextWithText("")
t.plays.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing t.plays.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing
t.bitrate = widget.NewRichTextWithText("") t.bitrate = widget.NewRichTextWithText("")
@@ -355,20 +379,16 @@ func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow
t.selectionRect.Hidden = true t.selectionRect.Hidden = true
t.container = container.NewMax(t.selectionRect, t.container = container.NewMax(t.selectionRect,
container.New(tracklist.colLayout, container.New(tracklist.colLayout,
t.num, t.name, t.artist, t.album, t.dur, t.year, t.plays, t.bitrate)) t.num, t.name, t.artist, t.album, t.dur, t.year, t.favorite, t.plays, t.bitrate))
return t return t
} }
func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) {
if tr.ID != t.trackID || isPlaying != t.isPlaying || tr.PlayCount != t.playCount { // Update info that can change if this row is bound to
t.isPlaying = isPlaying // a new track (*subsonic.Child)
if tr.ID != t.trackID {
t.trackID = tr.ID t.trackID = tr.ID
t.playCount = tr.PlayCount
if rowNum < 0 {
rowNum = tr.Track
}
t.num.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(rowNum)
t.name.Segments[0].(*widget.TextSegment).Text = tr.Title t.name.Segments[0].(*widget.TextSegment).Text = tr.Title
t.artist.Segments[0].(*widget.TextSegment).Text = tr.Artist t.artist.Segments[0].(*widget.TextSegment).Text = tr.Artist
t.album.Segments[0].(*widget.TextSegment).Text = tr.Album t.album.Segments[0].(*widget.TextSegment).Text = tr.Album
@@ -376,7 +396,27 @@ func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) {
t.year.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.Year) t.year.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.Year)
t.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount)) t.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount))
t.bitrate.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.BitRate) t.bitrate.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.BitRate)
}
// Update track num if needed
// (which can change based on bound *subsonic.Child or tracklist.AutoNumber)
if t.trackNum != rowNum {
if rowNum < 0 {
rowNum = tr.Track
}
t.trackNum = rowNum
t.num.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(rowNum)
}
// Update play count if needed
if tr.PlayCount != t.playCount {
t.playCount = tr.PlayCount
t.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount))
}
// Render whether track is playing or not
if isPlaying != t.isPlaying {
t.isPlaying = isPlaying
t.name.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying t.name.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying
t.artist.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying t.artist.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying
t.album.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying t.album.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying
@@ -392,16 +432,47 @@ func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) {
} }
} }
// Render favorite column
// TODO: right now the only way for the favorite status to change while the tracklist is visible
// is by the user clicking on the heart icon in the favorites column
// If this changes in the future (e.g. context menu entry on tracklist), then we will
// need better state management/onChanged notif so we know to re-render the column
// (maybe update the Starred field directly on the track struct and issue a Refresh call -
// like we do to update the now playing value when scrobbles happen)
if tr.Starred.IsZero() {
t.isFavorite = false
t.favorite.Objects[0].(*TappableIcon).Resource = res.ResHeartOutlineInvertPng
} else {
t.isFavorite = true
t.favorite.Objects[0].(*TappableIcon).Resource = res.ResHeartFilledInvertPng
}
// Show only columns configured to be visible
t.artist.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnArtist)] t.artist.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnArtist)]
t.album.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnAlbum)] t.album.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnAlbum)]
t.dur.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnTime)] t.dur.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnTime)]
t.year.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnYear)] t.year.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnYear)]
t.favorite.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnFavorite)]
t.plays.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnPlays)] t.plays.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnPlays)]
t.bitrate.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnBitrate)] t.bitrate.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnBitrate)]
t.Refresh() t.Refresh()
} }
func (t *TrackRow) toggleFavorited() {
if t.isFavorite {
t.favorite.Objects[0].(*TappableIcon).Resource = res.ResHeartOutlineInvertPng
t.favorite.Refresh()
t.isFavorite = false
t.tracklist.onSetFavorite(t.trackID, false)
} else {
t.favorite.Objects[0].(*TappableIcon).Resource = res.ResHeartFilledInvertPng
t.favorite.Refresh()
t.isFavorite = true
t.tracklist.onSetFavorite(t.trackID, true)
}
}
func (t *TrackRow) CreateRenderer() fyne.WidgetRenderer { func (t *TrackRow) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(t.container) return widget.NewSimpleRenderer(t.container)
} }