From ee9fd597f968a3f63afff81d37e82b41d495d0e6 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 28 Dec 2024 15:25:51 -0800 Subject: [PATCH 1/4] add icon buttons to bottom of GridViewItem when hovered --- ui/theme/theme.go | 3 + ui/util/svg.go | 321 +++++++++++++++++++++++++++++++++++++ ui/widgets/gridviewitem.go | 72 ++++++++- 3 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 ui/util/svg.go diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 0f75d83..9d35406 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -33,6 +33,9 @@ const ( ) var ( + GridViewIconColor color.Color = color.White + GridViewHoveredIconColor color.Color = darkenColor(GridViewIconColor, 0.05) + AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) RadioIcon fyne.Resource = theme.NewThemedResource(res.ResBroadcastSvg) diff --git a/ui/util/svg.go b/ui/util/svg.go new file mode 100644 index 0000000..90edfbe --- /dev/null +++ b/ui/util/svg.go @@ -0,0 +1,321 @@ +package util + +// Contents of this file come from the fyne internal SVG package +// Once https://github.com/fyne-io/fyne/pull/5345 is available in main, +// this file can be retired, and the ColorizeSVG func can be replaced with +// `canvas.ColorizeSVG` + +import ( + "bytes" + "encoding/hex" + "encoding/xml" + "fmt" + "image/color" + "io" + "strconv" +) + +// ColorizeSVG creates a new SVG from a given one by replacing all fill colors by the given color. +func ColorizeSVG(src []byte, clr color.Color) ([]byte, error) { + rdr := bytes.NewReader(src) + s, err := svgFromXML(rdr) + if err != nil { + return src, fmt.Errorf("could not load SVG, falling back to static content: %v", err) + } + if err := s.replaceFillColor(clr); err != nil { + return src, fmt.Errorf("could not replace fill color, falling back to static content: %v", err) + } + colorized, err := xml.Marshal(s) + if err != nil { + return src, fmt.Errorf("could not marshal svg, falling back to static content: %v", err) + } + return colorized, nil +} + +// svg holds the unmarshaled XML from a Scalable Vector Graphic +type svg struct { + XMLName xml.Name `xml:"svg"` + XMLNS string `xml:"xmlns,attr"` + Width string `xml:"width,attr,omitempty"` + Height string `xml:"height,attr,omitempty"` + ViewBox string `xml:"viewBox,attr,omitempty"` + Paths []*pathObj `xml:"path"` + Rects []*rectObj `xml:"rect"` + Circles []*circleObj `xml:"circle"` + Ellipses []*ellipseObj `xml:"ellipse"` + Polygons []*polygonObj `xml:"polygon"` + Groups []*objGroup `xml:"g"` +} + +type pathObj struct { + XMLName xml.Name `xml:"path"` + Fill string `xml:"fill,attr,omitempty"` + FillOpacity string `xml:"fill-opacity,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth string `xml:"stroke-width,attr,omitempty"` + StrokeLineCap string `xml:"stroke-linecap,attr,omitempty"` + StrokeLineJoin string `xml:"stroke-linejoin,attr,omitempty"` + StrokeDashArray string `xml:"stroke-dasharray,attr,omitempty"` + D string `xml:"d,attr"` + Transform string `xml:"transform,attr,omitempty"` +} + +type rectObj struct { + XMLName xml.Name `xml:"rect"` + Fill string `xml:"fill,attr,omitempty"` + FillOpacity string `xml:"fill-opacity,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth string `xml:"stroke-width,attr,omitempty"` + StrokeLineCap string `xml:"stroke-linecap,attr,omitempty"` + StrokeLineJoin string `xml:"stroke-linejoin,attr,omitempty"` + StrokeDashArray string `xml:"stroke-dasharray,attr,omitempty"` + X string `xml:"x,attr,omitempty"` + Y string `xml:"y,attr,omitempty"` + Width string `xml:"width,attr,omitempty"` + Height string `xml:"height,attr,omitempty"` + Transform string `xml:"transform,attr,omitempty"` +} + +type circleObj struct { + XMLName xml.Name `xml:"circle"` + Fill string `xml:"fill,attr,omitempty"` + FillOpacity string `xml:"fill-opacity,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth string `xml:"stroke-width,attr,omitempty"` + StrokeLineCap string `xml:"stroke-linecap,attr,omitempty"` + StrokeLineJoin string `xml:"stroke-linejoin,attr,omitempty"` + StrokeDashArray string `xml:"stroke-dasharray,attr,omitempty"` + CX string `xml:"cx,attr,omitempty"` + CY string `xml:"cy,attr,omitempty"` + R string `xml:"r,attr,omitempty"` + Transform string `xml:"transform,attr,omitempty"` +} + +type ellipseObj struct { + XMLName xml.Name `xml:"ellipse"` + Fill string `xml:"fill,attr,omitempty"` + FillOpacity string `xml:"fill-opacity,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth string `xml:"stroke-width,attr,omitempty"` + StrokeLineCap string `xml:"stroke-linecap,attr,omitempty"` + StrokeLineJoin string `xml:"stroke-linejoin,attr,omitempty"` + StrokeDashArray string `xml:"stroke-dasharray,attr,omitempty"` + CX string `xml:"cx,attr,omitempty"` + CY string `xml:"cy,attr,omitempty"` + RX string `xml:"rx,attr,omitempty"` + RY string `xml:"ry,attr,omitempty"` + Transform string `xml:"transform,attr,omitempty"` +} + +type polygonObj struct { + XMLName xml.Name `xml:"polygon"` + Fill string `xml:"fill,attr,omitempty"` + FillOpacity string `xml:"fill-opacity,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth string `xml:"stroke-width,attr,omitempty"` + StrokeLineCap string `xml:"stroke-linecap,attr,omitempty"` + StrokeLineJoin string `xml:"stroke-linejoin,attr,omitempty"` + StrokeDashArray string `xml:"stroke-dasharray,attr,omitempty"` + Points string `xml:"points,attr"` + Transform string `xml:"transform,attr,omitempty"` +} + +type objGroup struct { + XMLName xml.Name `xml:"g"` + ID string `xml:"id,attr,omitempty"` + Fill string `xml:"fill,attr,omitempty"` + Stroke string `xml:"stroke,attr,omitempty"` + StrokeWidth string `xml:"stroke-width,attr,omitempty"` + StrokeLineCap string `xml:"stroke-linecap,attr,omitempty"` + StrokeLineJoin string `xml:"stroke-linejoin,attr,omitempty"` + StrokeDashArray string `xml:"stroke-dasharray,attr,omitempty"` + Transform string `xml:"transform,attr,omitempty"` + Paths []*pathObj `xml:"path"` + Circles []*circleObj `xml:"circle"` + Ellipses []*ellipseObj `xml:"ellipse"` + Rects []*rectObj `xml:"rect"` + Polygons []*polygonObj `xml:"polygon"` + Groups []*objGroup `xml:"g"` +} + +func replacePathsFill(paths []*pathObj, hexColor string, opacity string) { + for _, path := range paths { + if path.Fill != "none" { + path.Fill = hexColor + path.FillOpacity = opacity + } + } +} + +func replaceRectsFill(rects []*rectObj, hexColor string, opacity string) { + for _, rect := range rects { + if rect.Fill != "none" { + rect.Fill = hexColor + rect.FillOpacity = opacity + } + } +} + +func replaceCirclesFill(circles []*circleObj, hexColor string, opacity string) { + for _, circle := range circles { + if circle.Fill != "none" { + circle.Fill = hexColor + circle.FillOpacity = opacity + } + } +} + +func replaceEllipsesFill(ellipses []*ellipseObj, hexColor string, opacity string) { + for _, ellipse := range ellipses { + if ellipse.Fill != "none" { + ellipse.Fill = hexColor + ellipse.FillOpacity = opacity + } + } +} + +func replacePolygonsFill(polys []*polygonObj, hexColor string, opacity string) { + for _, poly := range polys { + if poly.Fill != "none" { + poly.Fill = hexColor + poly.FillOpacity = opacity + } + } +} + +func replaceGroupObjectFill(groups []*objGroup, hexColor string, opacity string) { + for _, grp := range groups { + replaceCirclesFill(grp.Circles, hexColor, opacity) + replaceEllipsesFill(grp.Ellipses, hexColor, opacity) + replacePathsFill(grp.Paths, hexColor, opacity) + replaceRectsFill(grp.Rects, hexColor, opacity) + replacePolygonsFill(grp.Polygons, hexColor, opacity) + replaceGroupObjectFill(grp.Groups, hexColor, opacity) + } +} + +// replaceFillColor alters an svg objects fill color. Note that if an svg with multiple fill +// colors is being operated upon, all fills will be converted to a single color. Mostly used +// to recolor Icons to match the theme's IconColor. +func (s *svg) replaceFillColor(color color.Color) error { + hexColor, opacity := colorToHexAndOpacity(color) + replacePathsFill(s.Paths, hexColor, opacity) + replaceRectsFill(s.Rects, hexColor, opacity) + replaceCirclesFill(s.Circles, hexColor, opacity) + replaceEllipsesFill(s.Ellipses, hexColor, opacity) + replacePolygonsFill(s.Polygons, hexColor, opacity) + replaceGroupObjectFill(s.Groups, hexColor, opacity) + return nil +} + +func svgFromXML(reader io.Reader) (*svg, error) { + var s svg + bSlice, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + if err := xml.Unmarshal(bSlice, &s); err != nil { + return nil, err + } + return &s, nil +} + +func colorToHexAndOpacity(color color.Color) (hexStr, aStr string) { + r, g, b, a := toNRGBA(color) + cBytes := []byte{byte(r), byte(g), byte(b)} + hexStr, aStr = "#"+hex.EncodeToString(cBytes), strconv.FormatFloat(float64(a)/0xff, 'f', 6, 64) + return +} + +// toNRGBA converts a color to RGBA values which are not premultiplied, unlike color.RGBA(). +func toNRGBA(c color.Color) (r, g, b, a int) { + // We use UnmultiplyAlpha with RGBA, RGBA64, and unrecognized implementations of Color. + // It works for all Colors whose RGBA() method is implemented according to spec, but is only necessary for those. + // Only RGBA and RGBA64 have components which are already premultiplied. + switch col := c.(type) { + // NRGBA and NRGBA64 are not premultiplied + case color.NRGBA: + r = int(col.R) + g = int(col.G) + b = int(col.B) + a = int(col.A) + case *color.NRGBA: + r = int(col.R) + g = int(col.G) + b = int(col.B) + a = int(col.A) + case color.NRGBA64: + r = int(col.R) >> 8 + g = int(col.G) >> 8 + b = int(col.B) >> 8 + a = int(col.A) >> 8 + case *color.NRGBA64: + r = int(col.R) >> 8 + g = int(col.G) >> 8 + b = int(col.B) >> 8 + a = int(col.A) >> 8 + // Gray and Gray16 have no alpha component + case *color.Gray: + r = int(col.Y) + g = int(col.Y) + b = int(col.Y) + a = 0xff + case color.Gray: + r = int(col.Y) + g = int(col.Y) + b = int(col.Y) + a = 0xff + case *color.Gray16: + r = int(col.Y) >> 8 + g = int(col.Y) >> 8 + b = int(col.Y) >> 8 + a = 0xff + case color.Gray16: + r = int(col.Y) >> 8 + g = int(col.Y) >> 8 + b = int(col.Y) >> 8 + a = 0xff + // Alpha and Alpha16 contain only an alpha component. + case color.Alpha: + r = 0xff + g = 0xff + b = 0xff + a = int(col.A) + case *color.Alpha: + r = 0xff + g = 0xff + b = 0xff + a = int(col.A) + case color.Alpha16: + r = 0xff + g = 0xff + b = 0xff + a = int(col.A) >> 8 + case *color.Alpha16: + r = 0xff + g = 0xff + b = 0xff + a = int(col.A) >> 8 + default: // RGBA, RGBA64, and unknown implementations of Color + r, g, b, a = unmultiplyAlpha(c) + } + return +} + +// unmultiplyAlpha returns a color's RGBA components as 8-bit integers by calling c.RGBA() and then removing the alpha premultiplication. +// It is only used by ToRGBA. +func unmultiplyAlpha(c color.Color) (r, g, b, a int) { + red, green, blue, alpha := c.RGBA() + if alpha != 0 && alpha != 0xffff { + red = (red * 0xffff) / alpha + green = (green * 0xffff) / alpha + blue = (blue * 0xffff) / alpha + } + // Convert from range 0-65535 to range 0-255 + r = int(red >> 8) + g = int(green >> 8) + b = int(blue >> 8) + a = int(alpha >> 8) + return +} diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index f56a747..735827e 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -29,6 +29,10 @@ type coverImage struct { Im *ImagePlaceholder playbtn *canvas.Image + favoriteButton *canvas.Image + moreButton *canvas.Image + prevTheme fyne.ThemeVariant + bottomPanel *fyne.Container mouseInsideBtn bool OnPlay func() OnShowPage func() @@ -38,9 +42,43 @@ type coverImage struct { var ( playBtnSize = fyne.NewSize(60, 60) playBtnHoveredSize = fyne.NewSize(65, 65) + + resourcesInitted bool + heartFilledResource fyne.Resource + heartFilledHoveredResource fyne.Resource + heartUnfilledResource fyne.Resource + heartUnfilledHoveredResource fyne.Resource + moreVerticalResource fyne.Resource + moreVerticalHoveredResource fyne.Resource + inlineIconSize float32 ) +func initResources() { + if resourcesInitted { + return + } + resourcesInitted = true + + inlineIconSize = fyne.CurrentApp().Settings().Theme().Size(theme.SizeNameInlineIcon) + + // TODO: replace util.ColorizeSVG with Fyne's canvas.ColorizeSVG once + // https://github.com/fyne-io/fyne/pull/5345 is available in Fyne + c, _ := util.ColorizeSVG(myTheme.NotFavoriteIcon.Content(), myTheme.GridViewIconColor) + heartUnfilledResource = fyne.NewStaticResource("gridviewnotfavorite", c) + c, _ = util.ColorizeSVG(myTheme.NotFavoriteIcon.Content(), myTheme.GridViewHoveredIconColor) + heartUnfilledHoveredResource = fyne.NewStaticResource("gridviewnotfavorite_hover", c) + c, _ = util.ColorizeSVG(myTheme.FavoriteIcon.Content(), myTheme.GridViewIconColor) + heartFilledResource = fyne.NewStaticResource("gridviewfavorite", c) + c, _ = util.ColorizeSVG(myTheme.FavoriteIcon.Content(), myTheme.GridViewHoveredIconColor) + heartFilledHoveredResource = fyne.NewStaticResource("gridviewfavorite_hover", c) + c, _ = util.ColorizeSVG(theme.MoreVerticalIcon().Content(), myTheme.GridViewIconColor) + moreVerticalResource = fyne.NewStaticResource("gridviewmore", c) + c, _ = util.ColorizeSVG(theme.MoreVerticalIcon().Content(), myTheme.GridViewHoveredIconColor) + moreVerticalHoveredResource = fyne.NewStaticResource("gridviewmore_hover", c) +} + func newCoverImage(placeholderResource fyne.Resource) *coverImage { + initResources() c := &coverImage{} c.Im = NewImagePlaceholder(placeholderResource, 200) c.Im.OnTapped = c.Tapped @@ -49,13 +87,43 @@ func newCoverImage(placeholderResource fyne.Resource) *coverImage { c.playbtn = &canvas.Image{FillMode: canvas.ImageFillContain, Resource: res.ResPlaybuttonPng} c.playbtn.SetMinSize(playBtnSize) c.playbtn.Hidden = true + + c.favoriteButton = canvas.NewImageFromResource(heartUnfilledResource) + c.favoriteButton.SetMinSize(fyne.NewSquareSize(inlineIconSize)) + c.moreButton = canvas.NewImageFromResource(moreVerticalResource) + c.moreButton.SetMinSize(fyne.NewSquareSize(inlineIconSize)) + c.bottomPanel = container.NewStack( + canvas.NewVerticalGradient(color.Transparent, color.Black), + container.NewVBox( + layout.NewSpacer(), // keep the HBox pushed down + container.NewHBox( + layout.NewSpacer(), + c.favoriteButton, + c.moreButton, + util.NewHSpace(0), + ), + container.New( + layout.NewCustomPaddedLayout(0, theme.Padding()*2, 0, 0), + layout.NewSpacer(), + ), + ), + ) + c.bottomPanel.Hidden = true + c.ExtendBaseWidget(c) return c } func (c *coverImage) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer( - container.NewStack(c.Im, container.NewCenter(c.playbtn)), + container.NewStack( + c.Im, + container.NewCenter(c.playbtn), + container.NewGridWithRows(2, + layout.NewSpacer(), + c.bottomPanel, + ), + ), ) } @@ -83,12 +151,14 @@ func (c *coverImage) TappedSecondary(e *fyne.PointEvent) { func (a *coverImage) MouseIn(*desktop.MouseEvent) { a.playbtn.Hidden = false + a.bottomPanel.Hidden = false a.Refresh() } func (a *coverImage) MouseOut() { a.mouseInsideBtn = false a.playbtn.Hidden = true + a.bottomPanel.Hidden = true a.Refresh() } From f702ef5c3175fbdc1c6884f82e9d1a0e993bbba6 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 28 Dec 2024 16:37:59 -0800 Subject: [PATCH 2/4] hook up favorite status with GridView items --- ui/browsing/artistpage.go | 10 ++++++---- ui/browsing/favoritespage.go | 14 ++++++++------ ui/widgets/gridview.go | 12 ++++++++---- ui/widgets/gridviewitem.go | 15 ++++++++++++++- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/ui/browsing/artistpage.go b/ui/browsing/artistpage.go index 34d8c3b..9ddc0c2 100644 --- a/ui/browsing/artistpage.go +++ b/ui/browsing/artistpage.go @@ -219,10 +219,12 @@ func (a *ArtistPage) getGridViewAlbumsModel() []widgets.GridViewItemModel { sort.Slice(a.artistInfo.Albums, sortFunc) return sharedutil.MapSlice(a.artistInfo.Albums, func(al *mediaprovider.Album) widgets.GridViewItemModel { return widgets.GridViewItemModel{ - Name: al.Name, - ID: al.ID, - CoverArtID: al.CoverArtID, - Secondary: []string{strconv.Itoa(al.YearOrZero())}, + Name: al.Name, + ID: al.ID, + CoverArtID: al.CoverArtID, + Secondary: []string{strconv.Itoa(al.YearOrZero())}, + CanFavorite: true, + IsFavorite: al.Favorite, } }) } diff --git a/ui/browsing/favoritespage.go b/ui/browsing/favoritespage.go index 82a2f35..d0b1f87 100644 --- a/ui/browsing/favoritespage.go +++ b/ui/browsing/favoritespage.go @@ -104,7 +104,7 @@ func (a *FavoritesPage) createHeader(activeBtnIdx int) { a.shuffleBtn.Hidden = activeBtnIdx != 2 /*favorite songs*/ a.searcher = widgets.NewSearchEntry() a.searcher.PlaceHolder = lang.L("Search page") - a.searcher.OnSearched = a.OnSearched + a.searcher.OnSearched = a.onSearched a.searcher.Entry.Text = a.searchText a.filterBtn = widgets.NewAlbumFilterButton(a.filter, a.mp.GetGenres) a.filterBtn.FavoriteDisabled = true @@ -267,7 +267,7 @@ func (a *FavoritesPage) SearchWidget() fyne.Focusable { return a.searcher } -func (a *FavoritesPage) OnSearched(query string) { +func (a *FavoritesPage) onSearched(query string) { if query == "" { a.albumGrid.ResetFromState(a.gridState) a.searchGridState = nil @@ -386,10 +386,12 @@ func buildArtistGridViewModel(artists []*mediaprovider.Artist) []widgets.GridVie albums = lang.L("album") } model = append(model, widgets.GridViewItemModel{ - ID: ar.ID, - CoverArtID: ar.CoverArtID, - Name: ar.Name, - Secondary: []string{fmt.Sprintf("%d %s", ar.AlbumCount, albums)}, + ID: ar.ID, + CoverArtID: ar.CoverArtID, + Name: ar.Name, + Secondary: []string{fmt.Sprintf("%d %s", ar.AlbumCount, albums)}, + CanFavorite: true, + IsFavorite: ar.Favorite, }) } return model diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index a438ee6..f895749 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -60,6 +60,8 @@ func (g gridViewAlbumIterator) NextN(n int) []GridViewItemModel { CoverArtID: al.CoverArtID, Secondary: al.ArtistNames, SecondaryIDs: al.ArtistIDs, + CanFavorite: true, + IsFavorite: al.Favorite, } if y := al.Date.Year; y != nil { model.Suffix = strconv.Itoa(*al.Date.Year) @@ -84,10 +86,12 @@ func (g gridViewArtistIterator) NextN(n int) []GridViewItemModel { albumsLabel = lang.L("album") } return GridViewItemModel{ - Name: ar.Name, - ID: ar.ID, - CoverArtID: ar.CoverArtID, - Secondary: []string{fmt.Sprintf("%d %s", ar.AlbumCount, albumsLabel)}, + Name: ar.Name, + ID: ar.ID, + CoverArtID: ar.CoverArtID, + Secondary: []string{fmt.Sprintf("%d %s", ar.AlbumCount, albumsLabel)}, + CanFavorite: true, + IsFavorite: ar.Favorite, } }) } diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index 735827e..b98413f 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -27,6 +27,9 @@ var _ fyne.Widget = (*coverImage)(nil) type coverImage struct { widget.BaseWidget + EnableFavorite bool + IsFavorite bool + Im *ImagePlaceholder playbtn *canvas.Image favoriteButton *canvas.Image @@ -118,11 +121,11 @@ func (c *coverImage) CreateRenderer() fyne.WidgetRenderer { return widget.NewSimpleRenderer( container.NewStack( c.Im, - container.NewCenter(c.playbtn), container.NewGridWithRows(2, layout.NewSpacer(), c.bottomPanel, ), + container.NewCenter(c.playbtn), ), ) } @@ -151,6 +154,12 @@ func (c *coverImage) TappedSecondary(e *fyne.PointEvent) { func (a *coverImage) MouseIn(*desktop.MouseEvent) { a.playbtn.Hidden = false + if a.IsFavorite { + a.favoriteButton.Resource = heartFilledResource + } else { + a.favoriteButton.Resource = heartUnfilledResource + } + a.favoriteButton.Hidden = !a.EnableFavorite a.bottomPanel.Hidden = false a.Refresh() } @@ -204,6 +213,8 @@ type GridViewItemModel struct { Secondary []string SecondaryIDs []string Suffix string + CanFavorite bool + IsFavorite bool } type GridViewItem struct { @@ -282,6 +293,8 @@ func (g *GridViewItem) NeedsUpdate(model GridViewItemModel) bool { } func (g *GridViewItem) Update(model GridViewItemModel) { + g.Cover.IsFavorite = model.IsFavorite + g.Cover.EnableFavorite = model.CanFavorite g.itemID = model.ID g.secondaryIDs = model.SecondaryIDs g.primaryText.SetText(model.Name) From 0f74bee746407dfe227a729fa6ee64c546b23cbe Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sat, 28 Dec 2024 20:06:15 -0800 Subject: [PATCH 3/4] hook up tap handling and action dispatch --- ui/controller/connectactions.go | 10 +++ ui/theme/theme.go | 4 +- ui/widgets/gridview.go | 8 +- ui/widgets/gridviewitem.go | 145 ++++++++++++++++++++++++++------ 4 files changed, 136 insertions(+), 31 deletions(-) diff --git a/ui/controller/connectactions.go b/ui/controller/connectactions.go index d9006c8..e6d08d7 100644 --- a/ui/controller/connectactions.go +++ b/ui/controller/connectactions.go @@ -84,6 +84,11 @@ func (m *Controller) ConnectAlbumGridActions(grid *widgets.GridView) { grid.OnPlay = func(albumID string, shuffle bool) { go m.App.PlaybackManager.PlayAlbum(albumID, 0, shuffle) } + grid.OnFavorite = func(albumID string, favorite bool) { + m.App.ServerManager.Server.SetFavorite(mediaprovider.RatingFavoriteParameters{ + AlbumIDs: []string{albumID}, + }, favorite) + } grid.OnShowItemPage = func(albumID string) { m.NavigateTo(AlbumRoute(albumID)) } @@ -128,6 +133,11 @@ func (m *Controller) ConnectArtistGridActions(grid *widgets.GridView) { go m.DoAddTracksToPlaylistWorkflow( sharedutil.TracksToIDs(m.GetArtistTracks(artistID))) } + grid.OnFavorite = func(artistID string, favorite bool) { + m.App.ServerManager.Server.SetFavorite(mediaprovider.RatingFavoriteParameters{ + ArtistIDs: []string{artistID}, + }, favorite) + } grid.OnDownload = func(artistID string) { go func() { tracks := m.GetArtistTracks(artistID) diff --git a/ui/theme/theme.go b/ui/theme/theme.go index 9d35406..0af1edf 100644 --- a/ui/theme/theme.go +++ b/ui/theme/theme.go @@ -33,8 +33,8 @@ const ( ) var ( - GridViewIconColor color.Color = color.White - GridViewHoveredIconColor color.Color = darkenColor(GridViewIconColor, 0.05) + GridViewHoveredIconColor color.Color = color.White + GridViewIconColor color.Color = darkenColor(color.White, 0.2) AlbumIcon fyne.Resource = theme.NewThemedResource(res.ResDiscSvg) ArtistIcon fyne.Resource = theme.NewThemedResource(res.ResPeopleSvg) diff --git a/ui/widgets/gridview.go b/ui/widgets/gridview.go index f895749..e0e7b05 100644 --- a/ui/widgets/gridview.go +++ b/ui/widgets/gridview.go @@ -133,6 +133,7 @@ type GridViewState struct { OnPlayNext func(id string) OnAddToQueue func(id string) OnAddToPlaylist func(id string) + OnFavorite func(id string, fav bool) OnDownload func(id string) OnShare func(id string) OnShowItemPage func(id string) @@ -280,6 +281,11 @@ func (g *GridView) createNewItemCard() fyne.CanvasObject { card.ImgLoader = util.NewThumbnailLoader(g.imageFetcher, card.Cover.SetImage) card.ImgLoader.OnBeforeLoad = func() { card.Cover.SetImage(nil) } card.OnPlay = func() { g.onPlay(card.ItemID(), false) } + card.OnFavorite = func(fav bool) { + if g.OnFavorite != nil { + g.OnFavorite(card.itemID, fav) + } + } card.OnShowSecondaryPage = func(id string) { if g.OnShowSecondaryPage != nil { g.OnShowSecondaryPage(id) @@ -342,7 +348,7 @@ func (g *GridView) doUpdateItemCard(itemIdx int, card *GridViewItem) { } card.Cover.Im.PlaceholderIcon = g.Placeholder g.stateMutex.Unlock() - card.Update(item) + card.Update(&item) card.ImgLoader.Load(item.CoverArtID) // if user has scrolled near the bottom, fetch more diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index b98413f..689a624 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -30,16 +30,20 @@ type coverImage struct { EnableFavorite bool IsFavorite bool - Im *ImagePlaceholder - playbtn *canvas.Image - favoriteButton *canvas.Image - moreButton *canvas.Image - prevTheme fyne.ThemeVariant - bottomPanel *fyne.Container - mouseInsideBtn bool OnPlay func() + OnFavorite func(bool) OnShowPage func() OnShowContextMenu func(fyne.Position) + + Im *ImagePlaceholder + playbtn *canvas.Image + favoriteButton *canvas.Image + moreButton *canvas.Image + prevTheme fyne.ThemeVariant + bottomPanel *fyne.Container + mouseInsidePlay bool + mouseInsideFav bool + mouseInsideMore bool } var ( @@ -131,6 +135,9 @@ func (c *coverImage) CreateRenderer() fyne.WidgetRenderer { } func (c *coverImage) Cursor() desktop.Cursor { + if c.mouseInsideFav || c.mouseInsideMore || c.mouseInsidePlay { + return desktop.DefaultCursor + } return desktop.PointerCursor } @@ -139,10 +146,18 @@ func (c *coverImage) Tapped(e *fyne.PointEvent) { if c.OnPlay != nil { c.OnPlay() } - return - } - if c.OnShowPage != nil { - c.OnShowPage() + } else if c.mouseInsideFav { + if c.OnFavorite != nil { + c.IsFavorite = !c.IsFavorite + c.updateFavoriteIcon(true) + c.OnFavorite(c.IsFavorite) + } + } else if c.mouseInsideMore { + c.TappedSecondary(e) + } else { + if c.OnShowPage != nil { + c.OnShowPage() + } } } @@ -154,36 +169,100 @@ func (c *coverImage) TappedSecondary(e *fyne.PointEvent) { func (a *coverImage) MouseIn(*desktop.MouseEvent) { a.playbtn.Hidden = false - if a.IsFavorite { - a.favoriteButton.Resource = heartFilledResource - } else { - a.favoriteButton.Resource = heartUnfilledResource - } + a.updateFavoriteIcon(false) a.favoriteButton.Hidden = !a.EnableFavorite a.bottomPanel.Hidden = false a.Refresh() } func (a *coverImage) MouseOut() { - a.mouseInsideBtn = false + a.mouseInsidePlay = false + a.mouseInsideFav = false + a.mouseInsideMore = false a.playbtn.Hidden = true a.bottomPanel.Hidden = true a.Refresh() } func (a *coverImage) MouseMoved(e *desktop.MouseEvent) { - if isInside(a.center(), a.playbtn.MinSize().Height/2, e.Position) { - if !a.mouseInsideBtn { + updateMouseInsidePlay := func(in bool) { + if in == a.mouseInsidePlay { + return + } + if in { a.playbtn.SetMinSize(playBtnHoveredSize) - a.playbtn.Refresh() - } - a.mouseInsideBtn = true - } else { - if a.mouseInsideBtn { + } else { a.playbtn.SetMinSize(playBtnSize) - a.playbtn.Refresh() } - a.mouseInsideBtn = false + a.playbtn.Refresh() + a.mouseInsidePlay = in + } + updateMouseInsideFav := func(in bool) { + if in == a.mouseInsideFav { + return + } + if a.IsFavorite { + if in { + a.favoriteButton.Resource = heartFilledHoveredResource + } else { + a.favoriteButton.Resource = heartFilledResource + } + } else { + if in { + a.favoriteButton.Resource = heartUnfilledHoveredResource + } else { + a.favoriteButton.Resource = heartUnfilledResource + } + } + a.favoriteButton.Refresh() + a.mouseInsideFav = in + } + updateMouseInsideMore := func(in bool) { + if in == a.mouseInsideMore { + return + } + if in { + a.moreButton.Resource = moreVerticalHoveredResource + } else { + a.moreButton.Resource = moreVerticalResource + } + a.moreButton.Refresh() + a.mouseInsideMore = in + } + + pad := theme.Padding() + overFavBtn := e.Position.Y > a.Size().Height-inlineIconSize-pad*3 && + e.Position.X > a.Size().Width-inlineIconSize*2-pad*3 && + e.Position.X < a.Size().Height-inlineIconSize-pad + overMoreBtn := e.Position.Y > a.Size().Height-inlineIconSize-pad*3 && + e.Position.X > a.Size().Width-inlineIconSize-pad + if isInside(a.center(), a.playbtn.MinSize().Height/2, e.Position) { + updateMouseInsidePlay(true) + updateMouseInsideFav(false) + updateMouseInsideMore(false) + } else if overFavBtn { + updateMouseInsideFav(true) + updateMouseInsidePlay(false) + updateMouseInsideMore(false) + } else if overMoreBtn { + updateMouseInsideMore(true) + updateMouseInsideFav(false) + updateMouseInsidePlay(false) + } else { + updateMouseInsideFav(false) + updateMouseInsidePlay(false) + updateMouseInsideMore(false) + } +} + +func (a *coverImage) updateFavoriteIcon(refresh bool) { + if a.IsFavorite { + a.favoriteButton.Resource = heartFilledResource + } else { + a.favoriteButton.Resource = heartUnfilledResource + } + if refresh { + a.favoriteButton.Refresh() } } @@ -197,7 +276,7 @@ func (a *coverImage) SetImage(im image.Image) { func (a *coverImage) ResetPlayButton() { a.playbtn.SetMinSize(playBtnSize) - a.mouseInsideBtn = false + a.mouseInsidePlay = false a.playbtn.Hidden = true } @@ -222,6 +301,7 @@ type GridViewItem struct { ShowSuffix bool + model *GridViewItemModel itemID string secondaryIDs []string primaryText *ttwidget.Hyperlink @@ -236,6 +316,7 @@ type GridViewItem struct { ItemIndex int OnPlay func() + OnFavorite func(bool) OnShowContextMenu func(fyne.Position) OnShowItemPage func() OnShowSecondaryPage func(string) @@ -260,6 +341,14 @@ func NewGridViewItem(placeholderResource fyne.Resource) *GridViewItem { g.OnPlay() } } + g.Cover.OnFavorite = func(fav bool) { + if g.model != nil { + g.model.IsFavorite = fav + } + if g.OnFavorite != nil { + g.OnFavorite(fav) + } + } g.Cover.OnShowContextMenu = func(pos fyne.Position) { if g.OnShowContextMenu != nil { g.OnShowContextMenu(pos) @@ -292,7 +381,7 @@ func (g *GridViewItem) NeedsUpdate(model GridViewItemModel) bool { (!g.ShowSuffix && g.secondaryText.Suffix != "") } -func (g *GridViewItem) Update(model GridViewItemModel) { +func (g *GridViewItem) Update(model *GridViewItemModel) { g.Cover.IsFavorite = model.IsFavorite g.Cover.EnableFavorite = model.CanFavorite g.itemID = model.ID From bb2a4aac66de7e827fa1d7b2e023a14028fee2b5 Mon Sep 17 00:00:00 2001 From: Drew Weymouth Date: Sun, 29 Dec 2024 07:50:46 -0800 Subject: [PATCH 4/4] just use pointer cursor --- ui/widgets/gridviewitem.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/ui/widgets/gridviewitem.go b/ui/widgets/gridviewitem.go index 689a624..04a1f00 100644 --- a/ui/widgets/gridviewitem.go +++ b/ui/widgets/gridviewitem.go @@ -135,9 +135,6 @@ func (c *coverImage) CreateRenderer() fyne.WidgetRenderer { } func (c *coverImage) Cursor() desktop.Cursor { - if c.mouseInsideFav || c.mouseInsideMore || c.mouseInsidePlay { - return desktop.DefaultCursor - } return desktop.PointerCursor }