Merge pull request #164 from dweymouth/refactor/generic-server-adapter

Prepare for eventual Jellyfin integration: create a generic MediaProvider model + interface
This commit is contained in:
Drew Weymouth
2023-05-15 18:51:04 -07:00
committed by GitHub
34 changed files with 1045 additions and 635 deletions
+2 -4
View File
@@ -25,7 +25,6 @@ type App struct {
Config *Config Config *Config
ServerManager *ServerManager ServerManager *ServerManager
ImageManager *ImageManager ImageManager *ImageManager
LibraryManager *LibraryManager
PlaybackManager *PlaybackManager PlaybackManager *PlaybackManager
Player *player.Player Player *player.Player
UpdateChecker UpdateChecker UpdateChecker UpdateChecker
@@ -63,11 +62,10 @@ func StartupApp(appName, appVersionTag, configFile, latestReleaseURL string) (*A
a.ServerManager = NewServerManager(appName) a.ServerManager = NewServerManager(appName)
a.PlaybackManager = NewPlaybackManager(a.bgrndCtx, a.ServerManager, a.Player, &a.Config.Scrobbling) 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.ImageManager = NewImageManager(a.bgrndCtx, a.ServerManager, configdir.LocalCache(a.appName))
a.LibraryManager.PreCacheCoverFn = func(coverID string) { a.ServerManager.SetPrefetchAlbumCoverCallback(func(coverID string) {
_, _ = a.ImageManager.GetCoverThumbnail(coverID) _, _ = a.ImageManager.GetCoverThumbnail(coverID)
} })
return a, nil return a, nil
} }
+1 -1
View File
@@ -124,7 +124,7 @@ func DefaultConfig(appVersionTag string) *Config {
TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"}, TracklistColumns: []string{"Artist", "Time", "Plays", "Favorite", "Rating"},
}, },
AlbumsPage: AlbumsPageConfig{ AlbumsPage: AlbumsPageConfig{
SortOrder: string(AlbumSortRecentlyAdded), SortOrder: string("Recently Added"),
}, },
ArtistPage: ArtistPageConfig{ ArtistPage: ArtistPageConfig{
InitialView: "Discography", InitialView: "Discography",
+4 -2
View File
@@ -18,6 +18,8 @@ import (
const CachedImageValidTime = 24 * time.Hour const CachedImageValidTime = 24 * time.Hour
const coverArtThumbnailSize = 300
type ImageManager struct { type ImageManager struct {
s *ServerManager s *ServerManager
baseCacheDir string baseCacheDir string
@@ -72,7 +74,7 @@ func (i *ImageManager) GetFullSizeCoverArt(coverID string) (image.Image, error)
if i.cachedFullSizeCoverID == coverID { if i.cachedFullSizeCoverID == coverID {
return i.cachedFullSizeCover, nil return i.cachedFullSizeCover, nil
} }
im, err := i.s.Server.GetCoverArt(coverID, nil) im, err := i.s.Server.GetCoverArt(coverID, 0)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -143,7 +145,7 @@ func (i *ImageManager) fetchAndCacheCoverFromDiskOrServer(coverID string, ttl ti
} }
func (i *ImageManager) fetchAndCacheCoverFromServer(coverID string, ttl time.Duration) (image.Image, error) { func (i *ImageManager) fetchAndCacheCoverFromServer(coverID string, ttl time.Duration) (image.Image, error) {
img, err := i.s.Server.GetCoverArt(coverID, map[string]string{"size": "300"}) img, err := i.s.Server.GetCoverArt(coverID, coverArtThumbnailSize)
if err != nil { if err != nil {
return nil, err return nil, err
} }
-39
View File
@@ -1,39 +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,
}
}
func (l *LibraryManager) GetUserOwnedPlaylists() ([]*subsonic.Playlist, error) {
pl, err := l.s.Server.GetPlaylists(nil)
userPl := make([]*subsonic.Playlist, 0)
if err != nil {
return nil, err
}
for _, p := range pl {
if p.Owner == l.s.Server.User {
userPl = append(userPl, p)
}
}
return userPl, nil
}
+86
View File
@@ -0,0 +1,86 @@
package mediaprovider
import "image"
type AlbumFilter struct {
MinYear int
MaxYear int // 0 == unset/match any
Genres []string // len(0) == unset/match any
ExcludeFavorited bool // mut. exc. with ExcludeUnfavorited
ExcludeUnfavorited bool // mut. exc. with ExcludeFavorited
}
type AlbumIterator interface {
Next() *Album
}
type TrackIterator interface {
Next() *Track
}
type RatingFavoriteParameters struct {
AlbumIDs []string
ArtistIDs []string
TrackIDs []string
}
type Favorites struct {
Albums []*Album
Artists []*Artist
Tracks []*Track
}
type MediaProvider interface {
SetPrefetchCoverCallback(cb func(coverArtID string))
GetAlbum(albumID string) (*AlbumWithTracks, error)
GetArtist(artistID string) (*ArtistWithAlbums, error)
GetArtistInfo(artistID string) (*ArtistInfo, error)
GetPlaylist(playlistID string) (*PlaylistWithTracks, error)
GetCoverArt(coverArtID string, size int) (image.Image, error)
AlbumSortOrders() []string
IterateAlbums(sortOrder string, filter AlbumFilter) AlbumIterator
IterateTracks(searchQuery string) TrackIterator
SearchAlbums(searchQuery string, filter AlbumFilter) AlbumIterator
GetRandomTracks(genre string, count int) ([]*Track, error)
GetSimilarTracks(artistID string, count int) ([]*Track, error)
GetArtists() ([]*Artist, error)
GetGenres() ([]*Genre, error)
GetFavorites() (Favorites, error)
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)
CreatePlaylist(name string, trackIDs []string) error
EditPlaylist(id, name, description string, public bool) error
EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error
ReplacePlaylistTracks(id string, trackIDs []string) error
DeletePlaylist(id string) error
Scrobble(trackID string, submission bool) error
}
+82
View File
@@ -0,0 +1,82 @@
package mediaprovider
type Album struct {
ID string
CoverArtID string
Name string
Duration int
ArtistIDs []string
ArtistNames []string
Year int
Genres []string
TrackCount int
Favorite bool
}
type AlbumWithTracks struct {
Album
Tracks []*Track
}
type Artist struct {
ID string
Name string
Favorite bool
AlbumCount int
}
type ArtistWithAlbums struct {
Artist
Albums []*Album
}
type ArtistInfo struct {
Biography string
LastFMUrl string
ImageURL string
SimilarArtists []*Artist
}
type Genre struct {
Name string
AlbumCount int
TrackCount int
}
type Track struct {
ID string
CoverArtID string
ParentID string
Name string
Duration int
TrackNumber int
DiscNumber int
Genre string
ArtistIDs []string
ArtistNames []string
Album string
AlbumID string
Year int
Rating int
Favorite bool
Size int64
PlayCount int
FilePath string
BitRate int
}
type Playlist struct {
ID string
CoverArtID string
Name string
Description string
Public bool
Owner string
Duration int
TrackCount int
}
type PlaylistWithTracks struct {
Playlist
Tracks []*Track
}
@@ -1,4 +1,4 @@
package backend package subsonic
import ( import (
"log" "log"
@@ -6,45 +6,35 @@ import (
"strings" "strings"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/go-subsonic/subsonic"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
) )
type AlbumSortOrder string
const ( const (
AlbumSortRecentlyAdded AlbumSortOrder = "Recently Added" AlbumSortRecentlyAdded string = "Recently Added"
AlbumSortRecentlyPlayed AlbumSortOrder = "Recently Played" AlbumSortRecentlyPlayed string = "Recently Played"
AlbumSortFrequentlyPlayed AlbumSortOrder = "Frequently Played" AlbumSortFrequentlyPlayed string = "Frequently Played"
AlbumSortRandom AlbumSortOrder = "Random" AlbumSortRandom string = "Random"
AlbumSortTitleAZ AlbumSortOrder = "Title (A-Z)" AlbumSortTitleAZ string = "Title (A-Z)"
AlbumSortArtistAZ AlbumSortOrder = "Artist (A-Z)" AlbumSortArtistAZ string = "Artist (A-Z)"
AlbumSortYearAscending AlbumSortOrder = "Year (ascending)" AlbumSortYearAscending string = "Year (ascending)"
AlbumSortYearDescending AlbumSortOrder = "Year (descending)" AlbumSortYearDescending string = "Year (descending)"
) )
var ( func (s *subsonicMediaProvider) AlbumSortOrders() []string {
AlbumSortOrders []string = []string{ return []string{
string(AlbumSortRecentlyAdded), AlbumSortRecentlyAdded,
string(AlbumSortRecentlyPlayed), AlbumSortRecentlyPlayed,
string(AlbumSortFrequentlyPlayed), AlbumSortFrequentlyPlayed,
string(AlbumSortRandom), AlbumSortRandom,
string(AlbumSortTitleAZ), AlbumSortTitleAZ,
string(AlbumSortArtistAZ), AlbumSortArtistAZ,
string(AlbumSortYearAscending), AlbumSortYearAscending,
string(AlbumSortYearDescending), AlbumSortYearDescending,
} }
)
type AlbumFilter struct {
MinYear int
MaxYear int // 0 == unset/match any
Genres []string // len(0) == unset/match any
ExcludeFavorited bool // mut. exc. with ExcludeUnfavorited
ExcludeUnfavorited bool // mut. exc. with ExcludeFavorited
} }
func (f *AlbumFilter) Matches(album *subsonic.AlbumID3) bool { func filterMatches(f mediaprovider.AlbumFilter, album *subsonic.AlbumID3) bool {
if album == nil { if album == nil {
return false return false
} }
@@ -68,82 +58,66 @@ func (f *AlbumFilter) Matches(album *subsonic.AlbumID3) bool {
return false return false
} }
func (f *AlbumFilter) IsEmpty() bool { func (s *subsonicMediaProvider) IterateAlbums(sortOrder string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
return !f.ExcludeFavorited && !f.ExcludeUnfavorited && if sortOrder == "" && len(filter.Genres) == 1 {
f.MinYear == 0 && f.MaxYear == 0 && len(f.Genres) == 0 return s.newBaseIter("byGenre", filter, s.prefetchCoverCB, map[string]string{"genre": filter.Genres[0]})
} }
if sortOrder == "" && filter.ExcludeUnfavorited {
func (l *LibraryManager) AlbumsIter(sort AlbumSortOrder, filter AlbumFilter) AlbumIterator { return s.newBaseIter("starred", filter, s.prefetchCoverCB, make(map[string]string))
switch sort { }
if sortOrder == "" {
sortOrder = AlbumSortRecentlyAdded // default
}
switch sortOrder {
case AlbumSortRecentlyAdded: case AlbumSortRecentlyAdded:
return l.newBaseIter("newest", filter, make(map[string]string)) return s.newBaseIter("newest", filter, s.prefetchCoverCB, make(map[string]string))
case AlbumSortRecentlyPlayed: case AlbumSortRecentlyPlayed:
return l.newBaseIter("recent", filter, make(map[string]string)) return s.newBaseIter("recent", filter, s.prefetchCoverCB, make(map[string]string))
case AlbumSortFrequentlyPlayed: case AlbumSortFrequentlyPlayed:
return l.newBaseIter("frequent", filter, make(map[string]string)) return s.newBaseIter("frequent", filter, s.prefetchCoverCB, make(map[string]string))
case AlbumSortRandom: case AlbumSortRandom:
return l.newRandomIter() return s.newRandomIter(filter, s.prefetchCoverCB)
case AlbumSortTitleAZ: case AlbumSortTitleAZ:
return l.newBaseIter("alphabeticalByName", filter, make(map[string]string)) return s.newBaseIter("alphabeticalByName", filter, s.prefetchCoverCB, make(map[string]string))
case AlbumSortArtistAZ: case AlbumSortArtistAZ:
return l.newBaseIter("alphabeticalByArtist", filter, make(map[string]string)) return s.newBaseIter("alphabeticalByArtist", filter, s.prefetchCoverCB, make(map[string]string))
case AlbumSortYearAscending: case AlbumSortYearAscending:
return l.newBaseIter("byYear", filter, map[string]string{"fromYear": "0", "toYear": "3000"}) return s.newBaseIter("byYear", filter, s.prefetchCoverCB, map[string]string{"fromYear": "0", "toYear": "3000"})
case AlbumSortYearDescending: case AlbumSortYearDescending:
return l.newBaseIter("byYear", filter, map[string]string{"fromYear": "3000", "toYear": "0"}) return s.newBaseIter("byYear", filter, s.prefetchCoverCB, map[string]string{"fromYear": "3000", "toYear": "0"})
default: default:
log.Printf("Undefined album sort order: %s", sort) log.Printf("Undefined album sort order: %s", sortOrder)
return nil return nil
} }
} }
func (l *LibraryManager) StarredIter(filter AlbumFilter) AlbumIterator { func (s *subsonicMediaProvider) SearchAlbums(searchQuery string, filter mediaprovider.AlbumFilter) mediaprovider.AlbumIterator {
return l.newBaseIter("starred", filter, make(map[string]string)) return s.newSearchIter(searchQuery, filter, s.prefetchCoverCB)
}
func (l *LibraryManager) GenreIter(genre string, filter AlbumFilter) AlbumIterator {
return l.newBaseIter("byGenre", filter, map[string]string{"genre": genre})
}
func (l *LibraryManager) SearchIter(query string) AlbumIterator {
return l.newSearchIter(query, AlbumFilter{})
}
func (l *LibraryManager) SearchIterWithFilter(query string, filter AlbumFilter) AlbumIterator {
return l.newSearchIter(query, filter)
}
func (l *LibraryManager) GetAlbum(id string) (*subsonic.AlbumID3, error) {
a, err := l.s.Server.GetAlbum(id)
if err != nil {
return nil, err
}
return a, nil
} }
type baseIter struct { type baseIter struct {
listType string listType string
filter AlbumFilter filter mediaprovider.AlbumFilter
prefetchCB func(string)
serverPos int serverPos int
l *LibraryManager
s *subsonic.Client s *subsonic.Client
opts map[string]string opts map[string]string
prefetched []*subsonic.AlbumID3 prefetched []*mediaprovider.Album
prefetchedPos int prefetchedPos int
done bool done bool
} }
func (l *LibraryManager) newBaseIter(listType string, filter AlbumFilter, opts map[string]string) *baseIter { func (s *subsonicMediaProvider) newBaseIter(listType string, filter mediaprovider.AlbumFilter, cb func(string), opts map[string]string) *baseIter {
return &baseIter{ return &baseIter{
listType: listType, prefetchCB: cb,
filter: filter, listType: listType,
l: l, filter: filter,
s: l.s.Server, s: s.client,
opts: opts, opts: opts,
} }
} }
func (r *baseIter) Next() *subsonic.AlbumID3 { func (r *baseIter) Next() *mediaprovider.Album {
if r.done { if r.done {
return nil return nil
} }
@@ -165,46 +139,45 @@ func (r *baseIter) Next() *subsonic.AlbumID3 {
return nil return nil
} }
r.serverPos += len(albums) r.serverPos += len(albums)
albums = sharedutil.FilterSlice(albums, r.filter.Matches) albums = sharedutil.FilterSlice(albums, func(al *subsonic.AlbumID3) bool { return filterMatches(r.filter, al) })
r.prefetched = albums r.prefetched = sharedutil.MapSlice(albums, toAlbum)
if len(albums) > 0 { if len(albums) > 0 {
break break
} }
} }
r.prefetchedPos = 1 r.prefetchedPos = 1
if r.l.PreCacheCoverFn != nil { if r.prefetchCB != nil {
for _, album := range r.prefetched { for _, album := range r.prefetched {
go r.l.PreCacheCoverFn(album.CoverArt) go r.prefetchCB(album.CoverArtID)
} }
} }
return r.prefetched[0] return r.prefetched[0]
} }
type searchIter struct { type searchIter struct {
searchIterBase searchIterBase
l *LibraryManager prefetchCB func(string)
filter AlbumFilter filter mediaprovider.AlbumFilter
prefetched []*subsonic.AlbumID3 prefetched []*subsonic.AlbumID3
prefetchedPos int prefetchedPos int
albumIDset map[string]bool albumIDset map[string]bool
done bool done bool
} }
func (l *LibraryManager) newSearchIter(query string, filter AlbumFilter) *searchIter { func (s *subsonicMediaProvider) newSearchIter(query string, filter mediaprovider.AlbumFilter, cb func(string)) *searchIter {
return &searchIter{ return &searchIter{
searchIterBase: searchIterBase{ searchIterBase: searchIterBase{
query: query, query: query,
s: l.s.Server, s: s.client,
}, },
l: l, prefetchCB: cb,
filter: filter, filter: filter,
albumIDset: make(map[string]bool), albumIDset: make(map[string]bool),
} }
} }
func (s *searchIter) Next() *subsonic.AlbumID3 { func (s *searchIter) Next() *mediaprovider.Album {
if s.done { if s.done {
return nil return nil
} }
@@ -257,7 +230,7 @@ func (s *searchIter) Next() *subsonic.AlbumID3 {
s.prefetchedPos = 0 s.prefetchedPos = 0
} }
return a return toAlbum(a)
} }
return nil return nil
@@ -268,20 +241,21 @@ func (s *searchIter) addNewAlbums(al []*subsonic.AlbumID3) {
if _, have := s.albumIDset[album.ID]; have { if _, have := s.albumIDset[album.ID]; have {
continue continue
} }
if !s.filter.Matches(album) { if !filterMatches(s.filter, album) {
continue continue
} }
s.prefetched = append(s.prefetched, album) s.prefetched = append(s.prefetched, album)
if s.l.PreCacheCoverFn != nil { if s.prefetchCB != nil {
go s.l.PreCacheCoverFn(album.CoverArt) go s.prefetchCB(album.CoverArt)
} }
s.albumIDset[album.ID] = true s.albumIDset[album.ID] = true
} }
} }
type randomIter struct { type randomIter struct {
filter mediaprovider.AlbumFilter
prefetchCB func(coverArtID string)
albumIDSet map[string]bool albumIDSet map[string]bool
l *LibraryManager
s *subsonic.Client s *subsonic.Client
prefetched []*subsonic.AlbumID3 prefetched []*subsonic.AlbumID3
prefetchedPos int prefetchedPos int
@@ -296,44 +270,45 @@ type randomIter struct {
done bool done bool
} }
func (l *LibraryManager) newRandomIter() *randomIter { func (s *subsonicMediaProvider) newRandomIter(filter mediaprovider.AlbumFilter, cb func(string)) *randomIter {
return &randomIter{ return &randomIter{
l: l, filter: filter,
s: l.s.Server, prefetchCB: cb,
s: s.client,
albumIDSet: make(map[string]bool), albumIDSet: make(map[string]bool),
} }
} }
func (r *randomIter) Next() *subsonic.AlbumID3 { func (r *randomIter) Next() *mediaprovider.Album {
if r.done { if r.done {
return nil return nil
} }
if r.prefetched == nil { // repeat fetch task until we have matching results
// or we reach the end (handled via short circuit return)
for len(r.prefetched) == 0 {
if r.phaseTwo { if r.phaseTwo {
for len(r.prefetched) == 0 { // fetch albums from deterministic order
albums, err := r.s.GetAlbumList2("newest", map[string]string{"size": "20", "offset": strconv.Itoa(r.offset)}) albums, err := r.s.GetAlbumList2("newest", map[string]string{"size": "25", "offset": strconv.Itoa(r.offset)})
if err != nil { if err != nil {
log.Println(err) log.Printf("error fetching albums: %s", err.Error())
albums = nil albums = nil
} }
if len(albums) == 0 { if len(albums) == 0 {
r.done = true r.done = true
r.albumIDSet = nil r.albumIDSet = nil
return nil return nil
} }
r.offset += len(albums) r.offset += len(albums)
for _, album := range albums { for _, album := range albums {
if _, ok := r.albumIDSet[album.ID]; !ok { if _, ok := r.albumIDSet[album.ID]; !ok && filterMatches(r.filter, album) {
r.prefetched = append(r.prefetched, album) r.prefetched = append(r.prefetched, album)
if r.l.PreCacheCoverFn != nil { if r.prefetchCB != nil {
go r.l.PreCacheCoverFn(album.CoverArt) go r.prefetchCB(album.CoverArt)
}
r.albumIDSet[album.ID] = true
} }
r.albumIDSet[album.ID] = true
} }
} }
r.prefetchedPos = 0
} else { } else {
albums, err := r.s.GetAlbumList2("random", map[string]string{"size": "25"}) albums, err := r.s.GetAlbumList2("random", map[string]string{"size": "25"})
if err != nil { if err != nil {
@@ -345,12 +320,16 @@ func (r *randomIter) Next() *subsonic.AlbumID3 {
var hitCount int var hitCount int
for _, album := range albums { for _, album := range albums {
if _, ok := r.albumIDSet[album.ID]; !ok { if _, ok := r.albumIDSet[album.ID]; !ok {
// still need to keep track even if album is not matched
// by the filter because we need to know when to move to phase two
hitCount++ hitCount++
r.prefetched = append(r.prefetched, album)
if r.l.PreCacheCoverFn != nil {
go r.l.PreCacheCoverFn(album.CoverArt)
}
r.albumIDSet[album.ID] = true r.albumIDSet[album.ID] = true
if filterMatches(r.filter, album) {
r.prefetched = append(r.prefetched, album)
if r.prefetchCB != nil {
go r.prefetchCB(album.CoverArt)
}
}
} }
} }
if successRatio := float64(hitCount) / float64(25); successRatio < 0.3 { if successRatio := float64(hitCount) / float64(25); successRatio < 0.3 {
@@ -368,30 +347,8 @@ func (r *randomIter) Next() *subsonic.AlbumID3 {
r.prefetchedPos = 0 r.prefetchedPos = 0
} }
return a return toAlbum(a)
} }
return nil return nil
} }
type BatchingIterator struct {
iter AlbumIterator
}
func NewBatchingIterator(iter AlbumIterator) *BatchingIterator {
return &BatchingIterator{iter}
}
func (b *BatchingIterator) NextN(n int) []*subsonic.AlbumID3 {
results := make([]*subsonic.AlbumID3, 0, n)
i := 0
for i < n {
album := b.iter.Next()
if album == nil {
break
}
results = append(results, album)
i++
}
return results
}
@@ -0,0 +1,33 @@
package subsonic
import (
"log"
"strconv"
"github.com/dweymouth/go-subsonic/subsonic"
)
type searchIterBase struct {
query string
artistOffset int
albumOffset int
songOffset int
s *subsonic.Client
}
func (s *searchIterBase) fetchResults() *subsonic.SearchResult3 {
searchOpts := map[string]string{
"artistOffset": strconv.Itoa(s.artistOffset),
"albumOffset": strconv.Itoa(s.albumOffset),
"songOffset": strconv.Itoa(s.songOffset),
}
results, err := s.s.Search3(s.query, searchOpts)
if err != nil {
log.Println(err)
results = nil
}
if results == nil || len(results.Album)+len(results.Artist)+len(results.Song) == 0 {
return nil
}
return results
}
@@ -0,0 +1,338 @@
package subsonic
import (
"image"
"math"
"strconv"
"sync"
"time"
"github.com/dweymouth/go-subsonic/subsonic"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil"
)
type subsonicMediaProvider struct {
client *subsonic.Client
prefetchCoverCB func(coverArtID string)
}
func SubsonicMediaProvider(subsonicClient *subsonic.Client) mediaprovider.MediaProvider {
return &subsonicMediaProvider{client: subsonicClient}
}
func (s *subsonicMediaProvider) SetPrefetchCoverCallback(cb func(coverArtID string)) {
s.prefetchCoverCB = cb
}
func (s *subsonicMediaProvider) CreatePlaylist(name string, trackIDs []string) error {
return s.client.CreatePlaylistWithTracks(trackIDs, map[string]string{"name": name})
}
func (s *subsonicMediaProvider) DeletePlaylist(id string) error {
return s.client.DeletePlaylist(id)
}
func (s *subsonicMediaProvider) EditPlaylist(id, name, description string, public bool) error {
return s.client.UpdatePlaylist(id, map[string]string{
"name": name,
"comment": description,
"public": strconv.FormatBool(public),
})
}
func (s *subsonicMediaProvider) EditPlaylistTracks(id string, trackIDsToAdd []string, trackIndexesToRemove []int) error {
return s.client.UpdatePlaylistTracks(id, trackIDsToAdd, trackIndexesToRemove)
}
func (s *subsonicMediaProvider) GetAlbum(albumID string) (*mediaprovider.AlbumWithTracks, error) {
al, err := s.client.GetAlbum(albumID)
if err != nil {
return nil, err
}
album := &mediaprovider.AlbumWithTracks{
Tracks: sharedutil.MapSlice(al.Song, toTrack),
}
fillAlbum(al, &album.Album)
return album, nil
}
func (s *subsonicMediaProvider) GetArtist(artistID string) (*mediaprovider.ArtistWithAlbums, error) {
ar, err := s.client.GetArtist(artistID)
if err != nil {
return nil, err
}
return &mediaprovider.ArtistWithAlbums{
Artist: mediaprovider.Artist{
ID: ar.ID,
Name: ar.Name,
Favorite: !ar.Starred.IsZero(),
AlbumCount: ar.AlbumCount,
},
Albums: sharedutil.MapSlice(ar.Album, toAlbum),
}, nil
}
func (s *subsonicMediaProvider) GetArtistInfo(artistID string) (*mediaprovider.ArtistInfo, error) {
info, err := s.client.GetArtistInfo(artistID, map[string]string{})
if err != nil {
return nil, err
}
return &mediaprovider.ArtistInfo{
Biography: info.Biography,
LastFMUrl: info.LastFmUrl,
ImageURL: info.LargeImageUrl,
SimilarArtists: sharedutil.MapSlice(info.SimilarArtist, toArtist),
}, nil
}
func (s *subsonicMediaProvider) GetArtists() ([]*mediaprovider.Artist, error) {
idxs, err := s.client.GetArtists(map[string]string{})
if err != nil {
return nil, err
}
var artists []*mediaprovider.Artist
for _, idx := range idxs.Index {
for _, ar := range idx.Artist {
artists = append(artists, toArtistFromID3(ar))
}
}
return artists, nil
}
func (s *subsonicMediaProvider) GetCoverArt(id string, size int) (image.Image, error) {
params := map[string]string{}
if size > 0 {
params["size"] = strconv.Itoa(size)
}
return s.client.GetCoverArt(id, params)
}
func (s *subsonicMediaProvider) GetFavorites() (mediaprovider.Favorites, error) {
fav, err := s.client.GetStarred2(map[string]string{})
if err != nil {
return mediaprovider.Favorites{}, err
}
return mediaprovider.Favorites{
Albums: sharedutil.MapSlice(fav.Album, toAlbum),
Artists: sharedutil.MapSlice(fav.Artist, toArtistFromID3),
Tracks: sharedutil.MapSlice(fav.Song, toTrack),
}, nil
}
func (s *subsonicMediaProvider) GetGenres() ([]*mediaprovider.Genre, error) {
g, err := s.client.GetGenres()
if err != nil {
return nil, err
}
return sharedutil.MapSlice(g, func(g *subsonic.Genre) *mediaprovider.Genre {
return &mediaprovider.Genre{
Name: g.Name,
AlbumCount: g.AlbumCount,
TrackCount: g.SongCount,
}
}), nil
}
func (s *subsonicMediaProvider) GetPlaylist(playlistID string) (*mediaprovider.PlaylistWithTracks, error) {
pl, err := s.client.GetPlaylist(playlistID)
if err != nil {
return nil, err
}
playlist := &mediaprovider.PlaylistWithTracks{
Tracks: sharedutil.MapSlice(pl.Entry, toTrack),
}
fillPlaylist(pl, &playlist.Playlist)
return playlist, nil
}
func (s *subsonicMediaProvider) GetPlaylists() ([]*mediaprovider.Playlist, error) {
pl, err := s.client.GetPlaylists(map[string]string{})
if err != nil {
return nil, err
}
return sharedutil.MapSlice(pl, toPlaylist), nil
}
func (s *subsonicMediaProvider) GetRandomTracks(genreName string, count int) ([]*mediaprovider.Track, error) {
opts := map[string]string{"size": strconv.Itoa(count)}
if genreName != "" {
opts["genre"] = genreName
}
tr, err := s.client.GetRandomSongs(opts)
if err != nil {
return nil, err
}
return sharedutil.MapSlice(tr, toTrack), nil
}
func (s *subsonicMediaProvider) GetSimilarTracks(artistID string, count int) ([]*mediaprovider.Track, error) {
tr, err := s.client.GetSimilarSongs2(artistID, map[string]string{"count": strconv.Itoa(count)})
if err != nil {
return nil, err
}
return sharedutil.MapSlice(tr, toTrack), nil
}
func (s *subsonicMediaProvider) GetStreamURL(trackID string) (string, error) {
u, err := s.client.GetStreamURL(trackID, map[string]string{})
if err != nil {
return "", err
}
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})
}
func (s *subsonicMediaProvider) Scrobble(trackID string, submission bool) error {
return s.client.Scrobble(trackID, map[string]string{
"time": strconv.FormatInt(time.Now().UnixMilli(), 10),
"submission": strconv.FormatBool(submission)})
}
func (s *subsonicMediaProvider) SetFavorite(params mediaprovider.RatingFavoriteParameters, favorite bool) error {
subParams := subsonic.StarParameters{
AlbumIDs: params.AlbumIDs,
ArtistIDs: params.ArtistIDs,
SongIDs: params.TrackIDs,
}
if favorite {
return s.client.Star(subParams)
}
return s.client.Unstar(subParams)
}
func (s *subsonicMediaProvider) SetRating(params mediaprovider.RatingFavoriteParameters, rating int) error {
// 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
var err error
batchSetRating := func(offs int, wg *sync.WaitGroup) {
for i := 0; i < batchSize && offs+i < len(params.TrackIDs); i++ {
wg.Add(1)
go func(idx int) {
newErr := s.client.SetRating(params.TrackIDs[idx], rating)
if err == nil && newErr != nil {
err = newErr
}
wg.Done()
}(offs + i)
}
}
numBatches := int(math.Ceil(float64(len(params.TrackIDs)) / float64(batchSize)))
for i := 0; i < numBatches; i++ {
var wg sync.WaitGroup
batchSetRating(i*batchSize, &wg)
wg.Wait()
}
return err
}
func toTrack(ch *subsonic.Child) *mediaprovider.Track {
if ch == nil {
return nil
}
return &mediaprovider.Track{
ID: ch.ID,
CoverArtID: ch.CoverArt,
ParentID: ch.Parent,
Name: ch.Title,
Duration: ch.Duration,
TrackNumber: ch.Track,
DiscNumber: ch.DiscNumber,
Genre: ch.Genre,
ArtistIDs: []string{ch.ArtistID},
ArtistNames: []string{ch.Artist},
Album: ch.Album,
AlbumID: ch.AlbumID,
Year: ch.Year,
Rating: ch.UserRating,
Favorite: !ch.Starred.IsZero(),
PlayCount: int(ch.PlayCount),
FilePath: ch.Path,
Size: ch.Size,
BitRate: ch.BitRate,
}
}
func toAlbum(al *subsonic.AlbumID3) *mediaprovider.Album {
if al == nil {
return nil
}
album := &mediaprovider.Album{}
fillAlbum(al, album)
return album
}
func fillAlbum(subAlbum *subsonic.AlbumID3, album *mediaprovider.Album) {
album.ID = subAlbum.ID
album.CoverArtID = subAlbum.CoverArt
album.Name = subAlbum.Name
album.Duration = subAlbum.Duration
album.ArtistIDs = []string{subAlbum.ArtistID}
album.ArtistNames = []string{subAlbum.Artist}
album.Year = subAlbum.Year
album.TrackCount = subAlbum.SongCount
album.Genres = []string{subAlbum.Genre}
album.Favorite = !subAlbum.Starred.IsZero()
}
func toArtist(ar *subsonic.Artist) *mediaprovider.Artist {
if ar == nil {
return nil
}
return &mediaprovider.Artist{
ID: ar.ID,
Name: ar.Name,
Favorite: !ar.Starred.IsZero(),
}
}
func toArtistFromID3(ar *subsonic.ArtistID3) *mediaprovider.Artist {
if ar == nil {
return nil
}
return &mediaprovider.Artist{
ID: ar.ID,
Name: ar.Name,
Favorite: !ar.Starred.IsZero(),
AlbumCount: ar.AlbumCount,
}
}
func toPlaylist(pl *subsonic.Playlist) *mediaprovider.Playlist {
if pl == nil {
return nil
}
playlist := &mediaprovider.Playlist{}
fillPlaylist(pl, playlist)
return playlist
}
func fillPlaylist(pl *subsonic.Playlist, playlist *mediaprovider.Playlist) {
playlist.Name = pl.Name
playlist.ID = pl.ID
playlist.CoverArtID = pl.CoverArt
playlist.Description = pl.Comment
playlist.Owner = pl.Owner
playlist.Public = pl.Public
playlist.TrackCount = pl.SongCount
playlist.Duration = pl.Duration
}
@@ -1,62 +1,62 @@
package backend package subsonic
import ( import (
"log" "log"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/go-subsonic/subsonic"
"github.com/dweymouth/supersonic/backend/mediaprovider"
) )
func (l *LibraryManager) AllTracksIterator() TrackIterator { func (s *subsonicMediaProvider) IterateTracks(searchQuery string) mediaprovider.TrackIterator {
return &allTracksIterator{ if searchQuery == "" {
l: l, return &allTracksIterator{
albumIter: l.AlbumsIter(AlbumSortArtistAZ, AlbumFilter{}), s: s,
albumIter: s.IterateAlbums(AlbumSortArtistAZ, mediaprovider.AlbumFilter{}),
}
} }
}
func (l *LibraryManager) SearchTracksIterator(query string) TrackIterator {
return &searchTracksIterator{ return &searchTracksIterator{
searchIterBase: searchIterBase{ searchIterBase: searchIterBase{
s: l.s.Server, s: s.client,
query: query, query: searchQuery,
}, },
trackIDset: make(map[string]bool), trackIDset: make(map[string]bool),
} }
} }
type allTracksIterator struct { type allTracksIterator struct {
l *LibraryManager s *subsonicMediaProvider
albumIter AlbumIterator albumIter mediaprovider.AlbumIterator
curAlbum *subsonic.AlbumID3 curAlbum *mediaprovider.AlbumWithTracks
curTrackIdx int curTrackIdx int
done bool done bool
} }
func (a *allTracksIterator) Next() *subsonic.Child { func (a *allTracksIterator) Next() *mediaprovider.Track {
if a.done { if a.done {
return nil return nil
} }
// fetch next album // fetch next album
if a.curAlbum == nil || a.curTrackIdx >= len(a.curAlbum.Song) { if a.curAlbum == nil || a.curTrackIdx >= len(a.curAlbum.Tracks) {
al := a.albumIter.Next() al := a.albumIter.Next()
if al == nil { if al == nil {
a.done = true a.done = true
return nil return nil
} }
al, err := a.l.s.Server.GetAlbum(al.ID) alWithTracks, err := a.s.GetAlbum(al.ID)
if err != nil { if err != nil {
log.Printf("error fetching album: %s", err.Error()) log.Printf("error fetching album: %s", err.Error())
} }
if len(al.Song) == 0 { if len(alWithTracks.Tracks) == 0 {
// in the unlikely case of an album with zero tracks, // in the unlikely case of an album with zero tracks,
// just call recursively to move to next album // just call recursively to move to next album
return a.Next() return a.Next()
} }
a.curAlbum = al a.curAlbum = alWithTracks
a.curTrackIdx = 0 a.curTrackIdx = 0
} }
tr := a.curAlbum.Song[a.curTrackIdx] tr := a.curAlbum.Tracks[a.curTrackIdx]
a.curTrackIdx += 1 a.curTrackIdx += 1
return tr return tr
} }
@@ -70,7 +70,7 @@ type searchTracksIterator struct {
done bool done bool
} }
func (s *searchTracksIterator) Next() *subsonic.Child { func (s *searchTracksIterator) Next() *mediaprovider.Track {
if s.done { if s.done {
return nil return nil
} }
@@ -109,7 +109,7 @@ func (s *searchTracksIterator) Next() *subsonic.Child {
s.prefetched = s.prefetched[:0] s.prefetched = s.prefetched[:0]
s.prefetchedPos = 0 s.prefetchedPos = 0
} }
return tr return toTrack(tr)
} }
// no more results // no more results
+21 -35
View File
@@ -3,14 +3,12 @@ package backend
import ( import (
"context" "context"
"log" "log"
"strconv"
"time" "time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/backend/util" "github.com/dweymouth/supersonic/backend/util"
"github.com/dweymouth/supersonic/player" "github.com/dweymouth/supersonic/player"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/go-subsonic/subsonic"
) )
const ( const (
@@ -33,14 +31,14 @@ type PlaybackManager struct {
curTrackTime float64 curTrackTime float64
callbacksDisabled bool callbacksDisabled bool
playQueue []*subsonic.Child playQueue []*mediaprovider.Track
nowPlayingIdx int64 nowPlayingIdx int64
// to pass to onSongChange listeners; clear once listeners have been called // to pass to onSongChange listeners; clear once listeners have been called
lastScrobbled *subsonic.Child lastScrobbled *mediaprovider.Track
scrobbleCfg *ScrobbleConfig scrobbleCfg *ScrobbleConfig
onSongChange []func(nowPlaying *subsonic.Child, justScrobbledIfAny *subsonic.Child) onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track)
onPlayTimeUpdate []func(float64, float64) onPlayTimeUpdate []func(float64, float64)
} }
@@ -111,7 +109,7 @@ func (p *PlaybackManager) DisableCallbacks() {
} }
// Gets the curently playing song, if any. // Gets the curently playing song, if any.
func (p *PlaybackManager) NowPlaying() *subsonic.Child { func (p *PlaybackManager) NowPlaying() *mediaprovider.Track {
if len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped { if len(p.playQueue) == 0 || p.player.GetStatus().State == player.Stopped {
return nil return nil
} }
@@ -119,7 +117,7 @@ func (p *PlaybackManager) NowPlaying() *subsonic.Child {
} }
// Sets a callback that is notified whenever a new song begins playing. // Sets a callback that is notified whenever a new song begins playing.
func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *subsonic.Child, justScrobbledIfAny *subsonic.Child)) { func (p *PlaybackManager) OnSongChange(cb func(nowPlaying *mediaprovider.Track, justScrobbledIfAny *mediaprovider.Track)) {
p.onSongChange = append(p.onSongChange, cb) p.onSongChange = append(p.onSongChange, cb)
} }
@@ -134,7 +132,7 @@ func (p *PlaybackManager) LoadAlbum(albumID string, appendToQueue bool, shuffle
if err != nil { if err != nil {
return err return err
} }
return p.LoadTracks(album.Song, appendToQueue, shuffle) return p.LoadTracks(album.Tracks, appendToQueue, shuffle)
} }
// Loads the specified playlist into the play queue. // Loads the specified playlist into the play queue.
@@ -143,10 +141,10 @@ func (p *PlaybackManager) LoadPlaylist(playlistID string, appendToQueue bool, sh
if err != nil { if err != nil {
return err return err
} }
return p.LoadTracks(playlist.Entry, appendToQueue, shuffle) return p.LoadTracks(playlist.Tracks, appendToQueue, shuffle)
} }
func (p *PlaybackManager) LoadTracks(tracks []*subsonic.Child, appendToQueue, shuffle bool) error { func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, appendToQueue, shuffle bool) error {
if !appendToQueue { if !appendToQueue {
p.player.Stop() p.player.Stop()
p.nowPlayingIdx = 0 p.nowPlayingIdx = 0
@@ -157,11 +155,11 @@ func (p *PlaybackManager) LoadTracks(tracks []*subsonic.Child, appendToQueue, sh
util.ShuffleSlice(nums) util.ShuffleSlice(nums)
} }
for _, i := range nums { for _, i := range nums {
url, err := p.sm.Server.GetStreamURL(tracks[i].ID, map[string]string{}) url, err := p.sm.Server.GetStreamURL(tracks[i].ID)
if err != nil { if err != nil {
return err return err
} }
p.player.AppendFile(url.String()) p.player.AppendFile(url)
// ensure a deep copy of the track info so that we can maintain our own state // 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 // (tracking play count increases, favorite, and rating) without messing up
// other views' track models // other views' track models
@@ -200,11 +198,7 @@ func (p *PlaybackManager) PlayTrackAt(idx int) error {
} }
func (p *PlaybackManager) PlayRandomSongs(genreName string) { func (p *PlaybackManager) PlayRandomSongs(genreName string) {
params := map[string]string{"size": "100"} if songs, err := p.sm.Server.GetRandomTracks(genreName, 100); err != nil {
if genreName != "" {
params["genre"] = genreName
}
if songs, err := p.sm.Server.GetRandomSongs(params); err != nil {
log.Printf("error getting random songs: %s", err.Error()) log.Printf("error getting random songs: %s", err.Error())
} else { } else {
p.LoadTracks(songs, false, false) p.LoadTracks(songs, false, false)
@@ -213,8 +207,7 @@ func (p *PlaybackManager) PlayRandomSongs(genreName string) {
} }
func (p *PlaybackManager) PlaySimilarSongs(id string) { func (p *PlaybackManager) PlaySimilarSongs(id string) {
params := map[string]string{"size": "100"} if songs, err := p.sm.Server.GetSimilarTracks(id, 100); err != nil {
if songs, err := p.sm.Server.GetSimilarSongs2(id, params); err != nil {
log.Printf("error getting similar songs: %s", err.Error()) log.Printf("error getting similar songs: %s", err.Error())
} else { } else {
p.LoadTracks(songs, false, false) p.LoadTracks(songs, false, false)
@@ -222,8 +215,8 @@ func (p *PlaybackManager) PlaySimilarSongs(id string) {
} }
} }
func (p *PlaybackManager) GetPlayQueue() []*subsonic.Child { func (p *PlaybackManager) GetPlayQueue() []*mediaprovider.Track {
pq := make([]*subsonic.Child, len(p.playQueue)) pq := make([]*mediaprovider.Track, len(p.playQueue))
for i, tr := range p.playQueue { for i, tr := range p.playQueue {
copy := *tr copy := *tr
pq[i] = &copy pq[i] = &copy
@@ -235,11 +228,7 @@ func (p *PlaybackManager) GetPlayQueue() []*subsonic.Child {
// this should be called to ensure the in-memory track model is updated. // this should be called to ensure the in-memory track model is updated.
func (p *PlaybackManager) OnTrackFavoriteStatusChanged(id string, fav bool) { func (p *PlaybackManager) OnTrackFavoriteStatusChanged(id string, fav bool) {
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil {
if fav { tr.Favorite = fav
tr.Starred = time.Now()
} else {
tr.Starred = time.Time{}
}
} }
} }
@@ -247,13 +236,13 @@ func (p *PlaybackManager) OnTrackFavoriteStatusChanged(id string, fav bool) {
// this should be called to ensure the in-memory track model is updated. // this should be called to ensure the in-memory track model is updated.
func (p *PlaybackManager) OnTrackRatingChanged(id string, rating int) { func (p *PlaybackManager) OnTrackRatingChanged(id string, rating int) {
if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil { if tr := sharedutil.FindTrackByID(id, p.playQueue); tr != nil {
tr.UserRating = rating tr.Rating = rating
} }
} }
// 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([]*mediaprovider.Track, 0, len(p.playQueue)-len(trackIdxs))
rmCount := 0 rmCount := 0
rmIdx := 0 rmIdx := 0
for i, tr := range p.playQueue { for i, tr := range p.playQueue {
@@ -310,10 +299,10 @@ func (p *PlaybackManager) checkScrobble(playDur time.Duration) {
playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds) playDur.Seconds() >= float64(p.scrobbleCfg.ThresholdTimeSeconds)
if timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent) { if timeThresholdMet || pcnt >= float64(p.scrobbleCfg.ThresholdPercent) {
song := p.playQueue[p.nowPlayingIdx] song := p.playQueue[p.nowPlayingIdx]
log.Printf("Scrobbling %q", song.Title) log.Printf("Scrobbling %q", song.Name)
song.PlayCount += 1 song.PlayCount += 1
p.lastScrobbled = song p.lastScrobbled = song
go p.sm.Server.Scrobble(song.ID, map[string]string{"time": strconv.FormatInt(time.Now().Unix()*1000, 10)}) go p.sm.Server.Scrobble(song.ID, true)
} }
} }
@@ -322,10 +311,7 @@ func (p *PlaybackManager) sendNowPlayingScrobble() {
return return
} }
song := p.playQueue[p.nowPlayingIdx] song := p.playQueue[p.nowPlayingIdx]
go p.sm.Server.Scrobble(song.ID, map[string]string{ go p.sm.Server.Scrobble(song.ID, false)
"time": strconv.FormatInt(time.Now().Unix()*1000, 10),
"submission": "false",
})
} }
func (p *PlaybackManager) invokeOnSongChangeCallbacks() { func (p *PlaybackManager) invokeOnSongChangeCallbacks() {
+17 -3
View File
@@ -7,14 +7,18 @@ import (
"time" "time"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/go-subsonic/subsonic"
"github.com/dweymouth/supersonic/backend/mediaprovider"
subsonicMP "github.com/dweymouth/supersonic/backend/mediaprovider/subsonic"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/zalando/go-keyring" "github.com/zalando/go-keyring"
) )
type ServerManager struct { type ServerManager struct {
ServerID uuid.UUID LoggedInUser string
Server *subsonic.Client ServerID uuid.UUID
Server mediaprovider.MediaProvider
prefetchCoverCB func(string)
appName string appName string
onServerConnected []func() onServerConnected []func()
onLogout []func() onLogout []func()
@@ -26,12 +30,21 @@ func NewServerManager(appName string) *ServerManager {
return &ServerManager{appName: appName} return &ServerManager{appName: appName}
} }
func (s *ServerManager) SetPrefetchAlbumCoverCallback(cb func(string)) {
s.prefetchCoverCB = cb
if s.Server != nil {
s.Server.SetPrefetchCoverCallback(cb)
}
}
func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) error { func (s *ServerManager) ConnectToServer(conf *ServerConfig, password string) error {
cli, err := s.testConnectionAndCreateClient(conf.ServerConnection, password) cli, err := s.testConnectionAndCreateClient(conf.ServerConnection, password)
if err != nil { if err != nil {
return err return err
} }
s.Server = cli s.Server = subsonicMP.SubsonicMediaProvider(cli)
s.Server.SetPrefetchCoverCallback(s.prefetchCoverCB)
s.LoggedInUser = conf.Username
s.ServerID = conf.ID s.ServerID = conf.ID
for _, cb := range s.onServerConnected { for _, cb := range s.onServerConnected {
cb() cb()
@@ -116,6 +129,7 @@ func (s *ServerManager) Logout() {
cb() cb()
} }
s.Server = nil s.Server = nil
s.LoggedInUser = ""
s.ServerID = uuid.UUID{} s.ServerID = uuid.UUID{}
} }
} }
+7 -7
View File
@@ -4,7 +4,7 @@ import (
"math" "math"
"sort" "sort"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/supersonic/backend/mediaprovider"
) )
func SliceContains[T comparable](ts []T, t T) bool { func SliceContains[T comparable](ts []T, t T) bool {
@@ -40,7 +40,7 @@ func MapSlice[T any, U any](ts []T, f func(T) U) []U {
return result return result
} }
func FindTrackByID(id string, tracks []*subsonic.Child) *subsonic.Child { func FindTrackByID(id string, tracks []*mediaprovider.Track) *mediaprovider.Track {
for _, tr := range tracks { for _, tr := range tracks {
if id == tr.ID { if id == tr.ID {
return tr return tr
@@ -49,15 +49,15 @@ func FindTrackByID(id string, tracks []*subsonic.Child) *subsonic.Child {
return nil return nil
} }
func TrackIDOrEmptyStr(track *subsonic.Child) string { func TrackIDOrEmptyStr(track *mediaprovider.Track) string {
if track == nil { if track == nil {
return "" return ""
} }
return track.ID return track.ID
} }
func TracksToIDs(tracks []*subsonic.Child) []string { func TracksToIDs(tracks []*mediaprovider.Track) []string {
return MapSlice(tracks, func(tr *subsonic.Child) string { return MapSlice(tracks, func(tr *mediaprovider.Track) string {
return tr.ID return tr.ID
}) })
} }
@@ -73,8 +73,8 @@ const (
// Reorder tracks and return a new track slice. // Reorder tracks and return a new track slice.
// idxToMove must contain only valid indexes into tracks, and no repeats // idxToMove must contain only valid indexes into tracks, and no repeats
func ReorderTracks(tracks []*subsonic.Child, idxToMove []int, op TrackReorderOp) []*subsonic.Child { func ReorderTracks(tracks []*mediaprovider.Track, idxToMove []int, op TrackReorderOp) []*mediaprovider.Track {
newTracks := make([]*subsonic.Child, len(tracks)) newTracks := make([]*mediaprovider.Track, len(tracks))
switch op { switch op {
case MoveToTop: case MoveToTop:
topIdx := 0 topIdx := 0
+7 -7
View File
@@ -3,11 +3,11 @@ package sharedutil
import ( import (
"testing" "testing"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/supersonic/backend/mediaprovider"
) )
func Test_ReorderTracks(t *testing.T) { func Test_ReorderTracks(t *testing.T) {
tracks := []*subsonic.Child{ tracks := []*mediaprovider.Track{
{ID: "a"}, // 0 {ID: "a"}, // 0
{ID: "b"}, // 1 {ID: "b"}, // 1
{ID: "c"}, // 2 {ID: "c"}, // 2
@@ -18,7 +18,7 @@ func Test_ReorderTracks(t *testing.T) {
// test MoveToTop: // test MoveToTop:
idxToMove := []int{0, 2, 3, 5} idxToMove := []int{0, 2, 3, 5}
want := []*subsonic.Child{ want := []*mediaprovider.Track{
{ID: "a"}, {ID: "a"},
{ID: "c"}, {ID: "c"},
{ID: "d"}, {ID: "d"},
@@ -33,7 +33,7 @@ func Test_ReorderTracks(t *testing.T) {
// test MoveToBottom: // test MoveToBottom:
idxToMove = []int{0, 2, 5} idxToMove = []int{0, 2, 5}
want = []*subsonic.Child{ want = []*mediaprovider.Track{
{ID: "b"}, {ID: "b"},
{ID: "d"}, {ID: "d"},
{ID: "e"}, {ID: "e"},
@@ -48,7 +48,7 @@ func Test_ReorderTracks(t *testing.T) {
// test MoveUp: // test MoveUp:
idxToMove = []int{0, 1, 3, 5} idxToMove = []int{0, 1, 3, 5}
want = []*subsonic.Child{ want = []*mediaprovider.Track{
{ID: "a"}, {ID: "a"},
{ID: "b"}, {ID: "b"},
{ID: "d"}, {ID: "d"},
@@ -63,7 +63,7 @@ func Test_ReorderTracks(t *testing.T) {
// test MoveDown: // test MoveDown:
idxToMove = []int{2, 4, 5} idxToMove = []int{2, 4, 5}
want = []*subsonic.Child{ want = []*mediaprovider.Track{
{ID: "a"}, {ID: "a"},
{ID: "b"}, {ID: "b"},
{ID: "d"}, {ID: "d"},
@@ -77,7 +77,7 @@ func Test_ReorderTracks(t *testing.T) {
} }
} }
func tracklistsEqual(t *testing.T, a, b []*subsonic.Child) bool { func tracklistsEqual(t *testing.T, a, b []*mediaprovider.Track) bool {
t.Helper() t.Helper()
if len(a) != len(b) { if len(a) != len(b) {
return false return false
+6 -6
View File
@@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/player" "github.com/dweymouth/supersonic/player"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
@@ -15,7 +16,6 @@ import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type BottomPanel struct { type BottomPanel struct {
@@ -69,7 +69,7 @@ func NewBottomPanel(p *player.Player, contr *controller.Controller) *BottomPanel
contr.NavigateTo(controller.AlbumRoute(bp.playbackManager.NowPlaying().AlbumID)) contr.NavigateTo(controller.AlbumRoute(bp.playbackManager.NowPlaying().AlbumID))
}) })
bp.NowPlaying.OnArtistNameTapped(func() { bp.NowPlaying.OnArtistNameTapped(func() {
contr.NavigateTo(controller.ArtistRoute(bp.playbackManager.NowPlaying().ArtistID)) contr.NavigateTo(controller.ArtistRoute(bp.playbackManager.NowPlaying().ArtistIDs[0]))
}) })
bp.NowPlaying.OnTrackNameTapped(func() { bp.NowPlaying.OnTrackNameTapped(func() {
contr.NavigateTo(controller.NowPlayingRoute(bp.playbackManager.NowPlaying().ID)) contr.NavigateTo(controller.NowPlayingRoute(bp.playbackManager.NowPlaying().ID))
@@ -108,11 +108,11 @@ func (bp *BottomPanel) SetPlaybackManager(pm *backend.PlaybackManager) {
}) })
} }
func (bp *BottomPanel) onSongChange(song *subsonic.Child, _ *subsonic.Child) { func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) {
if song == nil { if song == nil {
bp.NowPlaying.Update("", "", false, "", nil) bp.NowPlaying.Update("", "", false, "", nil)
} else { } else {
bp.coverArtID = song.CoverArt bp.coverArtID = song.CoverArtID
var im image.Image var im image.Image
if bp.ImageManager != nil { if bp.ImageManager != nil {
// set image to expire not long after the length of the song // set image to expire not long after the length of the song
@@ -120,9 +120,9 @@ func (bp *BottomPanel) onSongChange(song *subsonic.Child, _ *subsonic.Child) {
// be in cache for the next song if it's from the same album, or // be in cache for the next song if it's from the same album, or
// if the user navigates to the album page for the track // if the user navigates to the album page for the track
imgTTLSec := song.Duration + 30 imgTTLSec := song.Duration + 30
im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArt, time.Duration(imgTTLSec)*time.Second) im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArtID, time.Duration(imgTTLSec)*time.Second)
} }
bp.NowPlaying.Update(song.Title, song.Artist, song.ArtistID != "", song.Album, im) bp.NowPlaying.Update(song.Name, song.ArtistNames[0], song.ArtistIDs[0] != "", song.Album, im)
} }
} }
+25 -32
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
@@ -17,8 +18,6 @@ import (
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type AlbumPage struct { type AlbumPage struct {
@@ -35,19 +34,17 @@ type AlbumPage struct {
type albumPageState struct { type albumPageState struct {
albumID string albumID string
cfg *backend.AlbumPageConfig cfg *backend.AlbumPageConfig
lm *backend.LibraryManager mp mediaprovider.MediaProvider
pm *backend.PlaybackManager pm *backend.PlaybackManager
im *backend.ImageManager im *backend.ImageManager
sm *backend.ServerManager
contr *controller.Controller contr *controller.Controller
} }
func NewAlbumPage( func NewAlbumPage(
albumID string, albumID string,
cfg *backend.AlbumPageConfig, cfg *backend.AlbumPageConfig,
sm *backend.ServerManager,
pm *backend.PlaybackManager, pm *backend.PlaybackManager,
lm *backend.LibraryManager, mp mediaprovider.MediaProvider,
im *backend.ImageManager, im *backend.ImageManager,
contr *controller.Controller, contr *controller.Controller,
) *AlbumPage { ) *AlbumPage {
@@ -55,9 +52,8 @@ func NewAlbumPage(
albumPageState: albumPageState{ albumPageState: albumPageState{
albumID: albumID, albumID: albumID,
cfg: cfg, cfg: cfg,
sm: sm,
pm: pm, pm: pm,
lm: lm, mp: mp,
im: im, im: im,
contr: contr, contr: contr,
}, },
@@ -92,11 +88,11 @@ func (a *AlbumPage) Route() controller.Route {
return controller.AlbumRoute(a.albumID) return controller.AlbumRoute(a.albumID)
} }
func (a *AlbumPage) OnSongChange(song *subsonic.Child, lastScrobbledIfAny *subsonic.Child) { func (a *AlbumPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
if song == nil { if track == nil {
a.nowPlayingID = "" a.nowPlayingID = ""
} else { } else {
a.nowPlayingID = song.ID a.nowPlayingID = track.ID
} }
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny)) a.tracklist.IncrementPlayCount(sharedutil.TrackIDOrEmptyStr(lastScrobbledIfAny))
@@ -116,14 +112,14 @@ func (a *AlbumPage) SelectAll() {
// 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.mp.GetAlbum(a.albumID)
if err != nil { if err != nil {
log.Printf("Failed to get album: %s", err.Error()) log.Printf("Failed to get album: %s", err.Error())
return return
} }
a.header.Update(album, a.im) a.header.Update(album, a.im)
a.tracklist.ShowDiscNumber = album.Song[0].DiscNumber != album.Song[len(album.Song)-1].DiscNumber a.tracklist.ShowDiscNumber = album.Tracks[0].DiscNumber != album.Tracks[len(album.Tracks)-1].DiscNumber
a.tracklist.Tracks = album.Song a.tracklist.Tracks = album.Tracks
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
} }
@@ -215,20 +211,20 @@ func (a *AlbumPageHeader) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container) return widget.NewSimpleRenderer(a.container)
} }
func (a *AlbumPageHeader) Update(album *subsonic.AlbumID3, im *backend.ImageManager) { func (a *AlbumPageHeader) Update(album *mediaprovider.AlbumWithTracks, im *backend.ImageManager) {
a.albumID = album.ID a.albumID = album.ID
a.coverID = album.CoverArt a.coverID = album.CoverArtID
a.artistID = album.ArtistID a.artistID = album.ArtistIDs[0]
a.titleLabel.Segments[0].(*widget.TextSegment).Text = album.Name a.titleLabel.Segments[0].(*widget.TextSegment).Text = album.Name
a.artistLabel.SetText(album.Artist) a.artistLabel.SetText(album.ArtistNames[0])
a.genre = album.Genre a.genre = album.Genres[0]
a.genreLabel.SetText(album.Genre) a.genreLabel.SetText(album.Genres[0])
a.miscLabel.SetText(formatMiscLabelStr(album)) a.miscLabel.SetText(formatMiscLabelStr(album))
a.toggleFavButton.IsFavorited = !album.Starred.IsZero() a.toggleFavButton.IsFavorited = album.Favorite
a.Refresh() a.Refresh()
go func() { go func() {
if cover, err := im.GetCoverThumbnail(album.CoverArt); err == nil { if cover, err := im.GetCoverThumbnail(album.CoverArtID); err == nil {
a.cover.Image.Image = cover a.cover.Image.Image = cover
a.cover.Refresh() a.cover.Refresh()
} else { } else {
@@ -238,11 +234,8 @@ func (a *AlbumPageHeader) Update(album *subsonic.AlbumID3, im *backend.ImageMana
} }
func (a *AlbumPageHeader) toggleFavorited() { func (a *AlbumPageHeader) toggleFavorited() {
if a.toggleFavButton.IsFavorited { params := mediaprovider.RatingFavoriteParameters{AlbumIDs: []string{a.albumID}}
a.page.sm.Server.Star(subsonic.StarParameters{AlbumIDs: []string{a.albumID}}) a.page.mp.SetFavorite(params, a.toggleFavButton.IsFavorited)
} else {
a.page.sm.Server.Unstar(subsonic.StarParameters{AlbumIDs: []string{a.albumID}})
}
} }
func (a *AlbumPageHeader) showPopUpCover() { func (a *AlbumPageHeader) showPopUpCover() {
@@ -254,18 +247,18 @@ func (a *AlbumPageHeader) showPopUpCover() {
a.page.contr.ShowPopUpImage(cover) a.page.contr.ShowPopUpImage(cover)
} }
func formatMiscLabelStr(a *subsonic.AlbumID3) string { func formatMiscLabelStr(a *mediaprovider.AlbumWithTracks) string {
var discs string var discs string
if discCount := a.Song[len(a.Song)-1].DiscNumber; discCount > 1 { if discCount := a.Tracks[len(a.Tracks)-1].DiscNumber; discCount > 1 {
discs = fmt.Sprintf("%d discs · ", discCount) discs = fmt.Sprintf("%d discs · ", discCount)
} }
tracks := "tracks" tracks := "tracks"
if a.SongCount == 1 { if a.TrackCount == 1 {
tracks = "track" tracks = "track"
} }
return fmt.Sprintf("%d · %d %s · %s%s", a.Year, a.SongCount, tracks, discs, util.SecondsToTimeString(float64(a.Duration))) return fmt.Sprintf("%d · %d %s · %s%s", a.Year, a.TrackCount, tracks, discs, util.SecondsToTimeString(float64(a.Duration)))
} }
func (s *albumPageState) Restore() Page { 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)
} }
+19 -17
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
@@ -23,13 +24,13 @@ type AlbumsPage struct {
contr *controller.Controller contr *controller.Controller
pm *backend.PlaybackManager pm *backend.PlaybackManager
im *backend.ImageManager im *backend.ImageManager
lm *backend.LibraryManager mp mediaprovider.MediaProvider
grid *widgets.GridView grid *widgets.GridView
searchGrid *widgets.GridView searchGrid *widgets.GridView
searcher *widgets.SearchEntry searcher *widgets.SearchEntry
filterBtn *widgets.AlbumFilterButton filterBtn *widgets.AlbumFilterButton
searchText string searchText string
filter backend.AlbumFilter filter mediaprovider.AlbumFilter
titleDisp *widget.RichText titleDisp *widget.RichText
sortOrder *selectWidget sortOrder *selectWidget
container *fyne.Container container *fyne.Container
@@ -54,12 +55,12 @@ func (s *selectWidget) MinSize() fyne.Size {
return fyne.NewSize(170, s.Select.MinSize().Height) 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{ a := &AlbumsPage{
cfg: cfg, cfg: cfg,
contr: contr, contr: contr,
pm: pm, pm: pm,
lm: lm, mp: mp,
im: im, im: im,
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
@@ -68,12 +69,12 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
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.sortOrder = NewSelect(backend.AlbumSortOrders, a.onSortOrderChanged) a.sortOrder = NewSelect(mp.AlbumSortOrders(), a.onSortOrderChanged)
if !sharedutil.SliceContains(backend.AlbumSortOrders, cfg.SortOrder) { if !sharedutil.SliceContains(mp.AlbumSortOrders(), cfg.SortOrder) {
cfg.SortOrder = string(backend.AlbumSortRecentlyAdded) cfg.SortOrder = string(mp.AlbumSortOrders()[0])
} }
a.sortOrder.Selected = cfg.SortOrder 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) a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
contr.ConnectAlbumGridActions(a.grid) contr.ConnectAlbumGridActions(a.grid)
a.createSearchAndFilter() a.createSearchAndFilter()
@@ -111,7 +112,7 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
cfg: saved.cfg, cfg: saved.cfg,
contr: saved.contr, contr: saved.contr,
pm: saved.pm, pm: saved.pm,
lm: saved.lm, mp: saved.mp,
im: saved.im, im: saved.im,
searchText: saved.searchText, searchText: saved.searchText,
filter: saved.filter, filter: saved.filter,
@@ -122,7 +123,7 @@ func restoreAlbumsPage(saved *savedAlbumsPage) *AlbumsPage {
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.sortOrder = NewSelect(backend.AlbumSortOrders, nil) a.sortOrder = NewSelect(a.mp.AlbumSortOrders(), nil)
a.sortOrder.Selected = saved.sortOrder a.sortOrder.Selected = saved.sortOrder
a.sortOrder.OnChanged = a.onSortOrderChanged a.sortOrder.OnChanged = a.onSortOrderChanged
a.grid = widgets.NewGridViewFromState(saved.gridState) a.grid = widgets.NewGridViewFromState(saved.gridState)
@@ -162,7 +163,7 @@ func (a *AlbumsPage) Reload() {
if a.searchText != "" { if a.searchText != "" {
a.doSearch(a.searchText) a.doSearch(a.searchText)
} else { } 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.Reset(widgets.NewGridViewAlbumIterator(iter))
a.grid.Refresh() a.grid.Refresh()
} }
@@ -173,7 +174,7 @@ func (a *AlbumsPage) Save() SavedPage {
cfg: a.cfg, cfg: a.cfg,
contr: a.contr, contr: a.contr,
pm: a.pm, pm: a.pm,
lm: a.lm, mp: a.mp,
im: a.im, im: a.im,
searchText: a.searchText, searchText: a.searchText,
filter: a.filter, filter: a.filter,
@@ -187,11 +188,12 @@ func (a *AlbumsPage) Save() SavedPage {
} }
func (a *AlbumsPage) doSearch(query string) { func (a *AlbumsPage) doSearch(query string) {
iter := widgets.NewGridViewAlbumIterator(a.mp.SearchAlbums(query, a.filter))
if a.searchGrid == nil { 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) a.contr.ConnectAlbumGridActions(a.searchGrid)
} else { } else {
a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(a.lm.SearchIterWithFilter(query, a.filter))) a.searchGrid.Reset(iter)
} }
a.container.Objects[0] = a.searchGrid a.container.Objects[0] = a.searchGrid
a.Refresh() a.Refresh()
@@ -199,7 +201,7 @@ func (a *AlbumsPage) doSearch(query string) {
func (a *AlbumsPage) onSortOrderChanged(order string) { func (a *AlbumsPage) onSortOrderChanged(order string) {
a.cfg.SortOrder = a.sortOrder.Selected 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)) a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
if a.searchText == "" { if a.searchText == "" {
a.container.Objects[0] = a.grid a.container.Objects[0] = a.grid
@@ -214,11 +216,11 @@ func (a *AlbumsPage) CreateRenderer() fyne.WidgetRenderer {
type savedAlbumsPage struct { type savedAlbumsPage struct {
searchText string searchText string
filter backend.AlbumFilter filter mediaprovider.AlbumFilter
cfg *backend.AlbumsPageConfig cfg *backend.AlbumsPageConfig
contr *controller.Controller contr *controller.Controller
pm *backend.PlaybackManager pm *backend.PlaybackManager
lm *backend.LibraryManager mp mediaprovider.MediaProvider
im *backend.ImageManager im *backend.ImageManager
sortOrder string sortOrder string
gridState widgets.GridViewState gridState widgets.GridViewState
+24 -28
View File
@@ -6,6 +6,7 @@ import (
"strings" "strings"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
@@ -19,8 +20,6 @@ import (
"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"
"github.com/dweymouth/go-subsonic/subsonic"
) )
var _ fyne.Widget = (*ArtistPage)(nil) var _ fyne.Widget = (*ArtistPage)(nil)
@@ -31,7 +30,7 @@ type artistPageState struct {
cfg *backend.ArtistPageConfig cfg *backend.ArtistPageConfig
pm *backend.PlaybackManager pm *backend.PlaybackManager
sm *backend.ServerManager mp mediaprovider.MediaProvider
im *backend.ImageManager im *backend.ImageManager
contr *controller.Controller contr *controller.Controller
} }
@@ -41,7 +40,7 @@ type ArtistPage struct {
artistPageState artistPageState
artistInfo *subsonic.ArtistID3 artistInfo *mediaprovider.ArtistWithAlbums
albumGrid *widgets.GridView albumGrid *widgets.GridView
tracklistCtr *fyne.Container tracklistCtr *fyne.Container
@@ -50,20 +49,20 @@ type ArtistPage struct {
container *fyne.Container container *fyne.Container
} }
func NewArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.PlaybackManager, sm *backend.ServerManager, im *backend.ImageManager, contr *controller.Controller) *ArtistPage { func NewArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager, contr *controller.Controller) *ArtistPage {
activeView := 0 activeView := 0
if cfg.InitialView == "Top Tracks" { if cfg.InitialView == "Top Tracks" {
activeView = 1 activeView = 1
} }
return newArtistPage(artistID, cfg, pm, sm, im, contr, activeView) return newArtistPage(artistID, cfg, pm, mp, im, contr, activeView)
} }
func newArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.PlaybackManager, sm *backend.ServerManager, im *backend.ImageManager, contr *controller.Controller, activeView int) *ArtistPage { func newArtistPage(artistID string, cfg *backend.ArtistPageConfig, pm *backend.PlaybackManager, mp mediaprovider.MediaProvider, im *backend.ImageManager, contr *controller.Controller, activeView int) *ArtistPage {
a := &ArtistPage{artistPageState: artistPageState{ a := &ArtistPage{artistPageState: artistPageState{
artistID: artistID, artistID: artistID,
cfg: cfg, cfg: cfg,
pm: pm, pm: pm,
sm: sm, mp: mp,
im: im, im: im,
contr: contr, contr: contr,
activeView: activeView, activeView: activeView,
@@ -118,7 +117,7 @@ func (a *ArtistPage) Save() SavedPage {
var _ CanShowNowPlaying = (*ArtistPage)(nil) var _ CanShowNowPlaying = (*ArtistPage)(nil)
func (a *ArtistPage) OnSongChange(track *subsonic.Child, lastScrobbledIfAny *subsonic.Child) { func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track) {
a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track) a.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
if a.tracklistCtr != nil { if a.tracklistCtr != nil {
tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist) tl := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
@@ -129,7 +128,7 @@ func (a *ArtistPage) OnSongChange(track *subsonic.Child, lastScrobbledIfAny *sub
func (a *ArtistPage) playAllTracks() { func (a *ArtistPage) playAllTracks() {
if a.artistInfo != nil { // page loaded 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.LoadAlbum(album.ID, i > 0 /*append*/, false /*shuffle*/)
} }
a.pm.PlayFromBeginning() a.pm.PlayFromBeginning()
@@ -142,7 +141,7 @@ func (a *ArtistPage) playArtistRadio() {
// should be called asynchronously // should be called asynchronously
func (a *ArtistPage) load() { func (a *ArtistPage) load() {
artist, err := a.sm.Server.GetArtist(a.artistID) artist, err := a.mp.GetArtist(a.artistID)
if err != nil { if err != nil {
log.Printf("Failed to get artist: %s", err.Error()) log.Printf("Failed to get artist: %s", err.Error())
return return
@@ -154,7 +153,7 @@ func (a *ArtistPage) load() {
} else { } else {
a.showTopTracks() a.showTopTracks()
} }
info, err := a.sm.Server.GetArtistInfo2(a.artistID, nil) info, err := a.mp.GetArtistInfo(a.artistID)
if err != nil { if err != nil {
log.Printf("Failed to get artist info: %s", err.Error()) log.Printf("Failed to get artist info: %s", err.Error())
} }
@@ -168,11 +167,11 @@ func (a *ArtistPage) showAlbumGrid() {
a.activeView = 0 // if page still loading, will show discography view first a.activeView = 0 // if page still loading, will show discography view first
return 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{ return widgets.GridViewItemModel{
Name: al.Name, Name: al.Name,
ID: al.ID, ID: al.ID,
CoverArtID: al.CoverArt, CoverArtID: al.CoverArtID,
Secondary: strconv.Itoa(al.Year), Secondary: strconv.Itoa(al.Year),
} }
}) })
@@ -190,7 +189,7 @@ func (a *ArtistPage) showTopTracks() {
a.activeView = 1 // if page still loading, will show tracks view first a.activeView = 1 // if page still loading, will show tracks view first
return return
} }
ts, err := a.sm.Server.GetTopSongs(a.artistInfo.Name, map[string]string{"count": "20"}) ts, err := a.mp.GetTopTracks(a.artistInfo.Artist, 20)
if err != nil { if err != nil {
log.Printf("error getting top songs: %s", err.Error()) log.Printf("error getting top songs: %s", err.Error())
return return
@@ -233,7 +232,7 @@ func (a *ArtistPage) CreateRenderer() fyne.WidgetRenderer {
} }
func (s *artistPageState) Restore() Page { func (s *artistPageState) Restore() Page {
return newArtistPage(s.artistID, s.cfg, s.pm, s.sm, s.im, s.contr, s.activeView) return newArtistPage(s.artistID, s.cfg, s.pm, s.mp, s.im, s.contr, s.activeView)
} }
type ArtistPageHeader struct { type ArtistPageHeader struct {
@@ -276,18 +275,18 @@ func NewArtistPageHeader(page *ArtistPage) *ArtistPageHeader {
return a return a
} }
func (a *ArtistPageHeader) Update(artist *subsonic.ArtistID3) { func (a *ArtistPageHeader) Update(artist *mediaprovider.ArtistWithAlbums) {
if artist == nil { if artist == nil {
return return
} }
a.favoriteBtn.IsFavorited = !artist.Starred.IsZero() a.favoriteBtn.IsFavorited = !artist.Favorite
a.favoriteBtn.Refresh() a.favoriteBtn.Refresh()
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()
} }
func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) { func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) {
if info == nil { if info == nil {
return return
} }
@@ -303,7 +302,7 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
} }
a.similarArtists.RemoveAll() a.similarArtists.RemoveAll()
for i, art := range info.SimilarArtist { for i, art := range info.SimilarArtists {
if i == 0 { if i == 0 {
a.similarArtists.Add(widget.NewLabel("Similar Artists:")) a.similarArtists.Add(widget.NewLabel("Similar Artists:"))
} }
@@ -320,11 +319,11 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
} }
a.similarArtists.Refresh() a.similarArtists.Refresh()
if info.LargeImageUrl != "" { if info.ImageURL != "" {
if a.artistImage.HaveImage() { if a.artistImage.HaveImage() {
_ = a.artistPage.im.RefreshCachedArtistImageIfExpired(a.artistID, info.LargeImageUrl) _ = a.artistPage.im.RefreshCachedArtistImageIfExpired(a.artistID, info.ImageURL)
} else { } else {
im, err := a.artistPage.im.FetchAndCacheArtistImage(a.artistID, info.LargeImageUrl) im, err := a.artistPage.im.FetchAndCacheArtistImage(a.artistID, info.ImageURL)
if err == nil { if err == nil {
a.artistImage.SetImage(im, true /*tappable*/) a.artistImage.SetImage(im, true /*tappable*/)
} }
@@ -333,11 +332,8 @@ func (a *ArtistPageHeader) UpdateInfo(info *subsonic.ArtistInfo2) {
} }
func (a *ArtistPageHeader) toggleFavorited() { func (a *ArtistPageHeader) toggleFavorited() {
if a.favoriteBtn.IsFavorited { params := mediaprovider.RatingFavoriteParameters{ArtistIDs: []string{a.artistID}}
a.artistPage.sm.Server.Star(subsonic.StarParameters{ArtistIDs: []string{a.artistID}}) a.artistPage.mp.SetFavorite(params, a.favoriteBtn.IsFavorited)
} else {
a.artistPage.sm.Server.Unstar(subsonic.StarParameters{ArtistIDs: []string{a.artistID}})
}
} }
func (a *ArtistPageHeader) createContainer() { func (a *ArtistPageHeader) createContainer() {
+21 -26
View File
@@ -3,9 +3,8 @@ package browsing
import ( import (
"log" "log"
"strings" "strings"
"time"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
@@ -16,8 +15,6 @@ import (
"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"
"github.com/dweymouth/go-subsonic/subsonic"
) )
var _ fyne.Widget = (*ArtistPage)(nil) var _ fyne.Widget = (*ArtistPage)(nil)
@@ -27,7 +24,7 @@ type ArtistsGenresPage struct {
isGenresPage bool isGenresPage bool
contr *controller.Controller contr *controller.Controller
sm *backend.ServerManager mp mediaprovider.MediaProvider
model []widgets.ArtistGenreListItemModel model []widgets.ArtistGenreListItemModel
list *widgets.ArtistGenreList list *widgets.ArtistGenreList
@@ -36,11 +33,11 @@ type ArtistsGenresPage struct {
searcher *widgets.SearchEntry searcher *widgets.SearchEntry
} }
func NewArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *backend.ServerManager) *ArtistsGenresPage { func NewArtistsGenresPage(isGenresPage bool, contr *controller.Controller, mp mediaprovider.MediaProvider) *ArtistsGenresPage {
return newArtistsGenresPage(isGenresPage, contr, sm, "") return newArtistsGenresPage(isGenresPage, contr, mp, "")
} }
func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *backend.ServerManager, searchText string) *ArtistsGenresPage { func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, mp mediaprovider.MediaProvider, searchText string) *ArtistsGenresPage {
title := "Artists" title := "Artists"
if isGenresPage { if isGenresPage {
title = "Genres" title = "Genres"
@@ -48,7 +45,7 @@ func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *b
a := &ArtistsGenresPage{ a := &ArtistsGenresPage{
isGenresPage: isGenresPage, isGenresPage: isGenresPage,
contr: contr, contr: contr,
sm: sm, mp: mp,
titleDisp: widget.NewRichTextWithText(title), titleDisp: widget.NewRichTextWithText(title),
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
@@ -74,13 +71,13 @@ func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, sm *b
// should be called asynchronously // should be called asynchronously
func (a *ArtistsGenresPage) load(searchOnLoad bool) { func (a *ArtistsGenresPage) load(searchOnLoad bool) {
if a.isGenresPage { if a.isGenresPage {
genres, err := a.sm.Server.GetGenres() genres, err := a.mp.GetGenres()
if err != nil { if err != nil {
log.Printf("error loading genres: %v", err.Error()) log.Printf("error loading genres: %v", err.Error())
} }
a.model = a.buildGenresListModel(genres) a.model = a.buildGenresListModel(genres)
} else { } else {
artists, err := a.sm.Server.GetArtists(nil) artists, err := a.mp.GetArtists()
if err != nil { if err != nil {
log.Printf("error loading artists: %v", err.Error()) log.Printf("error loading artists: %v", err.Error())
} }
@@ -129,7 +126,7 @@ func (a *ArtistsGenresPage) Save() SavedPage {
return &savedArtistsGenresPage{ return &savedArtistsGenresPage{
isGenresPage: a.isGenresPage, isGenresPage: a.isGenresPage,
contr: a.contr, contr: a.contr,
sm: a.sm, mp: a.mp,
searchText: a.searcher.Entry.Text, searchText: a.searcher.Entry.Text,
} }
} }
@@ -137,37 +134,35 @@ func (a *ArtistsGenresPage) Save() SavedPage {
type savedArtistsGenresPage struct { type savedArtistsGenresPage struct {
isGenresPage bool isGenresPage bool
contr *controller.Controller contr *controller.Controller
sm *backend.ServerManager mp mediaprovider.MediaProvider
searchText string searchText string
} }
func (s *savedArtistsGenresPage) Restore() Page { func (s *savedArtistsGenresPage) Restore() Page {
return newArtistsGenresPage(s.isGenresPage, s.contr, s.sm, s.searchText) return newArtistsGenresPage(s.isGenresPage, s.contr, s.mp, 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) model := make([]widgets.ArtistGenreListItemModel, 0)
for _, idx := range artists.Index { for _, artist := range artists {
for _, artist := range idx.Artist { model = append(model, widgets.ArtistGenreListItemModel{
model = append(model, widgets.ArtistGenreListItemModel{ ID: artist.ID,
ID: artist.ID, Name: artist.Name,
Name: artist.Name, AlbumCount: artist.AlbumCount,
AlbumCount: artist.AlbumCount, Favorite: artist.Favorite,
Favorite: artist.Starred != time.Time{}, })
})
}
} }
return model 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) model := make([]widgets.ArtistGenreListItemModel, 0)
for _, genre := range genres { for _, genre := range genres {
model = append(model, widgets.ArtistGenreListItemModel{ model = append(model, widgets.ArtistGenreListItemModel{
ID: genre.Name, ID: genre.Name,
Name: genre.Name, Name: genre.Name,
AlbumCount: genre.AlbumCount, AlbumCount: genre.AlbumCount,
TrackCount: genre.SongCount, TrackCount: genre.TrackCount,
Favorite: false, Favorite: false,
}) })
} }
+3 -4
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme" myTheme "github.com/dweymouth/supersonic/ui/theme"
@@ -11,8 +12,6 @@ import (
"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"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type Page interface { type Page interface {
@@ -37,7 +36,7 @@ type CanSelectAll interface {
} }
type CanShowNowPlaying interface { type CanShowNowPlaying interface {
OnSongChange(song *subsonic.Child, lastScrobbledIfAny *subsonic.Child) OnSongChange(song, lastScrobbledIfAny *mediaprovider.Track)
} }
type BrowsingPane struct { type BrowsingPane struct {
@@ -160,7 +159,7 @@ func (b *BrowsingPane) doSetPage(p Page) bool {
return true return true
} }
func (b *BrowsingPane) onSongChange(song *subsonic.Child, lastScrobbledIfAny *subsonic.Child) { func (b *BrowsingPane) onSongChange(song, lastScrobbledIfAny *mediaprovider.Track) {
if b.curPage == nil { if b.curPage == nil {
return return
} }
+22 -28
View File
@@ -4,6 +4,7 @@ import (
"log" "log"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
myTheme "github.com/dweymouth/supersonic/ui/theme" myTheme "github.com/dweymouth/supersonic/ui/theme"
@@ -15,8 +16,6 @@ import (
"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"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type FavoritesPage struct { type FavoritesPage struct {
@@ -26,10 +25,9 @@ type FavoritesPage struct {
contr *controller.Controller contr *controller.Controller
pm *backend.PlaybackManager pm *backend.PlaybackManager
im *backend.ImageManager im *backend.ImageManager
sm *backend.ServerManager mp mediaprovider.MediaProvider
lm *backend.LibraryManager
filter backend.AlbumFilter filter mediaprovider.AlbumFilter
searchText string searchText string
nowPlayingID string nowPlayingID string
pendingViewSwitch bool pendingViewSwitch bool
@@ -45,19 +43,18 @@ type FavoritesPage struct {
container *fyne.Container 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, mp mediaprovider.MediaProvider, pm *backend.PlaybackManager, im *backend.ImageManager) *FavoritesPage {
a := &FavoritesPage{ a := &FavoritesPage{
filter: backend.AlbumFilter{ExcludeUnfavorited: true}, filter: mediaprovider.AlbumFilter{ExcludeUnfavorited: true},
cfg: cfg, cfg: cfg,
contr: contr, contr: contr,
pm: pm, pm: pm,
lm: lm, mp: mp,
sm: sm,
im: im, im: im,
} }
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.createHeader(0) a.createHeader(0)
iter := lm.StarredIter(a.filter) iter := mp.IterateAlbums("", a.filter)
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im) a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
a.contr.ConnectAlbumGridActions(a.grid) a.contr.ConnectAlbumGridActions(a.grid)
if cfg.InitialView == "Artists" { if cfg.InitialView == "Artists" {
@@ -101,8 +98,7 @@ func restoreFavoritesPage(saved *savedFavoritesPage) *FavoritesPage {
cfg: saved.cfg, cfg: saved.cfg,
contr: saved.contr, contr: saved.contr,
pm: saved.pm, pm: saved.pm,
lm: saved.lm, mp: saved.mp,
sm: saved.sm,
im: saved.im, im: saved.im,
searchText: saved.searchText, searchText: saved.searchText,
filter: saved.filter, filter: saved.filter,
@@ -143,13 +139,13 @@ func (a *FavoritesPage) Reload() {
if a.searchText != "" { if a.searchText != "" {
a.doSearchAlbums(a.searchText) a.doSearchAlbums(a.searchText)
} else { } else {
iter := a.lm.StarredIter(a.filter) iter := a.mp.IterateAlbums("", a.filter)
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter)) a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
} }
if a.tracklistCtr != nil || a.artistListCtr != nil { if a.tracklistCtr != nil || a.artistListCtr != nil {
go func() { go func() {
// re-fetch starred info from server // re-fetch starred info from server
starred, err := a.sm.Server.GetStarred2(nil) starred, err := a.mp.GetFavorites()
if err != nil { if err != nil {
log.Printf("error getting starred items: %s", err.Error()) log.Printf("error getting starred items: %s", err.Error())
return return
@@ -157,7 +153,7 @@ func (a *FavoritesPage) Reload() {
if a.tracklistCtr != nil { if a.tracklistCtr != nil {
// refresh favorite songs view // refresh favorite songs view
tr := a.tracklistCtr.Objects[0].(*widgets.Tracklist) tr := a.tracklistCtr.Objects[0].(*widgets.Tracklist)
tr.Tracks = starred.Song tr.Tracks = starred.Tracks
if a.toggleBtns.ActivatedButtonIndex() == 2 { if a.toggleBtns.ActivatedButtonIndex() == 2 {
// favorite songs view is visible // favorite songs view is visible
tr.Refresh() tr.Refresh()
@@ -166,7 +162,7 @@ func (a *FavoritesPage) Reload() {
if a.artistListCtr != nil { if a.artistListCtr != nil {
// refresh favorite artists view // refresh favorite artists view
al := a.artistListCtr.Objects[0].(*widgets.ArtistGenreList) al := a.artistListCtr.Objects[0].(*widgets.ArtistGenreList)
al.Items = buildArtistListModel(starred.Artist) al.Items = buildArtistListModel(starred.Artists)
if a.toggleBtns.ActivatedButtonIndex() == 1 { if a.toggleBtns.ActivatedButtonIndex() == 1 {
// favorite artists view is visible // favorite artists view is visible
al.Refresh() al.Refresh()
@@ -181,9 +177,8 @@ func (a *FavoritesPage) Save() SavedPage {
cfg: a.cfg, cfg: a.cfg,
contr: a.contr, contr: a.contr,
pm: a.pm, pm: a.pm,
sm: a.sm, mp: a.mp,
im: a.im, im: a.im,
lm: a.lm,
filter: a.filter, filter: a.filter,
searchText: a.searchText, searchText: a.searchText,
gridState: a.grid.SaveToState(), gridState: a.grid.SaveToState(),
@@ -216,7 +211,7 @@ func (a *FavoritesPage) OnSearched(query string) {
var _ CanShowNowPlaying = (*FavoritesPage)(nil) var _ CanShowNowPlaying = (*FavoritesPage)(nil)
func (a *FavoritesPage) OnSongChange(song *subsonic.Child, _ *subsonic.Child) { func (a *FavoritesPage) OnSongChange(song, _ *mediaprovider.Track) {
a.nowPlayingID = "" a.nowPlayingID = ""
if song != nil { if song != nil {
a.nowPlayingID = song.ID a.nowPlayingID = song.ID
@@ -235,7 +230,7 @@ func (a *FavoritesPage) SelectAll() {
} }
func (a *FavoritesPage) doSearchAlbums(query string) { func (a *FavoritesPage) doSearchAlbums(query string) {
iter := a.lm.SearchIterWithFilter(query, a.filter) iter := a.mp.SearchAlbums(query, a.filter)
if a.searchGrid == nil { if a.searchGrid == nil {
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im) a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
a.contr.ConnectAlbumGridActions(a.searchGrid) a.contr.ConnectAlbumGridActions(a.searchGrid)
@@ -271,12 +266,12 @@ func (a *FavoritesPage) onShowFavoriteArtists() {
a.createContainer(layout.NewSpacer()) a.createContainer(layout.NewSpacer())
} }
go func() { go func() {
s, err := a.sm.Server.GetStarred2(nil) fav, err := a.mp.GetFavorites()
if err != nil { if err != nil {
log.Printf("error getting starred items: %s", err.Error()) log.Printf("error getting starred items: %s", err.Error())
return return
} }
model := buildArtistListModel(s.Artist) model := buildArtistListModel(fav.Artists)
artistList := widgets.NewArtistGenreList(model) artistList := widgets.NewArtistGenreList(model)
artistList.ShowAlbumCount = true artistList.ShowAlbumCount = true
artistList.OnNavTo = func(artistID string) { artistList.OnNavTo = func(artistID string) {
@@ -295,7 +290,7 @@ func (a *FavoritesPage) onShowFavoriteArtists() {
} }
} }
func buildArtistListModel(artists []*subsonic.ArtistID3) []widgets.ArtistGenreListItemModel { func buildArtistListModel(artists []*mediaprovider.Artist) []widgets.ArtistGenreListItemModel {
model := make([]widgets.ArtistGenreListItemModel, 0) model := make([]widgets.ArtistGenreListItemModel, 0)
for _, ar := range artists { for _, ar := range artists {
model = append(model, widgets.ArtistGenreListItemModel{ model = append(model, widgets.ArtistGenreListItemModel{
@@ -320,12 +315,12 @@ func (a *FavoritesPage) onShowFavoriteSongs() {
a.createContainer(layout.NewSpacer()) a.createContainer(layout.NewSpacer())
} }
go func() { go func() {
s, err := a.sm.Server.GetStarred2(nil) fav, err := a.mp.GetFavorites()
if err != nil { if err != nil {
log.Printf("error getting starred items: %s", err.Error()) log.Printf("error getting starred items: %s", err.Error())
return return
} }
tracklist := widgets.NewTracklist(s.Song) tracklist := widgets.NewTracklist(fav.Tracks)
tracklist.AutoNumber = true tracklist.AutoNumber = true
tracklist.SetVisibleColumns(a.cfg.TracklistColumns) tracklist.SetVisibleColumns(a.cfg.TracklistColumns)
tracklist.OnVisibleColumnsChanged = func(cols []string) { tracklist.OnVisibleColumnsChanged = func(cols []string) {
@@ -355,12 +350,11 @@ type savedFavoritesPage struct {
cfg *backend.FavoritesPageConfig cfg *backend.FavoritesPageConfig
contr *controller.Controller contr *controller.Controller
pm *backend.PlaybackManager pm *backend.PlaybackManager
sm *backend.ServerManager mp mediaprovider.MediaProvider
im *backend.ImageManager im *backend.ImageManager
lm *backend.LibraryManager
gridState widgets.GridViewState gridState widgets.GridViewState
searchGridState widgets.GridViewState searchGridState widgets.GridViewState
filter backend.AlbumFilter filter mediaprovider.AlbumFilter
searchText string searchText string
activeToggleBtn int activeToggleBtn int
} }
+13 -12
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
myTheme "github.com/dweymouth/supersonic/ui/theme" myTheme "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
@@ -22,12 +23,12 @@ type GenrePage struct {
contr *controller.Controller contr *controller.Controller
im *backend.ImageManager im *backend.ImageManager
pm *backend.PlaybackManager pm *backend.PlaybackManager
lm *backend.LibraryManager mp mediaprovider.MediaProvider
grid *widgets.GridView grid *widgets.GridView
searchGrid *widgets.GridView searchGrid *widgets.GridView
searcher *widgets.SearchEntry searcher *widgets.SearchEntry
searchText string searchText string
filter backend.AlbumFilter filter mediaprovider.AlbumFilter
filterBtn *widgets.AlbumFilterButton filterBtn *widgets.AlbumFilterButton
titleDisp *widget.RichText titleDisp *widget.RichText
playRandom *widget.Button playRandom *widget.Button
@@ -37,13 +38,13 @@ type GenrePage struct {
container *fyne.Container 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{ g := &GenrePage{
genre: genre, genre: genre,
filter: backend.AlbumFilter{Genres: []string{genre}}, filter: mediaprovider.AlbumFilter{Genres: []string{genre}},
contr: contr, contr: contr,
pm: pm, pm: pm,
lm: lm, mp: mp,
im: im, im: im,
} }
g.ExtendBaseWidget(g) g.ExtendBaseWidget(g)
@@ -53,7 +54,7 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba
SizeName: theme.SizeNameHeadingText, SizeName: theme.SizeNameHeadingText,
} }
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs) 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.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
g.contr.ConnectAlbumGridActions(g.grid) g.contr.ConnectAlbumGridActions(g.grid)
g.createSearchAndFilter() g.createSearchAndFilter()
@@ -90,7 +91,7 @@ func restoreGenrePage(saved *savedGenrePage) *GenrePage {
genre: saved.genre, genre: saved.genre,
contr: saved.contr, contr: saved.contr,
pm: saved.pm, pm: saved.pm,
lm: saved.lm, mp: saved.mp,
im: saved.im, im: saved.im,
searchText: saved.searchText, searchText: saved.searchText,
filter: saved.filter, filter: saved.filter,
@@ -124,7 +125,7 @@ func (g *GenrePage) Reload() {
if g.searchText != "" { if g.searchText != "" {
g.doSearch(g.searchText) g.doSearch(g.searchText)
} else { } else {
iter := g.lm.GenreIter(g.genre, g.filter) iter := g.mp.IterateAlbums("", g.filter)
g.grid.Reset(widgets.NewGridViewAlbumIterator(iter)) g.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
g.grid.Refresh() g.grid.Refresh()
} }
@@ -137,7 +138,7 @@ func (g *GenrePage) Save() SavedPage {
searchText: g.searchText, searchText: g.searchText,
contr: g.contr, contr: g.contr,
pm: g.pm, pm: g.pm,
lm: g.lm, mp: g.mp,
im: g.im, im: g.im,
gridState: g.grid.SaveToState(), gridState: g.grid.SaveToState(),
} }
@@ -167,7 +168,7 @@ func (g *GenrePage) OnSearched(query string) {
} }
func (g *GenrePage) doSearch(query string) { func (g *GenrePage) doSearch(query string) {
iter := g.lm.SearchIterWithFilter(query, g.filter) iter := g.mp.SearchAlbums(query, g.filter)
if g.searchGrid == nil { if g.searchGrid == nil {
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im) g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
g.contr.ConnectAlbumGridActions(g.searchGrid) g.contr.ConnectAlbumGridActions(g.searchGrid)
@@ -185,10 +186,10 @@ func (g *GenrePage) playRandomSongs() {
type savedGenrePage struct { type savedGenrePage struct {
genre string genre string
searchText string searchText string
filter backend.AlbumFilter filter mediaprovider.AlbumFilter
contr *controller.Controller contr *controller.Controller
pm *backend.PlaybackManager pm *backend.PlaybackManager
lm *backend.LibraryManager mp mediaprovider.MediaProvider
im *backend.ImageManager im *backend.ImageManager
gridState widgets.GridViewState gridState widgets.GridViewState
searchGridState widgets.GridViewState searchGridState widgets.GridViewState
+4 -7
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
@@ -10,8 +11,6 @@ import (
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type NowPlayingPage struct { type NowPlayingPage struct {
@@ -28,7 +27,6 @@ type NowPlayingPage struct {
type nowPlayingPageState struct { type nowPlayingPageState struct {
contr *controller.Controller contr *controller.Controller
conf *backend.NowPlayingPageConfig conf *backend.NowPlayingPageConfig
sm *backend.ServerManager
pm *backend.PlaybackManager pm *backend.PlaybackManager
} }
@@ -36,10 +34,9 @@ func NewNowPlayingPage(
highlightedTrackID string, highlightedTrackID string,
contr *controller.Controller, contr *controller.Controller,
conf *backend.NowPlayingPageConfig, conf *backend.NowPlayingPageConfig,
sm *backend.ServerManager,
pm *backend.PlaybackManager, pm *backend.PlaybackManager,
) *NowPlayingPage { ) *NowPlayingPage {
a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{contr: contr, conf: conf, sm: sm, pm: pm}} a := &NowPlayingPage{nowPlayingPageState: nowPlayingPageState{contr: contr, conf: conf, pm: pm}}
a.ExtendBaseWidget(a) a.ExtendBaseWidget(a)
a.tracklist = widgets.NewTracklist(nil) a.tracklist = widgets.NewTracklist(nil)
a.tracklist.SetVisibleColumns(conf.TracklistColumns) a.tracklist.SetVisibleColumns(conf.TracklistColumns)
@@ -83,7 +80,7 @@ func (a *NowPlayingPage) SelectAll() {
a.tracklist.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 { if song == nil {
a.nowPlayingID = "" a.nowPlayingID = ""
} else { } else {
@@ -118,5 +115,5 @@ func (a *NowPlayingPage) load(highlightedTrackID string) {
} }
func (s *nowPlayingPageState) Restore() Page { func (s *nowPlayingPageState) Restore() Page {
return NewNowPlayingPage("", s.contr, s.conf, s.sm, s.pm) return NewNowPlayingPage("", s.contr, s.conf, s.pm)
} }
+16 -20
View File
@@ -5,6 +5,7 @@ import (
"log" "log"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
@@ -17,8 +18,6 @@ import (
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type PlaylistPage struct { type PlaylistPage struct {
@@ -90,7 +89,7 @@ func (a *PlaylistPage) Route() controller.Route {
return controller.PlaylistRoute(a.playlistID) 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 { if song == nil {
a.nowPlayingID = "" a.nowPlayingID = ""
} else { } else {
@@ -119,7 +118,7 @@ func (a *PlaylistPage) load() {
log.Printf("Failed to get playlist: %s", err.Error()) log.Printf("Failed to get playlist: %s", err.Error())
return return
} }
a.tracklist.Tracks = playlist.Entry a.tracklist.Tracks = playlist.Tracks
a.tracklist.SetNowPlaying(a.nowPlayingID) a.tracklist.SetNowPlaying(a.nowPlayingID)
a.tracklist.Refresh() a.tracklist.Refresh()
a.header.Update(playlist) a.header.Update(playlist)
@@ -148,10 +147,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
for i, tr := range newTracks { for i, tr := range newTracks {
ids[i] = tr.ID ids[i] = tr.ID
} }
err := a.sm.Server.CreatePlaylistWithTracks(ids, map[string]string{ if err := a.sm.Server.ReplacePlaylistTracks(a.playlistID, ids); err != nil {
"playlistId": a.playlistID,
})
if err != nil {
log.Printf("error updating playlist: %s", err.Error()) log.Printf("error updating playlist: %s", err.Error())
} else { } else {
a.tracklist.Tracks = newTracks a.tracklist.Tracks = newTracks
@@ -161,7 +157,7 @@ func (a *PlaylistPage) doSetNewTrackOrder(op sharedutil.TrackReorderOp) {
} }
func (a *PlaylistPage) onRemoveSelectedFromPlaylist() { 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() a.tracklist.UnselectAll()
go a.Reload() go a.Reload()
} }
@@ -170,7 +166,7 @@ type PlaylistPageHeader struct {
widget.BaseWidget widget.BaseWidget
page *PlaylistPage page *PlaylistPage
playlistInfo *subsonic.Playlist playlistInfo *mediaprovider.PlaylistWithTracks
image *widgets.ImagePlaceholder image *widgets.ImagePlaceholder
editButton *widget.Button editButton *widget.Button
@@ -199,7 +195,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
a.trackTimeLabel = widget.NewLabel("") a.trackTimeLabel = widget.NewLabel("")
a.editButton = widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() { a.editButton = widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
if a.playlistInfo != nil { if a.playlistInfo != nil {
page.contr.DoEditPlaylistWorkflow(a.playlistInfo) page.contr.DoEditPlaylistWorkflow(&a.playlistInfo.Playlist)
} }
}) })
a.editButton.Hidden = true a.editButton.Hidden = true
@@ -244,18 +240,18 @@ func (a *PlaylistPageHeader) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(a.container) return widget.NewSimpleRenderer(a.container)
} }
func (a *PlaylistPageHeader) Update(playlist *subsonic.Playlist) { func (a *PlaylistPageHeader) Update(playlist *mediaprovider.PlaylistWithTracks) {
a.playlistInfo = playlist 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.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.ownerLabel.SetText(a.formatPlaylistOwnerStr(playlist))
a.trackTimeLabel.SetText(a.formatPlaylistTrackTimeStr(playlist)) a.trackTimeLabel.SetText(a.formatPlaylistTrackTimeStr(playlist))
a.createdAtLabel.SetText("created at TODO") a.createdAtLabel.SetText("created at TODO")
var haveCover bool var haveCover bool
if playlist.CoverArt != "" { if playlist.CoverArtID != "" {
if im, err := a.page.im.GetCoverThumbnail(playlist.CoverArt); err == nil && im != nil { if im, err := a.page.im.GetCoverThumbnail(playlist.CoverArtID); err == nil && im != nil {
a.image.SetImage(im, false /*tappable*/) a.image.SetImage(im, false /*tappable*/)
haveCover = true haveCover = true
} }
@@ -268,7 +264,7 @@ func (a *PlaylistPageHeader) Update(playlist *subsonic.Playlist) {
a.Refresh() a.Refresh()
} }
func (a *PlaylistPageHeader) formatPlaylistOwnerStr(p *subsonic.Playlist) string { func (a *PlaylistPageHeader) formatPlaylistOwnerStr(p *mediaprovider.PlaylistWithTracks) string {
pubPriv := "Public" pubPriv := "Public"
if !p.Public { if !p.Public {
pubPriv = "Private" pubPriv = "Private"
@@ -276,12 +272,12 @@ func (a *PlaylistPageHeader) formatPlaylistOwnerStr(p *subsonic.Playlist) string
return fmt.Sprintf("%s playlist by %s", pubPriv, p.Owner) 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" tracks := "tracks"
if p.SongCount == 1 { if p.TrackCount == 1 {
tracks = "track" 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 { func (s *playlistPageState) Restore() Page {
+26 -27
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
@@ -18,8 +19,6 @@ import (
"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"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type PlaylistsPage struct { type PlaylistsPage struct {
@@ -27,9 +26,9 @@ type PlaylistsPage struct {
cfg *backend.PlaylistsPageConfig cfg *backend.PlaylistsPageConfig
contr *controller.Controller contr *controller.Controller
sm *backend.ServerManager mp mediaprovider.MediaProvider
playlists []*subsonic.Playlist playlists []*mediaprovider.Playlist
searchedPlaylists []*subsonic.Playlist searchedPlaylists []*mediaprovider.Playlist
viewToggle *widgets.ToggleButtonGroup viewToggle *widgets.ToggleButtonGroup
searcher *widgets.SearchEntry searcher *widgets.SearchEntry
@@ -39,18 +38,18 @@ type PlaylistsPage struct {
gridView *widgets.GridView gridView *widgets.GridView
} }
func NewPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, sm *backend.ServerManager) *PlaylistsPage { func NewPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, mp mediaprovider.MediaProvider) *PlaylistsPage {
activeView := 0 activeView := 0
if cfg.InitialView == "Grid" { if cfg.InitialView == "Grid" {
activeView = 1 activeView = 1
} }
return newPlaylistsPage(contr, cfg, sm, "", activeView) return newPlaylistsPage(contr, cfg, mp, "", activeView)
} }
func newPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, sm *backend.ServerManager, searchText string, activeView int) *PlaylistsPage { func newPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageConfig, mp mediaprovider.MediaProvider, searchText string, activeView int) *PlaylistsPage {
a := &PlaylistsPage{ a := &PlaylistsPage{
cfg: cfg, cfg: cfg,
sm: sm, mp: mp,
contr: contr, contr: contr,
titleDisp: widget.NewRichTextWithText("Playlists"), titleDisp: widget.NewRichTextWithText("Playlists"),
} }
@@ -76,7 +75,7 @@ func newPlaylistsPage(contr *controller.Controller, cfg *backend.PlaylistsPageCo
} }
func (a *PlaylistsPage) load(searchOnLoad bool) { func (a *PlaylistsPage) load(searchOnLoad bool) {
playlists, err := a.sm.Server.GetPlaylists(nil) playlists, err := a.mp.GetPlaylists()
if err != nil { if err != nil {
log.Printf("error loading playlists: %v", err.Error()) log.Printf("error loading playlists: %v", err.Error())
} }
@@ -93,7 +92,7 @@ func (a *PlaylistsPage) createListView() {
a.listView.OnNavTo = a.showPlaylistPage a.listView.OnNavTo = a.showPlaylistPage
} }
func (a *PlaylistsPage) createGridView(playlists []*subsonic.Playlist) { func (a *PlaylistsPage) createGridView(playlists []*mediaprovider.Playlist) {
model := createPlaylistGridViewModel(playlists) model := createPlaylistGridViewModel(playlists)
a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager) a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager)
a.gridView.OnPlay = func(id string, shuffle bool) { 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()) log.Printf("error loading playlist: %s", err.Error())
return 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() a.container.Objects[0].Refresh()
} }
func createPlaylistGridViewModel(playlists []*subsonic.Playlist) []widgets.GridViewItemModel { func createPlaylistGridViewModel(playlists []*mediaprovider.Playlist) []widgets.GridViewItemModel {
return sharedutil.MapSlice(playlists, func(pl *subsonic.Playlist) widgets.GridViewItemModel { return sharedutil.MapSlice(playlists, func(pl *mediaprovider.Playlist) widgets.GridViewItemModel {
tracks := "tracks" tracks := "tracks"
if pl.SongCount == 1 { if pl.TrackCount == 1 {
tracks = "track" tracks = "track"
} }
return widgets.GridViewItemModel{ return widgets.GridViewItemModel{
Name: pl.Name, Name: pl.Name,
ID: pl.ID, ID: pl.ID,
CoverArtID: pl.CoverArt, CoverArtID: pl.CoverArtID,
Secondary: fmt.Sprintf("%d %s", pl.SongCount, tracks), Secondary: fmt.Sprintf("%d %s", pl.TrackCount, tracks),
} }
}) })
} }
@@ -164,15 +163,15 @@ func (a *PlaylistsPage) showPlaylistPage(id string) {
func (a *PlaylistsPage) onSearched(query string) { func (a *PlaylistsPage) onSearched(query string) {
// since the playlist list is returned in full non-paginated, we will do our own // 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 // 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 == "" { if query == "" {
a.searchedPlaylists = nil a.searchedPlaylists = nil
playlists = a.playlists playlists = a.playlists
} else { } 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) qLower := strings.ToLower(query)
return strings.Contains(strings.ToLower(p.Name), qLower) || 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) strings.Contains(strings.ToLower(p.Owner), qLower)
}) })
playlists = a.searchedPlaylists playlists = a.searchedPlaylists
@@ -182,7 +181,7 @@ func (a *PlaylistsPage) onSearched(query string) {
// update the model for both views if initialized, // update the model for both views if initialized,
// refresh the active view // refresh the active view
func (a *PlaylistsPage) refreshView(playlists []*subsonic.Playlist) { func (a *PlaylistsPage) refreshView(playlists []*mediaprovider.Playlist) {
if a.listView != nil { if a.listView != nil {
a.listView.Playlists = playlists a.listView.Playlists = playlists
} }
@@ -214,7 +213,7 @@ func (a *PlaylistsPage) Save() SavedPage {
return &savedPlaylistsPage{ return &savedPlaylistsPage{
contr: a.contr, contr: a.contr,
cfg: a.cfg, cfg: a.cfg,
sm: a.sm, mp: a.mp,
searchText: a.searcher.Entry.Text, searchText: a.searcher.Entry.Text,
activeView: a.viewToggle.ActivatedButtonIndex(), activeView: a.viewToggle.ActivatedButtonIndex(),
} }
@@ -223,13 +222,13 @@ func (a *PlaylistsPage) Save() SavedPage {
type savedPlaylistsPage struct { type savedPlaylistsPage struct {
contr *controller.Controller contr *controller.Controller
cfg *backend.PlaylistsPageConfig cfg *backend.PlaylistsPageConfig
sm *backend.ServerManager mp mediaprovider.MediaProvider
searchText string searchText string
activeView int activeView int
} }
func (s *savedPlaylistsPage) Restore() Page { func (s *savedPlaylistsPage) Restore() Page {
return newPlaylistsPage(s.contr, s.cfg, s.sm, s.searchText, s.activeView) return newPlaylistsPage(s.contr, s.cfg, s.mp, s.searchText, s.activeView)
} }
func (a *PlaylistsPage) buildContainer(initialView fyne.CanvasObject) { func (a *PlaylistsPage) buildContainer(initialView fyne.CanvasObject) {
@@ -247,7 +246,7 @@ func (a *PlaylistsPage) CreateRenderer() fyne.WidgetRenderer {
type PlaylistList struct { type PlaylistList struct {
widget.BaseWidget widget.BaseWidget
Playlists []*subsonic.Playlist Playlists []*mediaprovider.Playlist
OnNavTo func(string) OnNavTo func(string)
columnsLayout *layouts.ColumnsLayout columnsLayout *layouts.ColumnsLayout
@@ -274,9 +273,9 @@ func NewPlaylistList() *PlaylistList {
row := item.(*PlaylistListRow) row := item.(*PlaylistListRow)
row.ID = a.Playlists[id].ID row.ID = a.Playlists[id].ID
row.nameLabel.Text = a.Playlists[id].Name 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.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() row.Refresh()
}, },
) )
+10 -10
View File
@@ -28,27 +28,27 @@ func NewRouter(app *backend.App, controller *controller.Controller, nav Navigati
func (r Router) CreatePage(rte controller.Route) Page { func (r Router) CreatePage(rte controller.Route) Page {
switch rte.Page { switch rte.Page {
case controller.Album: 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: 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: case controller.Artist:
return NewArtistPage(rte.Arg, &r.App.Config.ArtistPage, r.App.PlaybackManager, r.App.ServerManager, r.App.ImageManager, r.Controller) return NewArtistPage(rte.Arg, &r.App.Config.ArtistPage, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager, r.Controller)
case controller.Artists: case controller.Artists:
return NewArtistsGenresPage(false, r.Controller, r.App.ServerManager) return NewArtistsGenresPage(false, r.Controller, r.App.ServerManager.Server)
case controller.Favorites: 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.Server, r.App.PlaybackManager, r.App.ImageManager)
case controller.Genre: 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: case controller.Genres:
return NewArtistsGenresPage(true, r.Controller, r.App.ServerManager) return NewArtistsGenresPage(true, r.Controller, r.App.ServerManager.Server)
case controller.NowPlaying: case controller.NowPlaying:
return NewNowPlayingPage(rte.Arg, r.Controller, &r.App.Config.NowPlayingPage, r.App.ServerManager, r.App.PlaybackManager) return NewNowPlayingPage(rte.Arg, r.Controller, &r.App.Config.NowPlayingPage, r.App.PlaybackManager)
case controller.Playlist: case controller.Playlist:
return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager) return NewPlaylistPage(rte.Arg, &r.App.Config.PlaylistPage, r.Controller, r.App.ServerManager, r.App.PlaybackManager, r.App.ImageManager)
case controller.Playlists: case controller.Playlists:
return NewPlaylistsPage(r.Controller, &r.App.Config.PlaylistsPage, r.App.ServerManager) return NewPlaylistsPage(r.Controller, &r.App.Config.PlaylistsPage, r.App.ServerManager.Server)
case controller.Tracks: 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 return nil
} }
+8 -9
View File
@@ -2,6 +2,7 @@ package browsing
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
@@ -12,8 +13,6 @@ import (
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type TracksPage struct { type TracksPage struct {
@@ -37,11 +36,11 @@ type tracksPageState struct {
searchText string searchText string
contr *controller.Controller contr *controller.Controller
conf *backend.TracksPageConfig conf *backend.TracksPageConfig
lm *backend.LibraryManager mp mediaprovider.MediaProvider
} }
func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, lm *backend.LibraryManager) *TracksPage { func NewTracksPage(contr *controller.Controller, conf *backend.TracksPageConfig, mp mediaprovider.MediaProvider) *TracksPage {
t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, lm: lm}} t := &TracksPage{tracksPageState: tracksPageState{contr: contr, conf: conf, mp: mp}}
t.ExtendBaseWidget(t) t.ExtendBaseWidget(t)
t.tracklist = widgets.NewTracklist(nil) t.tracklist = widgets.NewTracklist(nil)
@@ -79,12 +78,12 @@ func (t *TracksPage) Route() controller.Route {
func (t *TracksPage) Reload() { func (t *TracksPage) Reload() {
t.tracklist.Clear() t.tracklist.Clear()
iter := t.lm.AllTracksIterator() iter := t.mp.IterateTracks("")
// loads asynchronously // loads asynchronously
t.loader = widgets.NewTracklistLoader(t.tracklist, iter) 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.nowPlayingID = sharedutil.TrackIDOrEmptyStr(track)
t.tracklist.SetNowPlaying(t.nowPlayingID) t.tracklist.SetNowPlaying(t.nowPlayingID)
if t.searchTracklist != nil { if t.searchTracklist != nil {
@@ -130,7 +129,7 @@ func (t *TracksPage) doSearch(query string) {
} else { } else {
t.searchTracklist.Clear() t.searchTracklist.Clear()
} }
iter := t.lm.SearchTracksIterator(query) iter := t.mp.IterateTracks(query)
t.searchLoader = widgets.NewTracklistLoader(t.searchTracklist, iter) t.searchLoader = widgets.NewTracklistLoader(t.searchTracklist, iter)
t.container.Objects[0].(*fyne.Container).Objects[0] = t.searchTracklist t.container.Objects[0].(*fyne.Container).Objects[0] = t.searchTracklist
t.Refresh() t.Refresh()
@@ -146,7 +145,7 @@ func (t *TracksPage) Save() SavedPage {
} }
func (s *tracksPageState) Restore() Page { 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 t.searchText = s.searchText
if t.searchText != "" { if t.searchText != "" {
t.searcher.Entry.Text = t.searchText t.searcher.Entry.Text = t.searchText
+51 -83
View File
@@ -3,12 +3,10 @@ package controller
import ( import (
"image" "image"
"log" "log"
"math"
"strconv"
"sync"
"time" "time"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/player" "github.com/dweymouth/supersonic/player"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/dialogs" "github.com/dweymouth/supersonic/ui/dialogs"
@@ -20,8 +18,6 @@ import (
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
type NavigationHandler func(Route) type NavigationHandler func(Route)
@@ -94,14 +90,14 @@ func (m *Controller) ShowPopUpImage(img image.Image) {
func (m *Controller) ConnectTracklistActions(tracklist *widgets.Tracklist) { func (m *Controller) ConnectTracklistActions(tracklist *widgets.Tracklist) {
tracklist.OnAddToPlaylist = m.DoAddTracksToPlaylistWorkflow tracklist.OnAddToPlaylist = m.DoAddTracksToPlaylistWorkflow
tracklist.OnAddToQueue = func(tracks []*subsonic.Child) { tracklist.OnAddToQueue = func(tracks []*mediaprovider.Track) {
m.App.PlaybackManager.LoadTracks(tracks, true, false) m.App.PlaybackManager.LoadTracks(tracks, true, false)
} }
tracklist.OnPlayTrackAt = func(idx int) { tracklist.OnPlayTrackAt = func(idx int) {
m.App.PlaybackManager.LoadTracks(tracklist.Tracks, false, false) m.App.PlaybackManager.LoadTracks(tracklist.Tracks, false, false)
m.App.PlaybackManager.PlayTrackAt(idx) 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.LoadTracks(tracks, false, shuffle)
m.App.PlaybackManager.PlayFromBeginning() m.App.PlaybackManager.PlayFromBeginning()
} }
@@ -132,12 +128,14 @@ func (m *Controller) ConnectAlbumGridActions(grid *widgets.GridView) {
m.NavigateTo(ArtistRoute(artistID)) m.NavigateTo(ArtistRoute(artistID))
} }
grid.OnAddToPlaylist = func(albumID string) { grid.OnAddToPlaylist = func(albumID string) {
album, err := m.App.ServerManager.Server.GetAlbum(albumID) go func() {
if err != nil { album, err := m.App.ServerManager.Server.GetAlbum(albumID)
log.Printf("error loading album: %s", err.Error()) if err != nil {
return log.Printf("error loading album: %s", err.Error())
} return
m.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(album.Song)) }
m.DoAddTracksToPlaylistWorkflow(sharedutil.TracksToIDs(album.Tracks))
}()
} }
} }
@@ -173,37 +171,41 @@ func (m *Controller) PromptForFirstServer() {
// Depending on the results of that dialog, potentially create a new playlist // Depending on the results of that dialog, potentially create a new playlist
// Add tracks to the user-specified playlist // Add tracks to the user-specified playlist
func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) { func (m *Controller) DoAddTracksToPlaylistWorkflow(trackIDs []string) {
pls, err := m.App.LibraryManager.GetUserOwnedPlaylists() go func() {
if err != nil { pls, err := m.App.ServerManager.Server.GetPlaylists()
// TODO: surface this error to user pls = sharedutil.FilterSlice(pls, func(pl *mediaprovider.Playlist) bool {
log.Printf("error getting user-owned playlists: %s", err.Error()) return pl.Owner == m.App.ServerManager.LoggedInUser
return })
} if err != nil {
plNames := make([]string, 0, len(pls)) // TODO: surface this error to user
for _, pl := range pls { log.Printf("error getting user-owned playlists: %s", err.Error())
plNames = append(plNames, pl.Name) return
}
dlg := dialogs.NewAddToPlaylistDialog("Add to Playlist", plNames)
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
m.ClosePopUpOnEscape(pop)
dlg.OnCanceled = pop.Hide
dlg.OnSubmit = func(playlistChoice int, newPlaylistName string) {
pop.Hide()
m.doModalClosed()
if playlistChoice < 0 {
m.App.ServerManager.Server.CreatePlaylistWithTracks(
trackIDs, map[string]string{"name": newPlaylistName})
} else {
m.App.ServerManager.Server.UpdatePlaylistTracks(
pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/)
} }
} plNames := make([]string, 0, len(pls))
m.haveModal = true for _, pl := range pls {
pop.Show() plNames = append(plNames, pl.Name)
}
dlg := dialogs.NewAddToPlaylistDialog("Add to Playlist", plNames)
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
m.ClosePopUpOnEscape(pop)
dlg.OnCanceled = pop.Hide
dlg.OnSubmit = func(playlistChoice int, newPlaylistName string) {
pop.Hide()
m.doModalClosed()
if playlistChoice < 0 {
go m.App.ServerManager.Server.CreatePlaylist(newPlaylistName, trackIDs)
} else {
go m.App.ServerManager.Server.EditPlaylistTracks(
pls[playlistChoice].ID, trackIDs, nil /*tracksToRemove*/)
}
}
m.haveModal = true
pop.Show()
}()
} }
func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) { func (m *Controller) DoEditPlaylistWorkflow(playlist *mediaprovider.Playlist) {
dlg := dialogs.NewEditPlaylistDialog(playlist) dlg := dialogs.NewEditPlaylistDialog(playlist)
pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas()) pop := widget.NewModalPopUp(dlg, m.MainWindow.Canvas())
m.ClosePopUpOnEscape(pop) m.ClosePopUpOnEscape(pop)
@@ -234,11 +236,7 @@ func (m *Controller) DoEditPlaylistWorkflow(playlist *subsonic.Playlist) {
pop.Hide() pop.Hide()
m.doModalClosed() m.doModalClosed()
go func() { go func() {
err := m.App.ServerManager.Server.UpdatePlaylist(playlist.ID, map[string]string{ err := m.App.ServerManager.Server.EditPlaylist(playlist.ID, dlg.Name, dlg.Description, dlg.IsPublic)
"name": dlg.Name,
"comment": dlg.Description,
"public": strconv.FormatBool(dlg.IsPublic),
})
if err != nil { if err != nil {
log.Printf("error updating playlist: %s", err.Error()) log.Printf("error updating playlist: %s", err.Error())
} else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID { } else if rte := m.CurPageFunc(); rte.Page == Playlist && rte.Arg == playlist.ID {
@@ -405,49 +403,19 @@ func (c *Controller) doModalClosed() {
} }
func (c *Controller) SetTrackFavorites(trackIDs []string, favorite bool) { func (c *Controller) SetTrackFavorites(trackIDs []string, favorite bool) {
s := c.App.ServerManager.Server go c.App.ServerManager.Server.SetFavorite(mediaprovider.RatingFavoriteParameters{
if favorite { TrackIDs: trackIDs,
go s.Star(subsonic.StarParameters{SongIDs: trackIDs}) }, favorite)
} else {
go s.Unstar(subsonic.StarParameters{SongIDs: trackIDs})
}
for _, id := range trackIDs { for _, id := range trackIDs {
c.App.PlaybackManager.OnTrackFavoriteStatusChanged(id, favorite) c.App.PlaybackManager.OnTrackFavoriteStatusChanged(id, favorite)
} }
} }
func (c *Controller) SetTrackRatings(trackIDs []string, rating int) { func (c *Controller) SetTrackRatings(trackIDs []string, rating int) {
// Subsonic doesn't allow bulk setting ratings. go c.App.ServerManager.Server.SetRating(mediaprovider.RatingFavoriteParameters{
// To not overwhelm the server with requests, set rating for TrackIDs: trackIDs,
// only 5 tracks at a time concurrently }, rating)
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()
}
}()
}
// Notify PlaybackManager of rating change to update // Notify PlaybackManager of rating change to update
// the in-memory track models // the in-memory track models
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"fyne.io/fyne/v2/data/binding" "fyne.io/fyne/v2/data/binding"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic" "github.com/dweymouth/supersonic/backend/mediaprovider"
) )
type EditPlaylistDialog struct { type EditPlaylistDialog struct {
@@ -23,11 +23,11 @@ type EditPlaylistDialog struct {
container *fyne.Container container *fyne.Container
} }
func NewEditPlaylistDialog(playlist *subsonic.Playlist) *EditPlaylistDialog { func NewEditPlaylistDialog(playlist *mediaprovider.Playlist) *EditPlaylistDialog {
e := &EditPlaylistDialog{ e := &EditPlaylistDialog{
IsPublic: playlist.Public, IsPublic: playlist.Public,
Name: playlist.Name, Name: playlist.Name,
Description: playlist.Comment, Description: playlist.Description,
} }
e.ExtendBaseWidget(e) e.ExtendBaseWidget(e)
+3 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/ui/browsing" "github.com/dweymouth/supersonic/ui/browsing"
"github.com/dweymouth/supersonic/ui/controller" "github.com/dweymouth/supersonic/ui/controller"
@@ -15,7 +16,6 @@ import (
"fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
var ( var (
@@ -81,12 +81,12 @@ func NewMainWindow(fyneApp fyne.App, appName, appVersion string, app *backend.Ap
m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane) m.container = container.NewBorder(nil, m.BottomPanel, nil, nil, m.BrowsingPane)
m.Window.SetContent(m.container) m.Window.SetContent(m.container)
m.Window.Resize(size) m.Window.Resize(size)
app.PlaybackManager.OnSongChange(func(song *subsonic.Child, _ *subsonic.Child) { app.PlaybackManager.OnSongChange(func(song, _ *mediaprovider.Track) {
if song == nil { if song == nil {
m.Window.SetTitle(appName) m.Window.SetTitle(appName)
return return
} }
m.Window.SetTitle(fmt.Sprintf("%s %s · %s", song.Title, song.Artist, appName)) m.Window.SetTitle(fmt.Sprintf("%s %s · %s", song.Name, song.ArtistNames[0], appName))
}) })
app.ServerManager.OnServerConnected(func() { app.ServerManager.OnServerConnected(func() {
m.BrowsingPane.EnableNavigationButtons() m.BrowsingPane.EnableNavigationButtons()
+3 -3
View File
@@ -9,7 +9,7 @@ import (
"fyne.io/fyne/v2/container" "fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/ui/theme" "github.com/dweymouth/supersonic/ui/theme"
"github.com/dweymouth/supersonic/ui/util" "github.com/dweymouth/supersonic/ui/util"
) )
@@ -21,11 +21,11 @@ type AlbumFilterButton struct {
GenreDisabled bool GenreDisabled bool
FavoriteDisabled bool FavoriteDisabled bool
filter *backend.AlbumFilter filter *mediaprovider.AlbumFilter
dialog *widget.PopUp dialog *widget.PopUp
} }
func NewAlbumFilterButton(filter *backend.AlbumFilter) *AlbumFilterButton { func NewAlbumFilterButton(filter *mediaprovider.AlbumFilter) *AlbumFilterButton {
a := &AlbumFilterButton{ a := &AlbumFilterButton{
filter: filter, filter: filter,
Button: widget.Button{ Button: widget.Button{
+30 -9
View File
@@ -6,17 +6,38 @@ import (
"log" "log"
"sync" "sync"
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/res" "github.com/dweymouth/supersonic/res"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
const batchFetchSize = 6 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 { type ImageFetcher interface {
GetCoverThumbnailFromCache(string) (image.Image, bool) GetCoverThumbnailFromCache(string) (image.Image, bool)
GetCoverThumbnail(string) (image.Image, error) GetCoverThumbnail(string) (image.Image, error)
@@ -27,24 +48,24 @@ type GridViewIterator interface {
} }
type gridViewAlbumIterator struct { type gridViewAlbumIterator struct {
iter *backend.BatchingIterator iter BatchingIterator
} }
func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel { func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel {
albums := g.iter.NextN(n) 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{ return GridViewItemModel{
Name: al.Name, Name: al.Name,
ID: al.ID, ID: al.ID,
CoverArtID: al.CoverArt, CoverArtID: al.CoverArtID,
Secondary: al.Artist, Secondary: al.ArtistNames[0],
SecondaryID: al.ArtistID, SecondaryID: al.ArtistIDs[0],
} }
}) })
} }
func NewGridViewAlbumIterator(iter backend.AlbumIterator) GridViewIterator { func NewGridViewAlbumIterator(iter mediaprovider.AlbumIterator) GridViewIterator {
return gridViewAlbumIterator{iter: backend.NewBatchingIterator(iter)} return gridViewAlbumIterator{iter: NewBatchingIterator(iter)}
} }
type GridView struct { type GridView struct {
+30 -35
View File
@@ -5,8 +5,8 @@ import (
"log" "log"
"strconv" "strconv"
"sync" "sync"
"time"
"github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/sharedutil"
"github.com/dweymouth/supersonic/ui/layouts" "github.com/dweymouth/supersonic/ui/layouts"
"github.com/dweymouth/supersonic/ui/os" "github.com/dweymouth/supersonic/ui/os"
@@ -18,7 +18,6 @@ import (
"fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
"github.com/dweymouth/go-subsonic/subsonic"
) )
const ( const (
@@ -40,7 +39,7 @@ type Tracklist struct {
// Tracks is the set of tracks displayed by the widget. // Tracks is the set of tracks displayed by the widget.
// Direct access to this is not thread-safe but OK for // Direct access to this is not thread-safe but OK for
// views that only load tracks into the widget once at page load. // views that only load tracks into the widget once at page load.
Tracks []*subsonic.Child Tracks []*mediaprovider.Track
// AutoNumber sets whether to auto-number the tracks 1..N in display order, // AutoNumber sets whether to auto-number the tracks 1..N in display order,
// or to use the number from the track's metadata // or to use the number from the track's metadata
@@ -60,8 +59,8 @@ type Tracklist struct {
// user action callbacks // user action callbacks
OnPlayTrackAt func(int) OnPlayTrackAt func(int)
OnPlaySelection func(tracks []*subsonic.Child, shuffle bool) OnPlaySelection func(tracks []*mediaprovider.Track, shuffle bool)
OnAddToQueue func(trackIDs []*subsonic.Child) OnAddToQueue func(trackIDs []*mediaprovider.Track)
OnAddToPlaylist func(trackIDs []string) OnAddToPlaylist func(trackIDs []string)
OnSetFavorite func(trackIDs []string, fav bool) OnSetFavorite func(trackIDs []string, fav bool)
OnSetRating func(trackIDs []string, rating int) OnSetRating func(trackIDs []string, rating int)
@@ -85,7 +84,7 @@ type Tracklist struct {
container *fyne.Container container *fyne.Container
} }
func NewTracklist(tracks []*subsonic.Child) *Tracklist { func NewTracklist(tracks []*mediaprovider.Track) *Tracklist {
t := &Tracklist{Tracks: tracks, visibleColumns: make([]bool, 12)} t := &Tracklist{Tracks: tracks, visibleColumns: make([]bool, 12)}
t.ExtendBaseWidget(t) t.ExtendBaseWidget(t)
@@ -144,7 +143,7 @@ func (t *Tracklist) buildHeader() {
} }
// Gets the track at the given index. Thread-safe. // Gets the track at the given index. Thread-safe.
func (t *Tracklist) TrackAt(idx int) *subsonic.Child { func (t *Tracklist) TrackAt(idx int) *mediaprovider.Track {
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
if idx >= len(t.Tracks) { if idx >= len(t.Tracks) {
@@ -216,7 +215,7 @@ func (t *Tracklist) Clear() {
} }
// Append more tracks to the tracklist. Thread-safe. // Append more tracks to the tracklist. Thread-safe.
func (t *Tracklist) AppendTracks(trs []*subsonic.Child) { func (t *Tracklist) AppendTracks(trs []*mediaprovider.Track) {
t.tracksMutex.Lock() t.tracksMutex.Lock()
defer t.tracksMutex.Unlock() defer t.tracksMutex.Unlock()
t.Tracks = append(t.Tracks, trs...) t.Tracks = append(t.Tracks, trs...)
@@ -330,16 +329,12 @@ func (t *Tracklist) onSetFavorite(trackID string, fav bool) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
tr := sharedutil.FindTrackByID(trackID, t.Tracks) tr := sharedutil.FindTrackByID(trackID, t.Tracks)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
t.onSetFavorites([]*subsonic.Child{tr}, fav, false) t.onSetFavorites([]*mediaprovider.Track{tr}, fav, false)
} }
func (t *Tracklist) onSetFavorites(tracks []*subsonic.Child, fav bool, needRefresh bool) { func (t *Tracklist) onSetFavorites(tracks []*mediaprovider.Track, fav bool, needRefresh bool) {
for _, tr := range tracks { for _, tr := range tracks {
if fav { tr.Favorite = fav
tr.Starred = time.Now()
} else {
tr.Starred = time.Time{}
}
} }
if needRefresh { if needRefresh {
t.Refresh() t.Refresh()
@@ -355,12 +350,12 @@ func (t *Tracklist) onSetRating(trackID string, rating int) {
t.tracksMutex.RLock() t.tracksMutex.RLock()
tr := sharedutil.FindTrackByID(trackID, t.Tracks) tr := sharedutil.FindTrackByID(trackID, t.Tracks)
t.tracksMutex.RUnlock() t.tracksMutex.RUnlock()
t.onSetRatings([]*subsonic.Child{tr}, rating, false) t.onSetRatings([]*mediaprovider.Track{tr}, rating, false)
} }
func (t *Tracklist) onSetRatings(tracks []*subsonic.Child, rating int, needRefresh bool) { func (t *Tracklist) onSetRatings(tracks []*mediaprovider.Track, rating int, needRefresh bool) {
for _, tr := range tracks { for _, tr := range tracks {
tr.UserRating = rating tr.Rating = rating
} }
if needRefresh { if needRefresh {
t.Refresh() t.Refresh()
@@ -383,9 +378,9 @@ func (t *Tracklist) onAlbumTapped(albumID string) {
} }
} }
func (t *Tracklist) selectedTracks() []*subsonic.Child { func (t *Tracklist) selectedTracks() []*mediaprovider.Track {
sel := t.selectionMgr.GetSelection() sel := t.selectionMgr.GetSelection()
tracks := make([]*subsonic.Child, 0, len(sel)) tracks := make([]*mediaprovider.Track, 0, len(sel))
t.tracksMutex.RLock() t.tracksMutex.RLock()
defer t.tracksMutex.RUnlock() defer t.tracksMutex.RUnlock()
for _, idx := range sel { for _, idx := range sel {
@@ -484,7 +479,7 @@ type TrackRow struct {
albumID string albumID string
isPlaying bool isPlaying bool
isFavorite bool isFavorite bool
playCount int64 playCount int
num *widget.RichText num *widget.RichText
name *widget.RichText name *widget.RichText
@@ -543,37 +538,37 @@ func newTrailingAlignRichText() *widget.RichText {
return rt return rt
} }
func (t *TrackRow) Update(tr *subsonic.Child, rowNum int) { func (t *TrackRow) Update(tr *mediaprovider.Track, rowNum int) {
// Update info that can change if this row is bound to // Update info that can change if this row is bound to
// a new track (*subsonic.Child) // a new track (*mediaprovider.Track)
if tr.ID != t.trackID { if tr.ID != t.trackID {
if t.Focused { if t.Focused {
fyne.CurrentApp().Driver().CanvasForObject(t).Focus(nil) fyne.CurrentApp().Driver().CanvasForObject(t).Focus(nil)
t.Focused = false t.Focused = false
} }
t.trackID = tr.ID t.trackID = tr.ID
t.artistID = tr.ArtistID t.artistID = tr.ArtistIDs[0]
t.albumID = tr.AlbumID t.albumID = tr.AlbumID
t.name.Segments[0].(*widget.TextSegment).Text = tr.Title t.name.Segments[0].(*widget.TextSegment).Text = tr.Name
t.artist.SetText(tr.Artist) t.artist.SetText(tr.ArtistNames[0])
t.artist.Disabled = tr.ArtistID == "" t.artist.Disabled = tr.ArtistIDs[0] == ""
t.album.SetText(tr.Album) t.album.SetText(tr.Album)
t.dur.Segments[0].(*widget.TextSegment).Text = util.SecondsToTimeString(float64(tr.Duration)) t.dur.Segments[0].(*widget.TextSegment).Text = util.SecondsToTimeString(float64(tr.Duration))
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)
t.size.Segments[0].(*widget.TextSegment).Text = util.BytesToSizeString(tr.Size) t.size.Segments[0].(*widget.TextSegment).Text = util.BytesToSizeString(tr.Size)
t.path.Segments[0].(*widget.TextSegment).Text = tr.Path t.path.Segments[0].(*widget.TextSegment).Text = tr.FilePath
} }
// Update track num if needed // Update track num if needed
// (which can change based on bound *subsonic.Child or tracklist.AutoNumber) // (which can change based on bound *mediaprovider.Track or tracklist.AutoNumber)
if t.trackNum != rowNum { if t.trackNum != rowNum {
discNum := -1 discNum := -1
var str string var str string
if rowNum < 0 { if rowNum < 0 {
rowNum = tr.Track rowNum = tr.TrackNumber
if t.tracklist.ShowDiscNumber { if t.tracklist.ShowDiscNumber {
discNum = tr.DiscNumber discNum = tr.DiscNumber
} }
@@ -612,15 +607,15 @@ func (t *TrackRow) Update(tr *subsonic.Child, rowNum int) {
} }
// Render favorite column // Render favorite column
if tr.Starred.IsZero() { if tr.Favorite {
t.isFavorite = false
t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.NotFavoriteIcon
} else {
t.isFavorite = true t.isFavorite = true
t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.FavoriteIcon t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.FavoriteIcon
} else {
t.isFavorite = false
t.favorite.Objects[0].(*TappableIcon).Resource = myTheme.NotFavoriteIcon
} }
t.rating.Rating = tr.UserRating t.rating.Rating = tr.Rating
// Show only columns configured to be visible // Show only columns configured to be visible
t.artist.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnArtist)] t.artist.Hidden = !t.tracklist.visibleColumns[ColNumber(ColumnArtist)]
+5 -7
View File
@@ -1,25 +1,23 @@
package widgets package widgets
import ( import (
"github.com/dweymouth/supersonic/backend" "github.com/dweymouth/supersonic/backend/mediaprovider"
"github.com/dweymouth/go-subsonic/subsonic"
) )
// Component that manages lazily loading more tracks into a Tracklist // Component that manages lazily loading more tracks into a Tracklist
// as the user scrolls near the bottom. // as the user scrolls near the bottom.
type TracklistLoader struct { type TracklistLoader struct {
tracklist *Tracklist tracklist *Tracklist
iter backend.TrackIterator iter mediaprovider.TrackIterator
trackBuffer []*subsonic.Child trackBuffer []*mediaprovider.Track
fetching bool fetching bool
done bool done bool
len int len int
highestShown int highestShown int
} }
func NewTracklistLoader(tracklist *Tracklist, iter backend.TrackIterator) TracklistLoader { func NewTracklistLoader(tracklist *Tracklist, iter mediaprovider.TrackIterator) TracklistLoader {
t := TracklistLoader{ t := TracklistLoader{
tracklist: tracklist, tracklist: tracklist,
iter: iter, iter: iter,
@@ -44,7 +42,7 @@ func (t *TracklistLoader) loadMoreTracks(num int) {
// repeat fetch task as long as user has scrolled near bottom // repeat fetch task as long as user has scrolled near bottom
for !t.done && t.highestShown >= t.len-25 { for !t.done && t.highestShown >= t.len-25 {
if t.trackBuffer == nil { if t.trackBuffer == nil {
t.trackBuffer = make([]*subsonic.Child, 0, num) t.trackBuffer = make([]*mediaprovider.Track, 0, num)
} }
t.trackBuffer = t.trackBuffer[:0] t.trackBuffer = t.trackBuffer[:0]
for i := 0; i < num; i++ { for i := 0; i < num; i++ {