diff --git a/backend/mediaprovider/subsonic/subsonicmediaprovider.go b/backend/mediaprovider/subsonic/subsonicmediaprovider.go index a83eda7..e31c545 100644 --- a/backend/mediaprovider/subsonic/subsonicmediaprovider.go +++ b/backend/mediaprovider/subsonic/subsonicmediaprovider.go @@ -276,6 +276,18 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { if ch == nil { return nil } + var artistNames, artistIDs []string + if len(ch.Artists) > 0 { + // OpenSubsonic extension + for _, a := range ch.Artists { + artistIDs = append(artistIDs, a.ID) + artistNames = append(artistNames, a.Name) + } + } else { + artistNames = append(artistNames, ch.Artist) + artistIDs = append(artistIDs, ch.ArtistID) + } + return &mediaprovider.Track{ ID: ch.ID, CoverArtID: ch.CoverArt, @@ -285,8 +297,8 @@ func toTrack(ch *subsonic.Child) *mediaprovider.Track { TrackNumber: ch.Track, DiscNumber: ch.DiscNumber, Genre: ch.Genre, - ArtistIDs: []string{ch.ArtistID}, - ArtistNames: []string{ch.Artist}, + ArtistIDs: artistIDs, + ArtistNames: artistNames, Album: ch.Album, AlbumID: ch.AlbumID, Year: ch.Year, @@ -309,15 +321,37 @@ func toAlbum(al *subsonic.AlbumID3) *mediaprovider.Album { } func fillAlbum(subAlbum *subsonic.AlbumID3, album *mediaprovider.Album) { + var artistNames, artistIDs []string + if len(subAlbum.Artists) > 0 { + // OpenSubsonic extension + for _, a := range subAlbum.Artists { + artistIDs = append(artistIDs, a.ID) + artistNames = append(artistNames, a.Name) + } + } else { + artistNames = append(artistNames, subAlbum.Artist) + artistIDs = append(artistIDs, subAlbum.ArtistID) + } + + var genres []string + if len(subAlbum.Genres) > 0 { + // OpenSubsonic extension + for _, g := range subAlbum.Genres { + genres = append(genres, g.Name) + } + } else { + genres = append(genres, subAlbum.Genre) + } + 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.ArtistIDs = artistIDs + album.ArtistNames = artistNames album.Year = subAlbum.Year album.TrackCount = subAlbum.SongCount - album.Genres = []string{subAlbum.Genre} + album.Genres = genres album.Favorite = !subAlbum.Starred.IsZero() } diff --git a/go.mod b/go.mod index 83b7bae..c4fef24 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( fyne.io/x/fyne v0.0.0-20230611151101-afdcd6b92cf3 github.com/20after4/configdir v0.1.1 github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee - github.com/dweymouth/go-subsonic v0.0.0-20231013011542-14b66c5a1fff + github.com/dweymouth/go-subsonic v0.0.0-20231014003317-6a0c88db38ce github.com/fsnotify/fsnotify v1.6.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.3.0 @@ -47,4 +47,4 @@ require ( honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect ) -replace fyne.io/fyne/v2 v2.3.5 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230505012127-ca61c153b2a5 +replace fyne.io/fyne/v2 v2.3.5 => github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231019154213-5f32d36c2968 diff --git a/go.sum b/go.sum index d267dc9..d358c7b 100644 --- a/go.sum +++ b/go.sum @@ -74,12 +74,12 @@ github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7h github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230505012127-ca61c153b2a5 h1:uXbHzg9HfefA7OVHu9gahobCP5B43df3L1MwU0GbdRs= -github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20230505012127-ca61c153b2a5/go.mod h1:X2+NrR+62mvAiAt2fwKT7035zQsE77KVV1NlvWo4vW8= +github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231019154213-5f32d36c2968 h1:RYHUvyoVXfXprkVU06XkzupfldcZ9vlhslxFYi00YCY= +github.com/dweymouth/fyne/v2 v2.3.0-rc1.0.20231019154213-5f32d36c2968/go.mod h1:X2+NrR+62mvAiAt2fwKT7035zQsE77KVV1NlvWo4vW8= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee h1:ZGyJ6wp7CAfT31BugypcF/TPKEy2RrGR9JFq1JOjOpY= github.com/dweymouth/go-mpv v0.0.0-20230406003141-7f1858e503ee/go.mod h1:Ov0ieN90M7i+0k3OxhA/g1dozGs+UcPHDsMKqPgRDk0= -github.com/dweymouth/go-subsonic v0.0.0-20231013011542-14b66c5a1fff h1:71TfIg5TMXMi9rGQYFhW8Gvs/GmBGK9O2gSmgzYXl4I= -github.com/dweymouth/go-subsonic v0.0.0-20231013011542-14b66c5a1fff/go.mod h1:fUez6NFiEJiQTZizZ1BThZr5GJXAbigzGYjEPNm4tdI= +github.com/dweymouth/go-subsonic v0.0.0-20231014003317-6a0c88db38ce h1:R12c/hE0licao74iAu2YDPJNI0jhGNuTyzmu3dMgFHE= +github.com/dweymouth/go-subsonic v0.0.0-20231014003317-6a0c88db38ce/go.mod h1:fUez6NFiEJiQTZizZ1BThZr5GJXAbigzGYjEPNm4tdI= github.com/eclipse/paho.mqtt.golang v1.3.5/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= diff --git a/sharedutil/sharedutil.go b/sharedutil/sharedutil.go index 35cd5cb..8710813 100644 --- a/sharedutil/sharedutil.go +++ b/sharedutil/sharedutil.go @@ -7,6 +7,18 @@ import ( "github.com/dweymouth/supersonic/backend/mediaprovider" ) +func SliceEqual[T comparable](a []T, b []T) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} + func SliceContains[T comparable](ts []T, t T) bool { for _, x := range ts { if x == t { diff --git a/ui/bottompanel.go b/ui/bottompanel.go index 69fe61d..bf4fc2a 100644 --- a/ui/bottompanel.go +++ b/ui/bottompanel.go @@ -76,8 +76,8 @@ func NewBottomPanel(p *player.Player, pm *backend.PlaybackManager, contr *contro bp.NowPlaying.OnAlbumNameTapped(func() { contr.NavigateTo(controller.AlbumRoute(bp.playbackManager.NowPlaying().AlbumID)) }) - bp.NowPlaying.OnArtistNameTapped(func() { - contr.NavigateTo(controller.ArtistRoute(bp.playbackManager.NowPlaying().ArtistIDs[0])) + bp.NowPlaying.OnArtistNameTapped(func(artistID string) { + contr.NavigateTo(controller.ArtistRoute(artistID)) }) bp.NowPlaying.OnTrackNameTapped(func() { contr.NavigateTo(controller.NowPlayingRoute(bp.playbackManager.NowPlaying().ID)) @@ -113,7 +113,7 @@ func NewBottomPanel(p *player.Player, pm *backend.PlaybackManager, contr *contro func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) { if song == nil { - bp.NowPlaying.Update("", "", false, "", nil) + bp.NowPlaying.Update("", []string{}, []string{}, "", nil) } else { bp.coverArtID = song.CoverArtID var im image.Image @@ -125,7 +125,7 @@ func (bp *BottomPanel) onSongChange(song, _ *mediaprovider.Track) { imgTTLSec := song.Duration + 30 im, _ = bp.ImageManager.GetCoverThumbnailWithTTL(song.CoverArtID, time.Duration(imgTTLSec)*time.Second) } - bp.NowPlaying.Update(song.Name, song.ArtistNames[0], song.ArtistIDs[0] != "", song.Album, im) + bp.NowPlaying.Update(song.Name, song.ArtistNames, song.ArtistIDs, song.Album, im) } } diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index 1b38846..b0bd90e 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -166,17 +166,15 @@ func (a *AlbumPage) load() { type AlbumPageHeader struct { widget.BaseWidget - albumID string - coverID string - artistID string - genre string + albumID string + coverID string page *AlbumPage cover *widgets.TappableImage titleLabel *widget.RichText - artistLabel *widgets.CustomHyperlink - genreLabel *widgets.CustomHyperlink + artistLabel *widgets.MultiHyperlink + genreLabel *widgets.MultiHyperlink miscLabel *widget.Label toggleFavButton *widgets.FavoriteButton @@ -200,13 +198,13 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader { a.titleLabel.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ SizeName: theme.SizeNameHeadingText, } - a.artistLabel = widgets.NewCustomHyperlink() - a.artistLabel.OnTapped = func() { - a.page.contr.NavigateTo(controller.ArtistRoute(a.artistID)) + a.artistLabel = widgets.NewMultiHyperlink() + a.artistLabel.OnTapped = func(id string) { + a.page.contr.NavigateTo(controller.ArtistRoute(id)) } - a.genreLabel = widgets.NewCustomHyperlink() - a.genreLabel.OnTapped = func() { - a.page.contr.NavigateTo(controller.GenreRoute(a.genre)) + a.genreLabel = widgets.NewMultiHyperlink() + a.genreLabel.OnTapped = func(genre string) { + a.page.contr.NavigateTo(controller.GenreRoute(genre)) } a.miscLabel = widget.NewLabel("") playButton := widget.NewButtonWithIcon("Play", theme.MediaPlayIcon(), func() { @@ -264,11 +262,9 @@ func (a *AlbumPageHeader) CreateRenderer() fyne.WidgetRenderer { func (a *AlbumPageHeader) Update(album *mediaprovider.AlbumWithTracks, im *backend.ImageManager) { a.albumID = album.ID a.coverID = album.CoverArtID - a.artistID = album.ArtistIDs[0] a.titleLabel.Segments[0].(*widget.TextSegment).Text = album.Name - a.artistLabel.SetText(album.ArtistNames[0]) - a.genre = album.Genres[0] - a.genreLabel.SetText(album.Genres[0]) + a.artistLabel.BuildSegments(album.ArtistNames, album.ArtistIDs) + a.genreLabel.BuildSegments(album.Genres, album.Genres) a.miscLabel.SetText(formatMiscLabelStr(album)) a.toggleFavButton.IsFavorited = album.Favorite a.Refresh() @@ -286,10 +282,9 @@ func (a *AlbumPageHeader) Update(album *mediaprovider.AlbumWithTracks, im *backe func (a *AlbumPageHeader) Clear() { a.albumID = "" a.coverID = "" - a.artistID = "" a.titleLabel.Segments[0].(*widget.TextSegment).Text = "" - a.artistLabel.SetText("") - a.genreLabel.SetText("") + a.artistLabel.Segments = nil + a.genreLabel.Segments = nil a.miscLabel.SetText("") a.toggleFavButton.IsFavorited = false a.fullSizeCoverFetching = false diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 05bf1ed..80d4979 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -189,7 +189,7 @@ func (a *ArtistPage) showAlbumGrid() { Name: al.Name, ID: al.ID, CoverArtID: al.CoverArtID, - Secondary: strconv.Itoa(al.Year), + Secondary: []string{strconv.Itoa(al.Year)}, } }) if g := a.pool.Obtain(util.WidgetTypeGridView); g != nil { @@ -366,10 +366,9 @@ func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) { break } if len(a.similarArtists.Objects) <= i+1 { - a.similarArtists.Add(widgets.NewCustomHyperlink()) + a.similarArtists.Add(widget.NewHyperlink("", nil)) } - h := a.similarArtists.Objects[i+1].(*widgets.CustomHyperlink) - h.NoTruncate = true + h := a.similarArtists.Objects[i+1].(*widget.Hyperlink) h.SetText(art.Name) h.OnTapped = func(id string) func() { return func() { a.artistPage.contr.NavigateTo(controller.ArtistRoute(id)) } diff --git a/ui/browsing/artistspage.go b/ui/browsing/artistspage.go index 1fffc4a..1d039d0 100644 --- a/ui/browsing/artistspage.go +++ b/ui/browsing/artistspage.go @@ -158,7 +158,7 @@ func createArtistsGridViewModel(artists []*mediaprovider.Artist) []widgets.GridV Name: ar.Name, ID: ar.ID, CoverArtID: ar.CoverArtID, - Secondary: fmt.Sprintf("%d %s", ar.AlbumCount, albums), + Secondary: []string{fmt.Sprintf("%d %s", ar.AlbumCount, albums)}, } }) } diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 82758b6..a4406fe 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -333,7 +333,7 @@ func buildArtistGridViewModel(artists []*mediaprovider.Artist) []widgets.GridVie ID: ar.ID, CoverArtID: ar.CoverArtID, Name: ar.Name, - Secondary: fmt.Sprintf("%d %s", ar.AlbumCount, albums), + Secondary: []string{fmt.Sprintf("%d %s", ar.AlbumCount, albums)}, }) } return model diff --git a/ui/browsing/playlistspage.go b/ui/browsing/playlistspage.go index 4d6214e..dbf9b92 100644 --- a/ui/browsing/playlistspage.go +++ b/ui/browsing/playlistspage.go @@ -175,7 +175,7 @@ func createPlaylistGridViewModel(playlists []*mediaprovider.Playlist) []widgets. Name: pl.Name, ID: pl.ID, CoverArtID: pl.CoverArtID, - Secondary: fmt.Sprintf("%d %s", pl.TrackCount, tracks), + Secondary: []string{fmt.Sprintf("%d %s", pl.TrackCount, tracks)}, } }) } diff --git a/ui/mainwindow.go b/ui/mainwindow.go index 7eb8860..765cbd4 100644 --- a/ui/mainwindow.go +++ b/ui/mainwindow.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "strings" "github.com/20after4/configdir" "github.com/dweymouth/supersonic/backend" @@ -86,7 +87,7 @@ func NewMainWindow(fyneApp fyne.App, appName, displayAppName, appVersion string, m.Window.SetTitle(displayAppName) return } - m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", song.Name, song.ArtistNames[0], displayAppName)) + m.Window.SetTitle(fmt.Sprintf("%s – %s · %s", song.Name, strings.Join(song.ArtistNames, ", "), displayAppName)) }) app.ServerManager.OnServerConnected(func() { m.BrowsingPane.EnableNavigationButtons() diff --git a/ui/widgets/customhyperlink.go b/ui/widgets/customhyperlink.go deleted file mode 100644 index 321b127..0000000 --- a/ui/widgets/customhyperlink.go +++ /dev/null @@ -1,133 +0,0 @@ -package widgets - -import ( - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" -) - -type hyperlinkWrapper struct { - widget.Hyperlink - - textWidthCached float32 - MaxWidth float32 -} - -func newHyperlinkWrapper() *hyperlinkWrapper { - h := &hyperlinkWrapper{ - Hyperlink: widget.Hyperlink{ - Text: "", - Wrapping: fyne.TextTruncate, - }, - textWidthCached: -1, - } - h.ExtendBaseWidget(h) - return h -} - -func (h *hyperlinkWrapper) MinSize() fyne.Size { - if h.textWidthCached < 0 { - s := fyne.MeasureText(h.Text, theme.TextSize(), h.TextStyle) - // the 2.7 factor is a bit of a magic number but it works ¯\_(ツ)_/¯ - h.textWidthCached = s.Width + theme.Padding()*2.7 - } - return fyne.NewSize(fyne.Min(h.MaxWidth, h.textWidthCached), h.Hyperlink.MinSize().Height) -} - -func (h *hyperlinkWrapper) SetText(text string) { - h.Text = text - h.textWidthCached = -1 -} - -func (h *hyperlinkWrapper) TypedKey(e *fyne.KeyEvent) { - if e.Name == fyne.KeySpace { - if h.OnTapped != nil { - h.OnTapped() - } - } -} - -type CustomHyperlink struct { - widget.BaseWidget - h *hyperlinkWrapper - l *widget.Label - - OnTapped func() - NoTruncate bool - Disabled bool - - lastDisabled bool - container *fyne.Container - fullTextWidth float32 -} - -func NewCustomHyperlink() *CustomHyperlink { - c := &CustomHyperlink{ - h: newHyperlinkWrapper(), - l: widget.NewLabel(""), - container: container.NewMax(), - } - c.h.OnTapped = func() { - if c.OnTapped != nil { - c.OnTapped() - } - } - c.fullTextWidth = c.l.MinSize().Width - c.ExtendBaseWidget(c) - c.updateContainer(c.Disabled) - return c -} - -func (c *CustomHyperlink) SetText(text string) { - lastWrapping := c.l.Wrapping - c.l.Wrapping = fyne.TextWrapOff - c.l.SetText(text) - c.fullTextWidth = c.l.MinSize().Width - c.h.SetText(text) - c.l.Wrapping = lastWrapping - c.Refresh() -} - -func (c *CustomHyperlink) SetTextStyle(style fyne.TextStyle) { - c.h.TextStyle = style -} - -func (c *CustomHyperlink) Resize(size fyne.Size) { - c.h.MaxWidth = size.Width - c.BaseWidget.Resize(size) -} - -func (c *CustomHyperlink) Refresh() { - if c.NoTruncate { - c.l.Wrapping = fyne.TextWrapOff - } else { - c.l.Wrapping = fyne.TextTruncate - } - if c.lastDisabled != c.Disabled { - c.updateContainer(c.Disabled) - c.lastDisabled = c.Disabled - } - c.container.Refresh() -} - -func (c *CustomHyperlink) MinSize() fyne.Size { - if c.NoTruncate { - return fyne.NewSize(c.fullTextWidth, c.l.MinSize().Height) - } - return fyne.NewSize(0, c.l.MinSize().Height) -} - -func (c *CustomHyperlink) CreateRenderer() fyne.WidgetRenderer { - return widget.NewSimpleRenderer(c.container) -} - -func (c *CustomHyperlink) updateContainer(linkDisabled bool) { - c.container.RemoveAll() - if linkDisabled { - c.container.Add(c.l) - } else { - c.container.Add(container.NewHBox(c.h, layout.NewSpacer())) - } -} diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index 6b69322..cd49670 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -55,11 +55,11 @@ func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel { albums := g.iter.NextN(n) return sharedutil.MapSlice(albums, func(al *mediaprovider.Album) GridViewItemModel { return GridViewItemModel{ - Name: al.Name, - ID: al.ID, - CoverArtID: al.CoverArtID, - Secondary: al.ArtistNames[0], - SecondaryID: al.ArtistIDs[0], + Name: al.Name, + ID: al.ID, + CoverArtID: al.CoverArtID, + Secondary: al.ArtistNames, + SecondaryIDs: al.ArtistIDs, } }) } @@ -205,9 +205,9 @@ func (g *GridView) createGridWrap() { func() fyne.CanvasObject { card := NewGridViewItem(g.Placeholder) card.OnPlay = func() { g.onPlay(card.ItemID(), false) } - card.OnShowSecondaryPage = func() { + card.OnShowSecondaryPage = func(id string) { if g.OnShowSecondaryPage != nil { - g.OnShowSecondaryPage(card.SecondaryID()) + g.OnShowSecondaryPage(id) } } card.OnShowItemPage = func() { diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index 4ef8186..cfa9bf1 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -5,6 +5,7 @@ import ( "image" "github.com/dweymouth/supersonic/res" + "github.com/dweymouth/supersonic/sharedutil" "github.com/dweymouth/supersonic/ui/layouts" "fyne.io/fyne/v2" @@ -122,20 +123,20 @@ func isInside(origin fyne.Position, radius float32, point fyne.Position) bool { } type GridViewItemModel struct { - Name string - ID string - CoverArtID string - Secondary string - SecondaryID string + Name string + ID string + CoverArtID string + Secondary []string + SecondaryIDs []string } type GridViewItem struct { widget.BaseWidget itemID string - secondaryID string - primaryText *CustomHyperlink - secondaryText *CustomHyperlink + secondaryIDs []string + primaryText *widget.Hyperlink + secondaryText *MultiHyperlink container *fyne.Container // updated by GridView @@ -145,16 +146,17 @@ type GridViewItem struct { OnPlay func() OnShowContextMenu func(fyne.Position) OnShowItemPage func() - OnShowSecondaryPage func() + OnShowSecondaryPage func(string) } func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem { g := &GridViewItem{ - primaryText: NewCustomHyperlink(), - secondaryText: NewCustomHyperlink(), + primaryText: widget.NewHyperlink("", nil), + secondaryText: NewMultiHyperlink(), Cover: newCoverImage(placeholderResource), } - g.primaryText.SetTextStyle(fyne.TextStyle{Bold: true}) + g.primaryText.TextStyle.Bold = true + g.primaryText.Wrapping = fyne.TextTruncate g.ExtendBaseWidget(g) g.Cover.OnPlay = func() { if g.OnPlay != nil { @@ -173,9 +175,9 @@ func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem { } g.Cover.OnShowPage = showItemFn g.primaryText.OnTapped = showItemFn - g.secondaryText.OnTapped = func() { + g.secondaryText.OnTapped = func(s string) { if g.OnShowSecondaryPage != nil { - g.OnShowSecondaryPage() + g.OnShowSecondaryPage(s) } } @@ -191,15 +193,15 @@ func (g *GridViewItem) createContainer() { } func (g *GridViewItem) NeedsUpdate(model GridViewItemModel) bool { - return g.itemID != model.ID || g.secondaryID != model.SecondaryID + return g.itemID != model.ID || !sharedutil.SliceEqual(g.secondaryIDs, model.SecondaryIDs) } func (g *GridViewItem) Update(model GridViewItemModel) { g.itemID = model.ID - g.secondaryID = model.SecondaryID + g.secondaryIDs = model.SecondaryIDs g.primaryText.SetText(model.Name) - g.secondaryText.Disabled = model.SecondaryID == "" - g.secondaryText.SetText(model.Secondary) + g.secondaryText.BuildSegments(model.Secondary, model.SecondaryIDs) + g.secondaryText.Refresh() g.Cover.ResetPlayButton() } @@ -211,10 +213,6 @@ func (g *GridViewItem) ItemID() string { return g.itemID } -func (g *GridViewItem) SecondaryID() string { - return g.secondaryID -} - func (g *GridViewItem) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(g.container) } diff --git a/ui/widgets/multihyperlink.go b/ui/widgets/multihyperlink.go new file mode 100644 index 0000000..8feddda --- /dev/null +++ b/ui/widgets/multihyperlink.go @@ -0,0 +1,243 @@ +package widgets + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +type MultiHyperlink struct { + widget.BaseWidget + + Segments []MultiHyperlinkSegment + OnTapped func(string) + + minSegWidthCached float32 + minHeightCached float32 + separatorWCached float32 + + // TODO: Once https://github.com/fyne-io/fyne/issues/4336 is resolved, + // we can switch to the much cleaner RichText implementation + //provider *widget.RichText + content *fyne.Container +} + +type MultiHyperlinkSegment struct { + Text string + LinkID string +} + +func NewMultiHyperlink() *MultiHyperlink { + c := &MultiHyperlink{ + //provider: widget.NewRichText(), + content: container.NewWithoutLayout(), + } + c.ExtendBaseWidget(c) + //c.provider.Wrapping = fyne.TextTruncate + return c +} + +func (m *MultiHyperlink) BuildSegments(texts, links []string) { + l := len(links) + m.Segments = nil + for i, text := range texts { + link := "" + if l > i { + link = links[i] + } + m.Segments = append(m.Segments, MultiHyperlinkSegment{Text: text, LinkID: link}) + } +} + +func (c *MultiHyperlink) getMinSegWidth() float32 { + if c.minSegWidthCached == 0 { + c.minSegWidthCached = fyne.MeasureText(", W", theme.TextSize(), fyne.TextStyle{}).Width + } + return c.minSegWidthCached +} + +func (c *MultiHyperlink) getSeparatorWidth() float32 { + if c.separatorWCached == 0 { + c.separatorWCached = fyne.MeasureText(",", theme.TextSize(), fyne.TextStyle{}).Width + } + return c.separatorWCached +} + +func (c *MultiHyperlink) layoutObjects() { + if len(c.Segments) == 0 { + c.content.RemoveAll() + return + } + x := float32(0) + width := c.Size().Width + l := len(c.content.Objects) + + var i int // at end of loop should be index of last seg that was laid out for display + var seg MultiHyperlinkSegment + for i, seg = range c.Segments { + if i > 0 { + // check if we have enough room to show another segment + if x+c.getMinSegWidth() > width { + i -= 1 // we are not showing this segment + break + } + } + + appendingSegments := 2*i >= l + var obj fyne.CanvasObject + if !appendingSegments { + obj = c.content.Objects[2*i] + } else if i > 0 { + c.content.Objects = append(c.content.Objects, c.newSeparatorLabel()) + } + if seg.LinkID == "" { + obj = c.updateOrReplaceLabel(obj, seg.Text) + } else { + obj = c.updateOrReplaceHyperlink(obj, seg.Text, seg.LinkID) + } + if appendingSegments { + c.content.Objects = append(c.content.Objects, obj) + } else { + c.content.Objects[2*i] = obj + } + + if i > 0 { + // move and resize separator + obj = c.content.Objects[2*i-1] + ms := obj.MinSize() + obj.Resize(ms) + obj.Move(fyne.NewPos(x-ms.Width+c.getSeparatorWidth()+1, 0)) // this is really ugly + x -= theme.Padding() * 2 + } + // move and resize text object + textW := fyne.MeasureText(seg.Text, theme.TextSize(), fyne.TextStyle{}).Width + theme.Padding()*2 + theme.InnerPadding() + obj = c.content.Objects[2*i] + ms := obj.MinSize() + w := fyne.Min(width-x, textW) + obj.Resize(fyne.NewSize(w, ms.Height)) + obj.Move(fyne.NewPos(x, 0)) + x += w + } + + i += 1 + c.content.Objects = c.content.Objects[:2*i-1] +} + +func (c *MultiHyperlink) updateOrReplaceLabel(obj fyne.CanvasObject, text string) fyne.CanvasObject { + if obj != nil { + if label, ok := obj.(*widget.Label); ok { + label.Text = text + return label + } + } + l := widget.NewLabel(text) + l.Wrapping = fyne.TextTruncate + return l +} + +func (c *MultiHyperlink) updateOrReplaceHyperlink(obj fyne.CanvasObject, text, link string) fyne.CanvasObject { + if obj != nil { + if l, ok := obj.(*widget.Hyperlink); ok { + l.Text = text + l.OnTapped = func() { c.onSegmentTapped(link) } + return l + } + } + l := widget.NewHyperlink(text, nil) + l.Wrapping = fyne.TextTruncate + l.OnTapped = func() { c.onSegmentTapped(link) } + return l +} + +func (c *MultiHyperlink) newSeparatorLabel() *widget.Label { + return widget.NewLabel(", ") +} + +/*** + * RichText implementation + +func (c *MultiHyperlink) syncSegments() { + l := len(c.provider.Segments) + for i, seg := range c.Segments { + appendingSegments := 2*i >= l // true if we need to extend the RichText provider with new segments + var rtSeg widget.RichTextSegment + if !appendingSegments { + rtSeg = c.provider.Segments[2*i] + } else if i > 0 { + // append new separator segment + c.provider.Segments = append(c.provider.Segments, c.newSeparatorSegment()) + } + if seg.LinkID == "" { + rtSeg = c.updateOrReplaceTextSegment(rtSeg, seg.Text) + } else { + rtSeg = c.updateOrReplaceHyperlinkSegment(rtSeg, seg.Text, seg.LinkID) + } + if appendingSegments { + c.provider.Segments = append(c.provider.Segments, rtSeg) + } else { + c.provider.Segments[2*i] = rtSeg + } + } + // discard extra segments if shortening the multihyperlink + for i := 2 * len(c.Segments); i < l; i++ { + c.provider.Segments[i] = nil + } + c.provider.Segments = c.provider.Segments[:2*len(c.Segments)-1] +} + +func (c *MultiHyperlink) newSeparatorSegment() widget.RichTextSegment { + return &widget.TextSegment{Text: ", ", Style: widget.RichTextStyle{Inline: true}} +} + +func (c *MultiHyperlink) updateOrReplaceTextSegment(seg widget.RichTextSegment, text string) widget.RichTextSegment { + if seg != nil { + if ts, ok := seg.(*widget.TextSegment); ok { + ts.Text = text + return seg + } + } + return &widget.TextSegment{Text: text, Style: widget.RichTextStyle{Inline: true}} +} + +func (c *MultiHyperlink) updateOrReplaceHyperlinkSegment(seg widget.RichTextSegment, text, linkID string) widget.RichTextSegment { + if seg != nil { + if ts, ok := seg.(*widget.HyperlinkSegment); ok { + ts.Text = text + // TODO: OnTapped + return seg + } + } + return &widget.HyperlinkSegment{Text: text} // TODO: OnTapped +} + +*/ + +func (c *MultiHyperlink) onSegmentTapped(linkID string) { + if c.OnTapped != nil { + c.OnTapped(linkID) + } +} + +func (c *MultiHyperlink) MinSize() fyne.Size { + if c.minHeightCached == 0 { + c.minHeightCached = widget.NewLabel("").MinSize().Height + } + return fyne.NewSize(1, c.minHeightCached) +} + +func (c *MultiHyperlink) Resize(size fyne.Size) { + c.BaseWidget.Resize(size) + c.layoutObjects() +} + +func (c *MultiHyperlink) Refresh() { + //c.syncSegments() + c.layoutObjects() + c.BaseWidget.Refresh() +} + +func (c *MultiHyperlink) CreateRenderer() fyne.WidgetRenderer { + return widget.NewSimpleRenderer(c.content) + //return widget.NewSimpleRenderer(c.provider) +} diff --git a/ui/widgets/nowplayingcard.go b/ui/widgets/nowplayingcard.go index b961e3a..0ed7569 100644 --- a/ui/widgets/nowplayingcard.go +++ b/ui/widgets/nowplayingcard.go @@ -17,9 +17,9 @@ import ( type NowPlayingCard struct { widget.BaseWidget - trackName *CustomHyperlink - artistName *CustomHyperlink - albumName *CustomHyperlink + trackName *widget.Hyperlink + artistName *MultiHyperlink + albumName *widget.Hyperlink cover *TappableImage menu *widget.PopUpMenu @@ -33,17 +33,18 @@ type NowPlayingCard struct { func NewNowPlayingCard() *NowPlayingCard { n := &NowPlayingCard{ - trackName: NewCustomHyperlink(), - artistName: NewCustomHyperlink(), - albumName: NewCustomHyperlink(), + trackName: widget.NewHyperlink("", nil), + artistName: NewMultiHyperlink(), + albumName: widget.NewHyperlink("", nil), } n.ExtendBaseWidget(n) n.cover = NewTappableImage(n.onShowCoverImage) n.cover.OnTappedSecondary = n.showMenu n.trackName.Hidden = true - n.artistName.Hidden = true n.albumName.Hidden = true - n.trackName.SetTextStyle(fyne.TextStyle{Bold: true}) + n.albumName.Wrapping = fyne.TextTruncate + n.trackName.Wrapping = fyne.TextTruncate + n.trackName.TextStyle.Bold = true n.cover.SetMinSize(fyne.NewSize(85, 85)) n.cover.FillMode = canvas.ImageFillContain @@ -83,19 +84,17 @@ func (n *NowPlayingCard) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer(n.c) } -func (n *NowPlayingCard) Update(track, artist string, artistNavigable bool, album string, cover image.Image) { +func (n *NowPlayingCard) Update(track string, artists, artistIDs []string, album string, cover image.Image) { n.trackName.SetText(track) n.trackName.Hidden = track == "" - n.artistName.SetText(artist) - n.artistName.Hidden = artist == "" - n.artistName.Disabled = !artistNavigable + n.artistName.BuildSegments(artists, artistIDs) n.albumName.SetText(album) n.albumName.Hidden = album == "" n.cover.Image.Image = cover n.c.Refresh() } -func (n *NowPlayingCard) OnArtistNameTapped(f func()) { +func (n *NowPlayingCard) OnArtistNameTapped(f func(string)) { n.artistName.OnTapped = f } diff --git a/ui/widgets/tracklist.go b/ui/widgets/tracklist.go index 45d18a0..f48a3e0 100644 --- a/ui/widgets/tracklist.go +++ b/ui/widgets/tracklist.go @@ -396,7 +396,7 @@ func (t *Tracklist) doSortTracks() { case ColumnTitle: t.stringSort(func(tr *trackModel) string { return tr.track.Name }) case ColumnArtist: - t.stringSort(func(tr *trackModel) string { return tr.track.ArtistNames[0] }) + t.stringSort(func(tr *trackModel) string { return strings.Join(tr.track.ArtistNames, ", ") }) case ColumnAlbum: t.stringSort(func(tr *trackModel) string { return tr.track.Album }) case ColumnPath: @@ -674,18 +674,18 @@ type TrackRow struct { isFavorite bool playCount int - num *widget.RichText - name *widget.RichText - artist *CustomHyperlink - album *CustomHyperlink - dur *widget.RichText - year *widget.RichText + num *widget.Label + name *widget.RichText // for bold support + artist *MultiHyperlink + album *widget.Hyperlink + dur *widget.Label + year *widget.Label favorite *fyne.Container rating *StarRating - bitrate *widget.RichText - plays *widget.RichText - size *widget.RichText - path *widget.RichText + bitrate *widget.Label + plays *widget.Label + size *widget.Label + path *widget.Label OnTappedSecondary func(e *fyne.PointEvent, trackIdx int) @@ -695,24 +695,25 @@ type TrackRow struct { func NewTrackRow(tracklist *Tracklist, playingIcon fyne.CanvasObject) *TrackRow { t := &TrackRow{tracklist: tracklist, playingIcon: playingIcon} t.ExtendBaseWidget(t) - t.num = newTrailingAlignRichText() + t.num = newTrailingAlignLabel() t.name = newTruncatingRichText() - t.artist = NewCustomHyperlink() - t.artist.OnTapped = func() { tracklist.onArtistTapped(t.artistID) } - t.album = NewCustomHyperlink() + t.artist = NewMultiHyperlink() + t.artist.OnTapped = tracklist.onArtistTapped + t.album = widget.NewHyperlink("", nil) + t.album.Wrapping = fyne.TextTruncate t.album.OnTapped = func() { tracklist.onAlbumTapped(t.albumID) } - t.dur = newTrailingAlignRichText() - t.year = newTrailingAlignRichText() + t.dur = newTrailingAlignLabel() + t.year = newTrailingAlignLabel() favorite := NewTappableIcon(myTheme.NotFavoriteIcon) favorite.OnTapped = t.toggleFavorited t.favorite = container.NewCenter(favorite) t.rating = NewStarRating() t.rating.StarSize = 16 t.rating.OnRatingChanged = t.setTrackRating - t.plays = newTrailingAlignRichText() - t.bitrate = newTrailingAlignRichText() - t.size = newTrailingAlignRichText() - t.path = newTruncatingRichText() + t.plays = newTrailingAlignLabel() + t.bitrate = newTrailingAlignLabel() + t.size = newTrailingAlignLabel() + t.path = newTruncatingLabel() t.Content = container.New(tracklist.colLayout, t.num, t.name, t.artist, t.album, t.dur, t.year, t.favorite, t.rating, t.plays, t.bitrate, t.size, t.path) @@ -725,9 +726,15 @@ func newTruncatingRichText() *widget.RichText { return rt } -func newTrailingAlignRichText() *widget.RichText { - rt := widget.NewRichTextWithText("") - rt.Segments[0].(*widget.TextSegment).Style.Alignment = fyne.TextAlignTrailing +func newTruncatingLabel() *widget.Label { + rt := widget.NewLabel("") + rt.Wrapping = fyne.TextTruncate + return rt +} + +func newTrailingAlignLabel() *widget.Label { + rt := widget.NewLabel("") + rt.Alignment = fyne.TextAlignTrailing return rt } @@ -747,15 +754,14 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) { t.albumID = tr.AlbumID t.name.Segments[0].(*widget.TextSegment).Text = tr.Name - t.artist.SetText(tr.ArtistNames[0]) - t.artist.Disabled = tr.ArtistIDs[0] == "" + t.artist.BuildSegments(tr.ArtistNames, tr.ArtistIDs) t.album.SetText(tr.Album) - t.dur.Segments[0].(*widget.TextSegment).Text = util.SecondsToTimeString(float64(tr.Duration)) - t.year.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.Year) - t.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount)) - t.bitrate.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(tr.BitRate) - t.size.Segments[0].(*widget.TextSegment).Text = util.BytesToSizeString(tr.Size) - t.path.Segments[0].(*widget.TextSegment).Text = tr.FilePath + t.dur.Text = util.SecondsToTimeString(float64(tr.Duration)) + t.year.Text = strconv.Itoa(tr.Year) + t.plays.Text = strconv.Itoa(int(tr.PlayCount)) + t.bitrate.Text = strconv.Itoa(tr.BitRate) + t.size.Text = util.BytesToSizeString(tr.Size) + t.path.Text = tr.FilePath } // Update track num if needed @@ -775,25 +781,19 @@ func (t *TrackRow) Update(tm *trackModel, rowNum int) { } else { str = strconv.Itoa(rowNum) } - t.num.Segments[0].(*widget.TextSegment).Text = str + t.num.Text = str } // Update play count if needed if tr.PlayCount != t.playCount { t.playCount = tr.PlayCount - t.plays.Segments[0].(*widget.TextSegment).Text = strconv.Itoa(int(tr.PlayCount)) + t.plays.Text = strconv.Itoa(int(tr.PlayCount)) } // Render whether track is playing or not if isPlaying := t.tracklist.nowPlayingID == tr.ID; isPlaying != t.isPlaying { t.isPlaying = isPlaying t.name.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying - t.dur.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying - t.year.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying - t.plays.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying - t.bitrate.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying - t.size.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying - t.path.Segments[0].(*widget.TextSegment).Style.TextStyle.Bold = isPlaying if isPlaying { t.Content.(*fyne.Container).Objects[0] = container.NewCenter(t.playingIcon)