Merge branch 'main' into feature/sort-tracklist-cols
This commit is contained in:
@@ -20,6 +20,7 @@ type AlbumWithTracks struct {
|
||||
|
||||
type Artist struct {
|
||||
ID string
|
||||
CoverArtID string
|
||||
Name string
|
||||
Favorite bool
|
||||
AlbumCount int
|
||||
|
||||
@@ -311,6 +311,7 @@ func toArtistFromID3(ar *subsonic.ArtistID3) *mediaprovider.Artist {
|
||||
}
|
||||
return &mediaprovider.Artist{
|
||||
ID: ar.ID,
|
||||
CoverArtID: ar.CoverArt,
|
||||
Name: ar.Name,
|
||||
Favorite: !ar.Starred.IsZero(),
|
||||
AlbumCount: ar.AlbumCount,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB |
File diff suppressed because one or more lines are too long
@@ -1,7 +1,6 @@
|
||||
#!/bin/sh
|
||||
|
||||
fyne bundle -package res -prefix Res appicon-256.png > bundled.go
|
||||
fyne bundle -append -prefix Res albumplaceholder.png >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/freepik/disc.png >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/freepik/disc-invert.png >> bundled.go
|
||||
fyne bundle -append -prefix Res icons/freepik/headphones.png >> bundled.go
|
||||
|
||||
@@ -164,7 +164,7 @@ type AlbumPageHeader struct {
|
||||
func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader {
|
||||
a := &AlbumPageHeader{page: page}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.cover = widgets.NewTappableImage(func() { go a.showPopUpCover() })
|
||||
a.cover = widgets.NewTappableImage(func(*fyne.PointEvent) { go a.showPopUpCover() })
|
||||
a.cover.FillMode = canvas.ImageFillContain
|
||||
a.cover.SetMinSize(fyne.NewSize(225, 225))
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
|
||||
@@ -75,7 +76,7 @@ func NewAlbumsPage(cfg *backend.AlbumsPageConfig, contr *controller.Controller,
|
||||
}
|
||||
a.sortOrder.Selected = cfg.SortOrder
|
||||
iter := mp.IterateAlbums(a.sortOrder.Selected, a.filter)
|
||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im)
|
||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), im, myTheme.AlbumIcon)
|
||||
contr.ConnectAlbumGridActions(a.grid)
|
||||
a.createSearchAndFilter()
|
||||
a.createContainer(false)
|
||||
@@ -190,7 +191,7 @@ func (a *AlbumsPage) Save() SavedPage {
|
||||
func (a *AlbumsPage) doSearch(query string) {
|
||||
iter := widgets.NewGridViewAlbumIterator(a.mp.SearchAlbums(query, a.filter))
|
||||
if a.searchGrid == nil {
|
||||
a.searchGrid = widgets.NewGridView(iter, a.im)
|
||||
a.searchGrid = widgets.NewGridView(iter, a.im, myTheme.AlbumIcon)
|
||||
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
||||
} else {
|
||||
a.searchGrid.Reset(iter)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
@@ -131,15 +130,6 @@ func (a *ArtistPage) OnSongChange(track, lastScrobbledIfAny *mediaprovider.Track
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtistPage) playAllTracks() {
|
||||
if a.artistInfo != nil { // page loaded
|
||||
for i, album := range a.artistInfo.Albums {
|
||||
a.pm.LoadAlbum(album.ID, i > 0 /*append*/, false /*shuffle*/)
|
||||
}
|
||||
a.pm.PlayFromBeginning()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtistPage) playArtistRadio() {
|
||||
go a.pm.PlaySimilarSongs(a.artistID)
|
||||
}
|
||||
@@ -180,7 +170,7 @@ func (a *ArtistPage) showAlbumGrid() {
|
||||
Secondary: strconv.Itoa(al.Year),
|
||||
}
|
||||
})
|
||||
a.albumGrid = widgets.NewFixedGridView(model, a.im)
|
||||
a.albumGrid = widgets.NewFixedGridView(model, a.im, myTheme.AlbumIcon)
|
||||
a.contr.ConnectAlbumGridActions(a.albumGrid)
|
||||
}
|
||||
a.container.Objects[0].(*fyne.Container).Objects[0] = a.albumGrid
|
||||
@@ -266,14 +256,16 @@ func NewArtistPageHeader(page *ArtistPage) *ArtistPageHeader {
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
||||
SizeName: theme.SizeNameHeadingText,
|
||||
}
|
||||
a.artistImage = widgets.NewImagePlaceholder(res.ResPeopleInvertPng, 225)
|
||||
a.artistImage.OnTapped = func() {
|
||||
a.artistImage = widgets.NewImagePlaceholder(myTheme.ArtistIcon, 225)
|
||||
a.artistImage.OnTapped = func(*fyne.PointEvent) {
|
||||
if im := a.artistImage.Image(); im != nil {
|
||||
a.artistPage.contr.ShowPopUpImage(im)
|
||||
}
|
||||
}
|
||||
a.favoriteBtn = widgets.NewFavoriteButton(func() { go a.toggleFavorited() })
|
||||
a.playBtn = widget.NewButtonWithIcon("Play Discography", theme.MediaPlayIcon(), page.playAllTracks)
|
||||
a.playBtn = widget.NewButtonWithIcon("Play Discography", theme.MediaPlayIcon(), func() {
|
||||
go a.artistPage.contr.PlayArtistDiscography(a.artistID, false /*shuffle*/)
|
||||
})
|
||||
a.playRadioBtn = widget.NewButtonWithIcon(" Play Artist Radio", myTheme.ShuffleIcon, page.playArtistRadio)
|
||||
a.biographyDisp.Wrapping = fyne.TextWrapWord
|
||||
a.ExtendBaseWidget(a)
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package browsing
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
var _ fyne.Widget = (*ArtistPage)(nil)
|
||||
|
||||
type ArtistsGenresPage struct {
|
||||
widget.BaseWidget
|
||||
|
||||
isGenresPage bool
|
||||
contr *controller.Controller
|
||||
mp mediaprovider.MediaProvider
|
||||
model []widgets.ArtistGenreListItemModel
|
||||
list *widgets.ArtistGenreList
|
||||
|
||||
titleDisp *widget.RichText
|
||||
container *fyne.Container
|
||||
searcher *widgets.SearchEntry
|
||||
}
|
||||
|
||||
func NewArtistsGenresPage(isGenresPage bool, contr *controller.Controller, mp mediaprovider.MediaProvider) *ArtistsGenresPage {
|
||||
return newArtistsGenresPage(isGenresPage, contr, mp, "")
|
||||
}
|
||||
|
||||
func newArtistsGenresPage(isGenresPage bool, contr *controller.Controller, mp mediaprovider.MediaProvider, searchText string) *ArtistsGenresPage {
|
||||
title := "Artists"
|
||||
if isGenresPage {
|
||||
title = "Genres"
|
||||
}
|
||||
a := &ArtistsGenresPage{
|
||||
isGenresPage: isGenresPage,
|
||||
contr: contr,
|
||||
mp: mp,
|
||||
titleDisp: widget.NewRichTextWithText(title),
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
|
||||
a.list = widgets.NewArtistGenreList(nil)
|
||||
a.list.ShowAlbumCount = true
|
||||
a.list.ShowTrackCount = isGenresPage
|
||||
a.list.OnNavTo = func(id string) {
|
||||
if a.isGenresPage {
|
||||
a.contr.NavigateTo(controller.GenreRoute(id))
|
||||
} else {
|
||||
a.contr.NavigateTo(controller.ArtistRoute(id))
|
||||
}
|
||||
}
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.OnSearched = a.onSearched
|
||||
a.searcher.Entry.Text = searchText
|
||||
a.buildContainer()
|
||||
go a.load(searchText != "")
|
||||
return a
|
||||
}
|
||||
|
||||
// should be called asynchronously
|
||||
func (a *ArtistsGenresPage) load(searchOnLoad bool) {
|
||||
if a.isGenresPage {
|
||||
genres, err := a.mp.GetGenres()
|
||||
if err != nil {
|
||||
log.Printf("error loading genres: %v", err.Error())
|
||||
}
|
||||
a.model = a.buildGenresListModel(genres)
|
||||
} else {
|
||||
artists, err := a.mp.GetArtists()
|
||||
if err != nil {
|
||||
log.Printf("error loading artists: %v", err.Error())
|
||||
}
|
||||
a.model = a.buildArtistListModel(artists)
|
||||
}
|
||||
if searchOnLoad {
|
||||
a.onSearched(a.searcher.Entry.Text)
|
||||
} else {
|
||||
a.list.Items = a.model
|
||||
a.list.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) onSearched(query string) {
|
||||
// since the artists and genres lists are returned in full non-paginated, we will do our own
|
||||
// simple search based on the artist/genre name, rather than calling a server API
|
||||
if query == "" {
|
||||
a.list.Items = a.model
|
||||
} else {
|
||||
result := sharedutil.FilterSlice(a.model, func(x widgets.ArtistGenreListItemModel) bool {
|
||||
return strings.Contains(strings.ToLower(x.Name), strings.ToLower(query))
|
||||
})
|
||||
a.list.Items = result
|
||||
}
|
||||
a.list.Refresh()
|
||||
}
|
||||
|
||||
var _ Searchable = (*ArtistsGenresPage)(nil)
|
||||
|
||||
func (a *ArtistsGenresPage) SearchWidget() fyne.Focusable {
|
||||
return a.searcher
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) Route() controller.Route {
|
||||
if a.isGenresPage {
|
||||
return controller.GenresRoute()
|
||||
}
|
||||
return controller.ArtistsRoute()
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) Reload() {
|
||||
go a.load(false)
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) Save() SavedPage {
|
||||
return &savedArtistsGenresPage{
|
||||
isGenresPage: a.isGenresPage,
|
||||
contr: a.contr,
|
||||
mp: a.mp,
|
||||
searchText: a.searcher.Entry.Text,
|
||||
}
|
||||
}
|
||||
|
||||
type savedArtistsGenresPage struct {
|
||||
isGenresPage bool
|
||||
contr *controller.Controller
|
||||
mp mediaprovider.MediaProvider
|
||||
searchText string
|
||||
}
|
||||
|
||||
func (s *savedArtistsGenresPage) Restore() Page {
|
||||
return newArtistsGenresPage(s.isGenresPage, s.contr, s.mp, s.searchText)
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) buildArtistListModel(artists []*mediaprovider.Artist) []widgets.ArtistGenreListItemModel {
|
||||
model := make([]widgets.ArtistGenreListItemModel, 0)
|
||||
for _, artist := range artists {
|
||||
model = append(model, widgets.ArtistGenreListItemModel{
|
||||
ID: artist.ID,
|
||||
Name: artist.Name,
|
||||
AlbumCount: artist.AlbumCount,
|
||||
Favorite: artist.Favorite,
|
||||
})
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) buildGenresListModel(genres []*mediaprovider.Genre) []widgets.ArtistGenreListItemModel {
|
||||
model := make([]widgets.ArtistGenreListItemModel, 0)
|
||||
for _, genre := range genres {
|
||||
model = append(model, widgets.ArtistGenreListItemModel{
|
||||
ID: genre.Name,
|
||||
Name: genre.Name,
|
||||
AlbumCount: genre.AlbumCount,
|
||||
TrackCount: genre.TrackCount,
|
||||
Favorite: false,
|
||||
})
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) buildContainer() {
|
||||
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
||||
a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
|
||||
container.NewBorder(
|
||||
container.New(&layouts.MaxPadLayout{PadLeft: -5},
|
||||
container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)),
|
||||
nil, nil, nil, a.list))
|
||||
}
|
||||
|
||||
func (a *ArtistsGenresPage) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package browsing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
)
|
||||
|
||||
type ArtistsPage struct {
|
||||
widget.BaseWidget
|
||||
|
||||
contr *controller.Controller
|
||||
im *backend.ImageManager
|
||||
pm *backend.PlaybackManager
|
||||
mp mediaprovider.MediaProvider
|
||||
artists []*mediaprovider.Artist
|
||||
searchedArtists []*mediaprovider.Artist
|
||||
grid *widgets.GridView
|
||||
fullGridScrollPos float32
|
||||
searchGridScrollPos float32
|
||||
searcher *widgets.SearchEntry
|
||||
searchText string
|
||||
titleDisp *widget.RichText
|
||||
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewArtistsPage(
|
||||
contr *controller.Controller,
|
||||
pm *backend.PlaybackManager,
|
||||
mp mediaprovider.MediaProvider,
|
||||
im *backend.ImageManager,
|
||||
) *ArtistsPage {
|
||||
return newArtistsPage(contr, pm, mp, im, "", 0, 0)
|
||||
}
|
||||
|
||||
func newArtistsPage(
|
||||
contr *controller.Controller,
|
||||
pm *backend.PlaybackManager,
|
||||
mp mediaprovider.MediaProvider,
|
||||
im *backend.ImageManager,
|
||||
searchText string,
|
||||
fullGridScrollPos float32,
|
||||
searchGridScrollPos float32,
|
||||
) *ArtistsPage {
|
||||
a := &ArtistsPage{
|
||||
contr: contr,
|
||||
pm: pm,
|
||||
mp: mp,
|
||||
im: im,
|
||||
searchText: searchText,
|
||||
fullGridScrollPos: fullGridScrollPos,
|
||||
searchGridScrollPos: searchGridScrollPos,
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
|
||||
a.titleDisp = widget.NewRichTextWithText("Artists")
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
||||
SizeName: theme.SizeNameHeadingText,
|
||||
}
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.OnSearched = func(query string) { a.onSearched(query, false /*firstLoad*/) }
|
||||
a.searcher.Entry.Text = searchText
|
||||
a.grid = widgets.NewFixedGridView(nil, a.im, myTheme.ArtistIcon)
|
||||
a.contr.ConnectArtistGridActions(a.grid)
|
||||
|
||||
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
||||
a.container = container.NewBorder(
|
||||
container.NewHBox(util.NewHSpace(6),
|
||||
a.titleDisp, layout.NewSpacer(), searchVbox, util.NewHSpace(15)),
|
||||
nil, nil, nil, a.grid,
|
||||
)
|
||||
|
||||
go a.load()
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ArtistsPage) Reload() {
|
||||
a.searchGridScrollPos = 0
|
||||
a.fullGridScrollPos = 0
|
||||
go a.load()
|
||||
}
|
||||
|
||||
func (a *ArtistsPage) load() {
|
||||
artists, err := a.mp.GetArtists()
|
||||
if err != nil {
|
||||
log.Printf("error loading artists: %v", err.Error())
|
||||
}
|
||||
a.artists = artists
|
||||
a.onSearched(a.searcher.Entry.Text, true)
|
||||
}
|
||||
|
||||
func (a *ArtistsPage) onSearched(query string, firstLoad bool) {
|
||||
// since the playlist list is returned in full non-paginated, we will do our own
|
||||
// simple search based on the name, description, and owner, rather than calling a server API
|
||||
var artists []*mediaprovider.Artist
|
||||
scrollPos := float32(0)
|
||||
if query == "" {
|
||||
a.searchedArtists = nil
|
||||
artists = a.artists
|
||||
scrollPos = a.fullGridScrollPos
|
||||
} else {
|
||||
if firstLoad { // if reloading with a saved search state, set scroll position
|
||||
scrollPos = a.searchGridScrollPos
|
||||
}
|
||||
if a.searchText == "" {
|
||||
// if first search, capture scroll position of full, unsearched grid
|
||||
a.fullGridScrollPos = a.grid.GetScrollOffset()
|
||||
}
|
||||
qLower := strings.ToLower(query)
|
||||
a.searchedArtists = sharedutil.FilterSlice(a.artists, func(p *mediaprovider.Artist) bool {
|
||||
return strings.Contains(strings.ToLower(p.Name), qLower)
|
||||
})
|
||||
artists = a.searchedArtists
|
||||
}
|
||||
a.searchText = query
|
||||
a.grid.ResetFixed(createArtistsGridViewModel(artists))
|
||||
a.grid.Refresh()
|
||||
a.grid.ScrollToOffset(scrollPos)
|
||||
}
|
||||
|
||||
func createArtistsGridViewModel(artists []*mediaprovider.Artist) []widgets.GridViewItemModel {
|
||||
return sharedutil.MapSlice(artists, func(ar *mediaprovider.Artist) widgets.GridViewItemModel {
|
||||
albums := "albums"
|
||||
if ar.AlbumCount == 1 {
|
||||
albums = "album"
|
||||
}
|
||||
return widgets.GridViewItemModel{
|
||||
Name: ar.Name,
|
||||
ID: ar.ID,
|
||||
CoverArtID: ar.CoverArtID,
|
||||
Secondary: fmt.Sprintf("%d %s", ar.AlbumCount, albums),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ArtistsPage) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
|
||||
func (a *ArtistsPage) Route() controller.Route {
|
||||
return controller.ArtistsRoute()
|
||||
}
|
||||
|
||||
func (a *ArtistsPage) Save() SavedPage {
|
||||
s := &savedArtistsPage{
|
||||
contr: a.contr,
|
||||
im: a.im,
|
||||
pm: a.pm,
|
||||
mp: a.mp,
|
||||
searchText: a.searchText,
|
||||
fullGridScrollPos: a.fullGridScrollPos,
|
||||
}
|
||||
if a.searchText == "" {
|
||||
s.fullGridScrollPos = a.grid.GetScrollOffset()
|
||||
} else {
|
||||
s.searchGridScrollPos = a.grid.GetScrollOffset()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type savedArtistsPage struct {
|
||||
contr *controller.Controller
|
||||
im *backend.ImageManager
|
||||
pm *backend.PlaybackManager
|
||||
mp mediaprovider.MediaProvider
|
||||
searchText string
|
||||
fullGridScrollPos float32
|
||||
searchGridScrollPos float32
|
||||
}
|
||||
|
||||
func (s *savedArtistsPage) Restore() Page {
|
||||
return newArtistsPage(s.contr, s.pm, s.mp, s.im, s.searchText, s.fullGridScrollPos, s.searchGridScrollPos)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package browsing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
@@ -33,15 +34,15 @@ type FavoritesPage struct {
|
||||
nowPlayingID string
|
||||
pendingViewSwitch bool
|
||||
|
||||
grid *widgets.GridView
|
||||
searchGrid *widgets.GridView
|
||||
artistListCtr *fyne.Container
|
||||
tracklistCtr *fyne.Container
|
||||
searcher *widgets.SearchEntry
|
||||
filterBtn *widgets.AlbumFilterButton
|
||||
titleDisp *widget.RichText
|
||||
toggleBtns *widgets.ToggleButtonGroup
|
||||
container *fyne.Container
|
||||
grid *widgets.GridView
|
||||
searchGrid *widgets.GridView
|
||||
artistGrid *widgets.GridView
|
||||
tracklistCtr *fyne.Container
|
||||
searcher *widgets.SearchEntry
|
||||
filterBtn *widgets.AlbumFilterButton
|
||||
titleDisp *widget.RichText
|
||||
toggleBtns *widgets.ToggleButtonGroup
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Controller, mp mediaprovider.MediaProvider, pm *backend.PlaybackManager, im *backend.ImageManager) *FavoritesPage {
|
||||
@@ -56,7 +57,7 @@ func NewFavoritesPage(cfg *backend.FavoritesPageConfig, contr *controller.Contro
|
||||
a.ExtendBaseWidget(a)
|
||||
a.createHeader(0)
|
||||
iter := mp.IterateAlbums("", a.filter)
|
||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
||||
a.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im, myTheme.AlbumIcon)
|
||||
a.contr.ConnectAlbumGridActions(a.grid)
|
||||
if cfg.InitialView == "Artists" {
|
||||
a.toggleBtns.SetActivatedButton(1)
|
||||
@@ -145,7 +146,7 @@ func (a *FavoritesPage) Reload() {
|
||||
iter := a.mp.IterateAlbums("", a.filter)
|
||||
a.grid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
}
|
||||
if a.tracklistCtr != nil || a.artistListCtr != nil {
|
||||
if a.tracklistCtr != nil || a.artistGrid != nil {
|
||||
go func() {
|
||||
// re-fetch starred info from server
|
||||
starred, err := a.mp.GetFavorites()
|
||||
@@ -162,13 +163,12 @@ func (a *FavoritesPage) Reload() {
|
||||
tr.Refresh()
|
||||
}
|
||||
}
|
||||
if a.artistListCtr != nil {
|
||||
if a.artistGrid != nil {
|
||||
// refresh favorite artists view
|
||||
al := a.artistListCtr.Objects[0].(*widgets.ArtistGenreList)
|
||||
al.Items = buildArtistListModel(starred.Artists)
|
||||
a.artistGrid.ResetFixed(buildArtistGridViewModel(starred.Artists))
|
||||
if a.toggleBtns.ActivatedButtonIndex() == 1 {
|
||||
// favorite artists view is visible
|
||||
al.Refresh()
|
||||
a.artistGrid.Refresh()
|
||||
}
|
||||
}
|
||||
}()
|
||||
@@ -238,7 +238,7 @@ func (a *FavoritesPage) SelectAll() {
|
||||
func (a *FavoritesPage) doSearchAlbums(query string) {
|
||||
iter := a.mp.SearchAlbums(query, a.filter)
|
||||
if a.searchGrid == nil {
|
||||
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im)
|
||||
a.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), a.im, myTheme.AlbumIcon)
|
||||
a.contr.ConnectAlbumGridActions(a.searchGrid)
|
||||
} else {
|
||||
a.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
@@ -263,7 +263,7 @@ func (a *FavoritesPage) onShowFavoriteArtists() {
|
||||
a.cfg.InitialView = "Artists" // save setting
|
||||
a.searcher.Entry.Hide() // disable search on artists for now
|
||||
a.filterBtn.Hide()
|
||||
if a.artistListCtr == nil {
|
||||
if a.artistGrid == nil {
|
||||
if a.pendingViewSwitch {
|
||||
return
|
||||
}
|
||||
@@ -277,32 +277,31 @@ func (a *FavoritesPage) onShowFavoriteArtists() {
|
||||
log.Printf("error getting starred items: %s", err.Error())
|
||||
return
|
||||
}
|
||||
model := buildArtistListModel(fav.Artists)
|
||||
artistList := widgets.NewArtistGenreList(model)
|
||||
artistList.ShowAlbumCount = true
|
||||
artistList.OnNavTo = func(artistID string) {
|
||||
a.contr.NavigateTo(controller.ArtistRoute(artistID))
|
||||
}
|
||||
a.artistListCtr = container.New(
|
||||
&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
|
||||
artistList)
|
||||
a.container.Objects[0] = a.artistListCtr
|
||||
model := buildArtistGridViewModel(fav.Artists)
|
||||
a.artistGrid = widgets.NewFixedGridView(model, a.im, myTheme.ArtistIcon)
|
||||
a.contr.ConnectArtistGridActions(a.artistGrid)
|
||||
a.container.Objects[0] = a.artistGrid
|
||||
a.Refresh()
|
||||
a.pendingViewSwitch = false
|
||||
}()
|
||||
} else {
|
||||
a.container.Objects[0] = a.artistListCtr
|
||||
a.container.Objects[0] = a.artistGrid
|
||||
a.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func buildArtistListModel(artists []*mediaprovider.Artist) []widgets.ArtistGenreListItemModel {
|
||||
model := make([]widgets.ArtistGenreListItemModel, 0)
|
||||
func buildArtistGridViewModel(artists []*mediaprovider.Artist) []widgets.GridViewItemModel {
|
||||
model := make([]widgets.GridViewItemModel, 0)
|
||||
for _, ar := range artists {
|
||||
model = append(model, widgets.ArtistGenreListItemModel{
|
||||
albums := "albums"
|
||||
if ar.AlbumCount == 1 {
|
||||
albums = "album"
|
||||
}
|
||||
model = append(model, widgets.GridViewItemModel{
|
||||
ID: ar.ID,
|
||||
CoverArtID: ar.CoverArtID,
|
||||
Name: ar.Name,
|
||||
AlbumCount: ar.AlbumCount,
|
||||
Secondary: fmt.Sprintf("%d %s", ar.AlbumCount, albums),
|
||||
})
|
||||
}
|
||||
return model
|
||||
|
||||
@@ -33,8 +33,6 @@ type GenrePage struct {
|
||||
titleDisp *widget.RichText
|
||||
playRandom *widget.Button
|
||||
|
||||
OnPlayAlbum func(string, int)
|
||||
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
@@ -55,7 +53,7 @@ func NewGenrePage(genre string, contr *controller.Controller, pm *backend.Playba
|
||||
}
|
||||
g.playRandom = widget.NewButtonWithIcon(" Play random", myTheme.ShuffleIcon, g.playRandomSongs)
|
||||
iter := g.mp.IterateAlbums("", g.filter)
|
||||
g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
||||
g.grid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im, myTheme.AlbumIcon)
|
||||
g.contr.ConnectAlbumGridActions(g.grid)
|
||||
g.createSearchAndFilter()
|
||||
g.createContainer(false)
|
||||
@@ -171,7 +169,7 @@ func (g *GenrePage) OnSearched(query string) {
|
||||
func (g *GenrePage) doSearch(query string) {
|
||||
iter := g.mp.SearchAlbums(query, g.filter)
|
||||
if g.searchGrid == nil {
|
||||
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im)
|
||||
g.searchGrid = widgets.NewGridView(widgets.NewGridViewAlbumIterator(iter), g.im, myTheme.AlbumIcon)
|
||||
g.contr.ConnectAlbumGridActions(g.searchGrid)
|
||||
} else {
|
||||
g.searchGrid.Reset(widgets.NewGridViewAlbumIterator(iter))
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
package browsing
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/layout"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
var _ fyne.Widget = (*ArtistPage)(nil)
|
||||
|
||||
type GenresPage struct {
|
||||
widget.BaseWidget
|
||||
|
||||
contr *controller.Controller
|
||||
mp mediaprovider.MediaProvider
|
||||
genres []*mediaprovider.Genre
|
||||
list *GenreList
|
||||
|
||||
titleDisp *widget.RichText
|
||||
container *fyne.Container
|
||||
searcher *widgets.SearchEntry
|
||||
}
|
||||
|
||||
func NewGenresPage(contr *controller.Controller, mp mediaprovider.MediaProvider) *GenresPage {
|
||||
return newGenresPage(contr, mp, "")
|
||||
}
|
||||
|
||||
func newGenresPage(contr *controller.Controller, mp mediaprovider.MediaProvider, searchText string) *GenresPage {
|
||||
a := &GenresPage{
|
||||
contr: contr,
|
||||
mp: mp,
|
||||
titleDisp: widget.NewRichTextWithText("Genres"),
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.titleDisp.Segments[0].(*widget.TextSegment).Style.SizeName = theme.SizeNameHeadingText
|
||||
a.list = NewGenreList(nil)
|
||||
a.list.ShowAlbumCount = true
|
||||
a.list.ShowTrackCount = true
|
||||
a.list.OnNavTo = func(id string) { a.contr.NavigateTo(controller.GenreRoute(id)) }
|
||||
a.searcher = widgets.NewSearchEntry()
|
||||
a.searcher.OnSearched = a.onSearched
|
||||
a.searcher.Entry.Text = searchText
|
||||
a.buildContainer()
|
||||
go a.load(searchText != "")
|
||||
return a
|
||||
}
|
||||
|
||||
// should be called asynchronously
|
||||
func (a *GenresPage) load(searchOnLoad bool) {
|
||||
genres, err := a.mp.GetGenres()
|
||||
if err != nil {
|
||||
log.Printf("error loading genres: %v", err.Error())
|
||||
}
|
||||
if searchOnLoad {
|
||||
a.onSearched(a.searcher.Entry.Text)
|
||||
} else {
|
||||
a.list.Items = genres
|
||||
a.list.Refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenresPage) onSearched(query string) {
|
||||
// since the artists and genres lists are returned in full non-paginated, we will do our own
|
||||
// simple search based on the artist/genre name, rather than calling a server API
|
||||
if query == "" {
|
||||
a.list.Items = a.genres
|
||||
} else {
|
||||
result := sharedutil.FilterSlice(a.genres, func(x *mediaprovider.Genre) bool {
|
||||
return strings.Contains(strings.ToLower(x.Name), strings.ToLower(query))
|
||||
})
|
||||
a.list.Items = result
|
||||
}
|
||||
a.list.Refresh()
|
||||
}
|
||||
|
||||
var _ Searchable = (*GenresPage)(nil)
|
||||
|
||||
func (a *GenresPage) SearchWidget() fyne.Focusable {
|
||||
return a.searcher
|
||||
}
|
||||
|
||||
func (a *GenresPage) Route() controller.Route {
|
||||
return controller.GenresRoute()
|
||||
}
|
||||
|
||||
func (a *GenresPage) Reload() {
|
||||
go a.load(false)
|
||||
}
|
||||
|
||||
func (a *GenresPage) Save() SavedPage {
|
||||
return &savedArtistsGenresPage{
|
||||
contr: a.contr,
|
||||
mp: a.mp,
|
||||
searchText: a.searcher.Entry.Text,
|
||||
}
|
||||
}
|
||||
|
||||
type savedArtistsGenresPage struct {
|
||||
isGenresPage bool
|
||||
contr *controller.Controller
|
||||
mp mediaprovider.MediaProvider
|
||||
searchText string
|
||||
}
|
||||
|
||||
func (s *savedArtistsGenresPage) Restore() Page {
|
||||
return newGenresPage(s.contr, s.mp, s.searchText)
|
||||
}
|
||||
|
||||
func (a *GenresPage) buildContainer() {
|
||||
searchVbox := container.NewVBox(layout.NewSpacer(), a.searcher, layout.NewSpacer())
|
||||
a.container = container.New(&layouts.MaxPadLayout{PadLeft: 15, PadRight: 15, PadTop: 5, PadBottom: 15},
|
||||
container.NewBorder(
|
||||
container.New(&layouts.MaxPadLayout{PadLeft: -5},
|
||||
container.NewHBox(a.titleDisp, layout.NewSpacer(), searchVbox)),
|
||||
nil, nil, nil, a.list))
|
||||
}
|
||||
|
||||
func (a *GenresPage) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
|
||||
type GenreList struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Items []*mediaprovider.Genre
|
||||
ShowAlbumCount bool
|
||||
ShowTrackCount bool
|
||||
OnNavTo func(string)
|
||||
|
||||
columnsLayout *layouts.ColumnsLayout
|
||||
hdr *widgets.ListHeader
|
||||
list *widget.List
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
type GenreListRow struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Item *mediaprovider.Genre
|
||||
OnTapped func()
|
||||
|
||||
nameLabel *widget.Label
|
||||
albumCountLabel *widget.Label
|
||||
trackCountLabel *widget.Label
|
||||
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewGenreListRow(layout *layouts.ColumnsLayout) *GenreListRow {
|
||||
a := &GenreListRow{
|
||||
nameLabel: widget.NewLabel(""),
|
||||
albumCountLabel: widget.NewLabel(""),
|
||||
trackCountLabel: widget.NewLabel(""),
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.albumCountLabel.Alignment = fyne.TextAlignTrailing
|
||||
a.trackCountLabel.Alignment = fyne.TextAlignTrailing
|
||||
a.container = container.New(layout, a.nameLabel, a.albumCountLabel, a.trackCountLabel)
|
||||
return a
|
||||
}
|
||||
|
||||
func NewGenreList(items []*mediaprovider.Genre) *GenreList {
|
||||
a := &GenreList{
|
||||
Items: items,
|
||||
columnsLayout: layouts.NewColumnsLayout([]float32{-1, 125, 125}),
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.hdr = widgets.NewListHeader([]widgets.ListColumn{
|
||||
{"Name", false, false}, {"Album Count", true, false}, {"Track Count", true, false}}, a.columnsLayout)
|
||||
a.list = widget.NewList(
|
||||
func() int { return len(a.Items) },
|
||||
func() fyne.CanvasObject {
|
||||
r := NewGenreListRow(a.columnsLayout)
|
||||
r.OnTapped = func() { a.onRowDoubleTapped(r.Item) }
|
||||
return r
|
||||
},
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
row := item.(*GenreListRow)
|
||||
row.Item = a.Items[id]
|
||||
row.albumCountLabel.Hidden = !a.ShowAlbumCount
|
||||
row.trackCountLabel.Hidden = !a.ShowTrackCount
|
||||
row.nameLabel.Text = row.Item.Name
|
||||
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
|
||||
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
|
||||
row.Refresh()
|
||||
},
|
||||
)
|
||||
a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *GenreList) Refresh() {
|
||||
a.hdr.SetColumnVisible(1, a.ShowAlbumCount)
|
||||
a.hdr.SetColumnVisible(2, a.ShowTrackCount)
|
||||
a.BaseWidget.Refresh()
|
||||
}
|
||||
|
||||
func (a *GenreList) onRowDoubleTapped(item *mediaprovider.Genre) {
|
||||
if a.OnNavTo != nil {
|
||||
a.OnNavTo(item.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenreListRow) Tapped(*fyne.PointEvent) {
|
||||
if a.OnTapped != nil {
|
||||
a.OnTapped()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GenreList) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
|
||||
func (a *GenreListRow) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
@@ -208,7 +207,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader {
|
||||
a := &PlaylistPageHeader{page: page}
|
||||
a.ExtendBaseWidget(a)
|
||||
|
||||
a.image = widgets.NewImagePlaceholder(res.ResPlaylistInvertPng, 225)
|
||||
a.image = widgets.NewImagePlaceholder(myTheme.PlaylistIcon, 225)
|
||||
a.titleLabel = widget.NewRichTextWithText("")
|
||||
a.titleLabel.Wrapping = fyne.TextTruncate
|
||||
a.titleLabel.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
"github.com/dweymouth/supersonic/ui/controller"
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/widgets"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
@@ -94,7 +95,7 @@ func (a *PlaylistsPage) createListView() {
|
||||
|
||||
func (a *PlaylistsPage) createGridView(playlists []*mediaprovider.Playlist) {
|
||||
model := createPlaylistGridViewModel(playlists)
|
||||
a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager)
|
||||
a.gridView = widgets.NewFixedGridView(model, a.contr.App.ImageManager, myTheme.PlaylistIcon)
|
||||
a.gridView.OnPlay = func(id string, shuffle bool) {
|
||||
go a.contr.App.PlaybackManager.PlayPlaylist(id, 0, shuffle)
|
||||
}
|
||||
|
||||
@@ -34,13 +34,13 @@ func (r Router) CreatePage(rte controller.Route) Page {
|
||||
case controller.Artist:
|
||||
return NewArtistPage(rte.Arg, &r.App.Config.ArtistPage, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager, r.Controller)
|
||||
case controller.Artists:
|
||||
return NewArtistsGenresPage(false, r.Controller, r.App.ServerManager.Server)
|
||||
return NewArtistsPage(r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
||||
case controller.Favorites:
|
||||
return NewFavoritesPage(&r.App.Config.FavoritesPage, r.Controller, r.App.ServerManager.Server, r.App.PlaybackManager, r.App.ImageManager)
|
||||
case controller.Genre:
|
||||
return NewGenrePage(rte.Arg, r.Controller, r.App.PlaybackManager, r.App.ServerManager.Server, r.App.ImageManager)
|
||||
case controller.Genres:
|
||||
return NewArtistsGenresPage(true, r.Controller, r.App.ServerManager.Server)
|
||||
return NewGenresPage(r.Controller, r.App.ServerManager.Server)
|
||||
case controller.NowPlaying:
|
||||
return NewNowPlayingPage(rte.Arg, r.Controller, &r.App.Config.NowPlayingPage, r.App.PlaybackManager)
|
||||
case controller.Playlist:
|
||||
|
||||
@@ -140,6 +140,47 @@ func (m *Controller) ConnectAlbumGridActions(grid *widgets.GridView) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Controller) ConnectArtistGridActions(grid *widgets.GridView) {
|
||||
grid.OnShowItemPage = func(id string) { m.NavigateTo(ArtistRoute(id)) }
|
||||
grid.OnPlay = func(artistID string, shuffle bool) { go m.PlayArtistDiscography(artistID, shuffle) }
|
||||
grid.OnAddToQueue = func(artistID string) {
|
||||
go m.App.PlaybackManager.LoadTracks(m.GetArtistTracks(artistID), true /*append*/, false /*shuffle*/)
|
||||
}
|
||||
grid.OnAddToPlaylist = func(artistID string) {
|
||||
go m.DoAddTracksToPlaylistWorkflow(
|
||||
sharedutil.TracksToIDs(m.GetArtistTracks(artistID)))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Controller) GetArtistTracks(artistID string) []*mediaprovider.Track {
|
||||
server := m.App.ServerManager.Server
|
||||
if server == nil {
|
||||
log.Println("error playing artist discography: logged out")
|
||||
return nil
|
||||
}
|
||||
|
||||
artist, err := server.GetArtist(artistID)
|
||||
if err != nil {
|
||||
log.Printf("error getting artist discography: %v", err.Error())
|
||||
return nil
|
||||
}
|
||||
var allTracks []*mediaprovider.Track
|
||||
for _, album := range artist.Albums {
|
||||
album, err := server.GetAlbum(album.ID)
|
||||
if err != nil {
|
||||
log.Printf("error loading album tracks: %v", err.Error())
|
||||
return nil
|
||||
}
|
||||
allTracks = append(allTracks, album.Tracks...)
|
||||
}
|
||||
return allTracks
|
||||
}
|
||||
|
||||
func (m *Controller) PlayArtistDiscography(artistID string, shuffle bool) {
|
||||
m.App.PlaybackManager.LoadTracks(m.GetArtistTracks(artistID), false, shuffle)
|
||||
m.App.PlaybackManager.PlayFromBeginning()
|
||||
}
|
||||
|
||||
func (m *Controller) PromptForFirstServer() {
|
||||
d := dialogs.NewAddEditServerDialog("Connect to Server", false, nil, m.MainWindow.Canvas().Focus)
|
||||
pop := widget.NewModalPopUp(d, m.MainWindow.Canvas())
|
||||
|
||||
@@ -94,14 +94,12 @@ func (a *AboutDialog) buildCreditsContainer() fyne.CanvasObject {
|
||||
freepikURL, _ := url.Parse("https://www.flaticon.com/authors/freepik")
|
||||
appIconCredit := widget.NewLabel("The Supersonic app icon is a derivative of a work created by Piotr Siedlecki and placed in the public domain.")
|
||||
appIconCredit.Wrapping = fyne.TextWrapWord
|
||||
albumPlaceholderCredit := widget.NewLabel("The album cover placeholder image was created by @LaurenBacall originally for the MP3Tag project.")
|
||||
albumPlaceholderCredit.Wrapping = fyne.TextWrapWord
|
||||
return container.New(&layouts.VboxCustomPadding{ExtraPad: -10},
|
||||
widget.NewLabel("Major frameworks and modules used in this application include:"),
|
||||
container.NewHBox(widget.NewHyperlink("Fyne toolkit", fyneURL), widget.NewLabel("BSD 3-Clause License")),
|
||||
container.NewHBox(widget.NewHyperlink("go-subsonic", goSubsonicURL), widget.NewLabel("GPL v3 License")),
|
||||
container.NewHBox(widget.NewHyperlink("go-toml", goTomlURL), widget.NewLabel("MIT License")),
|
||||
appIconCredit, albumPlaceholderCredit,
|
||||
appIconCredit,
|
||||
container.NewHBox(widget.NewLabel("Additional icons by:"), widget.NewHyperlink("Freepik on flaticon.com", freepikURL)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ type ThemedRectangle struct {
|
||||
|
||||
rect *canvas.Rectangle
|
||||
|
||||
ColorName fyne.ThemeColorName
|
||||
ColorName fyne.ThemeColorName
|
||||
BorderWidth float32
|
||||
BorderColorName fyne.ThemeColorName
|
||||
}
|
||||
|
||||
func NewThemedRectangle(colorName fyne.ThemeColorName) *ThemedRectangle {
|
||||
@@ -25,8 +27,11 @@ func NewThemedRectangle(colorName fyne.ThemeColorName) *ThemedRectangle {
|
||||
}
|
||||
|
||||
func (t *ThemedRectangle) Refresh() {
|
||||
t.rect.FillColor = fyne.CurrentApp().Settings().Theme().Color(t.ColorName,
|
||||
fyne.CurrentApp().Settings().ThemeVariant())
|
||||
settings := fyne.CurrentApp().Settings()
|
||||
theme := settings.Theme()
|
||||
t.rect.FillColor = theme.Color(t.ColorName, settings.ThemeVariant())
|
||||
t.rect.StrokeWidth = t.BorderWidth
|
||||
t.rect.StrokeColor = theme.Color(t.BorderColorName, settings.ThemeVariant())
|
||||
t.BaseWidget.Refresh()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package widgets
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/dweymouth/supersonic/ui/layouts"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type ArtistGenreListItemModel struct {
|
||||
ID string
|
||||
Name string
|
||||
AlbumCount int
|
||||
TrackCount int
|
||||
Favorite bool
|
||||
}
|
||||
|
||||
type ArtistGenreList struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Items []ArtistGenreListItemModel
|
||||
ShowAlbumCount bool
|
||||
ShowTrackCount bool
|
||||
OnNavTo func(string)
|
||||
|
||||
columnsLayout *layouts.ColumnsLayout
|
||||
hdr *ListHeader
|
||||
list *widget.List
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
type ArtistGenreListRow struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Item ArtistGenreListItemModel
|
||||
OnTapped func()
|
||||
|
||||
nameLabel *widget.Label
|
||||
albumCountLabel *widget.Label
|
||||
trackCountLabel *widget.Label
|
||||
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewArtistGenreListRow(layout *layouts.ColumnsLayout) *ArtistGenreListRow {
|
||||
a := &ArtistGenreListRow{
|
||||
nameLabel: widget.NewLabel(""),
|
||||
albumCountLabel: widget.NewLabel(""),
|
||||
trackCountLabel: widget.NewLabel(""),
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.albumCountLabel.Alignment = fyne.TextAlignTrailing
|
||||
a.trackCountLabel.Alignment = fyne.TextAlignTrailing
|
||||
a.container = container.New(layout, a.nameLabel, a.albumCountLabel, a.trackCountLabel)
|
||||
return a
|
||||
}
|
||||
|
||||
func NewArtistGenreList(items []ArtistGenreListItemModel) *ArtistGenreList {
|
||||
a := &ArtistGenreList{
|
||||
Items: items,
|
||||
columnsLayout: layouts.NewColumnsLayout([]float32{-1, 125, 125}),
|
||||
}
|
||||
a.ExtendBaseWidget(a)
|
||||
a.hdr = NewListHeader([]ListColumn{
|
||||
{"Name", fyne.TextAlignLeading, false}, {"Album Count", fyne.TextAlignTrailing, false}, {"Track Count", fyne.TextAlignTrailing, false}}, a.columnsLayout)
|
||||
a.hdr.DisableSorting = true
|
||||
a.list = widget.NewList(
|
||||
func() int { return len(a.Items) },
|
||||
func() fyne.CanvasObject {
|
||||
r := NewArtistGenreListRow(a.columnsLayout)
|
||||
r.OnTapped = func() { a.onRowDoubleTapped(r.Item) }
|
||||
return r
|
||||
},
|
||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||
row := item.(*ArtistGenreListRow)
|
||||
row.Item = a.Items[id]
|
||||
row.albumCountLabel.Hidden = !a.ShowAlbumCount
|
||||
row.trackCountLabel.Hidden = !a.ShowTrackCount
|
||||
row.nameLabel.Text = row.Item.Name
|
||||
row.albumCountLabel.Text = strconv.Itoa(row.Item.AlbumCount)
|
||||
row.trackCountLabel.Text = strconv.Itoa(row.Item.TrackCount)
|
||||
row.Refresh()
|
||||
},
|
||||
)
|
||||
a.container = container.NewBorder(a.hdr, nil, nil, nil, a.list)
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *ArtistGenreList) Refresh() {
|
||||
a.hdr.SetColumnVisible(1, a.ShowAlbumCount)
|
||||
a.hdr.SetColumnVisible(2, a.ShowTrackCount)
|
||||
a.BaseWidget.Refresh()
|
||||
}
|
||||
|
||||
func (a *ArtistGenreList) onRowDoubleTapped(item ArtistGenreListItemModel) {
|
||||
if a.OnNavTo != nil {
|
||||
a.OnNavTo(item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtistGenreListRow) Tapped(*fyne.PointEvent) {
|
||||
if a.OnTapped != nil {
|
||||
a.OnTapped()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ArtistGenreList) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
|
||||
func (a *ArtistGenreListRow) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(a.container)
|
||||
}
|
||||
+16
-8
@@ -7,7 +7,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/res"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
@@ -81,6 +80,7 @@ type GridViewState struct {
|
||||
itemsMutex sync.RWMutex
|
||||
iter GridViewIterator
|
||||
imageFetcher ImageFetcher
|
||||
placeholder fyne.Resource
|
||||
highestShown int
|
||||
fetchCancel context.CancelFunc
|
||||
done bool
|
||||
@@ -96,12 +96,13 @@ type GridViewState struct {
|
||||
|
||||
var _ fyne.Widget = (*GridView)(nil)
|
||||
|
||||
func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher) *GridView {
|
||||
func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
|
||||
g := &GridView{
|
||||
GridViewState: GridViewState{
|
||||
items: items,
|
||||
done: true,
|
||||
imageFetcher: fetch,
|
||||
placeholder: placeholder,
|
||||
},
|
||||
}
|
||||
g.ExtendBaseWidget(g)
|
||||
@@ -109,7 +110,7 @@ func NewFixedGridView(items []GridViewItemModel, fetch ImageFetcher) *GridView {
|
||||
return g
|
||||
}
|
||||
|
||||
func NewGridView(iter GridViewIterator, fetch ImageFetcher) *GridView {
|
||||
func NewGridView(iter GridViewIterator, fetch ImageFetcher, placeholder fyne.Resource) *GridView {
|
||||
g := &GridView{
|
||||
GridViewState: GridViewState{
|
||||
iter: iter,
|
||||
@@ -117,7 +118,6 @@ func NewGridView(iter GridViewIterator, fetch ImageFetcher) *GridView {
|
||||
},
|
||||
}
|
||||
g.ExtendBaseWidget(g)
|
||||
|
||||
g.createGridWrap()
|
||||
|
||||
// fetch initial items
|
||||
@@ -174,6 +174,14 @@ func (g *GridView) ResetFixed(items []GridViewItemModel) {
|
||||
g.iter = nil
|
||||
}
|
||||
|
||||
func (g *GridView) GetScrollOffset() float32 {
|
||||
return g.grid.GetScrollOffset()
|
||||
}
|
||||
|
||||
func (g *GridView) ScrollToOffset(offs float32) {
|
||||
g.grid.ScrollToOffset(offs)
|
||||
}
|
||||
|
||||
func (g *GridView) createGridWrap() {
|
||||
g.grid = NewGridWrap(
|
||||
func() int {
|
||||
@@ -181,7 +189,7 @@ func (g *GridView) createGridWrap() {
|
||||
},
|
||||
// create func
|
||||
func() fyne.CanvasObject {
|
||||
card := NewGridViewItem()
|
||||
card := NewGridViewItem(g.placeholder)
|
||||
card.OnPlay = func(shuffle bool) {
|
||||
if g.OnPlay != nil {
|
||||
g.OnPlay(card.ItemID(), shuffle)
|
||||
@@ -244,7 +252,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
||||
if img, ok := g.imageFetcher.GetCoverThumbnailFromCache(item.CoverArtID); ok {
|
||||
card.Cover.SetImage(img)
|
||||
} else {
|
||||
card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
|
||||
card.Cover.SetImage(nil)
|
||||
// asynchronously fetch cover image
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
card.ImgLoadCancel = cancel
|
||||
@@ -263,8 +271,8 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) {
|
||||
}(ctx)
|
||||
}
|
||||
} else {
|
||||
// use the placeholder image for an item that has no cover art ID
|
||||
card.Cover.SetImageResource(res.ResAlbumplaceholderPng)
|
||||
// use the placeholder for an item that has no cover art ID
|
||||
card.Cover.SetImage(nil)
|
||||
}
|
||||
|
||||
// if user has scrolled near the bottom, fetch more
|
||||
|
||||
+10
-18
@@ -17,27 +17,27 @@ import (
|
||||
var _ fyne.Widget = (*GridViewItem)(nil)
|
||||
|
||||
var _ fyne.Widget = (*coverImage)(nil)
|
||||
var _ fyne.Tappable = (*coverImage)(nil)
|
||||
var _ fyne.SecondaryTappable = (*coverImage)(nil)
|
||||
|
||||
type coverImage struct {
|
||||
widget.BaseWidget
|
||||
|
||||
Im *canvas.Image
|
||||
Im *ImagePlaceholder
|
||||
playbtn *canvas.Image
|
||||
OnPlay func()
|
||||
OnShowPage func()
|
||||
OnShowContextMenu func(fyne.Position)
|
||||
}
|
||||
|
||||
func newCoverImage() *coverImage {
|
||||
func newCoverImage(placeholderResource fyne.Resource) *coverImage {
|
||||
c := &coverImage{}
|
||||
c.ExtendBaseWidget(c)
|
||||
c.Im = &canvas.Image{FillMode: canvas.ImageFillContain, ScaleMode: canvas.ImageScaleFastest}
|
||||
c.Im.SetMinSize(fyne.NewSize(200, 200))
|
||||
c.Im = NewImagePlaceholder(placeholderResource, 200)
|
||||
c.Im.OnTapped = c.Tapped
|
||||
c.Im.OnTappedSecondary = c.TappedSecondary
|
||||
c.Im.ScaleMode = canvas.ImageScaleFastest
|
||||
c.playbtn = &canvas.Image{FillMode: canvas.ImageFillContain, Resource: res.ResPlaybuttonPng}
|
||||
c.playbtn.SetMinSize(fyne.NewSize(60, 60))
|
||||
c.playbtn.Hidden = true
|
||||
c.ExtendBaseWidget(c)
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -93,15 +93,7 @@ func (a *coverImage) center() fyne.Position {
|
||||
}
|
||||
|
||||
func (a *coverImage) SetImage(im image.Image) {
|
||||
a.Im.Resource = nil
|
||||
a.Im.Image = im
|
||||
a.Refresh()
|
||||
}
|
||||
|
||||
func (a *coverImage) SetImageResource(res *fyne.StaticResource) {
|
||||
a.Im.Image = nil
|
||||
a.Im.Resource = res
|
||||
a.Refresh()
|
||||
a.Im.SetImage(im, true)
|
||||
}
|
||||
|
||||
func isInside(origin fyne.Position, radius float32, point fyne.Position) bool {
|
||||
@@ -141,11 +133,11 @@ type GridViewItem struct {
|
||||
OnShowSecondaryPage func()
|
||||
}
|
||||
|
||||
func NewGridViewItem() *GridViewItem {
|
||||
func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem {
|
||||
g := &GridViewItem{
|
||||
primaryText: NewCustomHyperlink(),
|
||||
secondaryText: NewCustomHyperlink(),
|
||||
Cover: newCoverImage(),
|
||||
Cover: newCoverImage(placeholderResource),
|
||||
}
|
||||
g.ExtendBaseWidget(g)
|
||||
g.Cover.OnPlay = func() { g.onPlay(false) }
|
||||
|
||||
@@ -2,48 +2,52 @@ package widgets
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
)
|
||||
|
||||
// A widget that can display an image or else
|
||||
// a placeholder with a rectangular border frame
|
||||
// and an icon positioned in the center of the frame.
|
||||
type ImagePlaceholder struct {
|
||||
ScaleMode canvas.ImageScale
|
||||
|
||||
widget.BaseWidget
|
||||
container *fyne.Container
|
||||
content *fyne.Container
|
||||
imageDisp *TappableImage
|
||||
image image.Image
|
||||
iconImage *canvas.Image
|
||||
border *myTheme.ThemedRectangle
|
||||
minSize float32
|
||||
|
||||
OnTapped func()
|
||||
OnTapped func(*fyne.PointEvent)
|
||||
OnTappedSecondary func(*fyne.PointEvent)
|
||||
}
|
||||
|
||||
func NewImagePlaceholder(centerIcon fyne.Resource, minSize float32) *ImagePlaceholder {
|
||||
m := &ImagePlaceholder{minSize: minSize}
|
||||
m.ExtendBaseWidget(m)
|
||||
img := canvas.NewImageFromResource(centerIcon)
|
||||
img.FillMode = canvas.ImageFillContain
|
||||
img.SetMinSize(fyne.NewSize(minSize/4, minSize/4))
|
||||
rect := canvas.NewRectangle(theme.BackgroundColor())
|
||||
rect.StrokeColor = color.Black
|
||||
rect.StrokeWidth = 3
|
||||
rect.SetMinSize(fyne.NewSize(minSize, minSize))
|
||||
m.container = container.NewMax(
|
||||
rect,
|
||||
container.NewCenter(img),
|
||||
i := &ImagePlaceholder{minSize: minSize}
|
||||
i.ExtendBaseWidget(i)
|
||||
i.iconImage = canvas.NewImageFromResource(centerIcon)
|
||||
i.iconImage.FillMode = canvas.ImageFillContain
|
||||
i.iconImage.SetMinSize(fyne.NewSize(minSize/4, minSize/4))
|
||||
i.imageDisp = NewTappableImage(i.onTapped)
|
||||
i.imageDisp.OnTappedSecondary = i.onTappedSecondary
|
||||
i.imageDisp.FillMode = canvas.ImageFillContain
|
||||
i.imageDisp.Hidden = true
|
||||
i.border = myTheme.NewThemedRectangle(theme.ColorNameBackground)
|
||||
i.border.BorderColorName = theme.ColorNameForeground
|
||||
i.border.BorderWidth = 3
|
||||
i.content = container.NewMax(
|
||||
i.border,
|
||||
container.NewCenter(i.iconImage),
|
||||
i.imageDisp,
|
||||
)
|
||||
return m
|
||||
}
|
||||
|
||||
type CanvasImage interface {
|
||||
fyne.CanvasObject
|
||||
|
||||
SetMinSize(fyne.Size)
|
||||
return i
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) HaveImage() bool {
|
||||
@@ -51,34 +55,43 @@ func (i *ImagePlaceholder) HaveImage() bool {
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) SetImage(img image.Image, tappable bool) {
|
||||
var cIm CanvasImage
|
||||
i.image = img
|
||||
if tappable {
|
||||
cImg := NewTappableImage(i.onTapped)
|
||||
cImg.Image.Image = img
|
||||
cImg.FillMode = canvas.ImageFillContain
|
||||
cIm = cImg
|
||||
} else {
|
||||
cImg := canvas.NewImageFromImage(img)
|
||||
cImg.FillMode = canvas.ImageFillContain
|
||||
cIm = cImg
|
||||
if img != nil {
|
||||
i.imageDisp.DisableTapping = !tappable
|
||||
i.imageDisp.Image.Image = img
|
||||
}
|
||||
cIm.SetMinSize(fyne.NewSize(i.minSize, i.minSize))
|
||||
i.container.RemoveAll()
|
||||
i.container.Add(cIm)
|
||||
i.container.Refresh()
|
||||
|
||||
i.Refresh()
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) Image() image.Image {
|
||||
return i.image
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) onTapped() {
|
||||
func (i *ImagePlaceholder) onTapped(e *fyne.PointEvent) {
|
||||
if i.OnTapped != nil {
|
||||
i.OnTapped()
|
||||
i.OnTapped(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(i.container)
|
||||
func (i *ImagePlaceholder) onTappedSecondary(e *fyne.PointEvent) {
|
||||
if i.OnTappedSecondary != nil {
|
||||
i.OnTappedSecondary(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) MinSize() fyne.Size {
|
||||
return fyne.NewSize(i.minSize, i.minSize)
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) Refresh() {
|
||||
i.border.Hidden = i.HaveImage()
|
||||
i.iconImage.Hidden = i.HaveImage()
|
||||
i.imageDisp.Hidden = !i.HaveImage()
|
||||
i.imageDisp.ScaleMode = i.ScaleMode
|
||||
i.BaseWidget.Refresh()
|
||||
}
|
||||
|
||||
func (i *ImagePlaceholder) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(i.content)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func NewNowPlayingCard() *NowPlayingCard {
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *NowPlayingCard) onShowCoverImage() {
|
||||
func (n *NowPlayingCard) onShowCoverImage(*fyne.PointEvent) {
|
||||
if n.OnShowCoverImage != nil {
|
||||
n.OnShowCoverImage()
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (n *NowPlayingCard) OnTrackNameTapped(f func()) {
|
||||
n.trackName.OnTapped = f
|
||||
}
|
||||
|
||||
func (n *NowPlayingCard) showMenu(pos fyne.Position) {
|
||||
func (n *NowPlayingCard) showMenu(e *fyne.PointEvent) {
|
||||
if n.menu == nil {
|
||||
ratingMenu := util.NewRatingSubmenu(n.onSetRating)
|
||||
m := fyne.NewMenu("",
|
||||
@@ -117,5 +117,5 @@ func (n *NowPlayingCard) showMenu(pos fyne.Position) {
|
||||
fyne.NewMenuItem("Add to playlist...", func() { n.onAddToPlaylist() }))
|
||||
n.menu = widget.NewPopUpMenu(m, fyne.CurrentApp().Driver().CanvasForObject(n))
|
||||
}
|
||||
n.menu.ShowAtPosition(pos)
|
||||
n.menu.ShowAtPosition(e.AbsolutePosition)
|
||||
}
|
||||
|
||||
@@ -54,11 +54,12 @@ type TappableImage struct {
|
||||
widget.BaseWidget
|
||||
canvas.Image
|
||||
|
||||
OnTapped func()
|
||||
OnTappedSecondary func(pos fyne.Position)
|
||||
DisableTapping bool
|
||||
OnTapped func(*fyne.PointEvent)
|
||||
OnTappedSecondary func(*fyne.PointEvent)
|
||||
}
|
||||
|
||||
func NewTappableImage(onTapped func()) *TappableImage {
|
||||
func NewTappableImage(onTapped func(*fyne.PointEvent)) *TappableImage {
|
||||
t := &TappableImage{OnTapped: onTapped}
|
||||
t.ExtendBaseWidget(t)
|
||||
return t
|
||||
@@ -85,21 +86,21 @@ func (t *TappableImage) Resize(size fyne.Size) {
|
||||
}
|
||||
|
||||
func (t *TappableImage) Cursor() desktop.Cursor {
|
||||
if t.haveImage() {
|
||||
if !t.DisableTapping && t.haveImage() {
|
||||
return desktop.PointerCursor
|
||||
}
|
||||
return desktop.DefaultCursor
|
||||
}
|
||||
|
||||
func (t *TappableImage) Tapped(_ *fyne.PointEvent) {
|
||||
if t.haveImage() && t.OnTapped != nil {
|
||||
t.OnTapped()
|
||||
func (t *TappableImage) Tapped(e *fyne.PointEvent) {
|
||||
if !t.DisableTapping && t.haveImage() && t.OnTapped != nil {
|
||||
t.OnTapped(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TappableImage) TappedSecondary(e *fyne.PointEvent) {
|
||||
if t.haveImage() && t.OnTappedSecondary != nil {
|
||||
t.OnTappedSecondary(e.AbsolutePosition)
|
||||
if !t.DisableTapping && t.haveImage() && t.OnTappedSecondary != nil {
|
||||
t.OnTappedSecondary(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user