From 5e6b1de52223a1b1d0a46d05e617015dd6f5a973 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Tue, 2 Dec 2025 08:35:03 -0800 Subject: [PATCH] Fix #774: Make headers for Album, Artist, Playlist page collapsible / compact --- backend/config.go | 3 ++ ui/browsing/albumpage.go | 82 +++++++++++++++++++++++------- ui/browsing/artistpage.go | 55 +++++++++++++++++--- ui/browsing/playlistpage.go | 60 ++++++++++++++++++---- ui/theme/theme.go | 3 ++ ui/widgets/headercollapsebutton.go | 61 ++++++++++++++++++++++ 6 files changed, 229 insertions(+), 35 deletions(-) create mode 100644 ui/widgets/headercollapsebutton.go diff --git a/backend/config.go b/backend/config.go index 354ba28..cadad20 100644 --- a/backend/config.go +++ b/backend/config.go @@ -70,6 +70,7 @@ type AppConfig struct { type AlbumPageConfig struct { TracklistColumns []string + CompactHeader bool } // shared between Albums and Genre pages @@ -84,6 +85,7 @@ type ArtistPageConfig struct { InitialView string DiscographySort string TracklistColumns []string + CompactHeader bool } type ArtistsPageConfig struct { @@ -102,6 +104,7 @@ type GridViewConfig struct { type PlaylistPageConfig struct { TracklistColumns []string + CompactHeader bool } type PlaylistsPageConfig struct { diff --git a/ui/browsing/albumpage.go b/ui/browsing/albumpage.go index f29294f..830a8a2 100644 --- a/ui/browsing/albumpage.go +++ b/ui/browsing/albumpage.go @@ -16,6 +16,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" @@ -85,12 +86,12 @@ func newAlbumPage( a.ExtendBaseWidget(a) if h := pool.Obtain(util.WidgetTypeAlbumPageHeader); h != nil { a.header = h.(*AlbumPageHeader) - a.header.page = a a.header.Clear() } else { a.header = NewAlbumPageHeader(a) } a.header.page = a + a.header.Compact = a.cfg.CompactHeader if t := a.pool.Obtain(util.WidgetTypeCompactTracklist); t != nil { a.tracklist = t.(*widgets.Tracklist) a.tracklist.Reset() @@ -203,19 +204,23 @@ func (a *AlbumPage) load() { type AlbumPageHeader struct { widget.BaseWidget + Compact bool + albumID string coverID string page *AlbumPage - cover *widgets.ImagePlaceholder - titleLabel *widget.RichText - releaseTypeLabel *widget.RichText - artistLabel *widgets.MultiHyperlink - artistLabelSpace *util.Space // TODO: remove when no longer needed - genreLabel *widgets.MultiHyperlink - miscLabel *widget.Label - shareMenuItem *fyne.MenuItem + cover *widgets.ImagePlaceholder + titleLabel *widget.RichText + releaseTypeLabel *widget.RichText + artistLabel *widgets.MultiHyperlink + artistLabelSpace *util.Space // TODO: remove when no longer needed + genreLabel *widgets.MultiHyperlink + miscLabel *widget.Label + shareMenuItem *fyne.MenuItem + collapseBtn *widgets.HeaderCollapseButton + artistReleaseTypeLine *fyne.Container toggleFavButton *widgets.FavoriteButton @@ -229,7 +234,7 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader { // be directly captured in a closure throughout this function! a := &AlbumPageHeader{page: page} a.ExtendBaseWidget(a) - a.cover = widgets.NewImagePlaceholder(myTheme.AlbumIcon, 225) + a.cover = widgets.NewImagePlaceholder(myTheme.AlbumIcon, myTheme.HeaderImageSize) a.cover.OnTapped = func(*fyne.PointEvent) { go a.showPopUpCover() } a.titleLabel = widget.NewRichTextWithText("") @@ -297,28 +302,39 @@ func NewAlbumPageHeader(page *AlbumPage) *AlbumPageHeader { } a.toggleFavButton = widgets.NewFavoriteButton(func() { go a.toggleFavorited() }) + a.collapseBtn = widgets.NewHeaderCollapseButton(func() { + a.Compact = !a.Compact + a.page.cfg.CompactHeader = a.Compact + a.page.Refresh() + }) + a.collapseBtn.Hidden = true + // TODO: Create a nicer custom layout to set this up properly // OR once TODO in MultiHyperlink to use RichText as a provider is solved, // extend MultiHyperlink to support prepending rich text segments and // don't use two separate widgets here at all. // n.b. cannot place MultiHyperlink in a HBox or it collapses in width - artistReleaseTypeLine := container.NewStack( + a.artistReleaseTypeLine = container.NewStack( a.releaseTypeLabel, container.NewBorder(nil, nil, a.artistLabelSpace, nil, a.artistLabel)) // TODO: there's got to be a way to make this less convoluted. Custom layout? a.container = util.AddHeaderBackground( - container.NewBorder(nil, nil, a.cover, nil, - container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), - a.titleLabel, - container.NewVBox( - container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-12), artistReleaseTypeLine, a.genreLabel, a.miscLabel), + container.NewStack( + container.NewBorder(nil, nil, a.cover, nil, + container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), + a.titleLabel, container.NewVBox( - container.NewHBox(util.NewHSpace(2), playButton, shuffleBtn, menuBtn), - container.NewHBox(util.NewHSpace(2), a.toggleFavButton), + container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-12), a.artistReleaseTypeLine, a.genreLabel, a.miscLabel), + container.NewVBox( + container.NewHBox(util.NewHSpace(2), playButton, shuffleBtn, menuBtn), + container.NewHBox(util.NewHSpace(2), a.toggleFavButton), + ), ), ), ), - )) + container.NewVBox(container.NewHBox(layout.NewSpacer(), a.collapseBtn)), + ), + ) return a } @@ -369,6 +385,34 @@ func (a *AlbumPageHeader) toggleFavorited() { a.page.mp.SetFavorite(params, a.toggleFavButton.IsFavorited) } +var _ desktop.Hoverable = (*AlbumPageHeader)(nil) + +func (a *AlbumPageHeader) MouseIn(e *desktop.MouseEvent) { + a.collapseBtn.Show() + a.Refresh() +} + +func (a *AlbumPageHeader) MouseOut() { + a.collapseBtn.HideIfNotMousedIn() +} + +func (a *AlbumPageHeader) MouseMoved(*desktop.MouseEvent) { +} + +func (a *AlbumPageHeader) Refresh() { + a.artistReleaseTypeLine.Hidden = a.Compact + a.genreLabel.Hidden = a.Compact + a.miscLabel.Hidden = a.Compact + a.toggleFavButton.Hidden = a.Compact + a.collapseBtn.Collapsed = a.Compact + if a.Compact { + a.cover.SetMinSize(fyne.NewSquareSize(myTheme.CompactHeaderImageSize)) + } else { + a.cover.SetMinSize(fyne.NewSquareSize(myTheme.HeaderImageSize)) + } + a.BaseWidget.Refresh() +} + // should be called asynchronously func (a *AlbumPageHeader) showPopUpCover() { if a.fullSizeCoverFetching { diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index f62523f..9989db0 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -16,6 +16,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" @@ -97,6 +98,7 @@ func newArtistPage(state artistPageState) *ArtistPage { a.header = NewArtistPageHeader(a) } a.header.artistPage = a + a.header.Compact = a.cfg.CompactHeader if img, ok := state.im.GetCachedArtistImage(state.artistID); ok { a.header.artistImage.SetImage(img, true /*tappable*/) } @@ -473,6 +475,8 @@ const artistBioNotAvailableKey = "Artist biography not available." type ArtistPageHeader struct { widget.BaseWidget + Compact bool + artistID string artistPage *ArtistPage artistImage *widgets.ImagePlaceholder @@ -485,6 +489,7 @@ type ArtistPageHeader struct { playRadioBtn *widget.Button menuBtn *widget.Button container *fyne.Container + collapseBtn *widgets.HeaderCollapseButton fullSizeCoverFetching bool // shareMenuItem *fyne.MenuItem } @@ -501,7 +506,7 @@ func NewArtistPageHeader(page *ArtistPage) *ArtistPageHeader { a.titleDisp.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ SizeName: theme.SizeNameHeadingText, } - a.artistImage = widgets.NewImagePlaceholder(myTheme.ArtistIcon, 225) + a.artistImage = widgets.NewImagePlaceholder(myTheme.ArtistIcon, myTheme.HeaderImageSize) a.artistImage.OnTapped = func(*fyne.PointEvent) { a.showPopUpCover() } a.favoriteBtn = widgets.NewFavoriteButton(func() { go a.toggleFavorited() }) a.playBtn = widget.NewButtonWithIcon(lang.L("Play Discography"), theme.MediaPlayIcon(), func() { @@ -546,6 +551,12 @@ func NewArtistPageHeader(page *ArtistPage) *ArtistPageHeader { a.biographyDisp.Wrapping = fyne.TextWrapWord a.biographyDisp.Truncation = fyne.TextTruncateEllipsis + a.collapseBtn = widgets.NewHeaderCollapseButton(func() { + a.Compact = !a.Compact + a.artistPage.cfg.CompactHeader = a.Compact + a.artistPage.Refresh() + }) + a.collapseBtn.Hidden = true a.ExtendBaseWidget(a) a.createContainer() return a @@ -636,6 +647,32 @@ func (a *ArtistPageHeader) UpdateInfo(info *mediaprovider.ArtistInfo) { } } +var _ desktop.Hoverable = (*ArtistPageHeader)(nil) + +func (a *ArtistPageHeader) MouseIn(*desktop.MouseEvent) { + a.collapseBtn.Show() + a.Refresh() +} + +func (a *ArtistPageHeader) MouseOut() { + a.collapseBtn.HideIfNotMousedIn() +} + +func (a *ArtistPageHeader) MouseMoved(*desktop.MouseEvent) { +} + +func (a *ArtistPageHeader) Refresh() { + a.biographyDisp.Hidden = a.Compact + a.similarArtists.Hidden = a.Compact + a.collapseBtn.Collapsed = a.Compact + if a.Compact { + a.artistImage.SetMinSize(fyne.NewSquareSize(myTheme.CompactHeaderImageSize)) + } else { + a.artistImage.SetMinSize(fyne.NewSquareSize(myTheme.HeaderImageSize)) + } + a.BaseWidget.Refresh() +} + // should NOT be called asynchronously func (a *ArtistPageHeader) showPopUpCover() { if a.artistImageID == "" { @@ -670,12 +707,16 @@ func (a *ArtistPageHeader) createContainer() { btnContainer := container.NewHBox(util.NewHSpace(2), a.favoriteBtn, a.playBtn, a.playRadioBtn, a.menuBtn) a.container = util.AddHeaderBackground( - container.NewBorder(nil, nil, a.artistImage, nil, - container.NewVBox( - container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), - a.titleDisp, a.biographyDisp, a.similarArtists), - btnContainer), - )) + container.NewStack( + container.NewBorder(nil, nil, a.artistImage, nil, + container.NewVBox( + container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), + a.titleDisp, a.biographyDisp, a.similarArtists), + btnContainer), + ), + container.NewVBox(container.NewHBox(layout.NewSpacer(), a.collapseBtn)), + ), + ) } func (a *ArtistPageHeader) CreateRenderer() fyne.WidgetRenderer { diff --git a/ui/browsing/playlistpage.go b/ui/browsing/playlistpage.go index 5448eb1..31374e1 100644 --- a/ui/browsing/playlistpage.go +++ b/ui/browsing/playlistpage.go @@ -18,6 +18,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/driver/desktop" "fyne.io/fyne/v2/lang" "fyne.io/fyne/v2/layout" "fyne.io/fyne/v2/theme" @@ -76,12 +77,12 @@ func newPlaylistPage( a.ExtendBaseWidget(a) if h := a.widgetPool.Obtain(util.WidgetTypePlaylistPageHeader); h != nil { a.header = h.(*PlaylistPageHeader) - a.header.page = a a.header.Clear() } else { a.header = NewPlaylistPageHeader(a) } a.header.page = a + a.header.Compact = a.conf.CompactHeader if tl := a.widgetPool.Obtain(util.WidgetTypeTracklist); tl != nil { a.tracklist = tl.(*widgets.Tracklist) a.tracklist.Reset() @@ -304,6 +305,8 @@ func (a *PlaylistPage) onSearched(query string) { type PlaylistPageHeader struct { widget.BaseWidget + Compact bool + page *PlaylistPage playlistInfo *mediaprovider.PlaylistWithTracks image *widgets.ImagePlaceholder @@ -314,6 +317,7 @@ type PlaylistPageHeader struct { createdAtLabel *widget.Label ownerLabel *widget.Label trackTimeLabel *widget.Label + collapseBtn *widgets.HeaderCollapseButton fullSizeCoverFetching bool @@ -326,7 +330,7 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { a := &PlaylistPageHeader{page: page} a.ExtendBaseWidget(a) - a.image = widgets.NewImagePlaceholder(myTheme.PlaylistIcon, 225) + a.image = widgets.NewImagePlaceholder(myTheme.PlaylistIcon, myTheme.HeaderImageSize) a.image.OnTapped = func(*fyne.PointEvent) { go a.showPopUpCover() } a.titleLabel = util.NewTruncatingRichText() a.titleLabel.Segments[0].(*widget.TextSegment).Style = widget.RichTextStyle{ @@ -433,14 +437,25 @@ func NewPlaylistPageHeader(page *PlaylistPage) *PlaylistPageHeader { buttonRow = container.NewHBox(a.editButton, playButton, shuffleBtn, searchBtn, menuBtn) + a.collapseBtn = widgets.NewHeaderCollapseButton(func() { + a.Compact = !a.Compact + a.page.conf.CompactHeader = a.Compact + a.page.Refresh() + }) + a.collapseBtn.Hidden = true + a.container = util.AddHeaderBackground( - container.NewBorder(nil, nil, a.image, nil, - container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), - a.descriptionLabel, - a.ownerLabel, - a.trackTimeLabel), - buttonRow, - ))) + container.NewStack( + container.NewBorder(nil, nil, a.image, nil, + container.NewVBox(a.titleLabel, container.New(layout.NewCustomPaddedVBoxLayout(theme.Padding()-10), + a.descriptionLabel, + a.ownerLabel, + a.trackTimeLabel), + buttonRow, + )), + container.NewVBox(container.NewHBox(layout.NewSpacer(), a.collapseBtn)), + ), + ) return a } @@ -479,6 +494,33 @@ func (a *PlaylistPageHeader) Update(playlist *mediaprovider.PlaylistWithTracks) a.Refresh() } +var _ desktop.Hoverable = (*PlaylistPageHeader)(nil) + +func (a *PlaylistPageHeader) MouseIn(*desktop.MouseEvent) { + a.collapseBtn.Show() + a.Refresh() +} + +func (a *PlaylistPageHeader) MouseOut() { + a.collapseBtn.HideIfNotMousedIn() +} + +func (a *PlaylistPageHeader) MouseMoved(*desktop.MouseEvent) { +} + +func (a *PlaylistPageHeader) Refresh() { + a.descriptionLabel.Hidden = a.Compact + a.ownerLabel.Hidden = a.Compact + a.trackTimeLabel.Hidden = a.Compact + a.collapseBtn.Collapsed = a.Compact + if a.Compact { + a.image.SetMinSize(fyne.NewSquareSize(myTheme.CompactHeaderImageSize)) + } else { + a.image.SetMinSize(fyne.NewSquareSize(myTheme.HeaderImageSize)) + } + a.BaseWidget.Refresh() +} + // should be called asynchronously func (a *PlaylistPageHeader) showPopUpCover() { if a.fullSizeCoverFetching || a.playlistInfo == nil { diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 8972e06..8318d1e 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -38,6 +38,9 @@ const ( AnimationDurationShort = canvas.DurationShort AnimationDurationMedium = 225 * time.Millisecond AnimationDurationLong = canvas.DurationStandard + + HeaderImageSize = 225 + CompactHeaderImageSize = 95 ) var ( diff --git a/ui/widgets/headercollapsebutton.go b/ui/widgets/headercollapsebutton.go new file mode 100644 index 0000000..d3ec63e --- /dev/null +++ b/ui/widgets/headercollapsebutton.go @@ -0,0 +1,61 @@ +package widgets + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/driver/desktop" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" +) + +type HeaderCollapseButton struct { + widget.Button + + Collapsed bool + + shouldHide bool +} + +func NewHeaderCollapseButton(onTapped func()) *HeaderCollapseButton { + b := &HeaderCollapseButton{ + Button: widget.Button{ + Icon: theme.ContentRemoveIcon(), + Importance: widget.LowImportance, + }, + } + b.OnTapped = func() { + b.Collapsed = !b.Collapsed + onTapped() + } + b.ExtendBaseWidget(b) + return b +} + +// HideIfNotMousedIn hides the button after a short delay +// if the mouse is not hovering over it. +func (b *HeaderCollapseButton) HideIfNotMousedIn() { + b.shouldHide = true + fyne.Do(func() { + if b.shouldHide { + b.Hide() + b.shouldHide = false + } + }) +} + +func (b *HeaderCollapseButton) MouseIn(e *desktop.MouseEvent) { + b.shouldHide = false + b.Button.MouseIn(e) +} + +func (b *HeaderCollapseButton) MinSize() fyne.Size { + return fyne.NewSize(24, 24) +} + +func (b *HeaderCollapseButton) Refresh() { + if b.Collapsed { + b.Icon = theme.ContentAddIcon() + } else { + b.Icon = theme.ContentRemoveIcon() + } + b.Button.Refresh() +}