1
0
mirror of https://github.com/ppy/osu.git synced 2025-03-11 02:17:19 +08:00

Merge branch 'master' into carousel-v2-implement-designs

This commit is contained in:
Dean Herbert 2025-02-07 19:37:06 +09:00
commit d8f3dbf988
No known key found for this signature in database
78 changed files with 1992 additions and 957 deletions

View File

@ -173,7 +173,7 @@ namespace osu.Desktop
new Button
{
Label = "View beatmap",
Url = $@"{api.WebsiteRootUrl}/beatmaps/{beatmapId}?mode={ruleset.Value.ShortName}"
Url = $@"{api.Endpoints.WebsiteUrl}/beatmaps/{beatmapId}?mode={ruleset.Value.ShortName}"
}
};
}

View File

@ -0,0 +1,52 @@
// 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 NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Tests.Visual;
using osu.Game.Tests.Visual.Metadata;
namespace osu.Game.Tests.Online
{
[TestFixture]
[HeadlessTest]
public partial class TestSceneMetadataClient : OsuTestScene
{
private TestMetadataClient client = null!;
[SetUp]
public void Setup() => Schedule(() =>
{
Child = client = new TestMetadataClient();
});
[Test]
public void TestWatchingMultipleTimesInvokesServerMethodsOnce()
{
int countBegin = 0;
int countEnd = 0;
IDisposable token1 = null!;
IDisposable token2 = null!;
AddStep("setup", () =>
{
client.OnBeginWatchingUserPresence += () => countBegin++;
client.OnEndWatchingUserPresence += () => countEnd++;
});
AddStep("begin watching presence (1)", () => token1 = client.BeginWatchingUserPresence());
AddAssert("server method invoked once", () => countBegin, () => Is.EqualTo(1));
AddStep("begin watching presence (2)", () => token2 = client.BeginWatchingUserPresence());
AddAssert("server method not invoked a second time", () => countBegin, () => Is.EqualTo(1));
AddStep("end watching presence (1)", () => token1.Dispose());
AddAssert("server method not invoked", () => countEnd, () => Is.EqualTo(0));
AddStep("end watching presence (2)", () => token2.Dispose());
AddAssert("server method invoked once", () => countEnd, () => Is.EqualTo(1));
}
}
}

View File

@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual.Editing
}
[Test]
[Solo]
public void TestCommitPlacementViaRightClick()
{
Playfield playfield = null!;

View File

@ -0,0 +1,47 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Overlays;
using osu.Game.Screens.Edit.Submission;
using osuTK;
namespace osu.Game.Tests.Visual.Editing
{
public partial class TestSceneSubmissionStageProgress : OsuTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Test]
public void TestAppearance()
{
SubmissionStageProgress progress = null!;
AddStep("create content", () => Child = new Container
{
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Child = progress = new SubmissionStageProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
StageDescription = "Frobnicating the foobarator...",
}
});
AddStep("not started", () => progress.SetNotStarted());
AddStep("indeterminate progress", () => progress.SetInProgress());
AddStep("30% progress", () => progress.SetInProgress(0.3f));
AddStep("70% progress", () => progress.SetInProgress(0.7f));
AddStep("completed", () => progress.SetCompleted());
AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated"));
AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe"));
AddStep("canceled", () => progress.SetCanceled());
}
}
}

View File

@ -16,6 +16,7 @@ using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Online.Solo;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
@ -234,6 +235,31 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("ensure no submission", () => Player.SubmittedScore == null);
}
[Test]
public void TestNoSubmissionWhenScoreZero()
{
prepareTestAPI(true);
createPlayerTest();
AddUntilStep("wait for token request", () => Player.TokenCreationRequested);
AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning);
AddUntilStep("wait for first result", () => Player.Results.Count > 0);
AddStep("add fake non-scoring hit", () =>
{
Player.ScoreProcessor.RevertResult(Player.Results.First());
Player.ScoreProcessor.ApplyResult(new OsuJudgementResult(Beatmap.Value.Beatmap.HitObjects.First(), new IgnoreJudgement())
{
Type = HitResult.IgnoreHit,
});
});
AddStep("exit", () => Player.Exit());
AddAssert("ensure no submission", () => Player.SubmittedScore == null);
}
[Test]
public void TestSubmissionOnExit()
{

View File

@ -80,7 +80,7 @@ namespace osu.Game.Tests.Visual.Menus
new APIMenuImage
{
Image = @"https://assets.ppy.sh/main-menu/project-loved-2@2x.png",
Url = $@"{API.WebsiteRootUrl}/home/news/2023-12-21-project-loved-december-2023",
Url = $@"{API.Endpoints.WebsiteUrl}/home/news/2023-12-21-project-loved-december-2023",
}
}
});

View File

@ -165,7 +165,6 @@ namespace osu.Game.Tests.Visual.Navigation
}
[Test]
[Solo]
public void TestEditorGameplayTestAlwaysUsesOriginalRuleset()
{
prepareBeatmap();

View File

@ -65,7 +65,9 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestBasicDisplay()
{
AddStep("Begin watching user presence", () => metadataClient.BeginWatchingUserPresence());
IDisposable token = null!;
AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence());
AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() }));
AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType<UserGridPanel>().FirstOrDefault()?.User.Id == 2);
AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType<PurpleRoundedButton>().First().Enabled.Value, () => Is.False);
@ -78,14 +80,16 @@ namespace osu.Game.Tests.Visual.Online
AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null));
AddUntilStep("Panel no longer present", () => !currentlyOnline.ChildrenOfType<UserGridPanel>().Any());
AddStep("End watching user presence", () => metadataClient.EndWatchingUserPresence());
AddStep("End watching user presence", () => token.Dispose());
}
[Test]
public void TestUserWasPlayingBeforeWatchingUserPresence()
{
IDisposable token = null!;
AddStep("User began playing", () => spectatorClient.SendStartPlay(streamingUser.Id, 0));
AddStep("Begin watching user presence", () => metadataClient.BeginWatchingUserPresence());
AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence());
AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.ChoosingBeatmap() }));
AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType<UserGridPanel>().FirstOrDefault()?.User.Id == 2);
AddAssert("Spectate button enabled", () => currentlyOnline.ChildrenOfType<PurpleRoundedButton>().First().Enabled.Value, () => Is.True);
@ -93,7 +97,7 @@ namespace osu.Game.Tests.Visual.Online
AddStep("User finished playing", () => spectatorClient.SendEndPlay(streamingUser.Id));
AddAssert("Spectate button disabled", () => currentlyOnline.ChildrenOfType<PurpleRoundedButton>().First().Enabled.Value, () => Is.False);
AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null));
AddStep("End watching user presence", () => metadataClient.EndWatchingUserPresence());
AddStep("End watching user presence", () => token.Dispose());
}
internal partial class TestUserLookupCache : UserLookupCache

View File

@ -4,17 +4,18 @@
using System;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Online;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Metadata;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Visual.Metadata;
using osu.Game.Users;
using osuTK;
@ -23,144 +24,142 @@ namespace osu.Game.Tests.Visual.Online
[TestFixture]
public partial class TestSceneUserPanel : OsuTestScene
{
private readonly Bindable<UserActivity?> activity = new Bindable<UserActivity?>();
private readonly Bindable<UserStatus?> status = new Bindable<UserStatus?>();
private UserGridPanel boundPanel1 = null!;
private TestUserListPanel boundPanel2 = null!;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
[Cached(typeof(LocalUserStatisticsProvider))]
private readonly TestUserStatisticsProvider statisticsProvider = new TestUserStatisticsProvider();
[Resolved]
private IRulesetStore rulesetStore { get; set; } = null!;
private TestUserStatisticsProvider statisticsProvider = null!;
private TestMetadataClient metadataClient = null!;
private TestUserListPanel panel = null!;
[SetUp]
public void SetUp() => Schedule(() =>
{
activity.Value = null;
status.Value = null;
Remove(statisticsProvider, false);
Clear();
Add(statisticsProvider);
Add(new FillFlowContainer
Child = new DependencyProvidingContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Spacing = new Vector2(10f),
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(LocalUserStatisticsProvider), statisticsProvider = new TestUserStatisticsProvider()),
(typeof(MetadataClient), metadataClient = new TestMetadataClient())
],
Children = new Drawable[]
{
new UserBrickPanel(new APIUser
statisticsProvider,
metadataClient,
new FillFlowContainer
{
Username = @"flyte",
Id = 3103765,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
}),
new UserBrickPanel(new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
}),
new UserGridPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
IsOnline = true
}) { Width = 300 },
boundPanel1 = new UserGridPanel(new APIUser
{
Username = @"peppy",
Id = 2,
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
IsSupporter = true,
SupportLevel = 3,
}) { Width = 300 },
boundPanel2 = new TestUserListPanel(new APIUser
{
Username = @"Evast",
Id = 8195163,
CountryCode = CountryCode.BY,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
IsOnline = false,
LastVisit = DateTimeOffset.Now
}),
new UserRankPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 }
}) { Width = 300 },
new UserRankPanel(new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
Statistics = new UserStatistics { GlobalRank = null, CountryRank = null }
}) { Width = 300 }
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Spacing = new Vector2(10f),
Children = new Drawable[]
{
new UserBrickPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
}),
new UserBrickPanel(new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
}),
new UserGridPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
IsOnline = true
}) { Width = 300 },
new UserGridPanel(new APIUser
{
Username = @"peppy",
Id = 2,
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
IsSupporter = true,
SupportLevel = 3,
}) { Width = 300 },
panel = new TestUserListPanel(new APIUser
{
Username = @"peppy",
Id = 2,
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
LastVisit = DateTimeOffset.Now
}),
new UserRankPanel(new APIUser
{
Username = @"flyte",
Id = 3103765,
CountryCode = CountryCode.JP,
CoverUrl = @"https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg",
Statistics = new UserStatistics { GlobalRank = 12345, CountryRank = 1234 }
}) { Width = 300 },
new UserRankPanel(new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/8195163/4a8e2ad5a02a2642b631438cfa6c6bd7e2f9db289be881cb27df18331f64144c.jpeg",
Statistics = new UserStatistics { GlobalRank = null, CountryRank = null }
}) { Width = 300 },
new UserGridPanel(API.LocalUser.Value)
{
Width = 300
}
}
}
}
});
};
boundPanel1.Status.BindTo(status);
boundPanel1.Activity.BindTo(activity);
boundPanel2.Status.BindTo(status);
boundPanel2.Activity.BindTo(activity);
metadataClient.BeginWatchingUserPresence();
});
[Test]
public void TestUserStatus()
{
AddStep("online", () => status.Value = UserStatus.Online);
AddStep("do not disturb", () => status.Value = UserStatus.DoNotDisturb);
AddStep("offline", () => status.Value = UserStatus.Offline);
AddStep("null status", () => status.Value = null);
AddStep("online", () => setPresence(UserStatus.Online, null));
AddStep("do not disturb", () => setPresence(UserStatus.DoNotDisturb, null));
AddStep("offline", () => setPresence(UserStatus.Offline, null));
}
[Test]
public void TestUserActivity()
{
AddStep("set online status", () => status.Value = UserStatus.Online);
AddStep("idle", () => activity.Value = null);
AddStep("watching replay", () => activity.Value = new UserActivity.WatchingReplay(createScore(@"nats")));
AddStep("spectating user", () => activity.Value = new UserActivity.SpectatingUser(createScore(@"mrekk")));
AddStep("solo (osu!)", () => activity.Value = soloGameStatusForRuleset(0));
AddStep("solo (osu!taiko)", () => activity.Value = soloGameStatusForRuleset(1));
AddStep("solo (osu!catch)", () => activity.Value = soloGameStatusForRuleset(2));
AddStep("solo (osu!mania)", () => activity.Value = soloGameStatusForRuleset(3));
AddStep("choosing", () => activity.Value = new UserActivity.ChoosingBeatmap());
AddStep("editing beatmap", () => activity.Value = new UserActivity.EditingBeatmap(new BeatmapInfo()));
AddStep("modding beatmap", () => activity.Value = new UserActivity.ModdingBeatmap(new BeatmapInfo()));
AddStep("testing beatmap", () => activity.Value = new UserActivity.TestingBeatmap(new BeatmapInfo()));
AddStep("idle", () => setPresence(UserStatus.Online, null));
AddStep("watching replay", () => setPresence(UserStatus.Online, new UserActivity.WatchingReplay(createScore(@"nats"))));
AddStep("spectating user", () => setPresence(UserStatus.Online, new UserActivity.SpectatingUser(createScore(@"mrekk"))));
AddStep("solo (osu!)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(0)));
AddStep("solo (osu!taiko)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(1)));
AddStep("solo (osu!catch)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(2)));
AddStep("solo (osu!mania)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(3)));
AddStep("choosing", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap()));
AddStep("editing beatmap", () => setPresence(UserStatus.Online, new UserActivity.EditingBeatmap(new BeatmapInfo())));
AddStep("modding beatmap", () => setPresence(UserStatus.Online, new UserActivity.ModdingBeatmap(new BeatmapInfo())));
AddStep("testing beatmap", () => setPresence(UserStatus.Online, new UserActivity.TestingBeatmap(new BeatmapInfo())));
}
[Test]
public void TestUserActivityChange()
{
AddAssert("visit message is visible", () => boundPanel2.LastVisitMessage.IsPresent);
AddStep("set online status", () => status.Value = UserStatus.Online);
AddAssert("visit message is not visible", () => !boundPanel2.LastVisitMessage.IsPresent);
AddStep("set choosing activity", () => activity.Value = new UserActivity.ChoosingBeatmap());
AddStep("set offline status", () => status.Value = UserStatus.Offline);
AddAssert("visit message is visible", () => boundPanel2.LastVisitMessage.IsPresent);
AddStep("set online status", () => status.Value = UserStatus.Online);
AddAssert("visit message is not visible", () => !boundPanel2.LastVisitMessage.IsPresent);
AddAssert("visit message is visible", () => panel.LastVisitMessage.IsPresent);
AddStep("set online status", () => setPresence(UserStatus.Online, null));
AddAssert("visit message is not visible", () => !panel.LastVisitMessage.IsPresent);
AddStep("set choosing activity", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap()));
AddStep("set offline status", () => setPresence(UserStatus.Offline, null));
AddAssert("visit message is visible", () => panel.LastVisitMessage.IsPresent);
AddStep("set online status", () => setPresence(UserStatus.Online, null));
AddAssert("visit message is not visible", () => !panel.LastVisitMessage.IsPresent);
}
[Test]
@ -185,6 +184,31 @@ namespace osu.Game.Tests.Visual.Online
AddStep("set statistics to empty", () => statisticsProvider.UpdateStatistics(new UserStatistics(), Ruleset.Value));
}
[Test]
public void TestLocalUserActivity()
{
AddStep("idle", () => setPresence(UserStatus.Online, null, API.LocalUser.Value.OnlineID));
AddStep("watching replay", () => setPresence(UserStatus.Online, new UserActivity.WatchingReplay(createScore(@"nats")), API.LocalUser.Value.OnlineID));
AddStep("spectating user", () => setPresence(UserStatus.Online, new UserActivity.SpectatingUser(createScore(@"mrekk")), API.LocalUser.Value.OnlineID));
AddStep("solo (osu!)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(0), API.LocalUser.Value.OnlineID));
AddStep("solo (osu!taiko)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(1), API.LocalUser.Value.OnlineID));
AddStep("solo (osu!catch)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(2), API.LocalUser.Value.OnlineID));
AddStep("solo (osu!mania)", () => setPresence(UserStatus.Online, soloGameStatusForRuleset(3), API.LocalUser.Value.OnlineID));
AddStep("choosing", () => setPresence(UserStatus.Online, new UserActivity.ChoosingBeatmap(), API.LocalUser.Value.OnlineID));
AddStep("editing beatmap", () => setPresence(UserStatus.Online, new UserActivity.EditingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID));
AddStep("modding beatmap", () => setPresence(UserStatus.Online, new UserActivity.ModdingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID));
AddStep("testing beatmap", () => setPresence(UserStatus.Online, new UserActivity.TestingBeatmap(new BeatmapInfo()), API.LocalUser.Value.OnlineID));
AddStep("set offline status", () => setPresence(UserStatus.Offline, null, API.LocalUser.Value.OnlineID));
}
private void setPresence(UserStatus status, UserActivity? activity, int? userId = null)
{
if (status == UserStatus.Offline)
metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, null);
else
metadataClient.UserPresenceUpdated(userId ?? panel.User.OnlineID, new UserPresence { Status = status, Activity = activity });
}
private UserActivity soloGameStatusForRuleset(int rulesetId) => new UserActivity.InSoloGame(new BeatmapInfo(), rulesetStore.GetRuleset(rulesetId)!);
private ScoreInfo createScore(string name) => new ScoreInfo(new TestBeatmap(Ruleset.Value).BeatmapInfo)

View File

@ -67,19 +67,19 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestLink()
{
AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/");
AddStep("set current path", () => markdownContainer.CurrentPath = $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/");
AddStep("set '/wiki/Main_page''", () => markdownContainer.Text = "[wiki main page](/wiki/Main_page)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Main_page");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Main_page");
AddStep("set '../FAQ''", () => markdownContainer.Text = "[FAQ](../FAQ)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/FAQ");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/FAQ");
AddStep("set './Writing''", () => markdownContainer.Text = "[wiki writing guidline](./Writing)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Writing");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/Writing");
AddStep("set 'Formatting''", () => markdownContainer.Text = "[wiki formatting guidline](Formatting)");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.WebsiteRootUrl}/wiki/Article_styling_criteria/Formatting");
AddAssert("check url", () => markdownContainer.Link.Url == $"{API.Endpoints.WebsiteUrl}/wiki/Article_styling_criteria/Formatting");
}
[Test]

View File

@ -62,12 +62,6 @@ namespace osu.Game.Tests.Visual.Ranking
if (beatmapInfo != null)
Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmapInfo);
});
AddToggleStep("toggle legacy classic skin", v =>
{
if (skins != null)
skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default;
});
}
[SetUp]
@ -84,6 +78,16 @@ namespace osu.Game.Tests.Visual.Ranking
}));
}
[Test]
public void TestLegacySkin()
{
AddToggleStep("toggle legacy classic skin", v =>
{
if (skins != null)
skins.CurrentSkinInfo.Value = v ? skins.DefaultClassicSkin.SkinInfo : skins.CurrentSkinInfo.Default;
});
}
private int onlineScoreID = 1;
[TestCase(1, ScoreRank.X, 0)]

