diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
index cae7781db7..5effc1f215 100644
--- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs
@@ -23,6 +23,11 @@ namespace osu.Game.Tests.Visual.Beatmaps
{
public class TestSceneBeatmapCard : OsuTestScene
{
+ ///
+ /// All cards on this scene use a common online ID to ensure that map download, preview tracks, etc. can be tested manually with online sources.
+ ///
+ private const int online_id = 163112;
+
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private APIBeatmapSet[] testCases;
@@ -38,7 +43,6 @@ private void load()
var normal = CreateAPIBeatmapSet(Ruleset.Value);
normal.HasVideo = true;
normal.HasStoryboard = true;
- normal.OnlineID = 241526;
var withStatistics = CreateAPIBeatmapSet(Ruleset.Value);
withStatistics.Title = withStatistics.TitleUnicode = "play favourite stats";
@@ -106,6 +110,9 @@ private void load()
explicitFeaturedMap,
longName
};
+
+ foreach (var testCase in testCases)
+ testCase.OnlineID = online_id;
}
private APIBeatmapSet getUndownloadableBeatmapSet() => new APIBeatmapSet
@@ -191,9 +198,9 @@ public void SetUpSteps()
private void ensureSoleilyRemoved()
{
AddUntilStep("ensure manager loaded", () => beatmaps != null);
- AddStep("remove soleily", () =>
+ AddStep("remove map", () =>
{
- var beatmap = beatmaps.QueryBeatmapSet(b => b.OnlineID == 241526);
+ var beatmap = beatmaps.QueryBeatmapSet(b => b.OnlineID == online_id);
if (beatmap != null) beatmaps.Delete(beatmap);
});
diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs
new file mode 100644
index 0000000000..1b4542d946
--- /dev/null
+++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs
@@ -0,0 +1,79 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps.Drawables.Cards;
+using osu.Game.Beatmaps.Drawables.Cards.Buttons;
+using osu.Game.Overlays;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Beatmaps
+{
+ public class TestSceneBeatmapCardThumbnail : OsuManualInputManagerTestScene
+ {
+ private PlayButton playButton => this.ChildrenOfType().Single();
+
+ [Cached]
+ private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
+
+ [Test]
+ public void TestThumbnailPreview()
+ {
+ BeatmapCardThumbnail thumbnail = null;
+
+ AddStep("create thumbnail", () =>
+ {
+ var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value);
+ beatmapSet.OnlineID = 241526; // ID hardcoded to ensure that the preview track exists online.
+
+ Child = thumbnail = new BeatmapCardThumbnail(beatmapSet)
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(200)
+ };
+ });
+ AddStep("enable dim", () => thumbnail.Dimmed.Value = true);
+ AddUntilStep("button visible", () => playButton.IsPresent);
+
+ AddStep("click button", () =>
+ {
+ InputManager.MoveMouseTo(playButton);
+ InputManager.Click(MouseButton.Left);
+ });
+ AddUntilStep("wait for start", () => playButton.Playing.Value && playButton.Enabled.Value);
+ iconIs(FontAwesome.Solid.Stop);
+
+ AddStep("click again", () =>
+ {
+ InputManager.MoveMouseTo(playButton);
+ InputManager.Click(MouseButton.Left);
+ });
+ AddUntilStep("wait for stop", () => !playButton.Playing.Value && playButton.Enabled.Value);
+ iconIs(FontAwesome.Solid.Play);
+
+ AddStep("click again", () =>
+ {
+ InputManager.MoveMouseTo(playButton);
+ InputManager.Click(MouseButton.Left);
+ });
+ AddUntilStep("wait for start", () => playButton.Playing.Value && playButton.Enabled.Value);
+ iconIs(FontAwesome.Solid.Stop);
+
+ AddStep("disable dim", () => thumbnail.Dimmed.Value = false);
+ AddWaitStep("wait some", 3);
+ AddAssert("button still visible", () => playButton.IsPresent);
+
+ AddUntilStep("wait for track to end", () => !playButton.Playing.Value);
+ AddUntilStep("button hidden", () => !playButton.IsPresent);
+ }
+
+ private void iconIs(IconUsage usage) => AddUntilStep("icon is correct", () => playButton.ChildrenOfType().Any(icon => icon.Icon.Equals(usage)));
+ }
+}
diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs
index e3af253db9..4ffad0f065 100644
--- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs
+++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs
@@ -23,7 +23,6 @@
using osuTK;
using osu.Game.Overlays.BeatmapListing.Panels;
using osu.Game.Resources.Localisation.Web;
-using osuTK.Graphics;
using DownloadButton = osu.Game.Beatmaps.Drawables.Cards.Buttons.DownloadButton;
namespace osu.Game.Beatmaps.Drawables.Cards
@@ -42,7 +41,7 @@ public class BeatmapCard : OsuClickableContainer
private readonly BeatmapDownloadTracker downloadTracker;
- private UpdateableOnlineBeatmapSetCover leftCover;
+ private BeatmapCardThumbnail thumbnail;
private FillFlowContainer leftIconArea;
private Container rightAreaBackground;
@@ -98,24 +97,17 @@ private void load()
Colour = Colour4.White
},
},
- new Container
+ thumbnail = new BeatmapCardThumbnail(beatmapSet)
{
Name = @"Left (icon) area",
Size = new Vector2(height),
- Children = new Drawable[]
+ Padding = new MarginPadding { Right = corner_radius },
+ Child = leftIconArea = new FillFlowContainer
{
- leftCover = new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List)
- {
- RelativeSizeAxes = Axes.Both,
- OnlineInfo = beatmapSet
- },
- leftIconArea = new FillFlowContainer
- {
- Margin = new MarginPadding(5),
- AutoSizeAxes = Axes.Both,
- Direction = FillDirection.Horizontal,
- Spacing = new Vector2(1)
- }
+ Margin = new MarginPadding(5),
+ AutoSizeAxes = Axes.Both,
+ Direction = FillDirection.Horizontal,
+ Spacing = new Vector2(1)
}
},
new Container
@@ -319,10 +311,10 @@ private void load()
};
if (beatmapSet.HasVideo)
- leftIconArea.Add(new IconPill(FontAwesome.Solid.Film));
+ leftIconArea.Add(new IconPill(FontAwesome.Solid.Film) { IconSize = new Vector2(20) });
if (beatmapSet.HasStoryboard)
- leftIconArea.Add(new IconPill(FontAwesome.Solid.Image));
+ leftIconArea.Add(new IconPill(FontAwesome.Solid.Image) { IconSize = new Vector2(20) });
if (beatmapSet.HasExplicitContent)
{
@@ -395,10 +387,11 @@ private void updateState()
if (IsHovered)
targetWidth = targetWidth - icon_area_width + corner_radius;
+ thumbnail.Dimmed.Value = IsHovered;
+
mainContent.ResizeWidthTo(targetWidth, TRANSITION_DURATION, Easing.OutQuint);
mainContentBackground.Dimmed.Value = IsHovered;
- leftCover.FadeColour(IsHovered ? OsuColour.Gray(0.2f) : Color4.White, TRANSITION_DURATION, Easing.OutQuint);
statisticsContainer.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint);
rightAreaBackground.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, TRANSITION_DURATION, Easing.OutQuint);
diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs
new file mode 100644
index 0000000000..f11a5916e1
--- /dev/null
+++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardThumbnail.cs
@@ -0,0 +1,95 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Beatmaps.Drawables.Cards.Buttons;
+using osu.Game.Graphics;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Overlays;
+using osu.Game.Screens.Ranking.Expanded.Accuracy;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Beatmaps.Drawables.Cards
+{
+ public class BeatmapCardThumbnail : Container
+ {
+ public BindableBool Dimmed { get; } = new BindableBool();
+
+ public new MarginPadding Padding
+ {
+ get => foreground.Padding;
+ set => foreground.Padding = value;
+ }
+
+ private readonly UpdateableOnlineBeatmapSetCover cover;
+ private readonly Container foreground;
+ private readonly PlayButton playButton;
+ private readonly SmoothCircularProgress progress;
+ private readonly Container content;
+
+ protected override Container Content => content;
+
+ public BeatmapCardThumbnail(APIBeatmapSet beatmapSetInfo)
+ {
+ InternalChildren = new Drawable[]
+ {
+ cover = new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List)
+ {
+ RelativeSizeAxes = Axes.Both,
+ OnlineInfo = beatmapSetInfo
+ },
+ foreground = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ playButton = new PlayButton(beatmapSetInfo)
+ {
+ RelativeSizeAxes = Axes.Both
+ },
+ progress = new SmoothCircularProgress
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(50),
+ InnerRadius = 0.2f
+ },
+ content = new Container
+ {
+ RelativeSizeAxes = Axes.Both
+ }
+ }
+ }
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OverlayColourProvider colourProvider)
+ {
+ progress.Colour = colourProvider.Highlight1;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ Dimmed.BindValueChanged(_ => updateState());
+
+ playButton.Playing.BindValueChanged(_ => updateState(), true);
+ ((IBindable)progress.Current).BindTo(playButton.Progress);
+
+ FinishTransforms(true);
+ }
+
+ private void updateState()
+ {
+ bool shouldDim = Dimmed.Value || playButton.Playing.Value;
+
+ playButton.FadeTo(shouldDim ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
+ cover.FadeColour(shouldDim ? OsuColour.Gray(0.2f) : Color4.White, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
+ }
+ }
+}
diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs
new file mode 100644
index 0000000000..4574d37da0
--- /dev/null
+++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs
@@ -0,0 +1,142 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+#nullable enable
+
+using System.Collections.Generic;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.IEnumerableExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Audio;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.UserInterface;
+using osuTK;
+
+namespace osu.Game.Beatmaps.Drawables.Cards.Buttons
+{
+ public class PlayButton : OsuHoverContainer
+ {
+ public IBindable Progress => progress;
+ private readonly BindableDouble progress = new BindableDouble();
+
+ public BindableBool Playing { get; } = new BindableBool();
+
+ private readonly IBeatmapSetInfo beatmapSetInfo;
+
+ protected override IEnumerable EffectTargets => icon.Yield();
+
+ private readonly SpriteIcon icon;
+ private readonly LoadingSpinner loadingSpinner;
+
+ [Resolved]
+ private PreviewTrackManager previewTrackManager { get; set; } = null!;
+
+ private PreviewTrack? previewTrack;
+
+ public PlayButton(IBeatmapSetInfo beatmapSetInfo)
+ {
+ this.beatmapSetInfo = beatmapSetInfo;
+
+ Anchor = Origin = Anchor.Centre;
+
+ Children = new Drawable[]
+ {
+ icon = new SpriteIcon
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Icon = FontAwesome.Solid.Play,
+ Size = new Vector2(14)
+ },
+ loadingSpinner = new LoadingSpinner
+ {
+ Size = new Vector2(14)
+ }
+ };
+
+ Action = () => Playing.Toggle();
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ HoverColour = colours.Yellow;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Playing.BindValueChanged(updateState, true);
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (Playing.Value && previewTrack != null && previewTrack.TrackLoaded)
+ progress.Value = previewTrack.CurrentTime / previewTrack.Length;
+ else
+ progress.Value = 0;
+ }
+
+ private void updateState(ValueChangedEvent playing)
+ {
+ icon.Icon = playing.NewValue ? FontAwesome.Solid.Stop : FontAwesome.Solid.Play;
+
+ if (!playing.NewValue)
+ {
+ stopPreview();
+ return;
+ }
+
+ if (previewTrack == null)
+ {
+ toggleLoading(true);
+ LoadComponentAsync(previewTrack = previewTrackManager.Get(beatmapSetInfo), onPreviewLoaded);
+ }
+ else
+ tryStartPreview();
+ }
+
+ private void stopPreview()
+ {
+ toggleLoading(false);
+ Playing.Value = false;
+ previewTrack?.Stop();
+ }
+
+ private void onPreviewLoaded(PreviewTrack loadedPreview)
+ {
+ // another async load might have completed before this one.
+ // if so, do not make any changes.
+ if (loadedPreview != previewTrack)
+ return;
+
+ AddInternal(loadedPreview);
+ toggleLoading(false);
+
+ loadedPreview.Stopped += () => Schedule(() => Playing.Value = false);
+
+ if (Playing.Value)
+ tryStartPreview();
+ }
+
+ private void tryStartPreview()
+ {
+ if (previewTrack?.Start() == false)
+ Playing.Value = false;
+ }
+
+ private void toggleLoading(bool loading)
+ {
+ Enabled.Value = !loading;
+ icon.FadeTo(loading ? 0 : 1, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
+ loadingSpinner.State.Value = loading ? Visibility.Visible : Visibility.Hidden;
+ }
+ }
+}