Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs
2264 views
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Resources.Localisation.Web;
using osuTK;
using osuTK.Graphics;

namespace osu.Game.Screens.SelectV2
{
    public partial class BeatmapTitleWedge
    {
        public partial class FavouriteButton : OsuClickableContainer
        {
            private readonly BindableBool isFavourite = new BindableBool();

            private Box background = null!;
            private OsuSpriteText valueText = null!;
            private LoadingSpinner loadingSpinner = null!;
            private Box hoverLayer = null!;
            private HeartIcon icon = null!;

            private APIBeatmapSet? onlineBeatmapSet;
            private PostBeatmapFavouriteRequest? favouriteRequest;

            [Resolved]
            private OverlayColourProvider colourProvider { get; set; } = null!;

            [Resolved]
            private OsuColour colours { get; set; } = null!;

            [Resolved]
            private IAPIProvider api { get; set; } = null!;

            [Resolved]
            private INotificationOverlay? notifications { get; set; }

            internal LocalisableString Text => valueText.Text;

            public FavouriteButton()
            {
                AutoSizeAxes = Axes.Both;
            }

            [BackgroundDependencyLoader]
            private void load()
            {
                Masking = true;
                CornerRadius = 5;
                Shear = OsuGame.SHEAR;

                AddRange(new Drawable[]
                {
                    background = new Box
                    {
                        RelativeSizeAxes = Axes.Both,
                        Colour = Color4.Black.Opacity(0.2f),
                    },
                    new FillFlowContainer
                    {
                        Anchor = Anchor.CentreLeft,
                        Origin = Anchor.CentreLeft,
                        AutoSizeAxes = Axes.Both,
                        Direction = FillDirection.Horizontal,
                        Margin = new MarginPadding { Left = 10, Right = 10, Vertical = 5f },
                        Spacing = new Vector2(4f, 0f),
                        Shear = -OsuGame.SHEAR,
                        Children = new Drawable[]
                        {
                            icon = new HeartIcon
                            {
                                Anchor = Anchor.CentreLeft,
                                Origin = Anchor.CentreLeft,
                                Size = new Vector2(OsuFont.Style.Heading2.Size),
                            },
                            new Container
                            {
                                Anchor = Anchor.CentreLeft,
                                Origin = Anchor.CentreLeft,
                                AutoSizeAxes = Axes.X,
                                Height = 20,
                                Children = new Drawable[]
                                {
                                    loadingSpinner = new LoadingSpinner
                                    {
                                        Anchor = Anchor.Centre,
                                        Origin = Anchor.Centre,
                                        Size = new Vector2(12f),
                                        State = { Value = Visibility.Visible },
                                    },
                                    new GridContainer
                                    {
                                        Anchor = Anchor.CentreLeft,
                                        Origin = Anchor.CentreLeft,
                                        AutoSizeAxes = Axes.Both,
                                        RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
                                        ColumnDimensions = new[]
                                        {
                                            new Dimension(GridSizeMode.AutoSize, minSize: 25),
                                        },
                                        Content = new[]
                                        {
                                            new[]
                                            {
                                                valueText = new OsuSpriteText
                                                {
                                                    Anchor = Anchor.Centre,
                                                    Origin = Anchor.Centre,
                                                    Font = OsuFont.Style.Heading2,
                                                    Colour = colourProvider.Content2,
                                                    Margin = new MarginPadding { Bottom = 2f },
                                                    AlwaysPresent = true,
                                                },
                                            }
                                        }
                                    },
                                },
                            },
                        },
                    },
                    hoverLayer = new Box
                    {
                        RelativeSizeAxes = Axes.Both,
                        Alpha = 0,
                        Colour = Colour4.White.Opacity(0.1f),
                        Blending = BlendingParameters.Additive,
                    },
                });
                Action = toggleFavourite;
            }

            protected override bool OnHover(HoverEvent e)
            {
                hoverLayer.FadeIn(500, Easing.OutQuint);
                return true;
            }

            protected override void OnHoverLost(HoverLostEvent e)
            {
                base.OnHoverLost(e);
                hoverLayer.FadeOut(500, Easing.OutQuint);
            }

            public override LocalisableString TooltipText => isFavourite.Value ? BeatmapsetsStrings.ShowDetailsUnfavourite.ToSentence() : BeatmapsetsStrings.ShowDetailsFavourite.ToSentence();

            // Note: `setLoading()` and `setBeatmapSet()` are called externally via their public counterparts by song select when the beatmap changes,
            // as well as internally in order to display the progress and result of the (un)favourite operation when the button is clicked.
            // In case of external calls, we want to cancel pending favourite requests, primarily to avoid a situation when a late success callback from an (un)favourite
            // could show the favourite count from a prior beatmap.

            public void SetLoading()
            {
                if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting)
                    favouriteRequest.Cancel();
                setLoading();
            }

            private void setLoading()
            {
                loadingSpinner.State.Value = Visibility.Visible;
                valueText.FadeOut(120, Easing.OutQuint);

                onlineBeatmapSet = null;
                updateFavouriteState();
            }

            public void SetBeatmapSet(APIBeatmapSet? beatmapSet)
            {
                if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting)
                    favouriteRequest.Cancel();
                setBeatmapSet(beatmapSet);
            }

            private void setBeatmapSet(APIBeatmapSet? beatmapSet, bool withHeartAnimation = false)
            {
                loadingSpinner.State.Value = Visibility.Hidden;
                valueText.FadeIn(120, Easing.OutQuint);

                onlineBeatmapSet = beatmapSet;
                updateFavouriteState(withHeartAnimation);
            }