View File

@ -1,6 +1,8 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
@ -17,10 +19,10 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
@ -57,16 +59,6 @@ namespace osu.Game.Tests.Visual.SongSelect
Scheduler.AddDelayed(updateStats, 100, true);
}
[SetUpSteps]
public virtual void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria { Sort = SortMode.Title });
}
protected void CreateCarousel()
{
AddStep("create components", () =>
@ -134,7 +126,7 @@ namespace osu.Game.Tests.Visual.SongSelect
});
}
protected void SortBy(FilterCriteria criteria) => AddStep($"sort {criteria.Sort} group {criteria.Group}", () => Carousel.Filter(criteria));
protected void SortBy(FilterCriteria criteria) => AddStep($"sort:{criteria.Sort} group:{criteria.Group}", () => Carousel.Filter(criteria));
protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType<ICarouselPanel>().Count(), () => Is.GreaterThan(0));
protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False);
@ -150,6 +142,9 @@ namespace osu.Game.Tests.Visual.SongSelect
protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null);
protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null);
protected ICarouselPanel? GetSelectedPanel() => Carousel.ChildrenOfType<ICarouselPanel>().SingleOrDefault(p => p.Selected.Value);
protected ICarouselPanel? GetKeyboardSelectedPanel() => Carousel.ChildrenOfType<ICarouselPanel>().SingleOrDefault(p => p.KeyboardSelected.Value);
protected void WaitForGroupSelection(int group, int panel)
{
AddUntilStep($"selected is group{group} panel{panel}", () =>
@ -175,6 +170,15 @@ namespace osu.Game.Tests.Visual.SongSelect
});
}
protected IEnumerable<T> GetVisiblePanels<T>()
where T : Drawable
{
return Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single()
.ChildrenOfType<T>()
.Where(p => ((ICarouselPanel)p).Item?.IsVisible == true)
.OrderBy(p => p.Y);
}
protected void ClickVisiblePanel<T>(int index)
where T : Drawable
{
@ -190,17 +194,66 @@ namespace osu.Game.Tests.Visual.SongSelect
});
}
protected void ClickVisiblePanelWithOffset<T>(int index, Vector2 positionOffsetFromCentre)
where T : Drawable
{
AddStep($"move mouse to panel {index} with offset {positionOffsetFromCentre}", () =>
{
var panel = Carousel.ChildrenOfType<UserTrackingScrollContainer>().Single()
.ChildrenOfType<T>()
.Where(p => ((ICarouselPanel)p).Item?.IsVisible == true)
.OrderBy(p => p.Y)
.ElementAt(index);
InputManager.MoveMouseTo(panel.ScreenSpaceDrawQuad.Centre + panel.ToScreenSpace(positionOffsetFromCentre) - panel.ToScreenSpace(Vector2.Zero));
});
AddStep("click", () => InputManager.Click(MouseButton.Left));
}
/// <summary>
/// Add requested beatmap sets count to list.
/// </summary>
/// <param name="count">The count of beatmap sets to add.</param>
/// <param name="fixedDifficultiesPerSet">If not null, the number of difficulties per set. If null, randomised difficulty count will be used.</param>
protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null) => AddStep($"add {count} beatmaps", () =>
/// <param name="randomMetadata">Whether to randomise the metadata to make groupings more uniform.</param>
protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null, bool randomMetadata = false) => AddStep($"add {count} beatmaps{(randomMetadata ? " with random data" : "")}", () =>
{
for (int i = 0; i < count; i++)
BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4)));
BeatmapSets.Add(CreateTestBeatmapSetInfo(fixedDifficultiesPerSet, randomMetadata));
});
protected static BeatmapSetInfo CreateTestBeatmapSetInfo(int? fixedDifficultiesPerSet, bool randomMetadata)
{
var beatmapSetInfo = TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4));
if (randomMetadata)
{
char randomCharacter = getRandomCharacter();
var metadata = new BeatmapMetadata
{
// Create random metadata, then we can check if sorting works based on these
Artist = $"{randomCharacter}ome Artist " + RNG.Next(0, 9),
Title = $"{randomCharacter}ome Song (set id {beatmapSetInfo.OnlineID:000}) {Guid.NewGuid()}",
Author = { Username = $"{randomCharacter}ome Guy " + RNG.Next(0, 9) },
};
foreach (var beatmap in beatmapSetInfo.Beatmaps)
beatmap.Metadata = metadata.DeepClone();
}
return beatmapSetInfo;
}
private static long randomCharPointer;
private static char getRandomCharacter()
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz*";
return chars[(int)((randomCharPointer++ / 2) % chars.Length)];
}
protected void RemoveAllBeatmaps() => AddStep("clear all beatmaps", () => BeatmapSets.Clear());
protected void RemoveFirstBeatmap() =>

View File

@ -0,0 +1,91 @@
// 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.Collections.Generic;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.SongSelect
{
/// <summary>
/// Covers common steps which can be used for manual testing.
/// </summary>
[TestFixture]
public partial class TestSceneBeatmapCarouselV2 : BeatmapCarouselV2TestScene
{
[Test]
[Explicit]
public void TestBasics()
{
CreateCarousel();
RemoveAllBeatmaps();
AddBeatmaps(10, randomMetadata: true);
AddBeatmaps(10);
AddBeatmaps(1);
}
[Test]
[Explicit]
public void TestSorting()
{
SortBy(new FilterCriteria { Sort = SortMode.Artist });
SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
SortBy(new FilterCriteria { Group = GroupMode.Artist, Sort = SortMode.Artist });
}
[Test]
[Explicit]
public void TestRemovals()
{
RemoveFirstBeatmap();
RemoveAllBeatmaps();
}
[Test]
[Explicit]
public void TestAddRemoveRepeatedOps()
{
AddRepeatStep("add beatmaps", () => BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
AddRepeatStep("remove beatmaps", () => BeatmapSets.RemoveAt(RNG.Next(0, BeatmapSets.Count)), 20);
}
[Test]
[Explicit]
public void TestMasking()
{
AddStep("disable masking", () => Scroll.Masking = false);
AddStep("enable masking", () => Scroll.Masking = true);
}
[Test]
[Explicit]
public void TestPerformanceWithManyBeatmaps()
{
const int count = 200000;
List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
AddStep($"populate {count} test beatmaps", () =>
{
generated.Clear();
Task.Run(() =>
{
for (int j = 0; j < count; j++)
generated.Add(CreateTestBeatmapSetInfo(3, true));
}).ConfigureAwait(true);
});
AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated));
}
}
}

View File

@ -0,0 +1,177 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
namespace osu.Game.Tests.Visual.SongSelect
{
[TestFixture]
public partial class TestSceneBeatmapCarouselV2ArtistGrouping : BeatmapCarouselV2TestScene
{
[SetUpSteps]
public void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria { Group = GroupMode.Artist, Sort = SortMode.Artist });
AddBeatmaps(10, 3, true);
WaitForDrawablePanels();
}
[Test]
public void TestOpenCloseGroupWithNoSelectionMouse()
{
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddUntilStep("no sets visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("some sets visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("no sets visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
}
[Test]
public void TestOpenCloseGroupWithNoSelectionKeyboard()
{
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddUntilStep("no sets visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
SelectNextPanel();
Select();
AddUntilStep("some sets visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddAssert("keyboard selected is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
CheckNoSelection();
Select();
AddUntilStep("no sets visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddAssert("keyboard selected is collapsed", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
CheckNoSelection();
}
[Test]
public void TestCarouselRemembersSelection()
{
SelectNextGroup();
object? selection = null;
AddStep("store drawable selection", () => selection = GetSelectedPanel()?.Item?.Model);
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null);
AddBeatmaps(10);
WaitForDrawablePanels();
CheckHasSelection();
AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item not visible", GetSelectedPanel, () => Is.Null);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
}
[Test]
public void TestGroupSelectionOnHeader()
{
SelectNextGroup();
WaitForGroupSelection(0, 1);
SelectPrevPanel();
SelectPrevPanel();
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
SelectPrevGroup();
WaitForGroupSelection(0, 1);
AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
SelectPrevGroup();
WaitForGroupSelection(0, 1);
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
}
[Test]
public void TestKeyboardSelection()
{
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
CheckNoSelection();
// open first group
Select();
CheckNoSelection();
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapSetPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
SelectNextPanel();
Select();
WaitForGroupSelection(3, 1);
SelectNextGroup();
WaitForGroupSelection(3, 5);
SelectNextGroup();
WaitForGroupSelection(4, 1);
SelectPrevGroup();
WaitForGroupSelection(3, 5);
SelectNextGroup();
WaitForGroupSelection(4, 1);
SelectNextGroup();
WaitForGroupSelection(4, 5);
SelectNextGroup();
WaitForGroupSelection(0, 1);
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
SelectNextGroup();
WaitForGroupSelection(0, 1);
SelectNextPanel();
SelectNextGroup();
WaitForGroupSelection(1, 1);
}
}
}

View File

@ -1,126 +0,0 @@
// 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.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.SongSelect
{
/// <summary>
/// Currently covers adding and removing of items and scrolling.
/// If we add more tests here, these two categories can likely be split out into separate scenes.
/// </summary>
[TestFixture]
public partial class TestSceneBeatmapCarouselV2Basics : BeatmapCarouselV2TestScene
{
[Test]
public void TestBasics()
{
AddBeatmaps(1);
AddBeatmaps(10);
RemoveFirstBeatmap();
RemoveAllBeatmaps();
}
[Test]
public void TestOffScreenLoading()
{
AddStep("disable masking", () => Scroll.Masking = false);
AddStep("enable masking", () => Scroll.Masking = true);
}
[Test]
public void TestAddRemoveOneByOne()
{
AddRepeatStep("add beatmaps", () => BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
AddRepeatStep("remove beatmaps", () => BeatmapSets.RemoveAt(RNG.Next(0, BeatmapSets.Count)), 20);
}
[Test]
public void TestSorting()
{
AddBeatmaps(10);
SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
SortBy(new FilterCriteria { Sort = SortMode.Artist });
}
[Test]
public void TestScrollPositionMaintainedOnAddSecondSelected()
{
Quad positionBefore = default;
AddBeatmaps(10);
WaitForDrawablePanels();
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2));
AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value)));
WaitForScrolling();
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
RemoveFirstBeatmap();
WaitForSorting();
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
() => Is.EqualTo(positionBefore));
}
[Test]
public void TestScrollPositionMaintainedOnAddLastSelected()
{
Quad positionBefore = default;
AddBeatmaps(10);
WaitForDrawablePanels();
AddStep("scroll to last item", () => Scroll.ScrollToEnd(false));
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last());
WaitForScrolling();
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
RemoveFirstBeatmap();
WaitForSorting();
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
() => Is.EqualTo(positionBefore));
}
[Test]
[Explicit]
public void TestPerformanceWithManyBeatmaps()
{
const int count = 200000;
List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
AddStep($"populate {count} test beatmaps", () =>
{
generated.Clear();
Task.Run(() =>
{
for (int j = 0; j < count; j++)
generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
}).ConfigureAwait(true);
});
AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated));
}
}
}

View File

