mirror of
https://github.com/ppy/osu.git
synced 2026-05-18 04:19:53 +08:00
372 lines
15 KiB
C#
372 lines
15 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|