            private void updateFavouriteState(bool withAnimation = false)
            {
                Enabled.Value = onlineBeatmapSet != null;

                if (loadingSpinner.State.Value == Visibility.Hidden)
                    valueText.Text = onlineBeatmapSet?.FavouriteCount.ToLocalisableString(@"N0") ?? "-";

                isFavourite.Value = onlineBeatmapSet?.HasFavourited == true;

                background.FadeColour(isFavourite.Value ? colours.Pink4.Darken(1f).Opacity(0.5f) : Color4.Black.Opacity(0.2f), 500, Easing.OutQuint);
                valueText.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint);
                icon.SetActive(isFavourite.Value, withAnimation);
            }

            private void toggleFavourite()
            {
                Debug.Assert(onlineBeatmapSet != null);

                // having this copy locally is important to capture this particular beatmap set instance rather than the field in the request success callback,
                // because if it was captured via the field / `this`, it could change value due to an external `setLoading()` or `setBeatmapSet()` call.
                // there's also the part where we want to call `setLoading()` here to show the spinner, but that also sets `onlineBeatmapSet` to null.
                var beatmapSet = onlineBeatmapSet;

                favouriteRequest = new PostBeatmapFavouriteRequest(beatmapSet.OnlineID, isFavourite.Value ? BeatmapFavouriteAction.UnFavourite : BeatmapFavouriteAction.Favourite);
                favouriteRequest.Success += () =>
                {
                    bool hasFavourited = favouriteRequest.Action == BeatmapFavouriteAction.Favourite;
                    beatmapSet.HasFavourited = hasFavourited;
                    beatmapSet.FavouriteCount += hasFavourited ? 1 : -1;

                    // if the beatmap set reference changed under the callback, abort visual updates to avoid showing stale data
                    if (onlineBeatmapSet == null || ReferenceEquals(beatmapSet, onlineBeatmapSet))
                        setBeatmapSet(beatmapSet, withHeartAnimation: hasFavourited);
                };
                favouriteRequest.Failure += e =>
                {
                    notifications?.Post(new SimpleNotification
                    {
                        Text = e.Message,
                        Icon = FontAwesome.Solid.Times,
                    });

                    // if the beatmap set reference changed under the callback, abort visual updates to avoid showing stale data
                    if (onlineBeatmapSet == null || ReferenceEquals(beatmapSet, onlineBeatmapSet))
                        setBeatmapSet(beatmapSet, withHeartAnimation: false);
                };
                api.Queue(favouriteRequest);
                setLoading();
            }
        }

        private partial class HeartIcon : CompositeDrawable
        {
            private readonly SpriteIcon icon;

            [Resolved]
            private OverlayColourProvider colourProvider { get; set; } = null!;

            [Resolved]
            private OsuColour colours { get; set; } = null!;

            public HeartIcon()
            {
                InternalChildren = new Drawable[]
                {
                    icon = new SpriteIcon
                    {
                        Anchor = Anchor.Centre,
                        Origin = Anchor.Centre,
                        Icon = FontAwesome.Regular.Heart,
                        RelativeSizeAxes = Axes.Both,
                    },
                };
            }

            private const double pop_out_duration = 100;
            private const double pop_in_duration = 500;

            private bool active;

            public void SetActive(bool active, bool withAnimation = false)
            {
                if (this.active == active)
                    return;

                this.active = active;

                FinishTransforms(true);

                if (active)
                {
                    transitionIcon(FontAwesome.Solid.Heart, colours.Pink1, emphasised: withAnimation);

                    if (withAnimation)
                        playFavouriteAnimation();
                }
                else
                {
                    transitionIcon(FontAwesome.Regular.Heart, colourProvider.Content2);
                }
            }

            private void transitionIcon(IconUsage newIcon, Color4 colour, bool emphasised = false)
            {
                icon.ScaleTo(emphasised ? 0.5f : 0.8f, pop_out_duration, Easing.OutQuad)
                    .Then()
                    .FadeColour(colour)
                    .Schedule(() => icon.Icon = newIcon)
                    .ScaleTo(1, pop_in_duration, Easing.OutElasticHalf);
            }

            private void playFavouriteAnimation()
            {
                var circle = new FastCircle
                {
                    RelativeSizeAxes = Axes.Both,
                    Anchor = Anchor.Centre,
                    Origin = Anchor.Centre,
                    Scale = new Vector2(0.5f),
                    Blending = BlendingParameters.Additive,
                    Alpha = 0,
                    Depth = float.MinValue,
                };

                AddInternal(circle);

                circle.Delay(pop_out_duration)
                      .FadeTo(0.35f)
                      .FadeOut(1400, Easing.OutCubic)
                      .ScaleTo(10f, 750, Easing.OutQuint)
                      .Expire();

                const int num_particles = 8;

                static float randomFloat(float min, float max) => min + Random.Shared.NextSingle() * (max - min);

                for (int i = 0; i < num_particles; i++)
                {
                    double duration = randomFloat(600, 1000);
                    float angle = (i + randomFloat(0, 0.75f)) / num_particles * MathF.PI * 2;
                    var direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle));
                    float distance = randomFloat(DrawWidth / 2, DrawWidth);

                    var particle = new FastCircle
                    {
                        Position = direction * DrawWidth / 4,
                        Size = new Vector2(3),
                        Anchor = Anchor.Centre,
                        Origin = Anchor.Centre,
                        Blending = BlendingParameters.Additive,
                        Alpha = 0,
                        Depth = 2,
                        Colour = colours.Pink,
                    };

                    AddInternal(particle);

                    particle
                        .Delay(pop_out_duration)
                        .FadeTo(0.5f)
                        .MoveTo(direction * distance, 1300, Easing.OutQuint)
                        .FadeOut(duration, Easing.Out)
                        .ScaleTo(0.5f, duration)
                        .Expire();
                }
            }
        }
    }
}