@ -1,7 +1,6 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
@ -10,28 +9,26 @@ using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.SongSelect
{
[TestFixture]
public partial class TestSceneBeatmapCarouselV2GroupSelection : BeatmapCarouselV2TestScene
public partial class TestSceneBeatmapCarouselV2DifficultyGrouping : BeatmapCarouselV2TestScene
{
public override void SetUpSteps()
[SetUpSteps]
public void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
AddBeatmaps(10, 3);
WaitForDrawablePanels();
}
[Test]
public void TestOpenCloseGroupWithNoSelectionMouse()
{
AddBeatmaps(10, 5);
WaitForDrawablePanels();
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
@ -47,86 +44,99 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestOpenCloseGroupWithNoSelectionKeyboard()
{
AddBeatmaps(10, 5);
WaitForDrawablePanels();
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
SelectNextPanel();
Select();
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
AddAssert("keyboard selected is expanded", () => getKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
AddAssert("keyboard selected is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
CheckNoSelection();
Select();
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
AddAssert("keyboard selected is collapsed", () => getKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
AddAssert("keyboard selected is collapsed", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
CheckNoSelection();
GroupPanel? getKeyboardSelectedPanel() => Carousel.ChildrenOfType<GroupPanel>().SingleOrDefault(p => p.KeyboardSelected.Value);
}
[Test]
public void TestCarouselRemembersSelection()
{
AddBeatmaps(10);
WaitForDrawablePanels();
SelectNextGroup();
object? selection = null;
AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model);
AddStep("store drawable selection", () => selection = GetSelectedPanel()?.Item?.Model);
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", getSelectedPanel, () => Is.Null);
AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null);
AddBeatmaps(10);
AddBeatmaps(5);
WaitForDrawablePanels();
CheckHasSelection();
AddAssert("no drawable selection", getSelectedPanel, () => Is.Null);
AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item not visible", getSelectedPanel, () => Is.Null);
AddUntilStep("carousel item not visible", GetSelectedPanel, () => Is.Null);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
AddUntilStep("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
}
[Test]
public void TestGroupSelectionOnHeader()
public void TestGroupSelectionOnHeaderKeyboard()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextGroup();
WaitForGroupSelection(0, 0);
SelectPrevPanel();
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
SelectPrevGroup();
WaitForGroupSelection(2, 9);
WaitForGroupSelection(0, 0);
AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
SelectPrevGroup();
WaitForGroupSelection(0, 0);
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
}
[Test]
public void TestGroupSelectionOnHeaderMouse()
{
SelectNextGroup();
WaitForGroupSelection(0, 0);
AddAssert("keyboard selected panel is beatmap", GetKeyboardSelectedPanel, Is.TypeOf<BeatmapPanel>);
AddAssert("selected panel is beatmap", GetSelectedPanel, Is.TypeOf<BeatmapPanel>);
ClickVisiblePanel<GroupPanel>(0);
AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, Is.TypeOf<GroupPanel>);
AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
ClickVisiblePanel<GroupPanel>(0);
AddAssert("keyboard selected panel is group", GetKeyboardSelectedPanel, Is.TypeOf<GroupPanel>);
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
AddAssert("selected panel is still beatmap", GetSelectedPanel, Is.TypeOf<BeatmapPanel>);
}
[Test]
public void TestKeyboardSelection()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
@ -161,60 +171,24 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestInputHandlingWithinGaps()
{
AddBeatmaps(5, 2);
WaitForDrawablePanels();
SelectNextGroup();
AddAssert("no beatmaps visible", () => !GetVisiblePanels<BeatmapPanel>().Any());
clickOnPanel(0, 1, p => p.LayoutRectangle.TopLeft + new Vector2(p.LayoutRectangle.Centre.X, -1f));
WaitForGroupSelection(0, 1);
// Clicks just above the first group panel should not actuate any action.
ClickVisiblePanelWithOffset<GroupPanel>(0, new Vector2(0, -(GroupPanel.HEIGHT / 2 + 1)));
clickOnPanel(0, 0, p => p.LayoutRectangle.BottomLeft + new Vector2(p.LayoutRectangle.Centre.X, 1f));
AddAssert("no beatmaps visible", () => !GetVisiblePanels<BeatmapPanel>().Any());
ClickVisiblePanelWithOffset<GroupPanel>(0, new Vector2(0, -(GroupPanel.HEIGHT / 2)));
AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels<BeatmapPanel>().Any());
CheckNoSelection();
// Beatmap panels expand their selection area to cover holes from spacing.
ClickVisiblePanelWithOffset<BeatmapPanel>(0, new Vector2(0, -(CarouselItem.DEFAULT_HEIGHT / 2 + 1)));
WaitForGroupSelection(0, 0);
SelectNextPanel();
Select();
ClickVisiblePanelWithOffset<BeatmapPanel>(1, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1)));
WaitForGroupSelection(0, 1);
clickOnGroup(0, p => p.LayoutRectangle.BottomLeft + new Vector2(p.LayoutRectangle.Centre.X, 1f));
AddAssert("group 0 collapsed", () => this.ChildrenOfType<GroupPanel>().OrderBy(g => g.Y).ElementAt(0).Expanded.Value, () => Is.False);
clickOnGroup(0, p => p.LayoutRectangle.Centre);
AddAssert("group 0 expanded", () => this.ChildrenOfType<GroupPanel>().OrderBy(g => g.Y).ElementAt(0).Expanded.Value, () => Is.True);
AddStep("scroll to end", () => Scroll.ScrollToEnd(false));
clickOnPanel(0, 4, p => p.LayoutRectangle.BottomLeft + new Vector2(p.LayoutRectangle.Centre.X, 1f));
WaitForGroupSelection(0, 4);
clickOnGroup(1, p => p.LayoutRectangle.TopLeft + new Vector2(p.LayoutRectangle.Centre.X, -1f));
AddAssert("group 1 expanded", () => this.ChildrenOfType<GroupPanel>().OrderBy(g => g.Y).ElementAt(1).Expanded.Value, () => Is.True);
}
private void clickOnGroup(int group, Func<GroupPanel, Vector2> pos)
{
AddStep($"click on group{group}", () =>
{
var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single();
var model = groupingFilter.GroupItems.Keys.ElementAt(group);
var panel = this.ChildrenOfType<GroupPanel>().Single(b => ReferenceEquals(b.Item!.Model, model));
InputManager.MoveMouseTo(panel.ToScreenSpace(pos(panel)));
InputManager.Click(MouseButton.Left);
});
}
private void clickOnPanel(int group, int panel, Func<BeatmapPanel, Vector2> pos)
{
AddStep($"click on group{group} panel{panel}", () =>
{
var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single();
var g = groupingFilter.GroupItems.Keys.ElementAt(group);
// offset by one because the group itself is included in the items list.
object model = groupingFilter.GroupItems[g].ElementAt(panel + 1).Model;
var p = this.ChildrenOfType<BeatmapPanel>().Single(b => ReferenceEquals(b.Item!.Model, model));
InputManager.MoveMouseTo(p.ToScreenSpace(pos(p)));
InputManager.Click(MouseButton.Left);
});
}
}
}

View File

@ -1,11 +1,12 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osuTK;
using osuTK.Input;
@ -13,8 +14,16 @@ using osuTK.Input;
namespace osu.Game.Tests.Visual.SongSelect
{
[TestFixture]
public partial class TestSceneBeatmapCarouselV2Selection : BeatmapCarouselV2TestScene
public partial class TestSceneBeatmapCarouselV2NoGrouping : BeatmapCarouselV2TestScene
{
[SetUpSteps]
public void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria { Sort = SortMode.Title });
}
/// <summary>
/// Keyboard selection via up and down arrows doesn't actually change the selection until
/// the select key is pressed.
@ -79,27 +88,26 @@ namespace osu.Game.Tests.Visual.SongSelect
object? selection = null;
AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model);
AddStep("store drawable selection", () => selection = GetSelectedPanel()?.Item?.Model);
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", getSelectedPanel, () => Is.Null);
AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null);
AddBeatmaps(10);
WaitForDrawablePanels();
CheckHasSelection();
AddAssert("no drawable selection", getSelectedPanel, () => Is.Null);
AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
}
[Test]
@ -130,6 +138,25 @@ namespace osu.Game.Tests.Visual.SongSelect
WaitForSelection(0, 0);
}
[Test]
public void TestGroupSelectionOnHeader()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextGroup();
SelectNextGroup();
WaitForSelection(1, 0);
SelectPrevPanel();
SelectPrevGroup();
WaitForSelection(1, 0);
SelectPrevPanel();
SelectNextGroup();
WaitForSelection(1, 0);
}
[Test]
public void TestKeyboardSelection()
{
@ -185,26 +212,29 @@ namespace osu.Game.Tests.Visual.SongSelect
{
AddBeatmaps(2, 5);
WaitForDrawablePanels();
SelectNextGroup();
clickOnDifficulty(0, 1, p => new Vector2(p.LayoutRectangle.Centre.X, -1f));
WaitForSelection(0, 1);
AddAssert("no beatmaps visible", () => !GetVisiblePanels<BeatmapPanel>().Any());
clickOnDifficulty(0, 0, p => new Vector2(p.LayoutRectangle.Centre.X, BeatmapPanel.HEIGHT + 1f));
// Clicks just above the first group panel should not actuate any action.
ClickVisiblePanelWithOffset<BeatmapSetPanel>(0, new Vector2(0, -(BeatmapSetPanel.HEIGHT / 2 + 1)));
AddAssert("no beatmaps visible", () => !GetVisiblePanels<BeatmapPanel>().Any());
ClickVisiblePanelWithOffset<BeatmapSetPanel>(0, new Vector2(0, -(BeatmapSetPanel.HEIGHT / 2)));
AddUntilStep("wait for beatmaps visible", () => GetVisiblePanels<BeatmapPanel>().Any());
WaitForSelection(0, 0);
clickOnDifficulty(0, 1, p => p.LayoutRectangle.Centre);
WaitForSelection(0, 1);
clickOnSet(0, p => new Vector2(p.LayoutRectangle.Centre.X, BeatmapSetPanel.HEIGHT + 1f));
// Beatmap panels expand their selection area to cover holes from spacing.
ClickVisiblePanelWithOffset<BeatmapPanel>(1, new Vector2(0, -(CarouselItem.DEFAULT_HEIGHT / 2 + 1)));
WaitForSelection(0, 0);
AddStep("scroll to end", () => Scroll.ScrollToEnd(false));
clickOnDifficulty(0, 4, p => new Vector2(p.LayoutRectangle.Centre.X, BeatmapPanel.HEIGHT + 1f));
WaitForSelection(0, 4);
// Panels with higher depth will handle clicks in the gutters for simplicity.
ClickVisiblePanelWithOffset<BeatmapPanel>(2, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1)));
WaitForSelection(0, 2);
clickOnSet(1, p => new Vector2(p.LayoutRectangle.Centre.X, -1f));
WaitForSelection(1, 0);
ClickVisiblePanelWithOffset<BeatmapPanel>(3, new Vector2(0, (CarouselItem.DEFAULT_HEIGHT / 2 + 1)));
WaitForSelection(0, 3);
}
private void checkSelectionIterating(bool isIterating)
@ -220,27 +250,5 @@ namespace osu.Game.Tests.Visual.SongSelect
AddUntilStep("selection not changed", () => Carousel.CurrentSelection == selection);
}
}
private void clickOnSet(int set, Func<BeatmapSetPanel, Vector2> pos)
{
AddStep($"click on set{set}", () =>
{
var model = BeatmapSets[set];
var panel = this.ChildrenOfType<BeatmapSetPanel>().Single(b => ReferenceEquals(b.Item!.Model, model));
InputManager.MoveMouseTo(panel.ToScreenSpace(pos(panel)));
InputManager.Click(MouseButton.Left);
});
}
private void clickOnDifficulty(int set, int diff, Func<BeatmapPanel, Vector2> pos)
{
AddStep($"click on set{set} diff{diff}", () =>
{
var model = BeatmapSets[set].Beatmaps[diff];
var panel = this.ChildrenOfType<BeatmapPanel>().Single(b => ReferenceEquals(b.Item!.Model, model));
InputManager.MoveMouseTo(panel.ToScreenSpace(pos(panel)));
InputManager.Click(MouseButton.Left);
});
}
}
}

View File

@ -0,0 +1,65 @@
// 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.Linq;
using NUnit.Framework;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Testing;
using osu.Game.Screens.Select;
using osu.Game.Screens.SelectV2;
namespace osu.Game.Tests.Visual.SongSelect
{
[TestFixture]
public partial class TestSceneBeatmapCarouselV2Scrolling : BeatmapCarouselV2TestScene
{
[SetUpSteps]
public void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria());
AddBeatmaps(10);
WaitForDrawablePanels();
}
[Test]
public void TestScrollPositionMaintainedOnAddSecondSelected()
{
Quad positionBefore = default;
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First());
AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value)));
WaitForScrolling();
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
RemoveFirstBeatmap();
WaitForSorting();
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
() => Is.EqualTo(positionBefore));
}
[Test]
public void TestScrollPositionMaintainedOnAddLastSelected()
{
Quad positionBefore = default;
AddStep("scroll to last item", () => Scroll.ScrollToEnd(false));
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last().Beatmaps.Last());
WaitForScrolling();
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
RemoveFirstBeatmap();
WaitForSorting();
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
() => Is.EqualTo(positionBefore));
}
}
}

View File

@ -1239,7 +1239,6 @@ namespace osu.Game.Tests.Visual.SongSelect
}
[Test]
[Solo]
public void TestHardDeleteHandledCorrectly()
{
createSongSelect();

View File

@ -31,49 +31,49 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
new GroupPanel
{
Item = new CarouselItem(new GroupDefinition("Group A"))
Item = new CarouselItem(new GroupDefinition('A', "Group A"))
},
new GroupPanel
{
Item = new CarouselItem(new GroupDefinition("Group A")),
Item = new CarouselItem(new GroupDefinition('A', "Group A")),
KeyboardSelected = { Value = true }
},
new GroupPanel
{
Item = new CarouselItem(new GroupDefinition("Group A")),
Item = new CarouselItem(new GroupDefinition('A', "Group A")),
Expanded = { Value = true }
},
new GroupPanel
{
Item = new CarouselItem(new GroupDefinition("Group A")),
Item = new CarouselItem(new GroupDefinition('A', "Group A")),
KeyboardSelected = { Value = true },
Expanded = { Value = true }
},
new StarsGroupPanel
{
Item = new CarouselItem(new StarsGroupDefinition(1))
Item = new CarouselItem(new GroupDefinition(1, "1"))
},
new StarsGroupPanel
{
Item = new CarouselItem(new StarsGroupDefinition(3)),
Item = new CarouselItem(new GroupDefinition(3, "3")),
Expanded = { Value = true }
},
new StarsGroupPanel
{
Item = new CarouselItem(new StarsGroupDefinition(5)),
Item = new CarouselItem(new GroupDefinition(5, "5")),
},
new StarsGroupPanel
{
Item = new CarouselItem(new StarsGroupDefinition(7)),
Item = new CarouselItem(new GroupDefinition(7, "7")),
Expanded = { Value = true }
},
new StarsGroupPanel
{
Item = new CarouselItem(new StarsGroupDefinition(8)),
Item = new CarouselItem(new GroupDefinition(8, "8")),
},
new StarsGroupPanel
{
Item = new CarouselItem(new StarsGroupDefinition(9)),
Item = new CarouselItem(new GroupDefinition(9, "9")),
Expanded = { Value = true }
},
}

View File

@ -59,7 +59,7 @@ namespace osu.Game.Beatmaps
if (beatmapInfo.OnlineID <= 0 || beatmapInfo.BeatmapSet == null)
return null;
return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}";
return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapInfo.BeatmapSet.OnlineID}#{ruleset?.ShortName ?? beatmapInfo.Ruleset.ShortName}/{beatmapInfo.OnlineID}";
}
}
}

View File

@ -41,9 +41,9 @@ namespace osu.Game.Beatmaps
return null;
if (ruleset != null)
return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}";
return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetInfo.OnlineID}#{ruleset.ShortName}";
return $@"{api.WebsiteRootUrl}/beatmapsets/{beatmapSetInfo.OnlineID}";
return $@"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetInfo.OnlineID}";
}
}
}

View File

@ -95,6 +95,9 @@ namespace osu.Game.Configuration
/// </summary>
DailyChallengeIntroPlayed,
/// <summary>
/// The activity for the current user to broadcast to other players.
/// </summary>
UserOnlineActivity,
}
}

View File

