diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 85d82e536d..2ff677becd 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -50,24 +52,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { base.LoadComplete(); - ((DummyAPIAccess)API).HandleRequest = request => - { - switch (request) - { - case GetBeatmapSetRequest set: - if (set.ID == currentOnlineSet?.OnlineID) - { - set.TriggerSuccess(currentOnlineSet); - return true; - } - - return false; - - default: - return false; - } - }; - AddRange(new Drawable[] { new Container @@ -151,6 +135,27 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestOnlineAvailability() { + AddStep("set up request handler", () => + { + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + default: + return false; + } + }; + }); + AddStep("online beatmapset", () => { var (working, onlineSet) = createTestBeatmap(); @@ -159,7 +164,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Beatmap.Value = working; }); AddAssert("play count = 10000", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "10,000"); - AddAssert("favourites count = 2345", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "2,345"); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); AddStep("online beatmapset with local diff", () => { var (working, onlineSet) = createTestBeatmap(); @@ -170,7 +175,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Beatmap.Value = working; }); AddAssert("play count = -", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "-"); - AddAssert("favourites count = 2345", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "2,345"); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); AddStep("local beatmapset", () => { var (working, _) = createTestBeatmap(); @@ -179,7 +184,75 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Beatmap.Value = working; }); AddAssert("play count = -", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "-"); - AddAssert("favourites count = -", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "-"); + AddAssert("favourites count = -", () => this.ChildrenOfType().Single().Text.ToString() == "-"); + } + + [Test] + public void TestFavouriting() + { + var resetEvent = new ManualResetEventSlim(false); + + AddStep("set up request handler", () => + { + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + case PostBeatmapFavouriteRequest favourite: + Task.Run(() => + { + resetEvent.Wait(10000); + favourite.TriggerSuccess(); + }); + return true; + + default: + return false; + } + }; + }); + + AddStep("online beatmapset", () => + { + var (working, onlineSet) = createTestBeatmap(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddUntilStep("play count = 10000", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "10,000"); + AddUntilStep("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); + + AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("allow request to complete", () => resetEvent.Set()); + AddAssert("favourites count = 2346", () => this.ChildrenOfType().Single().Text.ToString() == "2,346"); + + AddStep("reset event", () => resetEvent.Reset()); + AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("allow request to complete", () => resetEvent.Set()); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); + + AddStep("reset event", () => resetEvent.Reset()); + AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("change to another beatmap", () => + { + var (working, onlineSet) = createTestBeatmap(); + onlineSet.FavouriteCount = 9999; + working.BeatmapSetInfo.OnlineID = onlineSet.OnlineID = 99999; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("allow request to complete", () => resetEvent.Set()); + AddAssert("favourites count = 9999", () => this.ChildrenOfType().Single().Text.ToString() == "9,999"); } [TestCase(120, 125, null, "120-125 (mostly 120)")] diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 6b80fc69c9..28031f12fc 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -59,7 +58,7 @@ namespace osu.Game.Screens.SelectV2 internal string DisplayedArtist => artistLabel.Text.ToString(); private StatisticPlayCount playCount = null!; - private Statistic favouritesStatistic = null!; + private FavouriteButton favouriteButton = null!; private Statistic lengthStatistic = null!; private Statistic bpmStatistic = null!; @@ -157,7 +156,7 @@ namespace osu.Game.Screens.SelectV2 { Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, }, - favouritesStatistic = new Statistic(OsuIcon.Heart, background: true, minSize: 25f) + favouriteButton = new FavouriteButton { TooltipText = BeatmapsStrings.StatusFavourites, }, @@ -316,20 +315,13 @@ namespace osu.Game.Screens.SelectV2 if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) { playCount.Value = null; - favouritesStatistic.Text = null; - } - else if (currentOnlineBeatmapSet == null) - { - playCount.Value = new StatisticPlayCount.Data(-1, -1); - favouritesStatistic.Text = "-"; + favouriteButton.SetLoading(); } else { - var onlineBeatmapSet = currentOnlineBeatmapSet; - var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); - + var onlineBeatmap = currentOnlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1); - favouritesStatistic.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); + favouriteButton.SetBeatmapSet(currentOnlineBeatmapSet); } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs new file mode 100644 index 0000000000..bb2a0f3934 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +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 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 Box flashLayer = null!; + private SpriteIcon 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!; + + 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 SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = OsuIcon.Heart, + Size = new Vector2(OsuFont.Style.Heading2.Size), + Colour = colourProvider.Content2, + }, + 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, + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Colour4.White, + } + }); + 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); + } + + // 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) + { + loadingSpinner.State.Value = Visibility.Hidden; + valueText.FadeIn(120, Easing.OutQuint); + + onlineBeatmapSet = beatmapSet; + updateFavouriteState(); + } + + private void updateFavouriteState() + { + 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); + icon.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); + valueText.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); + + icon.Icon = isFavourite.Value ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart; + } + + 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; + setBeatmapSet(beatmapSet); + if (hasFavourited) + flashLayer.FadeOutFromOne(500, Easing.OutQuint); + }; + api.Queue(favouriteRequest); + setLoading(); + } + } + } +}