more refactoring - compiles now but not extensively tested
This commit is contained in:
+3
-5
@@ -25,7 +25,6 @@ type App struct {
|
||||
Config *Config
|
||||
ServerManager *ServerManager
|
||||
ImageManager *ImageManager
|
||||
LibraryManager *LibraryManager
|
||||
PlaybackManager *PlaybackManager
|
||||
Player *player.Player
|
||||
UpdateChecker UpdateChecker
|
||||
@@ -63,11 +62,10 @@ func StartupApp(appName, appVersionTag, configFile, latestReleaseURL string) (*A
|
||||
|
||||
a.ServerManager = NewServerManager(appName)
|
||||
a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling)
|
||||
a.LibraryManager = NewLibraryManager(a.ServerManager)
|
||||
a.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName))
|
||||
a.LibraryManager.PreCacheCoverFn = func(coverID string) {
|
||||
_, _ = a.ImageManager.GetCoverThumbnail(coverID)
|
||||
}
|
||||
//a.LibraryManager.PreCacheCoverFn = func(coverID string) {
|
||||
//_, _ = a.ImageManager.GetCoverThumbnail(coverID)
|
||||
//}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -124,7 +124,7 @@ func DefaultConfig(appVersionTag string) *Config {
|
||||
TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"},
|
||||
},
|
||||
AlbumsPage: AlbumsPageConfig{
|
||||
SortOrder: string(AlbumSortRecentlyAdded),
|
||||
SortOrder: string("Recently Added"),
|
||||
},
|
||||
ArtistPage: ArtistPageConfig{
|
||||
InitialView: "Discography",
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
subsonic "github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
type AlbumIterator interface {
|
||||
Next() *subsonic.AlbumID3
|
||||
}
|
||||
|
||||
type TrackIterator interface {
|
||||
Next() *subsonic.Child
|
||||
}
|
||||
|
||||
type LibraryManager struct {
|
||||
PreCacheCoverFn func(coverID string)
|
||||
|
||||
s *ServerManager
|
||||
}
|
||||
|
||||
func NewLibraryManager(s *ServerManager) *LibraryManager {
|
||||
return &LibraryManager{
|
||||
s: s,
|
||||
}
|
||||
}
|
||||
@@ -60,11 +60,13 @@ type MediaProvider interface {
|
||||
|
||||
GetStreamURL(trackID string) (string, error)
|
||||
|
||||
GetTopTracks(artist Artist, count int) ([]*Track, error)
|
||||
|
||||
SetFavorite(params RatingFavoriteParameters, favorite bool) error
|
||||
|
||||
SetRating(params RatingFavoriteParameters, rating int) error
|
||||
|
||||
GetPlaylists() ([]Playlist, error)
|
||||
GetPlaylists() ([]*Playlist, error)
|
||||
|
||||
CreatePlaylist(name string, trackIDs []string) error
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ type AlbumWithTracks struct {
|
||||
type Artist struct {
|
||||
ID string
|
||||
Name string
|
||||
Favorite bool
|
||||
AlbumCount int
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ type Playlist struct {
|
||||
Description string
|
||||
Public bool
|
||||
Owner string
|
||||
Duration int
|
||||
TrackCount int
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ func (s *subsonicMediaProvider) GetArtist(artistID string) (*mediaprovider.Artis
|
||||
Artist: mediaprovider.Artist{
|
||||
ID: ar.ID,
|
||||
Name: ar.Name,
|
||||
Favorite: !ar.Starred.IsZero(),
|
||||
AlbumCount: ar.AlbumCount,
|
||||
},
|
||||
Albums: sharedutil.MapSlice(ar.Album, toAlbum),
|
||||
@@ -168,12 +169,21 @@ func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.P
|
||||
return nil, err
|
||||
}
|
||||
return &mediaprovider.PlaylistWithTracks{
|
||||
Playlist: toPlaylist(pl),
|
||||
Playlist: mediaprovider.Playlist{
|
||||
ID: pl.ID,
|
||||
CoverArtID: pl.CoverArt,
|
||||
Name: pl.Name,
|
||||
Description: pl.Comment,
|
||||
TrackCount: pl.SongCount,
|
||||
Public: pl.Public,
|
||||
Owner: pl.Owner,
|
||||
Duration: pl.Duration,
|
||||
},
|
||||
Tracks: sharedutil.MapSlice(pl.Entry, toTrack),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetPlaylists() ([]mediaprovider.Playlist, error) {
|
||||
func (s *subsonicMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) {
|
||||
pl, err := s.client.GetPlaylists(map[string]string{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -209,6 +219,18 @@ func (s *subsonicMediaProvider) GetStreamURL(trackID string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) GetTopTracks(artist mediaprovider.Artist, count int) ([]*mediaprovider.Track, error) {
|
||||
params := map[string]string{}
|
||||
if count > 0 {
|
||||
params["count"] = strconv.Itoa(count)
|
||||
}
|
||||
tr, err := s.client.GetTopSongs(artist.Name, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sharedutil.MapSlice(tr, toTrack), nil
|
||||
}
|
||||
|
||||
func (s *subsonicMediaProvider) ReplacePlaylistTracks(playlistID string, trackIDs []string) error {
|
||||
return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"playlistId": playlistID})
|
||||
}
|
||||
@@ -321,6 +343,7 @@ func toArtist(ar *subsonic.Artist) *mediaprovider.Artist {
|
||||
return &mediaprovider.Artist{
|
||||
ID: ar.ID,
|
||||
Name: ar.Name,
|
||||
Favorite: !ar.Starred.IsZero(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,16 +354,20 @@ func toArtistFromID3(ar *subsonic.ArtistID3) *mediaprovider.Artist {
|
||||
return &mediaprovider.Artist{
|
||||
ID: ar.ID,
|
||||
Name: ar.Name,
|
||||
Favorite: !ar.Starred.IsZero(),
|
||||
AlbumCount: ar.AlbumCount,
|
||||
}
|
||||
}
|
||||
|
||||
func toPlaylist(pl *subsonic.Playlist) mediaprovider.Playlist {
|
||||
return mediaprovider.Playlist{
|
||||
func toPlaylist(pl *subsonic.Playlist) *mediaprovider.Playlist {
|
||||
return &mediaprovider.Playlist{
|
||||
Name: pl.Name,
|
||||
ID: pl.ID,
|
||||
CoverArtID: pl.CoverArt,
|
||||
Description: pl.Comment,
|
||||
Owner: pl.Owner,
|
||||
Public: pl.Public,
|
||||
TrackCount: pl.SongCount,
|
||||
Duration: pl.Duration,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,19 +34,17 @@ type AlbumPage struct {
|
||||
type albumPageState struct {
|
||||
albumID string
|
||||
cfg *backend.AlbumPageConfig
|
||||
lm *backend.LibraryManager
|
||||
mp mediaprovider.MediaProvider
|
||||
pm *backend.PlaybackManager
|
||||
im *backend.ImageManager
|
||||
sm *backend.ServerManager
|
||||
contr *controller.Controller
|
||||
}
|
||||
|
||||
func NewAlbumPage(
|
||||
albumID string,
|
||||
cfg *backend.AlbumPageConfig,
|
||||
sm *backend.ServerManager,
|
||||
pm *backend.PlaybackManager,
|
||||
lm *backend.LibraryManager,
|
||||
mp mediaprovider.MediaProvider,
|
||||
im *backend.ImageManager,
|
||||
contr *controller.Controller,
|
||||
) *AlbumPage {
|
||||
@@ -54,9 +52,8 @@ func NewAlbumPage(
|
||||
albumPageState: albumPageState{
|
||||
albumID: albumID,
|
||||
cfg: cfg,
|
||||
sm: sm,
|
||||
pm: pm,
|
||||
lm: lm,
|
||||
mp: mp,
|
||||
im: im,
|
||||
contr: contr,
|
||||
},
|
||||
@@ -115,7 +112,7 @@ func (a *AlbumPage) SelectAll() {
|
||||
|
||||
// should be called asynchronously
|
||||
func (a *AlbumPage) load() {
|
||||
album, err := a.sm.Server.GetAlbum(a.albumID)
|
||||
album, err := a.mp.GetAlbum(a.albumID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get album: %s", err.Error())
|
||||
return
|
||||
@@ -238,7 +235,7 @@ func (a *AlbumPageHeader) Update(album *mediaprovider.AlbumWithTracks, im *backe
|
||||
|
||||
func (a *AlbumPageHeader) toggleFavorited() {
|
||||
params := mediaprovider.RatingFavoriteParameters{AlbumIDs: []string{a.albumID}}
|
||||
a.page.sm.Server.SetFavorite(params, a.toggleFavButton.IsFavorited)
|
||||
a.page.mp.SetFavorite(params, a.toggleFavButton.IsFavorited)
|
||||
}
|
||||
|
||||
func (a *AlbumPageHeader) showPopUpCover() {
|
||||
@@ -263,5 +260,5 @@ func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
|
||||
}
|
||||
|
||||
func (s *albumPageState) Restore() Page {
|
||||
return NewAlbumPage(s.albumID, s.cfg, s.sm, s.pm, s.lm, s.im, s.contr)
|
||||
return NewAlbumPage(s.albumID, s.cfg, s.pm, s.mp, s.im, s.contr)
|
||||
}
|
||||
|
||||
+17
-16
@@ -24,7 +24,7 @@ type AlbumsPage struct {
|
||||
contr *controller.Controller
|
||||
pm *backend.PlaybackManager
|
||||
im *backend.ImageManager
|
||||
lm *backend.LibraryManager
|
||||
mp mediaprovider.MediaProvider
|
||||
grid *widgets.GridView
|
||||
searchGrid *widgets.GridView
|
||||
searcher *widgets.SearchEntry
|
||||
@@ -55,12 +55,12 @@ func (s *selectWidget) MinSize() fyne.Size {
|
||||
return fyne.NewSize(170, s.Select.MinSize().Height)
|
||||
}
|
||||
|
||||
func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *AlbumsPage {
|
||||
func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager) *AlbumsPage {
|
||||
a := &AlbumsPage{
|
||||
cfg: cfg,
|
||||
contr: contr,
|
||||
pm: pm,
|
||||
lm: lm,
|
||||
mp: mp,
|
||||
im: im,
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
@@ -69,12 +69,12 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
||||
SizeName: theme.SizeNameHeadingText,
|
||||
}
|
||||
a.sortOrder = NewSelect(backend.AlbumSortOrders, a.onSortOrderChanged)
|
||||
if !sharedutil.SliceContains(backend.AlbumSortOrders, cfg.SortOrder) {
|
||||
cfg.SortOrder = string(backend.AlbumSortRecentlyAdded)
|
||||
a.sortOrder = NewSelect(mp.AlbumSortOrders(), a.onSortOrderChanged)
|
||||
if !sharedutil.SliceContains(mp.AlbumSortOrders(), cfg.SortOrder) {
|
||||
cfg.SortOrder = string(mp.AlbumSortOrders()[0])
|
||||
}
|
||||
a.sortOrder.Selected = cfg.SortOrder
|
||||
iter := lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected), a.filter)
|
||||
iter := mp.IterateAlbums(a.sortOrder.Selected, "", a.filter)
|
||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
|
||||
contr.ConnectAlbumGridActions(a.grid)
|
||||
a.createSearchAndFilter()
|
||||
@@ -112,7 +112,7 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
|
||||
cfg: saved.cfg,
|
||||
contr: saved.contr,
|
||||
pm: saved.pm,
|
||||
lm: saved.lm,
|
||||
mp: saved.mp,
|
||||
im: saved.im,
|
||||
searchText: saved.searchText,
|
||||
filter: saved.filter,
|
||||
@@ -123,7 +123,7 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
||||
SizeName: theme.SizeNameHeadingText,
|
||||
}
|
||||
a.sortOrder = NewSelect(backend.AlbumSortOrders, nil)
|
||||
a.sortOrder = NewSelect(a.mp.AlbumSortOrders(), nil)
|
||||
a.sortOrder.Selected = saved.sortOrder
|
||||
a.sortOrder.OnChanged = a.onSortOrderChanged
|
||||
a.grid = widgets.NewGridViewFromState(saved.gridState)
|
||||
@@ -163,7 +163,7 @@ func (a *AlbumsPage) Reload() {
|
||||
if a.searchText != "" {
|
||||
a.doSearch(a.searchText)
|
||||
} else {
|
||||
iter := a.lm.AlbumsIter(backend.AlbumSortOrder(a.sortOrder.Selected), a.filter)
|
||||
iter := a.mp.IterateAlbums(a.sortOrder.Selected, "", a.filter)
|
||||
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
a.grid.Refresh()
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (a *AlbumsPage) Save() SavedPage {
|
||||
cfg: a.cfg,
|
||||
contr: a.contr,
|
||||
pm: a.pm,
|
||||
lm: a.lm,
|
||||
mp: a.mp,
|
||||
im: a.im,
|
||||
searchText: a.searchText,
|
||||
filter: a.filter,
|
||||
@@ -188,11 +188,12 @@ func (a *AlbumsPage) Save() SavedPage {
|
||||
}
|
||||
|
||||
func (a *AlbumsPage) doSearch(query string) {
|
||||
iter := widgets.NewGridViewAlbumIterator(a.mp.IterateAlbums("", query, a.filter))
|
||||
if a.searchGrid == nil {
|
||||
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(a.lm.SearchIter(query)), a.im)
|
||||
a.searchGrid = widgets.NewGridView(iter, a.im)
|
||||
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
||||
} else {
|
||||
a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(a.lm.SearchIterWithFilter(query, a.filter)))
|
||||
a.searchGrid.Reset(iter)
|
||||
}
|
||||
a.container.Objects[0] = a.searchGrid
|
||||
a.Refresh()
|
||||
@@ -200,7 +201,7 @@ func (a *AlbumsPage) doSearch(query string) {
|
||||
|
||||
func (a *AlbumsPage) onSortOrderChanged(order string) {
|
||||
a.cfg.SortOrder = a.sortOrder.Selected
|
||||
iter := a.lm.AlbumsIter(backend.AlbumSortOrder(order), a.filter)
|
||||
iter := a.mp.IterateAlbums(order, "", a.filter)
|
||||
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
if a.searchText == "" {
|
||||
a.container.Objects[0] = a.grid
|
||||
@@ -215,11 +216,11 @@ func (a *AlbumsPage) CreateRenderer() fyne.WidgetRenderer {
|
||||
|
||||
type savedAlbumsPage struct {
|
||||
searchText string
|
||||
filter backend.AlbumFilter
|
||||
filter mediaprovider.AlbumFilter
|
||||
cfg *backend.AlbumsPageConfig
|
||||
contr *controller.Controller
|
||||
pm *backend.PlaybackManager
|
||||
lm *backend.LibraryManager
|
||||
mp mediaprovider.MediaProvider
|
||||
im *backend.ImageManager
|
||||
sortOrder string
|
||||
gridState widgets.GridViewState
|
||||
|
||||
+15
-20
@@ -20,8 +20,6 @@ import (
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
var _ fyne.Widget = (*ArtistPage)(nil)
|
||||
@@ -42,7 +40,7 @@ type ArtistPage struct {
|
||||
|
||||
artistPageState
|
||||
|
||||
artistInfo *subsonic.ArtistID3
|
||||
artistInfo *mediaprovider.ArtistWithAlbums
|
||||
|
||||
albumGrid *widgets.GridView
|
||||
tracklistCtr *fyne.Container
|
||||
@@ -130,7 +128,7 @@ func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track
|
||||
|
||||
func (a *ArtistPage) playAllTracks() {
|
||||
if a.artistInfo != nil { // page loaded
|
||||
for i, album := range a.artistInfo.Album {
|
||||
for i, album := range a.artistInfo.Albums {
|
||||
a.pm.LoadAlbum(album.ID, i > 0 /*append*/, false /*shuffle*/)
|
||||
}
|
||||
a.pm.PlayFromBeginning()
|
||||
@@ -155,7 +153,7 @@ func (a *ArtistPage) load() {
|
||||
} else {
|
||||
a.showTopTracks()
|
||||
}
|
||||
info, err := a.sm.Server.GetArtistInfo2(a.artistID, nil)
|
||||
info, err := a.sm.Server.GetArtistInfo(a.artistID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get artist info: %s", err.Error())
|
||||
}
|
||||
@@ -169,11 +167,11 @@ func (a *ArtistPage) showAlbumGrid() {
|
||||
a.activeView = 0 // if page still loading, will show discography view first
|
||||
return
|
||||
}
|
||||
model := sharedutil.MapSlice(a.artistInfo.Album, func(al *subsonic.AlbumID3) widgets.GridViewItemModel {
|
||||
model := sharedutil.MapSlice(a.artistInfo.Albums, func(al *mediaprovider.Album) widgets.GridViewItemModel {
|
||||
return widgets.GridViewItemModel{
|
||||
Name: al.Name,
|
||||
ID: al.ID,
|
||||
CoverArtID: al.CoverArt,
|
||||
CoverArtID: al.CoverArtID,
|
||||
Secondary: strconv.Itoa(al.Year),
|
||||
}
|
||||
})
|
||||
@@ -191,7 +189,7 @@ func (a *ArtistPage) showTopTracks() {
|
||||
a.activeView = 1 // if page still loading, will show tracks view first
|
||||
return
|
||||
}
|
||||
ts, err := a.sm.Server.GetTopSongs(a.artistInfo.Name, map[string]string{"count": "20"})
|
||||
ts, err := a.sm.Server.GetTopTracks(a.artistInfo.Artist, 20)
|
||||
if err != nil {
|
||||
log.Printf("error getting top songs: %s", err.Error())
|
||||
return
|
||||
@@ -277,18 +275,18 @@ func NewArtistPageHeader(page *ArtistPage) *ArtistPageHeader {
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ArtistPageHeader) Update(artist *subsonic.ArtistID3) {
|
||||
func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums) {
|
||||
if artist == nil {
|
||||
return
|
||||
}
|
||||
a.favoriteBtn.IsFavorited = !artist.Starred.IsZero()
|
||||
a.favoriteBtn.IsFavorited = !artist.Favorite
|
||||
a.favoriteBtn.Refresh()
|
||||
a.artistID = artist.ID
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Text = artist.Name
|
||||
a.titleDisp.Refresh()
|
||||
}
|
||||
|
||||
func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
|
||||
func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
@@ -304,7 +302,7 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
|
||||
}
|
||||
|
||||
a.similarArtists.RemoveAll()
|
||||
for i, art := range info.SimilarArtist {
|
||||
for i, art := range info.SimilarArtists {
|
||||
if i == 0 {
|
||||
a.similarArtists.Add(widget.NewLabel("Similar Artists:"))
|
||||
}
|
||||
@@ -321,11 +319,11 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
|
||||
}
|
||||
a.similarArtists.Refresh()
|
||||
|
||||
if info.LargeImageUrl != "" {
|
||||
if info.ImageURL != "" {
|
||||
if a.artistImage.HaveImage() {
|
||||
_ = a.artistPage.im.RefreshCachedArtistImageIfExpired(a.artistID, info.LargeImageUrl)
|
||||
_ = a.artistPage.im.RefreshCachedArtistImageIfExpired(a.artistID, info.ImageURL)
|
||||
} else {
|
||||
im, err := a.artistPage.im.FetchAndCacheArtistImage(a.artistID, info.LargeImageUrl)
|
||||
im, err := a.artistPage.im.FetchAndCacheArtistImage(a.artistID, info.ImageURL)
|
||||
if err == nil {
|
||||
a.artistImage.SetImage(im, true /*tappable*/)
|
||||
}
|
||||
@@ -334,11 +332,8 @@ 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}})
|
||||
}
|
||||
params := mediaprovider.RatingFavoriteParameters{ArtistIDs: []string{a.artistID}}
|
||||
a.artistPage.sm.Server.SetFavorite(params, a.favoriteBtn.IsFavorited)
|
||||
}
|
||||
|
||||
func (a *ArtistPageHeader) createContainer() {
|
||||
|
||||
@@ -3,9 +3,9 @@ package browsing
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
var _ fyne.Widget = (*ArtistPage)(nil)
|
||||
@@ -80,7 +78,7 @@ func (a *ArtistsGenresPage) load(searchOnLoad bool) {
|
||||
}
|
||||
a.model = a.buildGenresListModel(genres)
|
||||
} else {
|
||||
artists, err := a.sm.Server.GetArtists(nil)
|
||||
artists, err := a.sm.Server.GetArtists()
|
||||
if err != nil {
|
||||
log.Printf("error loading artists: %v", err.Error())
|
||||
}
|
||||
@@ -145,29 +143,27 @@ func (s *savedArtistsGenresPage) Restore() Page {
|
||||
return newArtistsGenresPage(s.isGenresPage, s.contr, s.sm, s.searchText)
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) buildArtistListModel(artists *subsonic.ArtistsID3) []widgets.ArtistGenreListItemModel {
|
||||
func (a *ArtistsGenresPage) buildArtistListModel(artists []*mediaprovider.Artist) []widgets.ArtistGenreListItemModel {
|
||||
model := make([]widgets.ArtistGenreListItemModel, 0)
|
||||
for _, idx := range artists.Index {
|
||||
for _, artist := range idx.Artist {
|
||||
for _, artist := range artists {
|
||||
model = append(model, widgets.ArtistGenreListItemModel{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
AlbumCount: artist.AlbumCount,
|
||||
Favorite: artist.Starred != time.Time{},
|
||||
Favorite: artist.Favorite,
|
||||
})
|
||||
}
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) buildGenresListModel(genres []*subsonic.Genre) []widgets.ArtistGenreListItemModel {
|
||||
func (a *ArtistsGenresPage) buildGenresListModel(genres []*mediaprovider.Genre) []widgets.ArtistGenreListItemModel {
|
||||
model := make([]widgets.ArtistGenreListItemModel, 0)
|
||||
for _, genre := range genres {
|
||||
model = append(model, widgets.ArtistGenreListItemModel{
|
||||
ID: genre.Name,
|
||||
Name: genre.Name,
|
||||
AlbumCount: genre.AlbumCount,
|
||||
TrackCount: genre.SongCount,
|
||||
TrackCount: genre.TrackCount,
|
||||
Favorite: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,9 +26,8 @@ type FavoritesPage struct {
|
||||
pm *backend.PlaybackManager
|
||||
im *backend.ImageManager
|
||||
sm *backend.ServerManager
|
||||
lm *backend.LibraryManager
|
||||
|
||||
filter backend.AlbumFilter
|
||||
filter mediaprovider.AlbumFilter
|
||||
searchText string
|
||||
nowPlayingID string
|
||||
pendingViewSwitch bool
|
||||
@@ -44,19 +43,18 @@ type FavoritesPage struct {
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Controller, sm *backend.ServerManager, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *FavoritesPage {
|
||||
func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Controller, sm *backend.ServerManager, pm *backend.PlaybackManager, im *backend.ImageManager) *FavoritesPage {
|
||||
a := &FavoritesPage{
|
||||
filter: backend.AlbumFilter{ExcludeUnfavorited: true},
|
||||
filter: mediaprovider.AlbumFilter{ExcludeUnfavorited: true},
|
||||
cfg: cfg,
|
||||
contr: contr,
|
||||
pm: pm,
|
||||
lm: lm,
|
||||
sm: sm,
|
||||
im: im,
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.createHeader(0)
|
||||
iter := lm.StarredIter(a.filter)
|
||||
iter := sm.Server.IterateAlbums("", "", a.filter)
|
||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
||||
a.contr.ConnectAlbumGridActions(a.grid)
|
||||
if cfg.InitialView == "Artists" {
|
||||
@@ -100,7 +98,6 @@ func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
|
||||
cfg: saved.cfg,
|
||||
contr: saved.contr,
|
||||
pm: saved.pm,
|
||||
lm: saved.lm,
|
||||
sm: saved.sm,
|
||||
im: saved.im,
|
||||
searchText: saved.searchText,
|
||||
@@ -142,13 +139,13 @@ func (a *FavoritesPage) Reload() {
|
||||
if a.searchText != "" {
|
||||
a.doSearchAlbums(a.searchText)
|
||||
} else {
|
||||
iter := a.lm.StarredIter(a.filter)
|
||||
iter := a.sm.Server.IterateAlbums("", "", a.filter)
|
||||
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
}
|
||||
if a.tracklistCtr != nil || a.artistListCtr != nil {
|
||||
go func() {
|
||||
// re-fetch starred info from server
|
||||
starred, err := a.sm.Server.GetStarred2(nil)
|
||||
starred, err := a.sm.Server.GetFavorites()
|
||||
if err != nil {
|
||||
log.Printf("error getting starred items: %s", err.Error())
|
||||
return
|
||||
@@ -156,7 +153,7 @@ func (a *FavoritesPage) Reload() {
|
||||
if a.tracklistCtr != nil {
|
||||
// refresh favorite songs view
|
||||
tr := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
|
||||
tr.Tracks = starred.Song
|
||||
tr.Tracks = starred.Tracks
|
||||
if a.toggleBtns.ActivatedButtonIndex() == 2 {
|
||||
// favorite songs view is visible
|
||||
tr.Refresh()
|
||||
@@ -165,7 +162,7 @@ func (a *FavoritesPage) Reload() {
|
||||
if a.artistListCtr != nil {
|
||||
// refresh favorite artists view
|
||||
al := a.artistListCtr.Objects[0].(*widgets.ArtistGenreList)
|
||||
al.Items = buildArtistListModel(starred.Artist)
|
||||
al.Items = buildArtistListModel(starred.Artists)
|
||||
if a.toggleBtns.ActivatedButtonIndex() == 1 {
|
||||
// favorite artists view is visible
|
||||
al.Refresh()
|
||||
@@ -182,7 +179,6 @@ func (a *FavoritesPage) Save() SavedPage {
|
||||
pm: a.pm,
|
||||
sm: a.sm,
|
||||
im: a.im,
|
||||
lm: a.lm,
|
||||
filter: a.filter,
|
||||
searchText: a.searchText,
|
||||
gridState: a.grid.SaveToState(),
|
||||
@@ -234,7 +230,7 @@ func (a *FavoritesPage) SelectAll() {
|
||||
}
|
||||
|
||||
func (a *FavoritesPage) doSearchAlbums(query string) {
|
||||
iter := a.lm.SearchIterWithFilter(query, a.filter)
|
||||
iter := a.sm.Server.IterateAlbums("", query, a.filter)
|
||||
if a.searchGrid == nil {
|
||||
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
||||
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
||||
@@ -356,7 +352,6 @@ type savedFavoritesPage struct {
|
||||
pm *backend.PlaybackManager
|
||||
sm *backend.ServerManager
|
||||
im *backend.ImageManager
|
||||
lm *backend.LibraryManager
|
||||
gridState widgets.GridViewState
|
||||
searchGridState widgets.GridViewState
|
||||
filter mediaprovider.AlbumFilter
|
||||
|
||||
+13
-12
@@ -2,6 +2,7 @@ package browsing
|
||||
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
@@ -22,12 +23,12 @@ type GenrePage struct {
|
||||
contr *controller.Controller
|
||||
im *backend.ImageManager
|
||||
pm *backend.PlaybackManager
|
||||
lm *backend.LibraryManager
|
||||
mp mediaprovider.MediaProvider
|
||||
grid *widgets.GridView
|
||||
searchGrid *widgets.GridView
|
||||
searcher *widgets.SearchEntry
|
||||
searchText string
|
||||
filter backend.AlbumFilter
|
||||
filter mediaprovider.AlbumFilter
|
||||
filterBtn *widgets.AlbumFilterButton
|
||||
titleDisp *widget.RichText
|
||||
playRandom *widget.Button
|
||||
@@ -37,13 +38,13 @@ type GenrePage struct {
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewGenrePage(genre string, contr *controller.Controller, pm *backend.PlaybackManager, lm *backend.LibraryManager, im *backend.ImageManager) *GenrePage {
|
||||
func NewGenrePage(genre string, contr *controller.Controller, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager) *GenrePage {
|
||||
g := &GenrePage{
|
||||
genre: genre,
|
||||
filter: backend.AlbumFilter{Genres: []string{genre}},
|
||||
filter: mediaprovider.AlbumFilter{Genres: []string{genre}},
|
||||
contr: contr,
|
||||
pm: pm,
|
||||
lm: lm,
|
||||
mp: mp,
|
||||
im: im,
|
||||
}
|
||||
g.ExtendBaseWidget(g)
|
||||
@@ -53,7 +54,7 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba
|
||||
SizeName: theme.SizeNameHeadingText,
|
||||
}
|
||||
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
|
||||
iter := g.lm.GenreIter(g.genre, g.filter)
|
||||
iter := g.mp.IterateAlbums("", "", g.filter)
|
||||
g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
||||
g.contr.ConnectAlbumGridActions(g.grid)
|
||||
g.createSearchAndFilter()
|
||||
@@ -90,7 +91,7 @@ func restoreGenrePage(saved *savedGenrePage) *GenrePage {
|
||||
genre: saved.genre,
|
||||
contr: saved.contr,
|
||||
pm: saved.pm,
|
||||
lm: saved.lm,
|
||||
mp: saved.mp,
|
||||
im: saved.im,
|
||||
searchText: saved.searchText,
|
||||
filter: saved.filter,
|
||||
@@ -124,7 +125,7 @@ func (g *GenrePage) Reload() {
|
||||
if g.searchText != "" {
|
||||
g.doSearch(g.searchText)
|
||||
} else {
|
||||
iter := g.lm.GenreIter(g.genre, g.filter)
|
||||
iter := g.mp.IterateAlbums("", "", g.filter)
|
||||
g.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
g.grid.Refresh()
|
||||
}
|
||||
@@ -137,7 +138,7 @@ func (g *GenrePage) Save() SavedPage {
|
||||
searchText: g.searchText,
|
||||
contr: g.contr,
|
||||
pm: g.pm,
|
||||
lm: g.lm,
|
||||
mp: g.mp,
|
||||
im: g.im,
|
||||
gridState: g.grid.SaveToState(),
|
||||
}
|
||||
@@ -167,7 +168,7 @@ func (g *GenrePage) OnSearched(query string) {
|
||||
}
|
||||
|
||||
func (g *GenrePage) doSearch(query string) {
|
||||
iter := g.lm.SearchIterWithFilter(query, g.filter)
|
||||
iter := g.mp.IterateAlbums("", query, g.filter)
|
||||
if g.searchGrid == nil {
|
||||
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
||||
g.contr.ConnectAlbumGridActions(g.searchGrid)
|
||||
@@ -185,10 +186,10 @@ func (g *GenrePage) playRandomSongs() {
|
||||
type savedGenrePage struct {
|
||||
genre string
|
||||
searchText string
|
||||
filter backend.AlbumFilter
|
||||
filter mediaprovider.AlbumFilter
|
||||
contr *controller.Controller
|
||||
pm *backend.PlaybackManager
|
||||
lm *backend.LibraryManager
|
||||
mp mediaprovider.MediaProvider
|
||||
im *backend.ImageManager
|
||||
gridState widgets.GridViewState
|
||||
searchGridState widgets.GridViewState
|
||||
|
||||
@@ -2,6 +2,7 @@ package browsing
|
||||
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
@@ -10,8 +11,6 @@ import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
type NowPlayingPage struct {
|
||||
@@ -83,7 +82,7 @@ func (a *NowPlayingPage) SelectAll() {
|
||||
a.tracklist.SelectAll()
|
||||
}
|
||||
|
||||
func (a *NowPlayingPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny *subsonic.Child) {
|
||||
func (a *NowPlayingPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) {
|
||||
if song == nil {
|
||||
a.nowPlayingID = ""
|
||||
} else {
|
||||
|
||||
+16
-20
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
@@ -17,8 +18,6 @@ import (
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
type PlaylistPage struct {
|
||||
@@ -90,7 +89,7 @@ func (a *PlaylistPage) Route() controller.Route {
|
||||
return controller.PlaylistRoute(a.playlistID)
|
||||
}
|
||||
|
||||
func (a *PlaylistPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny *subsonic.Child) {
|
||||
func (a *PlaylistPage) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track) {
|
||||
if song == nil {
|
||||
a.nowPlayingID = ""
|
||||
} else {
|
||||
@@ -119,7 +118,7 @@ func (a *PlaylistPage) load() {
|
||||
log.Printf("Failed to get playlist: %s", err.Error())
|
||||
return
|
||||
}
|
||||
a.tracklist.Tracks = playlist.Entry
|
||||
a.tracklist.Tracks = playlist.Tracks
|
||||
a.tracklist.SetNowPlaying(a.nowPlayingID)
|
||||
a.tracklist.Refresh()
|
||||
a.header.Update(playlist)
|
||||
@@ -148,10 +147,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
|
||||
for i, tr := range newTracks {
|
||||
ids[i] = tr.ID
|
||||
}
|
||||
err := a.sm.Server.CreatePlaylistWithTracks(ids, map[string]string{
|
||||
"playlistId": a.playlistID,
|
||||
})
|
||||
if err != nil {
|
||||
if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
|
||||
log.Printf("error updating playlist: %s", err.Error())
|
||||
} else {
|
||||
a.tracklist.Tracks = newTracks
|
||||
@@ -161,7 +157,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
|
||||
}
|
||||
|
||||
func (a *PlaylistPage) onRemoveSelectedFromPlaylist() {
|
||||
a.sm.Server.UpdatePlaylistTracks(a.playlistID, nil, a.tracklist.SelectedTrackIndexes())
|
||||
a.sm.Server.EditPlaylistTracks(a.playlistID, nil, a.tracklist.SelectedTrackIndexes())
|
||||
a.tracklist.UnselectAll()
|
||||
go a.Reload()
|
||||
}
|
||||
@@ -170,7 +166,7 @@ type PlaylistPageHeader struct {
|
||||
widget.BaseWidget
|
||||
|
||||
page *PlaylistPage
|
||||
playlistInfo *subsonic.Playlist
|
||||
playlistInfo *mediaprovider.PlaylistWithTracks
|
||||
image *widgets.ImagePlaceholder
|
||||
|
||||
editButton *widget.Button
|
||||
@@ -199,7 +195,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
||||
a.trackTimeLabel = widget.NewLabel("")
|
||||
a.editButton = widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
||||
if a.playlistInfo != nil {
|
||||
page.contr.DoEditPlaylistWorkflow(a.playlistInfo)
|
||||
page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist)
|
||||
}
|
||||
})
|
||||
a.editButton.Hidden = true
|
||||
@@ -244,18 +240,18 @@ func (a *PlaylistPageHeader) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
|
||||
func (a *PlaylistPageHeader) Update(playlist *subsonic.Playlist) {
|
||||
func (a *PlaylistPageHeader) Update(playlist *mediaprovider.PlaylistWithTracks) {
|
||||
a.playlistInfo = playlist
|
||||
a.editButton.Hidden = playlist.Owner != a.page.sm.Server.User
|
||||
a.editButton.Hidden = playlist.Owner != a.page.sm.LoggedInUser
|
||||
a.titleLabel.Segments[0].(*widget.TextSegment).Text = playlist.Name
|
||||
a.descriptionLabel.SetText(playlist.Comment)
|
||||
a.descriptionLabel.SetText(playlist.Description)
|
||||
a.ownerLabel.SetText(a.formatPlaylistOwnerStr(playlist))
|
||||
a.trackTimeLabel.SetText(a.formatPlaylistTrackTimeStr(playlist))
|
||||
a.createdAtLabel.SetText("created at TODO")
|
||||
|
||||
var haveCover bool
|
||||
if playlist.CoverArt != "" {
|
||||
if im, err := a.page.im.GetCoverThumbnail(playlist.CoverArt); err == nil && im != nil {
|
||||
if playlist.CoverArtID != "" {
|
||||
if im, err := a.page.im.GetCoverThumbnail(playlist.CoverArtID); err == nil && im != nil {
|
||||
a.image.SetImage(im, false /*tappable*/)
|
||||
haveCover = true
|
||||
}
|
||||
@@ -268,7 +264,7 @@ func (a *PlaylistPageHeader) Update(playlist *subsonic.Playlist) {
|
||||
a.Refresh()
|
||||
}
|
||||
|
||||
func (a *PlaylistPageHeader) formatPlaylistOwnerStr(p *subsonic.Playlist) string {
|
||||
func (a *PlaylistPageHeader) formatPlaylistOwnerStr(p *mediaprovider.PlaylistWithTracks) string {
|
||||
pubPriv := "Public"
|
||||
if !p.Public {
|
||||
pubPriv = "Private"
|
||||
@@ -276,12 +272,12 @@ func (a *PlaylistPageHeader) formatPlaylistOwnerStr(p *subsonic.Playlist) string
|
||||
return fmt.Sprintf("%s playlist by %s", pubPriv, p.Owner)
|
||||
}
|
||||
|
||||
func (a *PlaylistPageHeader) formatPlaylistTrackTimeStr(p *subsonic.Playlist) string {
|
||||
func (a *PlaylistPageHeader) formatPlaylistTrackTimeStr(p *mediaprovider.PlaylistWithTracks) string {
|
||||
tracks := "tracks"
|
||||
if p.SongCount == 1 {
|
||||
if p.TrackCount == 1 {
|
||||
tracks = "track"
|
||||
}
|
||||
return fmt.Sprintf("%d %s, %s", p.SongCount, tracks, util.SecondsToTimeString(float64(p.Duration)))
|
||||
return fmt.Sprintf("%d %s, %s", p.TrackCount, tracks, util.SecondsToTimeString(float64(p.Duration)))
|
||||
}
|
||||
|
||||
func (s *playlistPageState) Restore() Page {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
@@ -18,8 +19,6 @@ import (
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
type PlaylistsPage struct {
|
||||
@@ -28,8 +27,8 @@ type PlaylistsPage struct {
|
||||
cfg *backend.PlaylistsPageConfig
|
||||
contr *controller.Controller
|
||||
sm *backend.ServerManager
|
||||
playlists []*subsonic.Playlist
|
||||
searchedPlaylists []*subsonic.Playlist
|
||||
playlists []*mediaprovider.Playlist
|
||||
searchedPlaylists []*mediaprovider.Playlist
|
||||
|
||||
viewToggle *widgets.ToggleButtonGroup
|
||||
searcher *widgets.SearchEntry
|
||||
@@ -76,7 +75,7 @@ func newPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageCo
|
||||
}
|
||||
|
||||
func (a *PlaylistsPage) load(searchOnLoad bool) {
|
||||
playlists, err := a.sm.Server.GetPlaylists(nil)
|
||||
playlists, err := a.sm.Server.GetPlaylists()
|
||||
if err != nil {
|
||||
log.Printf("error loading playlists: %v", err.Error())
|
||||
}
|
||||
@@ -93,7 +92,7 @@ func (a *PlaylistsPage) createListView() {
|
||||
a.listView.OnNavTo = a.showPlaylistPage
|
||||
}
|
||||
|
||||
func (a *PlaylistsPage) createGridView(playlists []*subsonic.Playlist) {
|
||||
func (a *PlaylistsPage) createGridView(playlists []*mediaprovider.Playlist) {
|
||||
model := createPlaylistGridViewModel(playlists)
|
||||
a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager)
|
||||
a.gridView.OnPlay = func(id string, shuffle bool) {
|
||||
@@ -110,7 +109,7 @@ func (a *PlaylistsPage) createGridView(playlists []*subsonic.Playlist) {
|
||||
log.Printf("error loading playlist: %s", err.Error())
|
||||
return
|
||||
}
|
||||
a.contr.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(pl.Entry))
|
||||
a.contr.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(pl.Tracks))
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -142,17 +141,17 @@ func (a *PlaylistsPage) showGridView() {
|
||||
a.container.Objects[0].Refresh()
|
||||
}
|
||||
|
||||
func createPlaylistGridViewModel(playlists []*subsonic.Playlist) []widgets.GridViewItemModel {
|
||||
return sharedutil.MapSlice(playlists, func(pl *subsonic.Playlist) widgets.GridViewItemModel {
|
||||
func createPlaylistGridViewModel(playlists []*mediaprovider.Playlist) []widgets.GridViewItemModel {
|
||||
return sharedutil.MapSlice(playlists, func(pl *mediaprovider.Playlist) widgets.GridViewItemModel {
|
||||
tracks := "tracks"
|
||||
if pl.SongCount == 1 {
|
||||
if pl.TrackCount == 1 {
|
||||
tracks = "track"
|
||||
}
|
||||
return widgets.GridViewItemModel{
|
||||
Name: pl.Name,
|
||||
ID: pl.ID,
|
||||
CoverArtID: pl.CoverArt,
|
||||
Secondary: fmt.Sprintf("%d %s", pl.SongCount, tracks),
|
||||
CoverArtID: pl.CoverArtID,
|
||||
Secondary: fmt.Sprintf("%d %s", pl.TrackCount, tracks),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -164,15 +163,15 @@ func (a *PlaylistsPage) showPlaylistPage(id string) {
|
||||
func (a *PlaylistsPage) onSearched(query string) {
|
||||
// since the playlist list is returned in full non-paginated, we will do our own
|
||||
// simple search based on the name, description, and owner, rather than calling a server API
|
||||
var playlists []*subsonic.Playlist
|
||||
var playlists []*mediaprovider.Playlist
|
||||
if query == "" {
|
||||
a.searchedPlaylists = nil
|
||||
playlists = a.playlists
|
||||
} else {
|
||||
a.searchedPlaylists = sharedutil.FilterSlice(a.playlists, func(p *subsonic.Playlist) bool {
|
||||
a.searchedPlaylists = sharedutil.FilterSlice(a.playlists, func(p *mediaprovider.Playlist) bool {
|
||||
qLower := strings.ToLower(query)
|
||||
return strings.Contains(strings.ToLower(p.Name), qLower) ||
|
||||
strings.Contains(strings.ToLower(p.Comment), qLower) ||
|
||||
strings.Contains(strings.ToLower(p.Description), qLower) ||
|
||||
strings.Contains(strings.ToLower(p.Owner), qLower)
|
||||
})
|
||||
playlists = a.searchedPlaylists
|
||||
@@ -182,7 +181,7 @@ func (a *PlaylistsPage) onSearched(query string) {
|
||||
|
||||
// update the model for both views if initialized,
|
||||
// refresh the active view
|
||||
func (a *PlaylistsPage) refreshView(playlists []*subsonic.Playlist) {
|
||||
func (a *PlaylistsPage) refreshView(playlists []*mediaprovider.Playlist) {
|
||||
if a.listView != nil {
|
||||
a.listView.Playlists = playlists
|
||||
}
|
||||
@@ -247,7 +246,7 @@ func (a *PlaylistsPage) CreateRenderer() fyne.WidgetRenderer {
|
||||
type PlaylistList struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Playlists []*subsonic.Playlist
|
||||
Playlists []*mediaprovider.Playlist
|
||||
OnNavTo func(string)
|
||||
|
||||
columnsLayout *layouts.ColumnsLayout
|
||||
@@ -274,9 +273,9 @@ func NewPlaylistList() *PlaylistList {
|
||||
row := item.(*PlaylistListRow)
|
||||
row.ID = a.Playlists[id].ID
|
||||
row.nameLabel.Text = a.Playlists[id].Name
|
||||
row.descrptionLabel.Text = a.Playlists[id].Comment
|
||||
row.descrptionLabel.Text = a.Playlists[id].Description
|
||||
row.ownerLabel.Text = a.Playlists[id].Owner
|
||||
row.trackCountLabel.Text = strconv.Itoa(a.Playlists[id].SongCount)
|
||||
row.trackCountLabel.Text = strconv.Itoa(a.Playlists[id].TrackCount)
|
||||
row.Refresh()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,17 +28,17 @@ func NewRouter(app *backend.App, controller *controller.Controller, nav Navigati
|
||||
func (r Router) CreatePage(rte controller.Route) Page {
|
||||
switch rte.Page {
|
||||
case controller.Album:
|
||||
return NewAlbumPage(rte.Arg, &r.App.Config.AlbumPage, r.App.ServerManager, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager, r.Controller)
|
||||
return NewAlbumPage(rte.Arg, &r.App.Config.AlbumPage, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager, r.Controller)
|
||||
case controller.Albums:
|
||||
return NewAlbumsPage(&r.App.Config.AlbumsPage, r.Controller, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager)
|
||||
return NewAlbumsPage(&r.App.Config.AlbumsPage, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
||||
case controller.Artist:
|
||||
return NewArtistPage(rte.Arg, &r.App.Config.ArtistPage, r.App.PlaybackManager, r.App.ServerManager, r.App.ImageManager, r.Controller)
|
||||
case controller.Artists:
|
||||
return NewArtistsGenresPage(false, r.Controller, r.App.ServerManager)
|
||||
case controller.Favorites:
|
||||
return NewFavoritesPage(&r.App.Config.FavoritesPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager)
|
||||
return NewFavoritesPage(&r.App.Config.FavoritesPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
|
||||
case controller.Genre:
|
||||
return NewGenrePage(rte.Arg, r.Controller, r.App.PlaybackManager, r.App.LibraryManager, r.App.ImageManager)
|
||||
return NewGenrePage(rte.Arg, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
||||
case controller.Genres:
|
||||
return NewArtistsGenresPage(true, r.Controller, r.App.ServerManager)
|
||||
case controller.NowPlaying:
|
||||
@@ -48,7 +48,7 @@ func (r Router) CreatePage(rte controller.Route) Page {
|
||||
case controller.Playlists:
|
||||
return NewPlaylistsPage(r.Controller, &r.App.Config.PlaylistsPage, r.App.ServerManager)
|
||||
case controller.Tracks:
|
||||
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.App.LibraryManager)
|
||||
return NewTracksPage(r.Controller, &r.App.Config.TracksPage, r.App.ServerManager.Server)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package browsing
|
||||
|
||||
import (
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
@@ -12,8 +13,6 @@ import (
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
type TracksPage struct {
|
||||
@@ -37,11 +36,11 @@ type tracksPageState struct {
|
||||
searchText string
|
||||
contr *controller.Controller
|
||||
conf *backend.TracksPageConfig
|
||||
lm *backend.LibraryManager
|
||||
mp mediaprovider.MediaProvider
|
||||
}
|
||||
|
||||
func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, lm *backend.LibraryManager) *TracksPage {
|
||||
t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, lm: lm}}
|
||||
func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, mp mediaprovider.MediaProvider) *TracksPage {
|
||||
t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, mp: mp}}
|
||||
t.ExtendBaseWidget(t)
|
||||
|
||||
t.tracklist = widgets.NewTracklist(nil)
|
||||
@@ -79,12 +78,12 @@ func (t *TracksPage) Route() controller.Route {
|
||||
|
||||
func (t *TracksPage) Reload() {
|
||||
t.tracklist.Clear()
|
||||
iter := t.lm.AllTracksIterator()
|
||||
iter := t.mp.IterateTracks("")
|
||||
// loads asynchronously
|
||||
t.loader = widgets.NewTracklistLoader(t.tracklist, iter)
|
||||
}
|
||||
|
||||
func (t *TracksPage) OnSongChange(track *subsonic.Child, lastScrobbledIfAny *subsonic.Child) {
|
||||
func (t *TracksPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
|
||||
t.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
|
||||
t.tracklist.SetNowPlaying(t.nowPlayingID)
|
||||
if t.searchTracklist != nil {
|
||||
@@ -130,7 +129,7 @@ func (t *TracksPage) doSearch(query string) {
|
||||
} else {
|
||||
t.searchTracklist.Clear()
|
||||
}
|
||||
iter := t.lm.SearchTracksIterator(query)
|
||||
iter := t.mp.IterateTracks(query)
|
||||
t.searchLoader = widgets.NewTracklistLoader(t.searchTracklist, iter)
|
||||
t.container.Objects[0].(*fyne.Container).Objects[0] = t.searchTracklist
|
||||
t.Refresh()
|
||||
@@ -146,7 +145,7 @@ func (t *TracksPage) Save() SavedPage {
|
||||
}
|
||||
|
||||
func (s *tracksPageState) Restore() Page {
|
||||
t := NewTracksPage(s.contr, s.conf, s.lm)
|
||||
t := NewTracksPage(s.contr, s.conf, s.mp)
|
||||
t.searchText = s.searchText
|
||||
if t.searchText != "" {
|
||||
t.searcher.Entry.Text = t.searchText
|
||||
|
||||
+19
-55
@@ -3,12 +3,10 @@ package controller
|
||||
import (
|
||||
"image"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/player"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/dialogs"
|
||||
@@ -20,8 +18,6 @@ import (
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
type NavigationHandler func(Route)
|
||||
@@ -94,14 +90,14 @@ func (m *Controller) ShowPopUpImage(img image.Image) {
|
||||
|
||||
func (m *Controller) ConnectTracklistActions(tracklist *widgets.Tracklist) {
|
||||
tracklist.OnAddToPlaylist = m.DoAddTracksToPlaylistWorkflow
|
||||
tracklist.OnAddToQueue = func(tracks []*subsonic.Child) {
|
||||
tracklist.OnAddToQueue = func(tracks []*mediaprovider.Track) {
|
||||
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, shuffle bool) {
|
||||
tracklist.OnPlaySelection = func(tracks []*mediaprovider.Track, shuffle bool) {
|
||||
m.App.PlaybackManager.LoadTracks(tracks, false, shuffle)
|
||||
m.App.PlaybackManager.PlayFromBeginning()
|
||||
}
|
||||
@@ -137,7 +133,7 @@ func (m *Controller) ConnectAlbumGridActions(grid *widgets.GridView) {
|
||||
log.Printf("error loading album: %s", err.Error())
|
||||
return
|
||||
}
|
||||
m.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(album.Song))
|
||||
m.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(album.Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +169,10 @@ func (m *Controller) PromptForFirstServer() {
|
||||
// Depending on the results of that dialog, potentially create a new playlist
|
||||
// Add tracks to the user-specified playlist
|
||||
func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
||||
pls, err := m.App.LibraryManager.GetUserOwnedPlaylists()
|
||||
pls, err := m.App.ServerManager.Server.GetPlaylists()
|
||||
pls = sharedutil.FilterSlice(pls, func(pl *mediaprovider.Playlist) bool {
|
||||
return pl.Owner == m.App.ServerManager.LoggedInUser
|
||||
})
|
||||
if err != nil {
|
||||
// TODO: surface this error to user
|
||||
log.Printf("error getting user-owned playlists: %s", err.Error())
|
||||
@@ -192,10 +191,9 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
||||
pop.Hide()
|
||||
m.doModalClosed()
|
||||
if playlistChoice < 0 {
|
||||
m.App.ServerManager.Server.CreatePlaylistWithTracks(
|
||||
trackIDs, map[string]string{"name": newPlaylistName})
|
||||
m.App.ServerManager.Server.CreatePlaylist(newPlaylistName, trackIDs)
|
||||
} else {
|
||||
m.App.ServerManager.Server.UpdatePlaylistTracks(
|
||||
m.App.ServerManager.Server.EditPlaylistTracks(
|
||||
pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/)
|
||||
}
|
||||
}
|
||||
@@ -203,7 +201,7 @@ func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
|
||||
pop.Show()
|
||||
}
|
||||
|
||||
func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) {
|
||||
func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
|
||||
dlg := dialogs.NewEditPlaylistDialog(playlist)
|
||||
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
|
||||
m.ClosePopUpOnEscape(pop)
|
||||
@@ -234,11 +232,7 @@ func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) {
|
||||
pop.Hide()
|
||||
m.doModalClosed()
|
||||
go func() {
|
||||
err := m.App.ServerManager.Server.UpdatePlaylist(playlist.ID, map[string]string{
|
||||
"name": dlg.Name,
|
||||
"comment": dlg.Description,
|
||||
"public": strconv.FormatBool(dlg.IsPublic),
|
||||
})
|
||||
err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic)
|
||||
if err != nil {
|
||||
log.Printf("error updating playlist: %s", err.Error())
|
||||
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
|
||||
@@ -405,49 +399,19 @@ func (c *Controller) doModalClosed() {
|
||||
}
|
||||
|
||||
func (c *Controller) SetTrackFavorites(trackIDs []string, favorite bool) {
|
||||
s := c.App.ServerManager.Server
|
||||
if favorite {
|
||||
go s.Star(subsonic.StarParameters{SongIDs: trackIDs})
|
||||
} else {
|
||||
go s.Unstar(subsonic.StarParameters{SongIDs: trackIDs})
|
||||
}
|
||||
c.App.ServerManager.Server.SetFavorite(mediaprovider.RatingFavoriteParameters{
|
||||
TrackIDs: trackIDs,
|
||||
}, favorite)
|
||||
|
||||
for _, id := range trackIDs {
|
||||
c.App.PlaybackManager.OnTrackFavoriteStatusChanged(id, favorite)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) SetTrackRatings(trackIDs []string, rating int) {
|
||||
// Subsonic doesn't allow bulk setting ratings.
|
||||
// To not overwhelm the server with requests, set rating for
|
||||
// only 5 tracks at a time concurrently
|
||||
batchSize := 5
|
||||
batchSetRating := func(offs int, wg *sync.WaitGroup) {
|
||||
for i := 0; i < batchSize && offs+i < len(trackIDs); i++ {
|
||||
if wg != nil {
|
||||
wg.Add(1)
|
||||
}
|
||||
go func(idx int) {
|
||||
c.App.ServerManager.Server.SetRating(trackIDs[idx], rating)
|
||||
if wg != nil {
|
||||
wg.Done()
|
||||
}
|
||||
}(offs + i)
|
||||
}
|
||||
}
|
||||
|
||||
if len(trackIDs) <= 5 {
|
||||
// one batch only - no need to use wait group
|
||||
batchSetRating(0, nil)
|
||||
} else {
|
||||
go func() {
|
||||
numBatches := int(math.Ceil(float64(len(trackIDs)) / float64(batchSize)))
|
||||
for i := 0; i < numBatches; i++ {
|
||||
var wg sync.WaitGroup
|
||||
batchSetRating(i*batchSize, &wg)
|
||||
wg.Wait()
|
||||
}
|
||||
}()
|
||||
}
|
||||
c.App.ServerManager.Server.SetRating(mediaprovider.RatingFavoriteParameters{
|
||||
TrackIDs: trackIDs,
|
||||
}, rating)
|
||||
|
||||
// Notify PlaybackManager of rating change to update
|
||||
// the in-memory track models
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"fyne.io/fyne/v2/data/binding"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
)
|
||||
|
||||
type EditPlaylistDialog struct {
|
||||
@@ -23,11 +23,11 @@ type EditPlaylistDialog struct {
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewEditPlaylistDialog(playlist *subsonic.Playlist) *EditPlaylistDialog {
|
||||
func NewEditPlaylistDialog(playlist *mediaprovider.Playlist) *EditPlaylistDialog {
|
||||
e := &EditPlaylistDialog{
|
||||
IsPublic: playlist.Public,
|
||||
Name: playlist.Name,
|
||||
Description: playlist.Comment,
|
||||
Description: playlist.Description,
|
||||
}
|
||||
e.ExtendBaseWidget(e)
|
||||
|
||||
|
||||
+30
-9
@@ -6,17 +6,38 @@ import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/dweymouth/go-subsonic/subsonic"
|
||||
)
|
||||
|
||||
const batchFetchSize = 6
|
||||
|
||||
type BatchingIterator struct {
|
||||
iter mediaprovider.AlbumIterator
|
||||
}
|
||||
|
||||
func NewBatchingIterator(iter mediaprovider.AlbumIterator) BatchingIterator {
|
||||
return BatchingIterator{iter}
|
||||
}
|
||||
|
||||
func (b *BatchingIterator) NextN(n int) []*mediaprovider.Album {
|
||||
results := make([]*mediaprovider.Album, 0, n)
|
||||
i := 0
|
||||
for i < n {
|
||||
album := b.iter.Next()
|
||||
if album == nil {
|
||||
break
|
||||
}
|
||||
results = append(results, album)
|
||||
i++
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
type ImageFetcher interface {
|
||||
GetCoverThumbnailFromCache(string) (image.Image, bool)
|
||||
GetCoverThumbnail(string) (image.Image, error)
|
||||
@@ -27,24 +48,24 @@ type GridViewIterator interface {
|
||||
}
|
||||
|
||||
type gridViewAlbumIterator struct {
|
||||
iter *backend.BatchingIterator
|
||||
iter BatchingIterator
|
||||
}
|
||||
|
||||
func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel {
|
||||
albums := g.iter.NextN(n)
|
||||
return sharedutil.MapSlice(albums, func(al *subsonic.AlbumID3) GridViewItemModel {
|
||||
return sharedutil.MapSlice(albums, func(al *mediaprovider.Album) GridViewItemModel {
|
||||
return GridViewItemModel{
|
||||
Name: al.Name,
|
||||
ID: al.ID,
|
||||
CoverArtID: al.CoverArt,
|
||||
Secondary: al.Artist,
|
||||
SecondaryID: al.ArtistID,
|
||||
CoverArtID: al.CoverArtID,
|
||||
Secondary: al.ArtistNames[0],
|
||||
SecondaryID: al.ArtistIDs[0],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func NewGridViewAlbumIterator(iter backend.AlbumIterator) GridViewIterator {
|
||||
return gridViewAlbumIterator{iter: backend.NewBatchingIterator(iter)}
|
||||
func NewGridViewAlbumIterator(iter mediaprovider.AlbumIterator) GridViewIterator {
|
||||
return gridViewAlbumIterator{iter: NewBatchingIterator(iter)}
|
||||
}
|
||||
|
||||
type GridView struct {
|
||||
|
||||
Reference in New Issue
Block a user