@ -40,9 +40,7 @@ namespace osu.Game.Online.API
private readonly Queue<APIRequest> queue = new Queue<APIRequest>();
public string APIEndpointUrl { get; }
public string WebsiteRootUrl { get; }
public EndpointConfiguration Endpoints { get; }
/// <summary>
/// The API response version.
@ -75,7 +73,7 @@ namespace osu.Game.Online.API
private readonly CancellationTokenSource cancellationToken = new CancellationTokenSource();
private readonly Logger log;
public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash)
public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpoints, string versionHash)
{
this.game = game;
this.config = config;
@ -89,14 +87,13 @@ namespace osu.Game.Online.API
APIVersion = now.Year * 10000 + now.Month * 100 + now.Day;
}
APIEndpointUrl = endpointConfiguration.APIEndpointUrl;
WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl;
Endpoints = endpoints;
NotificationsClient = setUpNotificationsClient();
authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl);
authentication = new OAuth(endpoints.APIClientID, endpoints.APIClientSecret, Endpoints.APIUrl);
log = Logger.GetLogger(LoggingTarget.Network);
log.Add($@"API endpoint root: {APIEndpointUrl}");
log.Add($@"API endpoint root: {Endpoints.APIUrl}");
log.Add($@"API request version: {APIVersion}");
ProvidedUsername = config.Get<string>(OsuSetting.Username);
@ -408,7 +405,7 @@ namespace osu.Game.Online.API
var req = new RegistrationRequest
{
Url = $@"{APIEndpointUrl}/users",
Url = $@"{Endpoints.APIUrl}/users",
Method = HttpMethod.Post,
Username = username,
Email = email,

View File

@ -71,7 +71,7 @@ namespace osu.Game.Online.API
protected virtual WebRequest CreateWebRequest() => new OsuWebRequest(Uri);
protected virtual string Uri => $@"{API!.APIEndpointUrl}/api/v2/{Target}";
protected virtual string Uri => $@"{API!.Endpoints.APIUrl}/api/v2/{Target}";
protected IAPIProvider? API;

View File

@ -41,9 +41,11 @@ namespace osu.Game.Online.API
public string ProvidedUsername => LocalUser.Value.Username;
public string APIEndpointUrl => "http://localhost";
public string WebsiteRootUrl => "http://localhost";
public EndpointConfiguration Endpoints { get; } = new EndpointConfiguration
{
APIUrl = "http://localhost",
WebsiteUrl = "http://localhost",
};
public int APIVersion => int.Parse(DateTime.Now.ToString("yyyyMMdd"));

View File

@ -51,14 +51,9 @@ namespace osu.Game.Online.API
string ProvidedUsername { get; }
/// <summary>
/// The URL endpoint for this API. Does not include a trailing slash.
/// Holds configuration for online endpoints.
/// </summary>
string APIEndpointUrl { get; }
/// <summary>
/// The root URL of the website, excluding the trailing slash.
/// </summary>
string WebsiteRootUrl { get; }
EndpointConfiguration Endpoints { get; }
/// <summary>
/// The version of the API.

View File

@ -0,0 +1,26 @@
// 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.Diagnostics;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public abstract class APIUploadRequest : APIRequest
{
protected override WebRequest CreateWebRequest()
{
var request = base.CreateWebRequest();
request.UploadProgress += onUploadProgress;
return request;
}
private void onUploadProgress(long current, long total)
{
Debug.Assert(API != null);
API.Schedule(() => Progressed?.Invoke(current, total));
}
public event APIProgressHandler? Progressed;
}
}

View File

@ -0,0 +1,55 @@
// 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.Collections.Generic;
using System.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class PatchBeatmapPackageRequest : APIUploadRequest
{
protected override string Uri
{
get
{
// can be removed once the service has been successfully deployed to production
if (API!.Endpoints.BeatmapSubmissionServiceUrl == null)
throw new NotSupportedException("Beatmap submission not supported in this configuration!");
return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl!}/beatmapsets/{BeatmapSetID}";
}
}
protected override string Target => throw new NotSupportedException();
public uint BeatmapSetID { get; }
// ReSharper disable once CollectionNeverUpdated.Global
public Dictionary<string, byte[]> FilesChanged { get; } = new Dictionary<string, byte[]>();
// ReSharper disable once CollectionNeverUpdated.Global
public HashSet<string> FilesDeleted { get; } = new HashSet<string>();
public PatchBeatmapPackageRequest(uint beatmapSetId)
{
BeatmapSetID = beatmapSetId;
}
protected override WebRequest CreateWebRequest()
{
var request = base.CreateWebRequest();
request.Method = HttpMethod.Patch;
foreach ((string filename, byte[] content) in FilesChanged)
request.AddFile(@"filesChanged", content, filename);
foreach (string filename in FilesDeleted)
request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form);
request.Timeout = 60_000;
return request;
}
}
}

View File

@ -0,0 +1,82 @@
// 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.Collections.Generic;
using System.Linq;
using System.Net.Http;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using osu.Framework.IO.Network;
using osu.Framework.Localisation;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.API.Requests
{
public class PutBeatmapSetRequest : APIRequest<PutBeatmapSetResponse>
{
protected override string Uri
{
get
{
// can be removed once the service has been successfully deployed to production
if (API!.Endpoints.BeatmapSubmissionServiceUrl == null)
throw new NotSupportedException("Beatmap submission not supported in this configuration!");
return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl}/beatmapsets";
}
}
protected override string Target => throw new NotSupportedException();
[JsonProperty("beatmapset_id")]
public uint? BeatmapSetID { get; init; }
[JsonProperty("beatmaps_to_create")]
public uint BeatmapsToCreate { get; init; }
[JsonProperty("beatmaps_to_keep")]
public uint[] BeatmapsToKeep { get; init; } = [];
[JsonProperty("target")]
public BeatmapSubmissionTarget SubmissionTarget { get; init; }
private PutBeatmapSetRequest()
{
}
public static PutBeatmapSetRequest CreateNew(uint beatmapCount, BeatmapSubmissionTarget target) => new PutBeatmapSetRequest
{
BeatmapsToCreate = beatmapCount,
SubmissionTarget = target,
};
public static PutBeatmapSetRequest UpdateExisting(uint beatmapSetId, IEnumerable<uint> beatmapsToKeep, uint beatmapsToCreate, BeatmapSubmissionTarget target) => new PutBeatmapSetRequest
{
BeatmapSetID = beatmapSetId,
BeatmapsToKeep = beatmapsToKeep.ToArray(),
BeatmapsToCreate = beatmapsToCreate,
SubmissionTarget = target,
};
protected override WebRequest CreateWebRequest()
{
var req = base.CreateWebRequest();
req.Method = HttpMethod.Put;
req.ContentType = @"application/json";
req.AddRaw(JsonConvert.SerializeObject(this));
return req;
}
}
[JsonConverter(typeof(StringEnumConverter))]
public enum BeatmapSubmissionTarget
{
[LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetWIP))]
WIP,
[LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetPending))]
Pending,
}
}

View File

@ -0,0 +1,45 @@
// 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.Net.Http;
using osu.Framework.IO.Network;
namespace osu.Game.Online.API.Requests
{
public class ReplaceBeatmapPackageRequest : APIUploadRequest
{
protected override string Uri
{
get
{
// can be removed once the service has been successfully deployed to production
if (API!.Endpoints.BeatmapSubmissionServiceUrl == null)
throw new NotSupportedException("Beatmap submission not supported in this configuration!");
return $@"{API!.Endpoints.BeatmapSubmissionServiceUrl}/beatmapsets/{BeatmapSetID}";
}
}
protected override string Target => throw new NotSupportedException();
public uint BeatmapSetID { get; }
private readonly byte[] oszPackage;
public ReplaceBeatmapPackageRequest(uint beatmapSetID, byte[] oszPackage)
{
this.oszPackage = oszPackage;
BeatmapSetID = beatmapSetID;
}
protected override WebRequest CreateWebRequest()
{
var request = base.CreateWebRequest();
request.AddFile(@"beatmapArchive", oszPackage);
request.Method = HttpMethod.Put;
request.Timeout = 60_000;
return request;
}
}
}

View File

@ -0,0 +1,30 @@
// 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.Collections.Generic;
using Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class PutBeatmapSetResponse
{
[JsonProperty("beatmapset_id")]
public uint BeatmapSetId { get; set; }
[JsonProperty("beatmap_ids")]
public ICollection<uint> BeatmapIds { get; set; } = Array.Empty<uint>();
[JsonProperty("files")]
public ICollection<BeatmapSetFile> Files { get; set; } = Array.Empty<BeatmapSetFile>();
}
public struct BeatmapSetFile
{
[JsonProperty("filename")]
public string Filename { get; set; }
[JsonProperty("sha2_hash")]
public string SHA2Hash { get; set; }
}
}

View File

@ -49,12 +49,12 @@ namespace osu.Game.Online.Chat
if (url.StartsWith('/'))
{
url = $"{api.WebsiteRootUrl}{url}";
url = $"{api.Endpoints.WebsiteUrl}{url}";
isTrustedDomain = true;
}
else
{
isTrustedDomain = url.StartsWith(api.WebsiteRootUrl, StringComparison.Ordinal);
isTrustedDomain = url.StartsWith(api.Endpoints.WebsiteUrl, StringComparison.Ordinal);
}
if (!url.CheckIsValidUrl())

View File

@ -95,7 +95,7 @@ namespace osu.Game.Online.Chat
string getBeatmapPart()
{
return beatmapOnlineID > 0 ? $"[{api.WebsiteRootUrl}/b/{beatmapOnlineID} {beatmapDisplayTitle}]" : beatmapDisplayTitle;
return beatmapOnlineID > 0 ? $"[{api.Endpoints.WebsiteUrl}/b/{beatmapOnlineID} {beatmapDisplayTitle}]" : beatmapDisplayTitle;
}
string getRulesetPart()

View File

@ -7,12 +7,12 @@ namespace osu.Game.Online
{
public DevelopmentEndpointConfiguration()
{
WebsiteRootUrl = APIEndpointUrl = @"https://dev.ppy.sh";
WebsiteUrl = APIUrl = @"https://dev.ppy.sh";
APIClientSecret = @"3LP2mhUrV89xxzD1YKNndXHEhWWCRLPNKioZ9ymT";
APIClientID = "5";
SpectatorEndpointUrl = $@"{APIEndpointUrl}/signalr/spectator";
MultiplayerEndpointUrl = $@"{APIEndpointUrl}/signalr/multiplayer";
MetadataEndpointUrl = $@"{APIEndpointUrl}/signalr/metadata";
SpectatorUrl = $@"{APIUrl}/signalr/spectator";
MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer";
MetadataUrl = $@"{APIUrl}/signalr/metadata";
}
}
}

View File

@ -8,16 +8,6 @@ namespace osu.Game.Online
/// </summary>
public class EndpointConfiguration
{
/// <summary>
/// The base URL for the website.
/// </summary>
public string WebsiteRootUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the main (osu-web) API.
/// </summary>
public string APIEndpointUrl { get; set; } = string.Empty;
/// <summary>
/// The OAuth client secret.
/// </summary>
@ -28,19 +18,34 @@ namespace osu.Game.Online
/// </summary>
public string APIClientID { get; set; } = string.Empty;
/// <summary>
/// The base URL for the website. Does not include a trailing slash.
/// </summary>
public string WebsiteUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the main (osu-web) API. Does not include a trailing slash.
/// </summary>
public string APIUrl { get; set; } = string.Empty;
/// <summary>
/// The root URL for the service handling beatmap submission. Does not include a trailing slash.
/// </summary>
public string? BeatmapSubmissionServiceUrl { get; set; }
/// <summary>
/// The endpoint for the SignalR spectator server.
/// </summary>
public string SpectatorEndpointUrl { get; set; } = string.Empty;
public string SpectatorUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the SignalR multiplayer server.
/// </summary>
public string MultiplayerEndpointUrl { get; set; } = string.Empty;
public string MultiplayerUrl { get; set; } = string.Empty;
/// <summary>
/// The endpoint for the SignalR metadata server.
/// </summary>
public string MetadataEndpointUrl { get; set; } = string.Empty;
public string MetadataUrl { get; set; } = string.Empty;
}
}

View File

