diff --git a/README.md b/README.md index aa29dbc..024ea4f 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Slightly outdated screenshots of Supersonic running against the Navidrome ScrobbleThreshold { log.Printf("Scrobbling %q", song.Title) + song.PlayCount += 1 p.lastScrobbled = song p.sm.Server.Scrobble(song.ID, map[string]string{"time": strconv.FormatInt(time.Now().Unix()*1000, 10)}) } diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go new file mode 100644 index 0000000..158aaa4 --- /dev/null +++ b/sharedutil/sharedutil.go @@ -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 +} diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index c5a58f9..76d44ab 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -5,6 +5,7 @@ import ( "log" "supersonic/backend" "supersonic/res" + "supersonic/sharedutil" "supersonic/ui/controller" "supersonic/ui/layouts" "supersonic/ui/util" @@ -66,14 +67,7 @@ func NewAlbumPage( a.header = NewAlbumPageHeader(a) a.tracklist = widgets.NewTracklist(nil) a.tracklist.SetVisibleColumns(a.cfg.TracklistColumns) - // connect tracklist actions - 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.contr.ConnectTracklistActions(a.tracklist) a.container = container.NewBorder( 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.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(lastScrobbledIfAny) + a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) } func (a *AlbumPage) Reload() { @@ -122,11 +116,6 @@ func (a *AlbumPage) 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 func (a *AlbumPage) load() { album, err := a.lm.GetAlbum(a.albumID) @@ -185,7 +174,7 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader { } a.miscLabel = widget.NewLabel("") playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() { - page.onPlayTrackAt(0) + go page.pm.PlayAlbum(page.albumID, 0) }) shuffleBtn := widget.NewButtonWithIcon(" Shuffle", res.ResShuffleInvertSvg, func() { page.pm.LoadTracks(page.tracklist.Tracks, false, true) diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 3f2d119..b1c291c 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -124,6 +124,7 @@ type ArtistPageHeader struct { titleDisp *widget.RichText biographyDisp *widget.RichText similarArtists *fyne.Container + favoriteBtn *widgets.FavoriteButton container *fyne.Container } @@ -139,6 +140,7 @@ func NewArtistPageHeader(page *ArtistPage, nav func(Route)) *ArtistPageHeader { SizeName: theme.SizeNameHeadingText, } a.artistImage = widgets.NewImagePlaceholder(res.ResPeopleInvertPng, 225) + a.favoriteBtn = widgets.NewFavoriteButton(func() { go a.toggleFavorited() }) a.biographyDisp.Wrapping = fyne.TextWrapWord a.ExtendBaseWidget(a) a.createContainer() @@ -149,6 +151,7 @@ func (a *ArtistPageHeader) Update(artist *subsonic.ArtistID3) { if artist == nil { return } + a.favoriteBtn.IsFavorited = !artist.Starred.IsZero() a.artistID = artist.ID a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name 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() { 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 { diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 9d015c1..b40eb74 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -1,7 +1,11 @@ package browsing import ( + "log" "supersonic/backend" + "supersonic/res" + "supersonic/ui/controller" + "supersonic/ui/layouts" "supersonic/ui/widgets" "time" @@ -16,45 +20,71 @@ import ( type FavoritesPage struct { widget.BaseWidget - pm *backend.PlaybackManager - im *backend.ImageManager - sm *backend.ServerManager - lm *backend.LibraryManager - nav func(Route) - grid *widgets.AlbumGrid - searchGrid *widgets.AlbumGrid - searcher *widgets.Searcher - searchText string - titleDisp *widget.RichText - container *fyne.Container + cfg *backend.FavoritesPageConfig + contr controller.Controller + pm *backend.PlaybackManager + im *backend.ImageManager + sm *backend.ServerManager + lm *backend.LibraryManager + nav func(Route) + + searchText string + nowPlayingID string + 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{ - pm: pm, - lm: lm, - sm: sm, - im: im, - nav: nav, + cfg: cfg, + contr: contr, + pm: pm, + lm: lm, + sm: sm, + im: im, + nav: nav, } a.ExtendBaseWidget(a) - a.createTitle() - iter := lm.StarredIter() - a.grid = widgets.NewAlbumGrid(iter, im, false) - 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.createHeader(0, "") + a.grid = widgets.NewAlbumGrid(a.lm.StarredIter(), a.im, false) + a.connectGridActions() 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 } -func (a *FavoritesPage) createTitle() { +func (a *FavoritesPage) createHeader(activeBtnIdx int, searchText string) { a.titleDisp = widget.NewRichTextWithText("Favorites") a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ 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) { @@ -64,31 +94,34 @@ func (a *FavoritesPage) createContainer(searchGrid bool) { gr = a.searchGrid } 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) } func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage { a := &FavoritesPage{ - pm: saved.pm, - lm: saved.lm, - sm: saved.sm, - im: saved.im, - nav: saved.nav, + cfg: saved.cfg, + contr: saved.contr, + pm: saved.pm, + lm: saved.lm, + sm: saved.sm, + im: saved.im, + nav: saved.nav, } a.ExtendBaseWidget(a) - a.createTitle() + a.createHeader(saved.activeToggleBtn, saved.searchText) a.grid = widgets.NewAlbumGridFromState(saved.gridState) - 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.searcher.Entry.Text = saved.searchText + a.connectGridActions() + if saved.searchText != "" { a.searchGrid = widgets.NewAlbumGridFromState(saved.searchGridState) } a.createContainer(saved.searchText != "") + if saved.activeToggleBtn == 1 { + a.onShowFavoriteArtists() + } else if saved.activeToggleBtn == 2 { + a.onShowFavoriteSongs() + } return a } @@ -98,22 +131,58 @@ func (a *FavoritesPage) Route() Route { } func (a *FavoritesPage) Reload() { + // reload favorite albums view if a.searchText != "" { - a.doSearch(a.searchText) + a.doSearchAlbums(a.searchText) } else { 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 { + if a.tracklistCtr != nil { + tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist) + a.cfg.TracklistColumns = tl.VisibleColumns() + } sf := &savedFavoritesPage{ - pm: a.pm, - sm: a.sm, - im: a.im, - lm: a.lm, - nav: a.nav, - searchText: a.searchText, - gridState: a.grid.SaveToState(), + cfg: a.cfg, + contr: a.contr, + pm: a.pm, + sm: a.sm, + im: a.im, + lm: a.lm, + nav: a.nav, + searchText: a.searchText, + gridState: a.grid.SaveToState(), + activeToggleBtn: a.toggleBtns.ActivatedButtonIndex(), } if a.searchGrid != nil { sf.searchGridState = a.searchGrid.SaveToState() @@ -137,10 +206,22 @@ func (a *FavoritesPage) OnSearched(query string) { a.Refresh() 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 { return al.Starred.After(time.Time{}) }) @@ -156,6 +237,94 @@ func (a *FavoritesPage) doSearch(query string) { 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) { go a.pm.PlayAlbum(albumID, 0) } @@ -174,6 +343,8 @@ func (a *FavoritesPage) CreateRenderer() fyne.WidgetRenderer { } type savedFavoritesPage struct { + cfg *backend.FavoritesPageConfig + contr controller.Controller pm *backend.PlaybackManager sm *backend.ServerManager im *backend.ImageManager @@ -181,6 +352,7 @@ type savedFavoritesPage struct { gridState widgets.AlbumGridState searchGridState widgets.AlbumGridState searchText string + activeToggleBtn int nav func(Route) } diff --git a/ui/browsing/nowplayingpage.go b/ui/browsing/nowplayingpage.go index 827b334..a66131c 100644 --- a/ui/browsing/nowplayingpage.go +++ b/ui/browsing/nowplayingpage.go @@ -2,6 +2,7 @@ package browsing import ( "supersonic/backend" + "supersonic/sharedutil" "supersonic/ui/controller" "supersonic/ui/layouts" "supersonic/ui/widgets" @@ -44,8 +45,9 @@ func NewNowPlayingPage( a.tracklist.SetVisibleColumns(conf.TracklistColumns) a.tracklist.AutoNumber = 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.OnAddToPlaylist = a.contr.DoAddTracksToPlaylistWorkflow a.tracklist.AuxiliaryMenuItems = []*fyne.MenuItem{ fyne.NewMenuItem("Remove from queue", a.onRemoveSelectedFromQueue), } @@ -86,7 +88,7 @@ func (a *NowPlayingPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny * a.nowPlayingID = song.ID } a.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(lastScrobbledIfAny) + a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) } func (a *NowPlayingPage) Reload() { diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 56ef385..bfd3cc3 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -5,6 +5,7 @@ import ( "log" "supersonic/backend" "supersonic/res" + "supersonic/sharedutil" "supersonic/ui/controller" "supersonic/ui/layouts" "supersonic/ui/util" @@ -57,13 +58,7 @@ func NewPlaylistPage( fyne.NewMenuItem("Remove from playlist", a.onRemoveSelectedFromPlaylist), } // connect tracklist actions - 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.contr.ConnectTracklistActions(a.tracklist) a.container = container.NewBorder( 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.tracklist.SetNowPlaying(a.nowPlayingID) - a.tracklist.IncrementPlayCount(lastScrobbledIfAny) + a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) } func (a *PlaylistPage) Reload() { @@ -108,11 +103,6 @@ func (a *PlaylistPage) 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 func (a *PlaylistPage) load() { playlist, err := a.sm.Server.GetPlaylist(a.playlistID) @@ -163,7 +153,8 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a.createdAtLabel = widget.NewLabel("") a.trackTimeLabel = widget.NewLabel("") 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 shuffleBtn := widget.NewButtonWithIcon(" Shuffle", res.ResShuffleInvertSvg, func() { diff --git a/ui/browsing/router.go b/ui/browsing/router.go index d9a5c6b..beeeb79 100644 --- a/ui/browsing/router.go +++ b/ui/browsing/router.go @@ -95,7 +95,7 @@ func (r Router) CreatePage(rte Route) Page { case Artists: return NewArtistsGenresPage(false, r.App.ServerManager, r.OpenRoute) 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: return NewGenrePage(rte.Arg, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager, r.OpenRoute) case Genres: diff --git a/ui/controller/controller.go b/ui/controller/controller.go index c226ad7..be192bc 100644 --- a/ui/controller/controller.go +++ b/ui/controller/controller.go @@ -6,12 +6,14 @@ import ( "supersonic/backend" "supersonic/ui/dialogs" "supersonic/ui/util" + "supersonic/ui/widgets" "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" + "github.com/dweymouth/go-subsonic/subsonic" ) 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() { d := dialogs.NewAddEditServerDialog("Connect to Server", nil) pop := widget.NewModalPopUp(d, m.MainWindow.Canvas()) diff --git a/ui/layouts/hboxcustompadding.go b/ui/layouts/hboxcustompadding.go new file mode 100644 index 0000000..cedac3c --- /dev/null +++ b/ui/layouts/hboxcustompadding.go @@ -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() +} diff --git a/ui/widgets/auxcontrols.go b/ui/widgets/auxcontrols.go index 1168a8e..41ebc0a 100644 --- a/ui/widgets/auxcontrols.go +++ b/ui/widgets/auxcontrols.go @@ -3,7 +3,6 @@ package widgets import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" @@ -56,37 +55,10 @@ func (v *volumeSlider) MinSize() fyne.Size { 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 { widget.BaseWidget - icon *tappableIcon + icon *TappableIcon slider *volumeSlider OnVolumeChanged func(int) @@ -100,7 +72,7 @@ type VolumeControl struct { func NewVolumeControl(initialVol int) *VolumeControl { v := &VolumeControl{} v.ExtendBaseWidget(v) - v.icon = newTappableIcon(theme.VolumeUpIcon()) + v.icon = NewTappbaleIcon(theme.VolumeUpIcon()) v.icon.OnTapped = v.toggleMute v.slider = NewVolumeSlider(100) v.lastVol = initialVol diff --git a/ui/widgets/tappableicon.go b/ui/widgets/tappableicon.go new file mode 100644 index 0000000..4ca70f3 --- /dev/null +++ b/ui/widgets/tappableicon.go @@ -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 +} diff --git a/ui/widgets/togglebuttongroup.go b/ui/widgets/togglebuttongroup.go new file mode 100644 index 0000000..bf81d0e --- /dev/null +++ b/ui/widgets/togglebuttongroup.go @@ -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) +} diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index d85ed56..8041e50 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -4,6 +4,8 @@ import ( "log" "runtime" "strconv" + "supersonic/res" + "supersonic/sharedutil" "supersonic/ui/layouts" "supersonic/ui/os" "supersonic/ui/util" @@ -19,12 +21,13 @@ import ( ) const ( - ColumnArtist = "Artist" - ColumnAlbum = "Album" - ColumnTime = "Time" - ColumnYear = "Year" - ColumnPlays = "Plays" - ColumnBitrate = "Bitrate" + ColumnArtist = "Artist" + ColumnAlbum = "Album" + ColumnTime = "Time" + ColumnYear = "Year" + ColumnFavorite = "Favorite" + ColumnPlays = "Plays" + ColumnBitrate = "Bitrate" ) type Tracklist struct { @@ -41,6 +44,7 @@ type Tracklist struct { OnPlaySelection func(tracks []*subsonic.Child) OnAddToQueue func(trackIDs []*subsonic.Child) OnAddToPlaylist func(trackIDs []string) + OnSetFavorite func(trackIDs []string, fav bool) visibleColumns []bool @@ -54,11 +58,12 @@ type Tracklist struct { } 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.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.hdr.OnColumnVisibilityChanged = t.setColumnVisible 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: "Time", AlignTrailing: true, CanToggleVisible: true}, {Text: "Year", AlignTrailing: true, CanToggleVisible: true}, + {Text: "Fav.", AlignTrailing: false, CanToggleVisible: true}, {Text: "Plays", AlignTrailing: true, CanToggleVisible: true}, {Text: "Bitrate", AlignTrailing: true, CanToggleVisible: true}}, t.colLayout) @@ -142,19 +148,13 @@ func (t *Tracklist) SetNowPlaying(trackID string) { break } } - t.list.Refresh() + t.Refresh() } -func (t *Tracklist) IncrementPlayCount(track *subsonic.Child) { - if track == nil { - return - } - for _, tr := range t.Tracks { - if tr.ID == track.ID { - tr.PlayCount += 1 - t.Refresh() - return - } +func (t *Tracklist) IncrementPlayCount(trackID string) { + if tr := sharedutil.FindTrackByID(trackID, t.Tracks); tr != nil { + tr.PlayCount += 1 + t.Refresh() } } @@ -239,6 +239,20 @@ func (t *Tracklist) onShowContextMenu(e *fyne.PointEvent, trackIdx int) { 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 { sel := t.selectionMgr.GetSelection() tracks := make([]*subsonic.Child, 0, len(sel)) @@ -272,10 +286,12 @@ func ColNumber(colName string) int { return 4 case ColumnYear: return 5 - case ColumnPlays: + case ColumnFavorite: return 6 - case ColumnBitrate: + case ColumnPlays: return 7 + case ColumnBitrate: + return 8 default: log.Printf("error: Tracklist: invalid column name %s", colName) return -100 @@ -294,8 +310,10 @@ func colName(i int) string { case 5: return ColumnYear case 6: - return ColumnPlays + return ColumnFavorite case 7: + return ColumnPlays + case 8: return ColumnBitrate default: return "" @@ -306,21 +324,24 @@ type TrackRow struct { widget.BaseWidget // internal state - tracklist *Tracklist - trackIdx int - trackID string - isPlaying bool - playCount int64 - tappedAt int64 // unixMillis + tracklist *Tracklist + trackIdx int + trackNum int + trackID string + isPlaying bool + isFavorite bool + playCount int64 + tappedAt int64 // unixMillis - num *widget.RichText - name *widget.RichText - artist *widget.RichText - album *widget.RichText - dur *widget.RichText - year *widget.RichText - bitrate *widget.RichText - plays *widget.RichText + num *widget.RichText + name *widget.RichText + artist *widget.RichText + album *widget.RichText + dur *widget.RichText + year *widget.RichText + favorite *fyne.Container + bitrate *widget.RichText + plays *widget.RichText OnTapped 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.year = widget.NewRichTextWithText("") 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.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing t.bitrate = widget.NewRichTextWithText("") @@ -355,20 +379,16 @@ func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow t.selectionRect.Hidden = true t.container = container.NewMax(t.selectionRect, 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 } func (t *TrackRow) Update(tr *subsonic.Child, isPlaying bool, rowNum int) { - if tr.ID != t.trackID || isPlaying != t.isPlaying || tr.PlayCount != t.playCount { - t.isPlaying = isPlaying + // Update info that can change if this row is bound to + // a new track (*subsonic.Child) + if tr.ID != t.trackID { 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.artist.Segments[0].(*widget.TextSegment).Text = tr.Artist 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.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount)) 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.artist.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.album.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnAlbum)] t.dur.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnTime)] 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.bitrate.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnBitrate)] 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 { return widget.NewSimpleRenderer(t.container) }