@ -436,7 +436,7 @@ namespace osu.Game.Online.Leaderboards
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = Score.Mods));
if (Score.OnlineID > 0)
items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{Score.OnlineID}")));
items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{Score.OnlineID}")));
if (Score.Files.Count > 0)
{

View File

@ -3,9 +3,13 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Users;
namespace osu.Game.Online.Metadata
@ -14,6 +18,9 @@ namespace osu.Game.Online.Metadata
{
public abstract IBindable<bool> IsConnected { get; }
[Resolved]
private IAPIProvider api { get; set; } = null!;
#region Beatmap metadata updates
public abstract Task<BeatmapUpdates> GetChangesSince(int queueId);
@ -32,11 +39,6 @@ namespace osu.Game.Online.Metadata
#region User presence updates
/// <summary>
/// Whether the client is currently receiving user presence updates from the server.
/// </summary>
public abstract IBindable<bool> IsWatchingUserPresence { get; }
/// <summary>
/// The <see cref="UserPresence"/> information about the current user.
/// </summary>
@ -52,31 +54,91 @@ namespace osu.Game.Online.Metadata
/// </summary>
public abstract IBindableDictionary<int, UserPresence> FriendPresences { get; }
/// <inheritdoc/>
/// <summary>
/// Attempts to retrieve the presence of a user.
/// </summary>
/// <param name="userId">The user ID.</param>
/// <returns>The user presence, or null if not available or the user's offline.</returns>
public UserPresence? GetPresence(int userId)
{
if (userId == api.LocalUser.Value.OnlineID)
return LocalUserPresence;
if (FriendPresences.TryGetValue(userId, out UserPresence presence))
return presence;
if (UserPresences.TryGetValue(userId, out presence))
return presence;
return null;
}
public abstract Task UpdateActivity(UserActivity? activity);
/// <inheritdoc/>
public abstract Task UpdateStatus(UserStatus? status);
/// <inheritdoc/>
public abstract Task BeginWatchingUserPresence();
private int userPresenceWatchCount;
/// <inheritdoc/>
public abstract Task EndWatchingUserPresence();
protected bool IsWatchingUserPresence
=> Interlocked.CompareExchange(ref userPresenceWatchCount, userPresenceWatchCount, userPresenceWatchCount) > 0;
/// <summary>
/// Signals to the server that we want to begin receiving status updates for all users.
/// </summary>
/// <returns>An <see cref="IDisposable"/> which will end the session when disposed.</returns>
public IDisposable BeginWatchingUserPresence() => new UserPresenceWatchToken(this);
Task IMetadataServer.BeginWatchingUserPresence()
{
if (Interlocked.Increment(ref userPresenceWatchCount) == 1)
return BeginWatchingUserPresenceInternal();
return Task.CompletedTask;
}
Task IMetadataServer.EndWatchingUserPresence()
{
if (Interlocked.Decrement(ref userPresenceWatchCount) == 0)
return EndWatchingUserPresenceInternal();
return Task.CompletedTask;
}
protected abstract Task BeginWatchingUserPresenceInternal();
protected abstract Task EndWatchingUserPresenceInternal();
/// <inheritdoc/>
public abstract Task UserPresenceUpdated(int userId, UserPresence? presence);
/// <inheritdoc/>
public abstract Task FriendPresenceUpdated(int userId, UserPresence? presence);
private class UserPresenceWatchToken : IDisposable
{
private readonly IMetadataServer server;
private bool isDisposed;
public UserPresenceWatchToken(IMetadataServer server)
{
this.server = server;
server.BeginWatchingUserPresence().FireAndForget();
}
public void Dispose()
{
if (isDisposed)
return;
server.EndWatchingUserPresence().FireAndForget();
isDisposed = true;
}
}
#endregion
#region Daily Challenge
public abstract IBindable<DailyChallengeInfo?> DailyChallengeInfo { get; }
/// <inheritdoc/>
public abstract Task DailyChallengeUpdated(DailyChallengeInfo? info);
#endregion

View File

@ -20,9 +20,6 @@ namespace osu.Game.Online.Metadata
{
public override IBindable<bool> IsConnected { get; } = new Bindable<bool>();
public override IBindable<bool> IsWatchingUserPresence => isWatchingUserPresence;
private readonly BindableBool isWatchingUserPresence = new BindableBool();
public override UserPresence LocalUserPresence => localUserPresence;
private UserPresence localUserPresence;
@ -50,7 +47,7 @@ namespace osu.Game.Online.Metadata
public OnlineMetadataClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.MetadataEndpointUrl;
endpoint = endpoints.MetadataUrl;
}
[BackgroundDependencyLoader]
@ -109,15 +106,18 @@ namespace osu.Game.Online.Metadata
{
Schedule(() =>
{
isWatchingUserPresence.Value = false;
userPresences.Clear();
friendPresences.Clear();
dailyChallengeInfo.Value = null;
localUserPresence = default;
});
return;
}
if (IsWatchingUserPresence)
BeginWatchingUserPresenceInternal();
if (localUser.Value is not GuestUser)
{
UpdateActivity(userActivity.Value);
@ -201,6 +201,31 @@ namespace osu.Game.Online.Metadata
return connection.InvokeAsync(nameof(IMetadataServer.UpdateStatus), status);
}
protected override Task BeginWatchingUserPresenceInternal()
{
if (connector?.IsConnected.Value != true)
return Task.CompletedTask;
Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network);
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence));
}
protected override Task EndWatchingUserPresenceInternal()
{
if (connector?.IsConnected.Value != true)
return Task.CompletedTask;
Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network);
// must be scheduled before any remote calls to avoid mis-ordering.
Schedule(() => userPresences.Clear());
Debug.Assert(connection != null);
return connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence));
}
public override Task UserPresenceUpdated(int userId, UserPresence? presence)
{
Schedule(() =>
@ -237,36 +262,6 @@ namespace osu.Game.Online.Metadata
return Task.CompletedTask;
}
public override async Task BeginWatchingUserPresence()
{
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
Debug.Assert(connection != null);
await connection.InvokeAsync(nameof(IMetadataServer.BeginWatchingUserPresence)).ConfigureAwait(false);
Schedule(() => isWatchingUserPresence.Value = true);
Logger.Log($@"{nameof(OnlineMetadataClient)} began watching user presence", LoggingTarget.Network);
}
public override async Task EndWatchingUserPresence()
{
try
{
if (connector?.IsConnected.Value != true)
throw new OperationCanceledException();
// must be scheduled before any remote calls to avoid mis-ordering.
Schedule(() => userPresences.Clear());
Debug.Assert(connection != null);
await connection.InvokeAsync(nameof(IMetadataServer.EndWatchingUserPresence)).ConfigureAwait(false);
Logger.Log($@"{nameof(OnlineMetadataClient)} stopped watching user presence", LoggingTarget.Network);
}
finally
{
Schedule(() => isWatchingUserPresence.Value = false);
}
}
public override Task DailyChallengeUpdated(DailyChallengeInfo? info)
{
Schedule(() => dailyChallengeInfo.Value = info);

View File

@ -32,7 +32,7 @@ namespace osu.Game.Online.Multiplayer
public OnlineMultiplayerClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.MultiplayerEndpointUrl;
endpoint = endpoints.MultiplayerUrl;
}
[BackgroundDependencyLoader]

View File

@ -7,12 +7,12 @@ namespace osu.Game.Online
{
public ProductionEndpointConfiguration()
{
WebsiteRootUrl = APIEndpointUrl = @"https://osu.ppy.sh";
WebsiteUrl = APIUrl = @"https://osu.ppy.sh";
APIClientSecret = @"FGc9GAtyHzeQDshWP5Ah7dega8hJACAJpQtw6OXk";
APIClientID = "5";
SpectatorEndpointUrl = "https://spectator.ppy.sh/spectator";
MultiplayerEndpointUrl = "https://spectator.ppy.sh/multiplayer";
MetadataEndpointUrl = "https://spectator.ppy.sh/metadata";
SpectatorUrl = "https://spectator.ppy.sh/spectator";
MultiplayerUrl = "https://spectator.ppy.sh/multiplayer";
MetadataUrl = "https://spectator.ppy.sh/metadata";
}
}
}

View File

@ -24,7 +24,7 @@ namespace osu.Game.Online.Spectator
public OnlineSpectatorClient(EndpointConfiguration endpoints)
{
endpoint = endpoints.SpectatorEndpointUrl;
endpoint = endpoints.SpectatorUrl;
}
[BackgroundDependencyLoader]

View File

@ -295,7 +295,7 @@ namespace osu.Game
EndpointConfiguration endpoints = CreateEndpoints();
MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl;
MessageFormatter.WebsiteRootUrl = endpoints.WebsiteUrl;
frameworkLocale = frameworkConfig.GetBindable<string>(FrameworkSetting.Locale);
frameworkLocale.BindValueChanged(_ => updateLanguage());

View File

@ -419,7 +419,7 @@ namespace osu.Game.Overlays.Comments
private void copyUrl()
{
clipboard.SetText($@"{api.APIEndpointUrl}/comments/{Comment.Id}");
clipboard.SetText($@"{api.Endpoints.APIUrl}/comments/{Comment.Id}");
onScreenDisplay?.Display(new CopyUrlToast());
}

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
@ -40,17 +38,20 @@ namespace osu.Game.Overlays.Dashboard
private readonly IBindableDictionary<int, UserPresence> onlineUserPresences = new BindableDictionary<int, UserPresence>();
private readonly Dictionary<int, OnlineUserPanel> userPanels = new Dictionary<int, OnlineUserPanel>();
private SearchContainer<OnlineUserPanel> userFlow;
private BasicSearchTextBox searchTextBox;
private SearchContainer<OnlineUserPanel> userFlow = null!;
private BasicSearchTextBox searchTextBox = null!;
[Resolved]
private IAPIProvider api { get; set; }
private IAPIProvider api { get; set; } = null!;
[Resolved]
private SpectatorClient spectatorClient { get; set; }
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private MetadataClient metadataClient { get; set; }
private MetadataClient metadataClient { get; set; } = null!;
[Resolved]
private UserLookupCache users { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
@ -99,9 +100,6 @@ namespace osu.Game.Overlays.Dashboard
searchTextBox.Current.ValueChanged += text => userFlow.SearchTerm = text.NewValue;
}
[Resolved]
private UserLookupCache users { get; set; }
protected override void LoadComplete()
{
base.LoadComplete();
@ -120,7 +118,7 @@ namespace osu.Game.Overlays.Dashboard
searchTextBox.TakeFocus();
}
private void onUserPresenceUpdated(object sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e) => Schedule(() =>
private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e) => Schedule(() =>
{
switch (e.Action)
{
@ -133,40 +131,13 @@ namespace osu.Game.Overlays.Dashboard
users.GetUserAsync(userId).ContinueWith(task =>
{
APIUser user = task.GetResultSafely();
if (user == null)
return;
Schedule(() =>
{
userFlow.Add(userPanels[userId] = createUserPanel(user).With(p =>
{
var presence = onlineUserPresences.GetValueOrDefault(userId);
p.Status.Value = presence.Status;
p.Activity.Value = presence.Activity;
}));
});
if (task.GetResultSafely() is APIUser user)
Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user)));
});
}
break;
case NotifyDictionaryChangedAction.Replace:
Debug.Assert(e.NewItems != null);
foreach (var kvp in e.NewItems)
{
if (userPanels.TryGetValue(kvp.Key, out var panel))
{
panel.Activity.Value = kvp.Value.Activity;
panel.Status.Value = kvp.Value.Status;
}
}
break;
case NotifyDictionaryChangedAction.Remove:
Debug.Assert(e.OldItems != null);
@ -181,7 +152,7 @@ namespace osu.Game.Overlays.Dashboard
}
});
private void onPlayingUsersChanged(object sender, NotifyCollectionChangedEventArgs e)
private void onPlayingUsersChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
@ -221,15 +192,12 @@ namespace osu.Game.Overlays.Dashboard
{
public readonly APIUser User;
public readonly Bindable<UserStatus?> Status = new Bindable<UserStatus?>();
public readonly Bindable<UserActivity> Activity = new Bindable<UserActivity>();
public BindableBool CanSpectate { get; } = new BindableBool();
public IEnumerable<LocalisableString> FilterTerms { get; }
[Resolved(canBeNull: true)]
private IPerformFromScreenRunner performer { get; set; }
[Resolved]
private IPerformFromScreenRunner? performer { get; set; }
public bool FilteringActive { set; get; }
@ -270,10 +238,7 @@ namespace osu.Game.Overlays.Dashboard
{
RelativeSizeAxes = Axes.X,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
// this is SHOCKING
Activity = { BindTarget = Activity },
Status = { BindTarget = Status },
Origin = Anchor.TopCentre
},
new PurpleRoundedButton
{

View File

@ -6,7 +6,6 @@ using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
using osu.Game.Online.Metadata;
using osu.Game.Online.Multiplayer;
using osu.Game.Overlays.Dashboard;
using osu.Game.Overlays.Dashboard.Friends;
@ -18,6 +17,7 @@ namespace osu.Game.Overlays
private MetadataClient metadataClient { get; set; } = null!;
private IBindable<bool> metadataConnected = null!;
private IDisposable? userPresenceWatchToken;
public DashboardOverlay()
: base(OverlayColourScheme.Purple)
@ -61,9 +61,12 @@ namespace osu.Game.Overlays
return;
if (State.Value == Visibility.Visible)
metadataClient.BeginWatchingUserPresence().FireAndForget();
userPresenceWatchToken ??= metadataClient.BeginWatchingUserPresence();
else
metadataClient.EndWatchingUserPresence().FireAndForget();
{
userPresenceWatchToken?.Dispose();
userPresenceWatchToken = null;
}
}
}
}

View File

@ -129,7 +129,7 @@ namespace osu.Game.Overlays.Login
}
};
forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.WebsiteRootUrl}/home/password-reset");
forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.Endpoints.WebsiteUrl}/home/password-reset");
password.OnCommit += (_, _) => performLogin();

View File

@ -98,7 +98,7 @@ namespace osu.Game.Overlays.Login
explainText.AddParagraph(UserVerificationStrings.BoxInfoCheckSpam);
// We can't support localisable strings with nested links yet. Not sure if we even can (probably need to allow markdown link formatting or something).
explainText.AddParagraph("If you can't access your email or have forgotten what you used, please follow the ");
explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.WebsiteRootUrl}/home/password-reset");
explainText.AddLink(UserVerificationStrings.BoxInfoRecoverLink, $"{api.Endpoints.WebsiteUrl}/home/password-reset");
explainText.AddText(". You can also ");
explainText.AddLink(UserVerificationStrings.BoxInfoReissueLink, () =>
{

View File

@ -124,12 +124,12 @@ namespace osu.Game.Overlays.Profile.Header
}
topLinkContainer.AddText("Contributed ");
topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.WebsiteRootUrl}/users/{user.Id}/posts", creationParameters: embolden);
topLinkContainer.AddLink("forum post".ToQuantity(user.PostCount, "#,##0"), $"{api.Endpoints.WebsiteUrl}/users/{user.Id}/posts", creationParameters: embolden);
addSpacer(topLinkContainer);
topLinkContainer.AddText("Posted ");
topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.WebsiteRootUrl}/comments?user_id={user.Id}", creationParameters: embolden);
topLinkContainer.AddLink("comment".ToQuantity(user.CommentsCount, "#,##0"), $"{api.Endpoints.WebsiteUrl}/comments?user_id={user.Id}", creationParameters: embolden);
string websiteWithoutProtocol = user.Website;

View File

@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
Texture = textures.Get(banner.Image),
};
Action = () => game?.OpenUrlExternally($@"{api.WebsiteRootUrl}/community/tournaments/{banner.TournamentId}");
Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/tournaments/{banner.TournamentId}");
}
protected override void LoadComplete()

View File

@ -213,7 +213,7 @@ namespace osu.Game.Overlays.Profile.Header
cover.User = user;
avatar.User = user;
usernameText.Text = user?.Username ?? string.Empty;
openUserExternally.Link = $@"{api.WebsiteRootUrl}/users/{user?.Id ?? 0}";
openUserExternally.Link = $@"{api.Endpoints.WebsiteUrl}/users/{user?.Id ?? 0}";
userFlag.CountryCode = user?.CountryCode ?? default;
userCountryText.Text = (user?.CountryCode ?? default).GetDescription();
userCountryContainer.Action = () => rankingsOverlay?.ShowCountry(user?.CountryCode ?? default);

View File

@ -223,7 +223,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent
private void addBeatmapsetLink()
=> content.AddLink(activity.Beatmapset.AsNonNull().Title, LinkAction.OpenBeatmapSet, getLinkArgument(activity.Beatmapset.AsNonNull().Url), creationParameters: t => t.Font = getLinkFont());
private object getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.WebsiteRootUrl}{url}").Argument.AsNonNull();
private object getLinkArgument(string url) => MessageFormatter.GetLinkDetails($"{api.Endpoints.WebsiteUrl}{url}").Argument.AsNonNull();
private FontUsage getLinkFont(FontWeight fontWeight = FontWeight.Regular)
=> OsuFont.GetFont(size: font_size, weight: fontWeight, italics: true);

View File

@ -54,7 +54,7 @@ namespace osu.Game.Overlays.Wiki
Padding = new MarginPadding(padding),
Child = new WikiPanelMarkdownContainer(isFullWidth)
{
CurrentPath = $@"{api.WebsiteRootUrl}/wiki/",
CurrentPath = $@"{api.Endpoints.WebsiteUrl}/wiki/",
Text = text,
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y

View File

@ -167,7 +167,7 @@ namespace osu.Game.Overlays
}
else
{
LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/{path.Value}/", response.Markdown));
LoadDisplay(articlePage = new WikiArticlePage($@"{api.Endpoints.WebsiteUrl}/wiki/{path.Value}/", response.Markdown));
}
}
@ -176,7 +176,7 @@ namespace osu.Game.Overlays
wikiData.Value = null;
path.Value = "error";
LoadDisplay(articlePage = new WikiArticlePage($@"{api.WebsiteRootUrl}/wiki/",
LoadDisplay(articlePage = new WikiArticlePage($@"{api.Endpoints.WebsiteUrl}/wiki/",
$"Something went wrong when trying to fetch page \"{originalPath}\".\n\n[Return to the main page]({INDEX_PATH})."));
}

View File

@ -0,0 +1,151 @@
// 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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Screens.Edit.Components.Menus;
namespace osu.Game.Screens.Edit
{
public partial class BookmarkController : Component, IKeyBindingHandler<GlobalAction>
{
/// <summary>
/// Bookmarks menu item (with submenu containing options). Should be added to the <see cref="Editor"/>'s global menu.
/// </summary>
public EditorMenuItem Menu { get; private set; }
[Resolved]
private EditorClock clock { get; set; } = null!;
[Resolved]
private EditorBeatmap editorBeatmap { get; set; } = null!;
[Resolved]
private IDialogOverlay? dialogOverlay { get; set; }
private readonly BindableList<int> bookmarks = new BindableList<int>();
private readonly EditorMenuItem removeBookmarkMenuItem;
private readonly EditorMenuItem seekToPreviousBookmarkMenuItem;
private readonly EditorMenuItem seekToNextBookmarkMenuItem;
private readonly EditorMenuItem resetBookmarkMenuItem;
public BookmarkController()
{
Menu = new EditorMenuItem(EditorStrings.Bookmarks)
{
Items = new MenuItem[]
{
new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime)
{
Hotkey = new Hotkey(GlobalAction.EditorAddBookmark),
},
removeBookmarkMenuItem = new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeClosestBookmark)
{
Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark)
},
seekToPreviousBookmarkMenuItem = new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1))
{
Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark)
},
seekToNextBookmarkMenuItem = new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1))
{
Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark)
},
resetBookmarkMenuItem = new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => dialogOverlay?.Push(new BookmarkResetDialog(editorBeatmap)))
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
bookmarks.BindTo(editorBeatmap.Bookmarks);
}
protected override void Update()
{
base.Update();
bool hasAnyBookmark = bookmarks.Count > 0;
bool hasBookmarkCloseEnoughForDeletion = bookmarks.Any(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000);
removeBookmarkMenuItem.Action.Disabled = !hasBookmarkCloseEnoughForDeletion;
seekToPreviousBookmarkMenuItem.Action.Disabled = !hasAnyBookmark;
seekToNextBookmarkMenuItem.Action.Disabled = !hasAnyBookmark;
resetBookmarkMenuItem.Action.Disabled = !hasAnyBookmark;
}
private void addBookmarkAtCurrentTime()
{
int bookmark = (int)clock.CurrentTimeAccurate;
int idx = bookmarks.BinarySearch(bookmark);
if (idx < 0)
bookmarks.Insert(~idx, bookmark);
}
private void removeClosestBookmark()
{
if (removeBookmarkMenuItem.Action.Disabled)
return;
int closestBookmark = bookmarks.MinBy(b => Math.Abs(b - clock.CurrentTimeAccurate));
bookmarks.Remove(closestBookmark);
}
private void seekBookmark(int direction)
{
int? targetBookmark = direction < 1
? bookmarks.Cast<int?>().LastOrDefault(b => b < clock.CurrentTimeAccurate)
: bookmarks.Cast<int?>().FirstOrDefault(b => b > clock.CurrentTimeAccurate);
if (targetBookmark != null)
clock.SeekSmoothlyTo(targetBookmark.Value);
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)
{
case GlobalAction.EditorSeekToPreviousBookmark:
seekBookmark(-1);
return true;
case GlobalAction.EditorSeekToNextBookmark:
seekBookmark(1);
return true;
}
if (e.Repeat)
return false;
switch (e.Action)
{
case GlobalAction.EditorAddBookmark:
addBookmarkAtCurrentTime();
return true;
case GlobalAction.EditorRemoveClosestBookmark:
removeClosestBookmark();
return true;
}
return false;
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
}
}

View File

@ -317,6 +317,9 @@ namespace osu.Game.Screens.Edit
workingBeatmapUpdated = true;
});
var bookmarkController = new BookmarkController();
AddInternal(bookmarkController);
OsuMenuItem undoMenuItem;
OsuMenuItem redoMenuItem;
@ -442,29 +445,7 @@ namespace osu.Game.Screens.Edit
Items = new MenuItem[]
{
new EditorMenuItem(EditorStrings.SetPreviewPointToCurrent, MenuItemType.Standard, SetPreviewPointToCurrentTime),
new EditorMenuItem(EditorStrings.Bookmarks)
{
Items = new MenuItem[]
{
new EditorMenuItem(EditorStrings.AddBookmark, MenuItemType.Standard, addBookmarkAtCurrentTime)
{
Hotkey = new Hotkey(GlobalAction.EditorAddBookmark),
},
new EditorMenuItem(EditorStrings.RemoveClosestBookmark, MenuItemType.Destructive, removeBookmarksInProximityToCurrentTime)
{
Hotkey = new Hotkey(GlobalAction.EditorRemoveClosestBookmark)
},
new EditorMenuItem(EditorStrings.SeekToPreviousBookmark, MenuItemType.Standard, () => seekBookmark(-1))
{
Hotkey = new Hotkey(GlobalAction.EditorSeekToPreviousBookmark)
},
new EditorMenuItem(EditorStrings.SeekToNextBookmark, MenuItemType.Standard, () => seekBookmark(1))
{
Hotkey = new Hotkey(GlobalAction.EditorSeekToNextBookmark)
},
new EditorMenuItem(EditorStrings.ResetBookmarks, MenuItemType.Destructive, () => dialogOverlay?.Push(new BookmarkResetDialog(editorBeatmap)))
}
}
bookmarkController.Menu,
}
}
}
@ -800,14 +781,6 @@ namespace osu.Game.Screens.Edit
case GlobalAction.EditorSeekToNextSamplePoint:
seekSamplePoint(1);
return true;
case GlobalAction.EditorSeekToPreviousBookmark:
seekBookmark(-1);
return true;
case GlobalAction.EditorSeekToNextBookmark:
seekBookmark(1);
return true;
}
if (e.Repeat)
@ -815,14 +788,6 @@ namespace osu.Game.Screens.Edit
switch (e.Action)
{
case GlobalAction.EditorAddBookmark:
addBookmarkAtCurrentTime();
return true;
case GlobalAction.EditorRemoveClosestBookmark:
removeBookmarksInProximityToCurrentTime();
return true;
case GlobalAction.EditorCloneSelection:
Clone();
return true;
@ -855,19 +820,6 @@ namespace osu.Game.Screens.Edit
return false;
}
private void addBookmarkAtCurrentTime()
{
int bookmark = (int)clock.CurrentTimeAccurate;
int idx = editorBeatmap.Bookmarks.BinarySearch(bookmark);
if (idx < 0)
editorBeatmap.Bookmarks.Insert(~idx, bookmark);
}
private void removeBookmarksInProximityToCurrentTime()
{
editorBeatmap.Bookmarks.RemoveAll(b => Math.Abs(b - clock.CurrentTimeAccurate) < 2000);
}
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
@ -1202,16 +1154,6 @@ namespace osu.Game.Screens.Edit
clock.SeekSmoothlyTo(found.StartTime);
}
private void seekBookmark(int direction)
{
int? targetBookmark = direction < 1
? editorBeatmap.Bookmarks.Cast<int?>().LastOrDefault(b => b < clock.CurrentTimeAccurate)
: editorBeatmap.Bookmarks.Cast<int?>().FirstOrDefault(b => b > clock.CurrentTimeAccurate);
if (targetBookmark != null)
clock.SeekSmoothlyTo(targetBookmark.Value);
}
private void seekSamplePoint(int direction)
{
double currentTime = clock.CurrentTimeAccurate;

View File

@ -115,7 +115,9 @@ namespace osu.Game.Screens.Edit
if (editorBeatmap.Bookmarks.Contains(newBookmark))
continue;
editorBeatmap.Bookmarks.Add(newBookmark);
int idx = editorBeatmap.Bookmarks.BinarySearch(newBookmark);
if (idx < 0)
editorBeatmap.Bookmarks.Insert(~idx, newBookmark);
}
}

View File

@ -46,14 +46,14 @@ namespace osu.Game.Screens.Edit.Submission
RelativeSizeAxes = Axes.X,
Caption = BeatmapSubmissionStrings.MappingHelpForumDescription,
ButtonText = BeatmapSubmissionStrings.MappingHelpForum,
Action = () => game?.OpenUrlExternally($@"{api.WebsiteRootUrl}/community/forums/56"),
Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/forums/56"),
},
new FormButton
{
RelativeSizeAxes = Axes.X,
Caption = BeatmapSubmissionStrings.ModdingQueuesForumDescription,
ButtonText = BeatmapSubmissionStrings.ModdingQueuesForum,
Action = () => game?.OpenUrlExternally($@"{api.WebsiteRootUrl}/community/forums/60"),
Action = () => game?.OpenUrlExternally($@"{api.Endpoints.WebsiteUrl}/community/forums/60"),
},
},
});

View File

@ -0,0 +1,212 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
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.Overlays;
using osuTK;
namespace osu.Game.Screens.Edit.Submission
{
public partial class SubmissionStageProgress : CompositeDrawable
{
public LocalisableString StageDescription { get; init; }
private Bindable<StageStatusType> status { get; } = new Bindable<StageStatusType>();
private Bindable<float?> progress { get; } = new Bindable<float?>();
private Container progressBarContainer = null!;
private Box progressBar = null!;
private Container iconContainer = null!;
private OsuTextFlowContainer errorMessage = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider)
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
InternalChildren = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Text = StageDescription,
},
new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Direction = FillDirection.Horizontal,
Spacing = new Vector2(5),
Children = new Drawable[]
{
iconContainer = new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
},
new Container
{
AutoSizeAxes = Axes.Both,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Children =
[
progressBarContainer = new Container
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Width = 150,
Height = 10,
CornerRadius = 5,
Masking = true,
Children = new[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background6,
},
progressBar = new Box
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 0,
Colour = colourProvider.Highlight1,
}
}
},
errorMessage = new OsuTextFlowContainer
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
// should really be `CentreRight` too, but that's broken due to a framework bug
// (https://github.com/ppy/osu-framework/issues/5084)
TextAnchor = Anchor.BottomRight,
Width = 450,
AutoSizeAxes = Axes.Y,
Alpha = 0,
Colour = colours.Red1,
}
]
}
}
}
};
}
protected override void LoadComplete()
{
base.LoadComplete();
status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true);
progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true);
}
public void SetNotStarted() => status.Value = StageStatusType.NotStarted;
public void SetInProgress(float? progress = null)
{
this.progress.Value = progress;
status.Value = StageStatusType.InProgress;
}
public void SetCompleted() => status.Value = StageStatusType.Completed;
public void SetFailed(string reason)
{
status.Value = StageStatusType.Failed;
errorMessage.Text = reason;
}
public void SetCanceled() => status.Value = StageStatusType.Canceled;
private const float transition_duration = 200;
private void updateProgress()
{
if (progress.Value != null)
progressBar.ResizeWidthTo(progress.Value.Value, transition_duration, Easing.OutQuint);
progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint);
}
private void updateStatus()
{
progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint);
errorMessage.FadeTo(status.Value == StageStatusType.Failed ? 1 : 0, transition_duration, Easing.OutQuint);
iconContainer.Clear();
iconContainer.ClearTransforms();
switch (status.Value)
{
case StageStatusType.InProgress:
iconContainer.Child = new LoadingSpinner
{
Size = new Vector2(16),
State = { Value = Visibility.Visible, },
};
iconContainer.Colour = colours.Orange1;
break;
case StageStatusType.Completed:
iconContainer.Child = new SpriteIcon
{
Icon = FontAwesome.Solid.CheckCircle,
Size = new Vector2(16),
};
iconContainer.Colour = colours.Green1;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
break;
case StageStatusType.Failed:
iconContainer.Child = new SpriteIcon
{
Icon = FontAwesome.Solid.ExclamationCircle,
Size = new Vector2(16),
};
iconContainer.Colour = colours.Red1;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
break;
case StageStatusType.Canceled:
iconContainer.Child = new SpriteIcon
{
Icon = FontAwesome.Solid.Ban,
Size = new Vector2(16),
};
iconContainer.Colour = colours.Gray8;
iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint);
break;
}
}
public enum StageStatusType
{
NotStarted,
InProgress,
Completed,
Failed,
Canceled,
}
}
}

View File

@ -361,7 +361,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components
return items.ToArray();
string formatRoomUrl(long id) => $@"{api.WebsiteRootUrl}/multiplayer/rooms/{id}";
string formatRoomUrl(long id) => $@"{api.Endpoints.WebsiteUrl}/multiplayer/rooms/{id}";
}
}

View File

@ -284,6 +284,13 @@ namespace osu.Game.Screens.Play
return Task.CompletedTask;
}
// zero scores should also never be submitted.
if (score.ScoreInfo.TotalScore == 0)
{
Logger.Log("Zero score, skipping score submission");
return Task.CompletedTask;
}
// mind the timing of this.
// once `scoreSubmissionSource` is created, it is presumed that submission is taking place in the background,
// so all exceptional circumstances that would disallow submission must be handled above.

View File

@ -11,13 +11,15 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.Leaderboards;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play.HUD;
using osu.Game.Users;
using osu.Game.Users.Drawables;
using osu.Game.Utils;
@ -67,7 +69,7 @@ namespace osu.Game.Screens.Ranking.Contracted
Colour = Color4.Black.Opacity(0.25f),
Type = EdgeEffectType.Shadow,
Radius = 1,
Offset = new Vector2(0, 4)
Offset = new Vector2(0, 2)
},
Children = new Drawable[]
{
@ -100,10 +102,10 @@ namespace osu.Game.Screens.Ranking.Contracted
CornerRadius = 20,
EdgeEffect = new EdgeEffectParameters
{
Colour = Color4.Black.Opacity(0.25f),
Colour = Color4.Black.Opacity(0.15f),
Type = EdgeEffectType.Shadow,
Radius = 8,
Offset = new Vector2(0, 4),
Offset = new Vector2(0, 1),
}
},
new OsuSpriteText
@ -134,14 +136,33 @@ namespace osu.Game.Screens.Ranking.Contracted
createStatistic(BeatmapsetsStrings.ShowScoreboardHeadersAccuracy, $"{score.Accuracy.FormatAccuracy()}"),
}
},
new ModFlowDisplay
new FillFlowContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
Current = { Value = score.Mods },
IconScale = 0.5f,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Full,
Spacing = new Vector2(3),
ChildrenEnumerable =
[
new DifficultyIcon(score.BeatmapInfo!, score.Ruleset)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Size = new Vector2(20),
TooltipType = DifficultyIconTooltipType.Extended,
Margin = new MarginPadding { Right = 2 }
},
..
score.Mods.AsOrdered().Select(m => new ModIcon(m)
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Scale = new Vector2(0.3f),
Margin = new MarginPadding { Top = -6 }
})
]
}
}
}

View File

@ -41,7 +41,6 @@ namespace osu.Game.Screens.Ranking.Expanded
private readonly List<StatisticDisplay> statisticDisplays = new List<StatisticDisplay>();
private FillFlowContainer starAndModDisplay;
private RollingCounter<long> scoreCounter;
[Resolved]
@ -139,12 +138,35 @@ namespace osu.Game.Screens.Ranking.Expanded
Alpha = 0,
AlwaysPresent = true
},
starAndModDisplay = new FillFlowContainer
new FillFlowContainer
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(5, 0),
Children = new Drawable[]
{
new StarRatingDisplay(beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely() ?? default)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
},
new DifficultyIcon(beatmap, score.Ruleset)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(20),
TooltipType = DifficultyIconTooltipType.Extended,
},
new ModDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
ExpansionMode = ExpansionMode.AlwaysExpanded,
Scale = new Vector2(0.5f),
Current = { Value = score.Mods }
}
}
},
new FillFlowContainer
{
@ -225,29 +247,6 @@ namespace osu.Game.Screens.Ranking.Expanded
if (score.Date != default)
AddInternal(new PlayedOnText(score.Date));
var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely();
if (starDifficulty != null)
{
starAndModDisplay.Add(new StarRatingDisplay(starDifficulty.Value)
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft
});
}
if (score.Mods.Any())
{
starAndModDisplay.Add(new ModDisplay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
ExpansionMode = ExpansionMode.AlwaysExpanded,
Scale = new Vector2(0.5f),
Current = { Value = score.Mods }
});
}
}
protected override void LoadComplete()

View File

@ -106,11 +106,9 @@ namespace osu.Game.Screens.SelectV2
private GroupDefinition? lastSelectedGroup;
private BeatmapInfo? lastSelectedBeatmap;
protected override bool HandleItemSelected(object? model)
protected override void HandleItemActivated(CarouselItem item)
{
base.HandleItemSelected(model);
switch (model)
switch (item.Model)
{
case GroupDefinition group:
// Special case collapsing an open group.
@ -118,37 +116,42 @@ namespace osu.Game.Screens.SelectV2
{
setExpansionStateOfGroup(lastSelectedGroup, false);
lastSelectedGroup = null;
return false;
return;
}
setExpandedGroup(group);
return false;
return;
case BeatmapSetInfo setInfo:
// Selecting a set isn't valid let's re-select the first difficulty.
CurrentSelection = setInfo.Beatmaps.First();
return false;
return;
case BeatmapInfo beatmapInfo:
// If we have groups, we need to account for them.
if (Criteria.SplitOutDifficulties)
{
// Find the containing group. There should never be too many groups so iterating is efficient enough.
GroupDefinition? group = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key;
if (group != null)
setExpandedGroup(group);
}
else
{
setExpandedSet(beatmapInfo);
}
return true;
CurrentSelection = beatmapInfo;
return;
}
}
return true;
protected override void HandleItemSelected(object? model)
{
base.HandleItemSelected(model);
switch (model)
{
case BeatmapSetInfo:
case GroupDefinition:
throw new InvalidOperationException("Groups should never become selected");
case BeatmapInfo beatmapInfo:
// Find any containing group. There should never be too many groups so iterating is efficient enough.
GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key;
if (containingGroup != null)
setExpandedGroup(containingGroup);
setExpandedSet(beatmapInfo);
break;
}
}
protected override bool CheckValidForGroupSelection(CarouselItem item)
@ -159,7 +162,7 @@ namespace osu.Game.Screens.SelectV2
return true;
case BeatmapInfo:
return Criteria.SplitOutDifficulties;
return !grouping.BeatmapSetsGroupedTogether;
case GroupDefinition:
return false;
@ -181,12 +184,46 @@ namespace osu.Game.Screens.SelectV2
{
if (grouping.GroupItems.TryGetValue(group, out var items))
{
foreach (var i in items)
if (expanded)
{
if (i.Model is GroupDefinition)
i.IsExpanded = expanded;
else
i.IsVisible = expanded;
foreach (var i in items)
{
switch (i.Model)
{
case GroupDefinition:
i.IsExpanded = true;
break;
case BeatmapSetInfo set:
// Case where there are set headers, header should be visible
// and items should use the set's expanded state.
i.IsVisible = true;
setExpansionStateOfSetItems(set, i.IsExpanded);
break;
default:
// Case where there are no set headers, all items should be visible.
if (!grouping.BeatmapSetsGroupedTogether)
i.IsVisible = true;
break;
}
}
}
else
{
foreach (var i in items)
{
switch (i.Model)
{
case GroupDefinition:
i.IsExpanded = false;
break;
default:
i.IsVisible = false;
break;
}
}
}
}
}
@ -263,7 +300,5 @@ namespace osu.Game.Screens.SelectV2
#endregion
}
public record GroupDefinition(string Title);
public record StarsGroupDefinition(int StarNumber);
public record GroupDefinition(object Data, string Title);
}

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Beatmaps;
@ -14,6 +13,8 @@ namespace osu.Game.Screens.SelectV2
{
public class BeatmapCarouselFilterGrouping : ICarouselFilter
{
public bool BeatmapSetsGroupedTogether { get; private set; }
/// <summary>
/// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection.
/// </summary>
@ -34,102 +35,106 @@ namespace osu.Game.Screens.SelectV2
this.getCriteria = getCriteria;
}
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken)
{
bool groupSetsTogether;
setItems.Clear();
groupItems.Clear();
var criteria = getCriteria();
var newItems = new List<CarouselItem>(items.Count());
// Add criteria groups.
switch (criteria.Group)
return await Task.Run(() =>
{
default:
groupSetsTogether = true;
newItems.AddRange(items);
break;
setItems.Clear();
groupItems.Clear();
case GroupMode.Difficulty:
groupSetsTogether = false;
int starGroup = int.MinValue;
var criteria = getCriteria();
var newItems = new List<CarouselItem>();
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
BeatmapInfo? lastBeatmap = null;
GroupDefinition? lastGroup = null;
var b = (BeatmapInfo)item.Model;
HashSet<CarouselItem>? currentGroupItems = null;
HashSet<CarouselItem>? currentSetItems = null;
if (b.StarRating > starGroup)
{
starGroup = (int)Math.Floor(b.StarRating);
var groupDefinition = new GroupDefinition($"{starGroup} - {++starGroup} *");
var groupItem = new CarouselItem(groupDefinition) { DrawHeight = GroupPanel.HEIGHT };
BeatmapSetsGroupedTogether = criteria.Group != GroupMode.Difficulty;
newItems.Add(groupItem);
groupItems[groupDefinition] = new HashSet<CarouselItem> { groupItem };
}
newItems.Add(item);
}
break;
}
// Add set headers wherever required.
CarouselItem? lastItem = null;
if (groupSetsTogether)
{
for (int i = 0; i < newItems.Count; i++)
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var item = newItems[i];
var beatmap = (BeatmapInfo)item.Model;
if (item.Model is BeatmapInfo beatmap)
if (createGroupIfRequired(criteria, beatmap, lastGroup) is GroupDefinition newGroup)
{
bool newBeatmapSet = lastItem == null || (lastItem.Model is BeatmapInfo lastBeatmap && lastBeatmap.BeatmapSet!.ID != beatmap.BeatmapSet!.ID);
// When reaching a new group, ensure we reset any beatmap set tracking.
currentSetItems = null;
lastBeatmap = null;
groupItems[newGroup] = currentGroupItems = new HashSet<CarouselItem>();
lastGroup = newGroup;
addItem(new CarouselItem(newGroup)
{
DrawHeight = GroupPanel.HEIGHT,
DepthLayer = -2,
});
}
if (BeatmapSetsGroupedTogether)
{
bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID;
if (newBeatmapSet)
{
var setItem = new CarouselItem(beatmap.BeatmapSet!) { DrawHeight = BeatmapSetPanel.HEIGHT };
setItems[beatmap.BeatmapSet!] = new HashSet<CarouselItem> { setItem };
newItems.Insert(i, setItem);
i++;
}
setItems[beatmap.BeatmapSet!] = currentSetItems = new HashSet<CarouselItem>();
setItems[beatmap.BeatmapSet!].Add(item);
item.IsVisible = false;
addItem(new CarouselItem(beatmap.BeatmapSet!)
{
DrawHeight = BeatmapSetPanel.HEIGHT,
DepthLayer = -1
});
}
}
lastItem = item;
addItem(item);
lastBeatmap = beatmap;
void addItem(CarouselItem i)
{
newItems.Add(i);
currentGroupItems?.Add(i);
currentSetItems?.Add(i);
i.IsVisible = i.Model is GroupDefinition || (lastGroup == null && (i.Model is BeatmapSetInfo || currentSetItems == null));
}
}
}
// Link group items to their headers.
GroupDefinition? lastGroup = null;
return newItems;
}, cancellationToken).ConfigureAwait(false);
}
foreach (var item in newItems)
private GroupDefinition? createGroupIfRequired(FilterCriteria criteria, BeatmapInfo beatmap, GroupDefinition? lastGroup)
{
switch (criteria.Group)
{
cancellationToken.ThrowIfCancellationRequested();
case GroupMode.Artist:
char groupChar = lastGroup?.Data as char? ?? (char)0;
char beatmapFirstChar = char.ToUpperInvariant(beatmap.Metadata.Artist[0]);
if (item.Model is GroupDefinition group)
{
lastGroup = group;
continue;
}
if (beatmapFirstChar > groupChar)
return new GroupDefinition(beatmapFirstChar, $"{beatmapFirstChar}");
if (lastGroup != null)
{
groupItems[lastGroup].Add(item);
item.IsVisible = false;
}
break;
case GroupMode.Difficulty:
int starGroup = lastGroup?.Data as int? ?? -1;
if (beatmap.StarRating > starGroup)
{
starGroup = (int)Math.Floor(beatmap.StarRating);
return new GroupDefinition(starGroup + 1, $"{starGroup} - {starGroup + 1} *");
}
break;
}
return newItems;
}, cancellationToken).ConfigureAwait(false);
return null;
}
}
}

View File

@ -36,7 +36,7 @@ namespace osu.Game.Screens.SelectV2
switch (criteria.Sort)
{
case SortMode.Artist:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.Metadata.Artist, bb.Metadata.Artist);
if (comparison == 0)
goto case SortMode.Title;
break;
@ -46,7 +46,7 @@ namespace osu.Game.Screens.SelectV2
break;
case SortMode.Title:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.Metadata.Title, bb.Metadata.Title);
break;
default:

View File

@ -57,6 +57,19 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private BeatmapCarousel? carousel { get; set; }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
var inputRectangle = DrawRectangle;
// Cover the gaps introduced by the spacing between BeatmapPanels so that clicks will not fall through the carousel.
//
// Caveat is that for simplicity, we are covering the full spacing, so panels with frontmost depth will have a slightly
// larger hit target.
inputRectangle = inputRectangle.Inflate(new MarginPadding { Vertical = BeatmapCarousel.SPACING / 2f });
return inputRectangle.Contains(ToLocalSpace(screenSpacePos));
}
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
@ -75,7 +88,7 @@ namespace osu.Game.Screens.SelectV2
InternalChild = panel = new CarouselPanelPiece(difficulty_x_offset)
{
Action = onAction,
Action = () => carousel?.Activate(Item!),
Icon = difficultyIcon = new ConstrainedIconContainer
{
Size = new Vector2(20),
@ -251,17 +264,6 @@ namespace osu.Game.Screens.SelectV2
panel.AccentColour = starRatingColour;
}
private void onAction()
{
if (carousel == null)
return;
if (carousel.CurrentSelection != Item!.Model)
carousel.CurrentSelection = Item!.Model;
else
carousel.TryActivateSelection();
}
#region ICarouselPanel
public CarouselItem? Item { get; set; }

View File

@ -1,7 +1,6 @@
// 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 System.Linq;
using osu.Framework.Allocation;
@ -59,7 +58,7 @@ namespace osu.Game.Screens.SelectV2
InternalChild = panel = new CarouselPanelPiece(set_x_offset)
{
Action = onAction,
Action = () => carousel?.Activate(Item!),
Icon = chevronIcon = new Container
{
Size = new Vector2(22),
@ -173,12 +172,6 @@ namespace osu.Game.Screens.SelectV2
difficultiesDisplay.BeatmapSet = null;
}
private void onAction()
{
if (carousel != null)
carousel.CurrentSelection = Item!.Model;
}
#region ICarouselPanel
public CarouselItem? Item { get; set; }
@ -190,8 +183,6 @@ namespace osu.Game.Screens.SelectV2
public void Activated()
{
// sets should never be activated.
throw new InvalidOperationException();
}
#endregion

View File

@ -86,7 +86,7 @@ namespace osu.Game.Screens.SelectV2
InternalChild = panel = new CarouselPanelPiece(standalone_x_offset)
{
Action = onAction,
Action = () => carousel?.Activate(Item!),
Icon = difficultyIcon = new ConstrainedIconContainer
{
Size = new Vector2(20),
@ -294,17 +294,6 @@ namespace osu.Game.Screens.SelectV2
difficultyStarRating.Current.Value = starDifficulty;
}
private void onAction()
{
if (carousel == null)
return;
if (carousel.CurrentSelection != Item!.Model)
carousel.CurrentSelection = Item!.Model;
else
carousel.TryActivateSelection();
}
#region ICarouselPanel
public CarouselItem? Item { get; set; }

View File

@ -16,6 +16,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
using osu.Framework.Logging;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
@ -89,26 +90,44 @@ namespace osu.Game.Screens.SelectV2
public object? CurrentSelection
{
get => currentSelection.Model;
set => setSelection(value);
set
{
if (currentSelection.Model != value)
{
HandleItemSelected(value);
if (currentSelection.Model != null)
HandleItemDeselected(currentSelection.Model);
currentKeyboardSelection = new Selection(value);
currentSelection = currentKeyboardSelection;
selectionValid.Invalidate();
}
else if (currentKeyboardSelection.Model != value)
{
// Even if the current selection matches, let's ensure the keyboard selection is reset
// to the newly selected object. This matches user expectations (for now).
currentKeyboardSelection = currentSelection;
selectionValid.Invalidate();
}
}
}
/// <summary>
/// Activate the current selection, if a selection exists and matches keyboard selection.
/// If keyboard selection does not match selection, this will transfer the selection on first invocation.
/// Activate the specified item.
/// </summary>
public void TryActivateSelection()
/// <param name="item"></param>
public void Activate(CarouselItem item)
{
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
CurrentSelection = currentKeyboardSelection.Model;
return;
}
// Regardless of how the item handles activation, update keyboard selection to the activated panel.
// In other words, when a panel is clicked, keyboard selection should default to matching the clicked
// item.
setKeyboardSelection(item.Model);
if (currentSelection.CarouselItem != null)
{
(GetMaterialisedDrawableForItem(currentSelection.CarouselItem) as ICarouselPanel)?.Activated();
HandleItemActivated(currentSelection.CarouselItem);
}
(GetMaterialisedDrawableForItem(item) as ICarouselPanel)?.Activated();
HandleItemActivated(item);
selectionValid.Invalidate();
}
/// <summary>
@ -176,30 +195,28 @@ namespace osu.Game.Screens.SelectV2
protected virtual bool CheckValidForGroupSelection(CarouselItem item) => true;
/// <summary>
/// Called when an item is "selected".
/// Called after an item becomes the <see cref="CurrentSelection"/>.
/// Should be used to handle any group expansion, item visibility changes, etc.
/// </summary>
/// <returns>Whether the item should be selected.</returns>
protected virtual bool HandleItemSelected(object? model) => true;
protected virtual void HandleItemSelected(object? model) { }
/// <summary>
/// Called when an item is "deselected".
/// Called when the <see cref="CurrentSelection"/> changes to a new selection.
/// Should be used to handle any group expansion, item visibility changes, etc.
/// </summary>
protected virtual void HandleItemDeselected(object? model)
{
}
protected virtual void HandleItemDeselected(object? model) { }
/// <summary>
/// Called when an item is "activated".
/// Called when an item is activated via user input (keyboard traversal or a mouse click).
/// </summary>
/// <remarks>
/// An activated item should for instance:
/// - Open or close a folder
/// - Start gameplay on a beatmap difficulty.
/// An activated item should decide to perform an action, such as:
/// - Change its expanded state (and show / hide children items).
/// - Set the item to the <see cref="CurrentSelection"/>.
/// - Start gameplay on a beatmap difficulty if already selected.
/// </remarks>
/// <param name="item">The carousel item which was activated.</param>
protected virtual void HandleItemActivated(CarouselItem item)
{
}
protected virtual void HandleItemActivated(CarouselItem item) { }
#endregion
@ -228,21 +245,16 @@ namespace osu.Game.Screens.SelectV2
private async Task performFilter()
{
Debug.Assert(SynchronizationContext.Current != null);
Stopwatch stopwatch = Stopwatch.StartNew();
var cts = new CancellationTokenSource();
lock (this)
{
cancellationSource.Cancel();
cancellationSource = cts;
}
var previousCancellationSource = Interlocked.Exchange(ref cancellationSource, cts);
await previousCancellationSource.CancelAsync().ConfigureAwait(false);
if (DebounceDelay > 0)
{
log($"Filter operation queued, waiting for {DebounceDelay} ms debounce");
await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(true);
await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(false);
}
// Copy must be performed on update thread for now (see ConfigureAwait above).
@ -266,19 +278,22 @@ namespace osu.Game.Screens.SelectV2
{
log("Cancelled due to newer request arriving");
}
}, cts.Token).ConfigureAwait(true);
}, cts.Token).ConfigureAwait(false);
if (cts.Token.IsCancellationRequested)
return;
log("Items ready for display");
carouselItems = items.ToList();
displayedRange = null;
Schedule(() =>
{
log("Items ready for display");
carouselItems = items.ToList();
displayedRange = null;
// Need to call this to ensure correct post-selection logic is handled on the new items list.
HandleItemSelected(currentSelection.Model);
// Need to call this to ensure correct post-selection logic is handled on the new items list.
HandleItemSelected(currentSelection.Model);
refreshAfterSelection();
refreshAfterSelection();
});
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
}
@ -314,7 +329,8 @@ namespace osu.Game.Screens.SelectV2
switch (e.Action)
{
case GlobalAction.Select:
TryActivateSelection();
if (currentKeyboardSelection.CarouselItem != null)
Activate(currentKeyboardSelection.CarouselItem);
return true;
case GlobalAction.SelectNext:
@ -383,32 +399,29 @@ namespace osu.Game.Screens.SelectV2
// If the user has a different keyboard selection and requests
// group selection, first transfer the keyboard selection to actual selection.
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
if (currentKeyboardSelection.CarouselItem != null && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
TryActivateSelection();
// There's a chance this couldn't resolve, at which point continue with standard traversal.
if (currentSelection.CarouselItem == currentKeyboardSelection.CarouselItem)
return;
Activate(currentKeyboardSelection.CarouselItem);
return;
}
int originalIndex;
int newIndex;
if (currentSelection.Index == null)
if (currentKeyboardSelection.Index == null)
{
// If there's no current selection, start from either end of the full list.
newIndex = originalIndex = direction > 0 ? carouselItems.Count - 1 : 0;
}
else
{
newIndex = originalIndex = currentSelection.Index.Value;
newIndex = originalIndex = currentKeyboardSelection.Index.Value;
// As a second special case, if we're group selecting backwards and the current selection isn't a group,
// make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early.
if (direction < 0)
{
while (!CheckValidForGroupSelection(carouselItems[newIndex]))
while (newIndex > 0 && !CheckValidForGroupSelection(carouselItems[newIndex]))
newIndex--;
}
}
@ -422,7 +435,7 @@ namespace osu.Game.Screens.SelectV2
if (CheckValidForGroupSelection(newItem))
{
setSelection(newItem.Model);
HandleItemActivated(newItem);
return;
}
} while (newIndex != originalIndex);
@ -437,23 +450,6 @@ namespace osu.Game.Screens.SelectV2
private Selection currentKeyboardSelection = new Selection();
private Selection currentSelection = new Selection();
private void setSelection(object? model)
{
if (currentSelection.Model == model)
return;
if (HandleItemSelected(model))
{
if (currentSelection.Model != null)
HandleItemDeselected(currentSelection.Model);
currentKeyboardSelection = new Selection(model);
currentSelection = currentKeyboardSelection;
}
selectionValid.Invalidate();
}
private void setKeyboardSelection(object? model)
{
currentKeyboardSelection = new Selection(model);
@ -559,6 +555,8 @@ namespace osu.Game.Screens.SelectV2
updateDisplayedRange(range);
}
double selectedYPos = currentSelection.CarouselItem?.CarouselYPosition ?? 0;
foreach (var panel in scroll.Panels)
{
var c = (ICarouselPanel)panel;
@ -567,8 +565,8 @@ namespace osu.Game.Screens.SelectV2
if (c.Item == null)
continue;
double selectedYPos = currentSelection?.CarouselItem?.CarouselYPosition ?? 0;
scroll.Panels.ChangeChildDepth(panel, (float)Math.Abs(c.DrawYPosition - selectedYPos));
float normalisedDepth = (float)(Math.Abs(selectedYPos - c.DrawYPosition) / DrawHeight);
scroll.Panels.ChangeChildDepth(panel, c.Item.DepthLayer + normalisedDepth);
if (c.DrawYPosition != c.Item.CarouselYPosition)
c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed);
@ -687,6 +685,15 @@ namespace osu.Game.Screens.SelectV2
carouselPanel.Expanded.Value = false;
}
protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
{
// handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed).
if (invalidation.HasFlag(Invalidation.DrawSize))
selectionValid.Invalidate();
return base.OnInvalidate(invalidation, source);
}
#endregion
#region Internal helper classes

View File

@ -29,6 +29,11 @@ namespace osu.Game.Screens.SelectV2
/// </summary>
public float DrawHeight { get; set; } = DEFAULT_HEIGHT;
/// <summary>
/// Defines the display depth relative to other <see cref="CarouselItem"/>s.
/// </summary>
public int DepthLayer { get; set; }
/// <summary>
/// Whether this item is visible or hidden.
/// </summary>

View File

@ -1,7 +1,6 @@
// 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;
@ -42,7 +41,7 @@ namespace osu.Game.Screens.SelectV2
InternalChild = panel = new CarouselPanelPiece(0)
{
Action = onAction,
Action = () => carousel?.Activate(Item!),
Icon = chevronIcon = new SpriteIcon
{
AlwaysPresent = true,
@ -126,12 +125,6 @@ namespace osu.Game.Screens.SelectV2
this.FadeInFromZero(500, Easing.OutQuint);
}
private void onAction()
{
if (carousel != null)
carousel.CurrentSelection = Item!.Model;
}
#region ICarouselPanel
public CarouselItem? Item { get; set; }
@ -143,8 +136,6 @@ namespace osu.Game.Screens.SelectV2
public void Activated()
{
// groups should never be activated.
throw new InvalidOperationException();
}
#endregion

View File

@ -778,7 +778,7 @@ namespace osu.Game.Screens.SelectV2.Leaderboards
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => SelectedMods.Value = score.Mods.Where(m => IsValidMod.Invoke(m)).ToArray()));
if (score.OnlineID > 0)
items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.WebsiteRootUrl}/scores/{score.OnlineID}")));
items.Add(new OsuMenuItem(CommonStrings.CopyLink, MenuItemType.Standard, () => clipboard?.SetText($@"{api.Endpoints.WebsiteUrl}/scores/{score.OnlineID}")));
if (score.Files.Count <= 0) return items.ToArray();

View File

@ -144,16 +144,16 @@ namespace osu.Game.Screens.SelectV2
Debug.Assert(Item != null);
StarsGroupDefinition group = (StarsGroupDefinition)Item.Model;
int starNumber = (int)((GroupDefinition)Item.Model).Data;
Color4 colour = group.StarNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(group.StarNumber);
Color4 contentColour = group.StarNumber >= 7 ? colours.Orange1 : colourProvider.Background5;
Color4 colour = starNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(starNumber);
Color4 contentColour = starNumber >= 7 ? colours.Orange1 : colourProvider.Background5;
panel.AccentColour = colour;
contentBackground.Colour = colour.Darken(0.3f);
starRatingDisplay.Current.Value = new StarDifficulty(group.StarNumber, 0);
starCounter.Current = group.StarNumber;
starRatingDisplay.Current.Value = new StarDifficulty(starNumber, 0);
starCounter.Current = starNumber;
chevronIcon.Colour = contentColour;
starCounter.Colour = contentColour;

View File

@ -12,6 +12,7 @@ using osu.Framework.Extensions.ObjectExtensions;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Metadata;
using osu.Game.Online.Spectator;
using osu.Game.Replays;
using osu.Game.Rulesets;
@ -38,6 +39,9 @@ namespace osu.Game.Screens.Spectate
[Resolved]
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private MetadataClient metadataClient { get; set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; } = null!;
@ -50,6 +54,7 @@ namespace osu.Game.Screens.Spectate
private readonly Dictionary<int, SpectatorGameplayState> gameplayStates = new Dictionary<int, SpectatorGameplayState>();
private IDisposable? realmSubscription;
private IDisposable? userWatchToken;
/// <summary>
/// Creates a new <see cref="SpectatorScreen"/>.
@ -64,6 +69,8 @@ namespace osu.Game.Screens.Spectate
{
base.LoadComplete();
userWatchToken = metadataClient.BeginWatchingUserPresence();
userLookupCache.GetUsersAsync(users.ToArray()).ContinueWith(task => Schedule(() =>
{
var foundUsers = task.GetResultSafely();
@ -282,6 +289,7 @@ namespace osu.Game.Screens.Spectate
}
realmSubscription?.Dispose();
userWatchToken?.Dispose();
}
}
}

View File

@ -16,9 +16,6 @@ namespace osu.Game.Tests.Visual.Metadata
public override IBindable<bool> IsConnected => isConnected;
private readonly BindableBool isConnected = new BindableBool(true);
public override IBindable<bool> IsWatchingUserPresence => isWatchingUserPresence;
private readonly BindableBool isWatchingUserPresence = new BindableBool();
public override UserPresence LocalUserPresence => localUserPresence;
private UserPresence localUserPresence;
@ -34,15 +31,18 @@ namespace osu.Game.Tests.Visual.Metadata
[Resolved]
private IAPIProvider api { get; set; } = null!;
public override Task BeginWatchingUserPresence()
public event Action? OnBeginWatchingUserPresence;
public event Action? OnEndWatchingUserPresence;
protected override Task BeginWatchingUserPresenceInternal()
{
isWatchingUserPresence.Value = true;
OnBeginWatchingUserPresence?.Invoke();
return Task.CompletedTask;
}
public override Task EndWatchingUserPresence()
protected override Task EndWatchingUserPresenceInternal()
{
isWatchingUserPresence.Value = false;
OnEndWatchingUserPresence?.Invoke();
return Task.CompletedTask;
}
@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Metadata
{
localUserPresence = localUserPresence with { Activity = activity };
if (isWatchingUserPresence.Value)
if (IsWatchingUserPresence)
{
if (userPresences.ContainsKey(api.LocalUser.Value.Id))
userPresences[api.LocalUser.Value.Id] = localUserPresence;
@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.Metadata
{
localUserPresence = localUserPresence with { Status = status };
if (isWatchingUserPresence.Value)
if (IsWatchingUserPresence)
{
if (userPresences.ContainsKey(api.LocalUser.Value.Id))
userPresences[api.LocalUser.Value.Id] = localUserPresence;
@ -74,7 +74,7 @@ namespace osu.Game.Tests.Visual.Metadata
public override Task UserPresenceUpdated(int userId, UserPresence? presence)
{
if (isWatchingUserPresence.Value)
if (IsWatchingUserPresence)
{
if (presence?.Status != null)
{

View File

@ -1,10 +1,8 @@
// 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.
#nullable disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -15,43 +13,51 @@ using osu.Game.Users.Drawables;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Metadata;
namespace osu.Game.Users
{
public abstract partial class ExtendedUserPanel : UserPanel
{
public readonly Bindable<UserStatus?> Status = new Bindable<UserStatus?>();
protected TextFlowContainer LastVisitMessage { get; private set; } = null!;
public readonly IBindable<UserActivity> Activity = new Bindable<UserActivity>();
private StatusIcon statusIcon = null!;
private StatusText statusMessage = null!;
protected TextFlowContainer LastVisitMessage { get; private set; }
[Resolved]
private MetadataClient? metadata { get; set; }
private StatusIcon statusIcon;
private StatusText statusMessage;
private UserStatus? lastStatus;
private UserActivity? lastActivity;
private DateTimeOffset? lastVisit;
protected ExtendedUserPanel(APIUser user)
: base(user)
{
lastVisit = user.LastVisit;
}
[BackgroundDependencyLoader]
private void load()
{
BorderColour = ColourProvider?.Light1 ?? Colours.GreyVioletLighter;
Status.ValueChanged += status => displayStatus(status.NewValue, Activity.Value);
Activity.ValueChanged += activity => displayStatus(Status.Value, activity.NewValue);
}
protected override void LoadComplete()
{
base.LoadComplete();
Status.TriggerChange();
updatePresence();
// Colour should be applied immediately on first load.
statusIcon.FinishTransforms();
}
protected override void Update()
{
base.Update();
updatePresence();
}
protected Container CreateStatusIcon() => statusIcon = new StatusIcon();
protected FillFlowContainer CreateStatusMessage(bool rightAlignedChildren)
@ -70,15 +76,6 @@ namespace osu.Game.Users
text.Origin = alignment;
text.AutoSizeAxes = Axes.Both;
text.Alpha = 0;
if (User.LastVisit.HasValue)
{
text.AddText(@"Last seen ");
text.AddText(new DrawableDate(User.LastVisit.Value, italic: false)
{
Shadow = false
});
}
}));
statusContainer.Add(statusMessage = new StatusText
@ -91,37 +88,47 @@ namespace osu.Game.Users
return statusContainer;
}
private void displayStatus(UserStatus? status, UserActivity activity = null)
private void updatePresence()
{
if (status != null)
UserPresence? presence = metadata?.GetPresence(User.OnlineID);
UserStatus status = presence?.Status ?? UserStatus.Offline;
UserActivity? activity = presence?.Activity;
if (status == lastStatus && activity == lastActivity)
return;
if (status == UserStatus.Offline && lastVisit != null)
{
LastVisitMessage.FadeTo(status == UserStatus.Offline && User.LastVisit.HasValue ? 1 : 0);
// Set status message based on activity (if we have one) and status is not offline
if (activity != null && status != UserStatus.Offline)
LastVisitMessage.FadeTo(1);
LastVisitMessage.Clear();
LastVisitMessage.AddText(@"Last seen ");
LastVisitMessage.AddText(new DrawableDate(lastVisit.Value, italic: false)
{
statusMessage.Text = activity.GetStatus();
statusMessage.TooltipText = activity.GetDetails();
statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint);
return;
}
Shadow = false
});
}
else
LastVisitMessage.FadeTo(0);
// Otherwise use only status
// Set status message based on activity (if we have one) and status is not offline
if (activity != null && status != UserStatus.Offline)
{
statusMessage.Text = activity.GetStatus();
statusMessage.TooltipText = activity.GetDetails() ?? string.Empty;
statusIcon.FadeColour(activity.GetAppropriateColour(Colours), 500, Easing.OutQuint);
}
// Otherwise use only status
else
{
statusMessage.Text = status.GetLocalisableDescription();
statusMessage.TooltipText = string.Empty;
statusIcon.FadeColour(status.Value.GetAppropriateColour(Colours), 500, Easing.OutQuint);
return;
statusIcon.FadeColour(status.GetAppropriateColour(Colours), 500, Easing.OutQuint);
}
// Fallback to web status if local one is null
if (User.IsOnline)
{
Status.Value = UserStatus.Online;
return;
}
Status.Value = UserStatus.Offline;
lastStatus = status;
lastActivity = activity;
lastVisit = status != UserStatus.Offline ? DateTimeOffset.Now : lastVisit;
}
protected override bool OnHover(HoverEvent e)

View File

@ -41,7 +41,7 @@ namespace osu.Game.Utils
{
this.game = game;
if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteRootUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal))
if (!game.IsDeployedBuild || !game.CreateEndpoints().WebsiteUrl.EndsWith(@".ppy.sh", StringComparison.Ordinal))
return;
sentrySession = SentrySdk.Init(options =>