From afce72896f437d0000ed9610be3925ccf13f2f47 Mon Sep 17 00:00:00 2001 From: Zihad Date: Thu, 27 Mar 2025 22:37:37 +0600 Subject: [PATCH 01/29] Implement blocking users from context menu --- osu.Game/Online/API/APIAccess.cs | 31 +++++++++++++++ osu.Game/Online/API/DummyAPIAccess.cs | 6 +++ osu.Game/Online/API/IAPIProvider.cs | 10 +++++ .../Online/API/Requests/BlockUserRequest.cs | 30 +++++++++++++++ .../Online/API/Requests/GetBlocksRequest.cs | 13 +++++++ .../Online/API/Requests/UnblockUserRequest.cs | 27 +++++++++++++ osu.Game/Users/UserPanel.cs | 38 +++++++++++++++++++ 7 files changed, 155 insertions(+) create mode 100644 osu.Game/Online/API/Requests/BlockUserRequest.cs create mode 100644 osu.Game/Online/API/Requests/GetBlocksRequest.cs create mode 100644 osu.Game/Online/API/Requests/UnblockUserRequest.cs diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 36712fbdaa..51fadb521a 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -58,6 +58,7 @@ namespace osu.Game.Online.API public IBindable LocalUser => localUser; public IBindableList Friends => friends; + public IBindableList Blocks => blocks; public INotificationsClient NotificationsClient { get; } @@ -66,6 +67,7 @@ namespace osu.Game.Online.API private Bindable localUser { get; } = new Bindable(createGuestUser()); private BindableList friends { get; } = new BindableList(); + private BindableList blocks { get; } = new BindableList(); protected bool HasLogin => authentication.Token.Value != null || (!string.IsNullOrEmpty(ProvidedUsername) && !string.IsNullOrEmpty(password)); @@ -638,6 +640,35 @@ namespace osu.Game.Online.API Queue(friendsReq); } + public void UpdateLocalBlocks() + { + if (!IsLoggedIn) + return; + + var blocksReq = new GetBlocksRequest(); + blocksReq.Failure += ex => + { + if (ex is not WebRequestFlushedException) + state.Value = APIState.Failing; + }; + blocksReq.Success += res => + { + var existingBlocks = blocks.Select(f => f.TargetID).ToHashSet(); + var updatedBlocks = res.Select(f => f.TargetID).ToHashSet(); + + // Add new blocked users to local list. + blocks.AddRange(res.Where(r => !existingBlocks.Contains(r.TargetID))); + + // Remove non-blocked users from local list. + blocks.RemoveAll(b => !updatedBlocks.Contains(b.TargetID)); + + // Remove friends who got blocked since last check. + friends.RemoveAll(f => updatedBlocks.Contains(f.TargetID)); + }; + + Queue(blocksReq); + } + private static APIUser createGuestUser() => new GuestUser(); protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index f9649cdd88..0c2ed9903c 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -26,6 +26,7 @@ namespace osu.Game.Online.API }); public BindableList Friends { get; } = new BindableList(); + public BindableList Blocks { get; } = new BindableList(); public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient(); INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient; @@ -180,6 +181,10 @@ namespace osu.Game.Online.API { } + public void UpdateLocalBlocks() + { + } + public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null; public IChatClient GetChatClient() => new TestChatClientConnector(this); @@ -194,6 +199,7 @@ namespace osu.Game.Online.API IBindable IAPIProvider.LocalUser => LocalUser; IBindableList IAPIProvider.Friends => Friends; + IBindableList IAPIProvider.Blocks => Blocks; /// /// Skip 2FA requirement for next login. diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 54eaaaafc2..3ab985e41f 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -23,6 +23,11 @@ namespace osu.Game.Online.API /// IBindableList Friends { get; } + /// + /// The users blocked by the local user. + /// + IBindableList Blocks { get; } + /// /// The language supplied by this provider to API requests. /// @@ -118,6 +123,11 @@ namespace osu.Game.Online.API /// void UpdateLocalFriends(); + /// + /// Update the list of users blocked by the current user. + /// + void UpdateLocalBlocks(); + /// /// Schedule a callback to run on the update thread. /// diff --git a/osu.Game/Online/API/Requests/BlockUserRequest.cs b/osu.Game/Online/API/Requests/BlockUserRequest.cs new file mode 100644 index 0000000000..bfcce075eb --- /dev/null +++ b/osu.Game/Online/API/Requests/BlockUserRequest.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class BlockUserRequest : APIRequest + { + public readonly int TargetId; + + public BlockUserRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + + req.Method = HttpMethod.Post; + req.AddParameter("target", TargetId.ToString(), RequestParameterType.Query); + + return req; + } + + protected override string Target => @"blocks"; + } +} diff --git a/osu.Game/Online/API/Requests/GetBlocksRequest.cs b/osu.Game/Online/API/Requests/GetBlocksRequest.cs new file mode 100644 index 0000000000..c16c256870 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetBlocksRequest.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetBlocksRequest : APIRequest> + { + protected override string Target => @"blocks"; + } +} diff --git a/osu.Game/Online/API/Requests/UnblockUserRequest.cs b/osu.Game/Online/API/Requests/UnblockUserRequest.cs new file mode 100644 index 0000000000..5f88631776 --- /dev/null +++ b/osu.Game/Online/API/Requests/UnblockUserRequest.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; + +namespace osu.Game.Online.API.Requests +{ + public class UnblockUserRequest : APIRequest + { + public readonly int TargetId; + + public UnblockUserRequest(int targetId) + { + TargetId = targetId; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Delete; + return req; + } + + protected override string Target => @$"blocks/{TargetId}"; + } +} diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 1010234e1f..76b7894a9e 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -14,6 +14,7 @@ using osu.Game.Overlays; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.UserInterface; using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Graphics.Containers; @@ -22,8 +23,10 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; using osu.Game.Localisation; +using osu.Game.Online.API.Requests; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; +using osu.Game.Overlays.Notifications; using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Users.Drawables; @@ -80,6 +83,11 @@ namespace osu.Game.Users [Resolved] private MetadataClient? metadataClient { get; set; } + [Resolved] + private INotificationOverlay? notifications { get; set; } + + private LoadingLayer loading { get; set; } = null!; + [BackgroundDependencyLoader] private void load() { @@ -96,6 +104,7 @@ namespace osu.Game.Users Add(background); Add(CreateLayout()); + Add(loading = new LoadingLayer(true)); base.Action = ViewProfile = () => { @@ -157,6 +166,10 @@ namespace osu.Game.Users chatOverlay?.Show(); })); + items.Add(isUserBlocked() + ? new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => blockUser(false)) + : new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => blockUser(true))); + if (isUserOnline()) { items.Add(new OsuMenuItem(ContextMenuStrings.SpectatePlayer, MenuItemType.Standard, () => @@ -179,9 +192,34 @@ namespace osu.Game.Users bool isUserOnline() => metadataClient?.GetPresence(User.OnlineID) != null; bool canInviteUser() => isUserOnline() && multiplayerClient?.Room?.Users.All(u => u.UserID != User.Id) == true; + bool isUserBlocked() => api.Blocks.Any(b => b.TargetID == User.OnlineID); } } + private void blockUser(bool block) + { + loading.Show(); + APIRequest req = block ? new BlockUserRequest(User.OnlineID) : new UnblockUserRequest(User.OnlineID); + + req.Success += () => + { + api.UpdateLocalBlocks(); + loading.Hide(); + }; + + req.Failure += e => + { + notifications?.Post(new SimpleNotification + { + Text = e.Message, + Icon = FontAwesome.Solid.Times, + }); + loading.Hide(); + }; + + api.Queue(req); + } + public IEnumerable FilterTerms => [User.Username]; public bool MatchingFilter From fbdea8f99019779e15babf2e41b9dda6da84aa70 Mon Sep 17 00:00:00 2001 From: Zihad Date: Thu, 27 Mar 2025 22:53:51 +0600 Subject: [PATCH 02/29] Add failing test --- .../Online/TestSceneCurrentlyOnlineDisplay.cs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs index a1d0d40811..8e99212bcb 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs @@ -9,6 +9,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Online.Spectator; @@ -99,6 +100,87 @@ namespace osu.Game.Tests.Visual.Online AddStep("End watching user presence", () => token.Dispose()); } + [Test] + public void TestBlockedUsersHidden() + { + IDisposable token = null!; + + AddStep("Clear blocks", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Blocks.Clear(); + }); + + AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); + AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); + AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); + + AddStep("Block online user", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Blocks.Add(new APIRelation() + { + RelationType = RelationType.Block, + TargetUser = streamingUser, + TargetID = streamingUser.Id + }); + }); + + AddAssert("Blocked user not shown", () => currentlyOnline.ChildrenOfType().All(p => p.User.Id != streamingUser.Id)); + + AddStep("Unblock online user", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Blocks.RemoveAll(b => b.TargetID == streamingUser.Id); + }); + + AddAssert("Unblocked user shown again", () => currentlyOnline.ChildrenOfType().Any(p => p.User.Id == streamingUser.Id)); + + AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); + AddStep("End watching user presence", () => token.Dispose()); + } + + [Test] + public void TestUnblockedOfflineUsersHidden() + { + IDisposable token = null!; + + AddStep("Clear blocks", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Blocks.Clear(); + }); + + AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); + AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); + AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); + + AddStep("Block online user", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Blocks.Add(new APIRelation() + { + RelationType = RelationType.Block, + TargetUser = streamingUser, + TargetID = streamingUser.Id + }); + }); + + AddAssert("Blocked user not shown", () => currentlyOnline.ChildrenOfType().All(p => p.User.Id != streamingUser.Id)); + + AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); + + AddStep("Unblock offline user", () => + { + DummyAPIAccess api = (DummyAPIAccess)API; + api.Blocks.RemoveAll(b => b.TargetID == streamingUser.Id); + }); + + AddAssert("Unblocked offline user not shown", () => currentlyOnline.ChildrenOfType().All(p => p.User.Id != streamingUser.Id)); + + AddStep("End watching user presence", () => token.Dispose()); + } + internal partial class TestUserLookupCache : UserLookupCache { private static readonly string[] usernames = From 7ca3a1895a412092da45c7db10c3c7babec59b40 Mon Sep 17 00:00:00 2001 From: Zihad Date: Thu, 27 Mar 2025 23:10:11 +0600 Subject: [PATCH 03/29] Hide blocked users from currently online --- .../Dashboard/CurrentlyOnlineDisplay.cs | 64 +++++++++++++++++-- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs index 39df3ba22c..bda23078d9 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Collections.Specialized; using System.Diagnostics; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -14,6 +16,7 @@ using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Database; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Resources.Localisation.Web; @@ -31,6 +34,7 @@ namespace osu.Game.Overlays.Dashboard private const float padding = 10; private readonly IBindableDictionary onlineUserPresences = new BindableDictionary(); + private readonly IBindableList blockedUsers = new BindableList(); private readonly Dictionary userPanels = new Dictionary(); private SearchContainer userFlow = null!; @@ -42,6 +46,9 @@ namespace osu.Game.Overlays.Dashboard [Resolved] private UserLookupCache users { get; set; } = null!; + [Resolved] + private IAPIProvider api { get; set; } = null!; + [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { @@ -95,6 +102,8 @@ namespace osu.Game.Overlays.Dashboard onlineUserPresences.BindTo(metadataClient.UserPresences); onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true); + blockedUsers.BindTo(api.Blocks); + blockedUsers.BindCollectionChanged(onBlocksUpdated); } protected override void OnFocus(FocusEvent e) @@ -104,6 +113,36 @@ namespace osu.Game.Overlays.Dashboard searchTextBox.TakeFocus(); } + private void onBlocksUpdated(object? sender, NotifyCollectionChangedEventArgs e) + { + switch (e.Action) + { + case NotifyCollectionChangedAction.Add: + Debug.Assert(e.NewItems != null); + + foreach (APIRelation block in e.NewItems.Cast()) + { + int userId = block.TargetID; + removeUserPanel(userId); + } + + break; + + case NotifyCollectionChangedAction.Remove: + Debug.Assert(e.OldItems != null); + + foreach (APIRelation block in e.OldItems) + { + int userId = block.TargetID; + if (!onlineUserPresences.ContainsKey(userId)) continue; + + addUserPanel(userId); + } + + break; + } + } + private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => { switch (e.Action) @@ -114,12 +153,9 @@ namespace osu.Game.Overlays.Dashboard foreach (var kvp in e.NewItems) { int userId = kvp.Key; + if (blockedUsers.Any(b => b.TargetID == userId)) continue; - users.GetUserAsync(userId).ContinueWith(task => - { - if (task.GetResultSafely() is APIUser user) - Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); - }); + addUserPanel(userId); } break; @@ -130,14 +166,28 @@ namespace osu.Game.Overlays.Dashboard foreach (var kvp in e.OldItems) { int userId = kvp.Key; - if (userPanels.Remove(userId, out var userPanel)) - userPanel.Expire(); + removeUserPanel(userId); } break; } }); + private void addUserPanel(int userId) + { + users.GetUserAsync(userId).ContinueWith(task => + { + if (task.GetResultSafely() is APIUser user) + Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); + }); + } + + private void removeUserPanel(int userId) + { + if (userPanels.Remove(userId, out var userPanel)) + userPanel.Expire(); + } + private OnlineUserPanel createUserPanel(APIUser user) => new OnlineUserPanel(user).With(panel => { From 68b3687315228dae283eb8ad1591e423bae166fb Mon Sep 17 00:00:00 2001 From: Zihad Date: Fri, 28 Mar 2025 16:06:49 +0600 Subject: [PATCH 04/29] Revert "Hide blocked users from currently online" This reverts commits 7ca3a1895a412092da45c7db10c3c7babec59b40 and fbdea8f99019779e15babf2e41b9dda6da84aa70. --- .../Online/TestSceneCurrentlyOnlineDisplay.cs | 82 ------------------- .../Dashboard/CurrentlyOnlineDisplay.cs | 64 ++------------- 2 files changed, 7 insertions(+), 139 deletions(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs index 8e99212bcb..a1d0d40811 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyOnlineDisplay.cs @@ -9,7 +9,6 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Database; -using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Online.Spectator; @@ -100,87 +99,6 @@ namespace osu.Game.Tests.Visual.Online AddStep("End watching user presence", () => token.Dispose()); } - [Test] - public void TestBlockedUsersHidden() - { - IDisposable token = null!; - - AddStep("Clear blocks", () => - { - DummyAPIAccess api = (DummyAPIAccess)API; - api.Blocks.Clear(); - }); - - AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); - AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); - AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); - - AddStep("Block online user", () => - { - DummyAPIAccess api = (DummyAPIAccess)API; - api.Blocks.Add(new APIRelation() - { - RelationType = RelationType.Block, - TargetUser = streamingUser, - TargetID = streamingUser.Id - }); - }); - - AddAssert("Blocked user not shown", () => currentlyOnline.ChildrenOfType().All(p => p.User.Id != streamingUser.Id)); - - AddStep("Unblock online user", () => - { - DummyAPIAccess api = (DummyAPIAccess)API; - api.Blocks.RemoveAll(b => b.TargetID == streamingUser.Id); - }); - - AddAssert("Unblocked user shown again", () => currentlyOnline.ChildrenOfType().Any(p => p.User.Id == streamingUser.Id)); - - AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); - AddStep("End watching user presence", () => token.Dispose()); - } - - [Test] - public void TestUnblockedOfflineUsersHidden() - { - IDisposable token = null!; - - AddStep("Clear blocks", () => - { - DummyAPIAccess api = (DummyAPIAccess)API; - api.Blocks.Clear(); - }); - - AddStep("Begin watching user presence", () => token = metadataClient.BeginWatchingUserPresence()); - AddStep("Add online user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, new UserPresence { Status = UserStatus.Online, Activity = new UserActivity.InSoloGame() })); - AddUntilStep("Panel loaded", () => currentlyOnline.ChildrenOfType().FirstOrDefault()?.User.Id == streamingUser.Id); - - AddStep("Block online user", () => - { - DummyAPIAccess api = (DummyAPIAccess)API; - api.Blocks.Add(new APIRelation() - { - RelationType = RelationType.Block, - TargetUser = streamingUser, - TargetID = streamingUser.Id - }); - }); - - AddAssert("Blocked user not shown", () => currentlyOnline.ChildrenOfType().All(p => p.User.Id != streamingUser.Id)); - - AddStep("Remove playing user", () => metadataClient.UserPresenceUpdated(streamingUser.Id, null)); - - AddStep("Unblock offline user", () => - { - DummyAPIAccess api = (DummyAPIAccess)API; - api.Blocks.RemoveAll(b => b.TargetID == streamingUser.Id); - }); - - AddAssert("Unblocked offline user not shown", () => currentlyOnline.ChildrenOfType().All(p => p.User.Id != streamingUser.Id)); - - AddStep("End watching user presence", () => token.Dispose()); - } - internal partial class TestUserLookupCache : UserLookupCache { private static readonly string[] usernames = diff --git a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs index bda23078d9..39df3ba22c 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyOnlineDisplay.cs @@ -2,9 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Collections.Specialized; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -16,7 +14,6 @@ using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Resources.Localisation.Web; @@ -34,7 +31,6 @@ namespace osu.Game.Overlays.Dashboard private const float padding = 10; private readonly IBindableDictionary onlineUserPresences = new BindableDictionary(); - private readonly IBindableList blockedUsers = new BindableList(); private readonly Dictionary userPanels = new Dictionary(); private SearchContainer userFlow = null!; @@ -46,9 +42,6 @@ namespace osu.Game.Overlays.Dashboard [Resolved] private UserLookupCache users { get; set; } = null!; - [Resolved] - private IAPIProvider api { get; set; } = null!; - [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { @@ -102,8 +95,6 @@ namespace osu.Game.Overlays.Dashboard onlineUserPresences.BindTo(metadataClient.UserPresences); onlineUserPresences.BindCollectionChanged(onUserPresenceUpdated, true); - blockedUsers.BindTo(api.Blocks); - blockedUsers.BindCollectionChanged(onBlocksUpdated); } protected override void OnFocus(FocusEvent e) @@ -113,36 +104,6 @@ namespace osu.Game.Overlays.Dashboard searchTextBox.TakeFocus(); } - private void onBlocksUpdated(object? sender, NotifyCollectionChangedEventArgs e) - { - switch (e.Action) - { - case NotifyCollectionChangedAction.Add: - Debug.Assert(e.NewItems != null); - - foreach (APIRelation block in e.NewItems.Cast()) - { - int userId = block.TargetID; - removeUserPanel(userId); - } - - break; - - case NotifyCollectionChangedAction.Remove: - Debug.Assert(e.OldItems != null); - - foreach (APIRelation block in e.OldItems) - { - int userId = block.TargetID; - if (!onlineUserPresences.ContainsKey(userId)) continue; - - addUserPanel(userId); - } - - break; - } - } - private void onUserPresenceUpdated(object? sender, NotifyDictionaryChangedEventArgs e) => Schedule(() => { switch (e.Action) @@ -153,9 +114,12 @@ namespace osu.Game.Overlays.Dashboard foreach (var kvp in e.NewItems) { int userId = kvp.Key; - if (blockedUsers.Any(b => b.TargetID == userId)) continue; - addUserPanel(userId); + users.GetUserAsync(userId).ContinueWith(task => + { + if (task.GetResultSafely() is APIUser user) + Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); + }); } break; @@ -166,28 +130,14 @@ namespace osu.Game.Overlays.Dashboard foreach (var kvp in e.OldItems) { int userId = kvp.Key; - removeUserPanel(userId); + if (userPanels.Remove(userId, out var userPanel)) + userPanel.Expire(); } break; } }); - private void addUserPanel(int userId) - { - users.GetUserAsync(userId).ContinueWith(task => - { - if (task.GetResultSafely() is APIUser user) - Schedule(() => userFlow.Add(userPanels[userId] = createUserPanel(user))); - }); - } - - private void removeUserPanel(int userId) - { - if (userPanels.Remove(userId, out var userPanel)) - userPanel.Expire(); - } - private OnlineUserPanel createUserPanel(APIUser user) => new OnlineUserPanel(user).With(panel => { From a675a5cfd569b873723de343d1f3b6054cf23b9c Mon Sep 17 00:00:00 2001 From: Zihad Date: Fri, 28 Mar 2025 16:13:04 +0600 Subject: [PATCH 05/29] Fix failing tests Now that `UserPanel` also has a `LoadingSpinner`, we need to use `.First` instead of `.Single` here. --- osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 25611cf8d5..52905fe5da 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -218,7 +218,7 @@ namespace osu.Game.Tests.Visual.Online } private void waitForLoad() - => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + => AddUntilStep("wait for panels to load", () => this.ChildrenOfType().First().State.Value, () => Is.EqualTo(Visibility.Hidden)); private void assertVisiblePanelCount(int expectedVisible) where T : UserPanel From dfd226394de4f2e3bff6d1a38767846c1c282497 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 18 Apr 2025 05:05:56 -0400 Subject: [PATCH 06/29] Add beatmap title wedge statistic display --- .../TestSceneBeatmapTitleWedgeStatistic.cs | 74 +++++++++ .../SelectV2/BeatmapTitleWedge_Statistic.cs | 151 ++++++++++++++++++ .../BeatmapTitleWedge_StatisticPlayCount.cs | 144 +++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs new file mode 100644 index 0000000000..96eab3e8ec --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Visual.UserInterface; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapTitleWedgeStatistic : ThemeComparisonTestScene + { + private BeatmapTitleWedge.StatisticPlayCount playCount = null!; + private BeatmapTitleWedge.Statistic statistic2 = null!; + private BeatmapTitleWedge.Statistic statistic3 = null!; + private BeatmapTitleWedge.Statistic statistic4 = null!; + + public TestSceneBeatmapTitleWedgeStatistic() + : base(false) + { + } + + [Test] + public void TestLoading() + { + AddStep("setup", () => CreateThemedContent(OverlayColourScheme.Aquamarine)); + AddStep("set loading", () => this.ChildrenOfType().ForEach(s => s.Value = null)); + AddWaitStep("wait", 3); + AddStep("set values", () => + { + playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(1234, 12); + statistic2.Value = "3,234"; + statistic3.Value = "12:34"; + statistic4.Value = "123"; + }); + } + + protected override Drawable CreateContent() => new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Both, + Children = new[] + { + playCount = new BeatmapTitleWedge.StatisticPlayCount(true, minSize: 50) + { + Value = new BeatmapTitleWedge.StatisticPlayCount.Data(1234, 12), + }, + statistic2 = new BeatmapTitleWedge.Statistic(OsuIcon.Clock, true, minSize: 30) + { + Value = "3,234", + TooltipText = "Statistic 2", + }, + statistic3 = new BeatmapTitleWedge.Statistic(OsuIcon.Metronome) + { + Value = "12:34", + Margin = new MarginPadding { Right = 10f }, + TooltipText = "Statistic 3", + }, + statistic4 = new BeatmapTitleWedge.Statistic(OsuIcon.Graphics) + { + Value = "123", + TooltipText = "Statistic 4", + }, + }, + }; + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs new file mode 100644 index 0000000000..b4ec72761f --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs @@ -0,0 +1,151 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class Statistic : CompositeDrawable, IHasTooltip + { + private readonly IconUsage icon; + private readonly bool background; + private readonly float leftPadding; + private readonly float? minSize; + + private OsuSpriteText valueText = null!; + private LoadingSpinner loading = null!; + + private LocalisableString? value; + + public LocalisableString? Value + { + get => value; + set + { + this.value = value; + + Schedule(() => + { + loading.State.Value = value != null ? Visibility.Hidden : Visibility.Visible; + + if (value != null) + { + valueText.Text = value.Value; + valueText.FadeIn(120, Easing.OutQuint); + } + else + valueText.FadeOut(120, Easing.OutQuint); + }); + } + } + + public LocalisableString TooltipText { get; set; } + + public Statistic(IconUsage icon, bool background = false, float leftPadding = 10f, float? minSize = null) + { + this.icon = icon; + this.background = background; + this.leftPadding = leftPadding; + this.minSize = minSize; + + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 5; + Shear = background ? OsuGame.SHEAR : Vector2.Zero; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + Alpha = background ? 0.2f : 0f, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = background ? leftPadding : 0, Right = background ? 10f : 0f, Vertical = 5f }, + Spacing = new Vector2(4f, 0f), + Shear = background ? -OsuGame.SHEAR : Vector2.Zero, + Children = new Drawable[] + { + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = icon, + Size = new Vector2(OsuFont.Style.Heading2.Size), + Colour = colourProvider.Content2, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + loading = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(14f), + State = { Value = Visibility.Visible }, + }, + new GridContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: minSize ?? 0), + }, + Content = new[] + { + new[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Heading2, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Bottom = 2f }, + AlwaysPresent = true, + }, + } + } + }, + }, + }, + }, + } + }; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs new file mode 100644 index 0000000000..2d480ad5f4 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs @@ -0,0 +1,144 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class StatisticPlayCount : Statistic, IHasCustomTooltip + { + public new Data? Value + { + set + { + base.Value = value?.Total < 0 ? "-" : value?.Total.ToLocalisableString("N0"); + TooltipContent = value; + } + } + + public Data? TooltipContent { get; private set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public StatisticPlayCount(bool background = false, float leftPadding = 10, float? minSize = null) + : base(OsuIcon.Play, background, leftPadding, minSize) + { + } + + ITooltip IHasCustomTooltip.GetCustomTooltip() => new PlayCountTooltip(colourProvider); + + public record Data(int Total, int User); + + private partial class PlayCountTooltip : VisibilityContainer, ITooltip + { + private readonly OverlayColourProvider colourProvider; + + private OsuSpriteText totalPlaysText = null!; + private OsuSpriteText personalPlaysText = null!; + + public PlayCountTooltip(OverlayColourProvider colourProvider) + { + this.colourProvider = colourProvider; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + CornerRadius = 10; + Masking = true; + + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Colour = Color4.Black.Opacity(0.25f), + Radius = 10f, + }; + + InternalChildren = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding(10), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(16f, 0f), + Children = new[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = "Total Plays", + }, + totalPlaysText = new OsuSpriteText + { + Colour = colourProvider.Content1, + Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular), + }, + } + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new[] + { + new OsuSpriteText + { + Colour = colourProvider.Content2, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Text = "Personal Plays", + }, + personalPlaysText = new OsuSpriteText + { + Colour = colourProvider.Content1, + Font = OsuFont.Style.Heading1.With(weight: FontWeight.Regular), + }, + } + }, + } + }, + }; + } + + public void SetContent(Data content) + { + totalPlaysText.Text = content.Total < 0 ? "-" : content.Total.ToLocalisableString("N0"); + personalPlaysText.Text = content.User < 0 ? "-" : content.User.ToLocalisableString("N0"); + } + + public void Move(Vector2 pos) => Position = pos; + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + } + } + } +} From a870a71b4b61c5d0888a16aa71835b2d0f57cb0c Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 18 Apr 2025 05:09:16 -0400 Subject: [PATCH 07/29] Add beatmap title wedge difficulty statistics --- .../TestSceneDifficultyStatisticsDisplay.cs | 166 ++++++++++++++ ...pTitleWedge_DifficultyStatisticsDisplay.cs | 205 ++++++++++++++++++ .../BeatmapTitleWedge_StatisticDifficulty.cs | 196 +++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs new file mode 100644 index 0000000000..3dd6fed708 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneDifficultyStatisticsDisplay.cs @@ -0,0 +1,166 @@ +// Copyright (c) ppy Pty Ltd . 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.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Overlays; +using osu.Game.Screens.SelectV2; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneDifficultyStatisticsDisplay : OsuTestScene + { + private Container displayContainer = null!; + private BeatmapTitleWedge.DifficultyStatisticsDisplay display = null!; + + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("setup", () => + { + Child = displayContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 300, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black, + }, + display = new BeatmapTitleWedge.DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.5f, 0.5f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.7f, 0.7f, 1f), + } + } + } + }; + }); + AddSliderStep("display width", 0, 300, 300, v => + { + if (displayContainer.IsNotNull()) + displayContainer.Width = v; + }); + } + + [Test] + public void TestEmpty() + { + AddStep("set empty", () => display.Statistics = Array.Empty()); + AddAssert("no statistics", () => !display.ChildrenOfType().Any()); + AddAssert("no tiny statistics", () => !display.ChildrenOfType().Single().Content.Any()); + } + + [Test] + public void TestDisplay() + { + AddStep("change data with same labels", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.7f, 0.7f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.4f, 0.8f, 1f), + }); + + AddStep("change data with different labels", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 4", 0.3f, 0.3f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 5", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 6", 0.5f, 0.5f, 1f), + }); + + AddAssert("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("shrink width", () => displayContainer.Width = 100); + AddAssert("statistics hidden", () => display.ChildrenOfType().First().Parent!.Alpha == 0); + AddUntilStep("tiny statistics displayed", () => display.ChildrenOfType().Last().Alpha == 1); + } + + [Test] + public void TestContraction() + { + AddAssert("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("set too many statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.7f, 0.7f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.4f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 4", 0.3f, 0.3f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 5", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 6", 0.5f, 0.5f, 1f), + }); + + AddAssert("statistics hidden", () => display.ChildrenOfType().First().Parent!.Alpha == 0); + AddUntilStep("tiny statistics displayed", () => display.ChildrenOfType().Last().Alpha == 1); + + AddStep("set less statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + }); + + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + AddUntilStep("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + } + + [Test] + public void TestAutoSize() + { + AddStep("setup auto size", () => Child = display = new BeatmapTitleWedge.DifficultyStatisticsDisplay(true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.5f, 0.5f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.7f, 0.7f, 1f), + } + }); + + AddAssert("statistics visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("set too many statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 2", 0.7f, 0.7f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 3", 0.4f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 4", 0.3f, 0.3f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 5", 0.8f, 0.8f, 1f), + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 6", 0.5f, 0.5f, 1f), + }); + + AddAssert("statistics still visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics still hidden", () => display.ChildrenOfType().Last().Alpha == 0); + + AddStep("set less statistics", () => display.Statistics = new[] + { + new BeatmapTitleWedge.StatisticDifficulty.Data("Statistic 1", 0.2f, 0.2f, 1f), + }); + + AddAssert("statistics still visible", () => display.ChildrenOfType().First().Parent!.Alpha == 1); + AddAssert("tiny statistics still hidden", () => display.ChildrenOfType().Last().Alpha == 0); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs new file mode 100644 index 0000000000..1cafe1c6db --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs @@ -0,0 +1,205 @@ +// Copyright (c) ppy Pty Ltd . 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class DifficultyStatisticsDisplay : CompositeDrawable, IHasAccentColour + { + private readonly bool autoSize; + private readonly FillFlowContainer statisticsFlow; + private readonly GridContainer tinyStatisticsGrid; + + private IReadOnlyList statistics = Array.Empty(); + + public IReadOnlyList Statistics + { + get => statistics; + set + { + statistics = value; + + if (IsLoaded) + { + updateStatistics(); + updateTinyStatistics(); + } + } + } + + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + foreach (var statistic in statisticsFlow) + statistic.AccentColour = value; + } + } + + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public DifficultyStatisticsDisplay(bool autoSize = false) + { + this.autoSize = autoSize; + + if (autoSize) + AutoSizeAxes = Axes.Both; + else + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + statisticsFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(8f, 0f), + Direction = FillDirection.Horizontal, + AlwaysPresent = true, + }, + tinyStatisticsGrid = new GridContainer + { + Alpha = 0f, + AutoSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 8), + new Dimension(GridSizeMode.AutoSize), + } + }, + }; + + AddLayout(drawSizeLayout); + } + + [Resolved] + private LocalisationManager localisations { get; set; } = null!; + + private IBindable? localisationParameters; + + protected override void LoadComplete() + { + base.LoadComplete(); + + localisationParameters = localisations.CurrentParameters.GetBoundCopy(); + localisationParameters.BindValueChanged(_ => updateStatisticsSizing()); + + updateStatistics(); + updateTinyStatistics(); + } + + protected override void Update() + { + base.Update(); + + if (!drawSizeLayout.IsValid) + { + updateLayout(); + drawSizeLayout.Validate(); + } + } + + private bool displayedTinyStatistics; + + private void updateLayout() + { + if (statisticsFlow.Count == 0) + return; + + float flowWidth = statisticsFlow[0].Width * statisticsFlow.Count + statisticsFlow.Spacing.X * (statisticsFlow.Count - 1); + bool tiny = !autoSize && DrawWidth < flowWidth; + + if (displayedTinyStatistics != tiny) + { + if (tiny) + { + statisticsFlow.Hide(); + tinyStatisticsGrid.FadeIn(200, Easing.InQuint); + } + else + { + tinyStatisticsGrid.Hide(); + statisticsFlow.FadeIn(200, Easing.InQuint); + } + + displayedTinyStatistics = tiny; + } + } + + private void updateStatisticsSizing() => SchedulerAfterChildren.AddOnce(() => + { + if (statisticsFlow.Count == 0) + return; + + float statisticWidth = Math.Max(65, statisticsFlow.Max(s => s.LabelWidth)); + + foreach (var statistic in statisticsFlow) + statistic.Width = statisticWidth; + + drawSizeLayout.Invalidate(); + }); + + private void updateStatistics() + { + var oldStatistics = statisticsFlow.Select(s => s.Value).ToArray(); + + if (oldStatistics.Select(s => s.Label).SequenceEqual(statistics.Select(s => s.Label))) + { + for (int i = 0; i < statistics.Count; i++) + statisticsFlow[i].Value = statistics[i]; + } + else + { + statisticsFlow.ChildrenEnumerable = statistics.Select(d => new StatisticDifficulty { Value = d }); + updateStatisticsSizing(); + } + } + + private void updateTinyStatistics() + { + tinyStatisticsGrid.RowDimensions = statistics.Select(_ => new Dimension(GridSizeMode.AutoSize)).ToArray(); + tinyStatisticsGrid.Content = statistics.Select(s => new[] + { + new OsuSpriteText + { + Text = s.Label, + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + Colour = colourProvider.Content2, + }, + Empty(), + new OsuSpriteText + { + Font = OsuFont.Style.Caption2.With(weight: FontWeight.SemiBold), + Text = s.Content ?? s.Value.ToLocalisableString("0.##"), + Colour = colourProvider.Content1, + }, + }).ToArray(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs new file mode 100644 index 0000000000..b533d21c1e --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticDifficulty.cs @@ -0,0 +1,196 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +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.Sprites; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class StatisticDifficulty : CompositeDrawable, IHasAccentColour + { + private Data value = new Data(string.Empty, 0, 0, 0); + + public Data Value + { + get => value; + set + { + this.value = value; + + if (IsLoaded) + updateDisplay(); + } + } + + public float LabelWidth => labelText.DrawWidth; + + private readonly Circle bar; + private readonly Circle adjustedBar; + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText valueText; + private readonly SpriteIcon valueIcon; + private readonly Container bars; + + public Color4 AccentColour + { + get => bar.Colour; + set => bar.Colour = value; + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public StatisticDifficulty() + { + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + bars = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + new Circle + { + RelativeSizeAxes = Axes.X, + Height = 2f, + Colour = Color4.Black, + Masking = true, + CornerRadius = 1f, + Depth = float.MaxValue, + }, + bar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 2f, + Masking = true, + CornerRadius = 1f, + }, + adjustedBar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 2f, + Masking = true, + CornerRadius = 1f, + }, + }, + }, + labelText = new OsuSpriteText + { + Margin = new MarginPadding { Top = 2f }, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Font = OsuFont.Style.Body, + }, + valueIcon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding + { + Top = -4f, + Left = 2, + }, + Size = new Vector2(8), + } + }, + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colourProvider.Content2; + valueText.Colour = colourProvider.Content1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateDisplay(); + } + + private void updateDisplay() + { + bar.ResizeWidthTo(value.Maximum == 0 ? 0 : Math.Clamp(value.Value / value.Maximum, 0, 1), 300, Easing.OutQuint); + adjustedBar.ResizeWidthTo(value.Maximum == 0 ? 0 : Math.Clamp(value.AdjustedValue / value.Maximum, 0, 1), 300, Easing.OutQuint); + + labelText.Text = value.Label; + valueText.Text = value.Content ?? value.AdjustedValue.ToLocalisableString("0.##"); + + if (value.Value == value.AdjustedValue) + { + adjustedBar.FadeColour(Color4.Transparent, 300, Easing.OutQuint); + bar.FadeIn(300, Easing.OutQuint); + + valueText.FadeColour(Color4.White, 300, Easing.OutQuint); + valueIcon.Hide(); + } + else + { + bool difficultyIncrease = value.Value < value.AdjustedValue; + + if (difficultyIncrease) + { + bars.ChangeChildDepth(adjustedBar, 1); + bar.FadeIn(300, Easing.OutQuint); + adjustedBar.FadeColour(ColourInfo.GradientHorizontal(Color4.Black, colours.Red1), 300, Easing.OutQuint); + + valueText.FadeColour(colours.Red1, 300, Easing.OutQuint); + valueIcon.Show(); + valueIcon.Colour = colours.Red1; + valueIcon.Icon = FontAwesome.Solid.SortUp; + } + else + { + bar.FadeTo(0.5f, 300, Easing.OutQuint); + bars.ChangeChildDepth(adjustedBar, -1); + adjustedBar.FadeColour(colours.Lime1, 300, Easing.OutQuint); + + valueText.FadeColour(colours.Lime1, 300, Easing.OutQuint); + valueIcon.Show(); + valueIcon.Colour = colours.Lime1; + valueIcon.Icon = FontAwesome.Solid.SortDown; + } + } + } + + public record Data(LocalisableString Label, float Value, float AdjustedValue, float Maximum, string? Content = null); + } + } +} From 04fa95d924ffa6a0598aa4e8f7b6a84525f5c7ae Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 18 Apr 2025 05:09:23 -0400 Subject: [PATCH 08/29] Add beatmap title wedge --- .../TestSceneBeatmapTitleWedge.cs | 161 +++++++ .../Mods/AdjustedAttributesTooltip.cs | 8 +- .../Screens/SelectV2/BeatmapTitleWedge.cs | 324 +++++++++++++++ .../BeatmapTitleWedge_DifficultyDisplay.cs | 392 ++++++++++++++++++ 4 files changed, 884 insertions(+), 1 deletion(-) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs new file mode 100644 index 0000000000..8a674d43a5 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -0,0 +1,161 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Visual.SongSelect; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapTitleWedge : SongSelectComponentsTestScene + { + private RulesetStore rulesets = null!; + + private BeatmapTitleWedge titleWedge = null!; + private BeatmapTitleWedge.DifficultyDisplay difficultyDisplay => titleWedge.ChildrenOfType().Single(); + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + this.rulesets = rulesets; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + AddRange(new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + titleWedge = new BeatmapTitleWedge + { + State = { Value = Visibility.Visible }, + }, + }, + } + }); + + AddSliderStep("change star difficulty", 0, 11.9, 4.18, v => + { + ((BindableDouble)difficultyDisplay.DisplayedStars).Value = v; + }); + } + + [Test] + public void TestNullBeatmap() + { + selectBeatmap(null); + AddAssert("check default title", () => titleWedge.DisplayedTitle == Beatmap.Default.BeatmapInfo.Metadata.Title); + AddAssert("check default artist", () => titleWedge.DisplayedArtist == Beatmap.Default.BeatmapInfo.Metadata.Artist); + AddAssert("check empty version", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedVersion.ToString())); + AddAssert("check empty author", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedAuthor.ToString())); + AddAssert("check no statistics", () => difficultyDisplay.ChildrenOfType().All(d => !d.Statistics.Any())); + } + + [Test] + public void TestBPMUpdates() + { + const double bpm = 120; + IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / bpm }); + + OsuModDoubleTime doubleTime = null!; + + selectBeatmap(beatmap); + checkDisplayedBPM($"{bpm}"); + + AddStep("select DT", () => SelectedMods.Value = new[] { doubleTime = new OsuModDoubleTime() }); + checkDisplayedBPM($"{bpm * 1.5f}"); + + AddStep("change DT rate", () => doubleTime.SpeedChange.Value = 2); + checkDisplayedBPM($"{bpm * 2}"); + + AddStep("select HT", () => SelectedMods.Value = new[] { new OsuModHalfTime() }); + checkDisplayedBPM($"{bpm * 0.75f}"); + } + + [Test] + public void TestRulesetChange() + { + selectBeatmap(Beatmap.Value.Beatmap); + + AddWaitStep("wait for select", 3); + + foreach (var rulesetInfo in rulesets.AvailableRulesets) + { + var testBeatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(rulesetInfo); + + setRuleset(rulesetInfo); + selectBeatmap(testBeatmap); + } + } + + [Test] + public void TestWedgeVisibility() + { + AddStep("hide", () => { titleWedge.Hide(); }); + AddWaitStep("wait for hide", 3); + AddAssert("check visibility", () => titleWedge.Alpha == 0); + AddStep("show", () => { titleWedge.Show(); }); + AddWaitStep("wait for show", 1); + AddAssert("check visibility", () => titleWedge.Alpha > 0); + } + + [TestCase(120, 125, null, "120-125 (mostly 120)")] + [TestCase(120, 120.6, null, "120-121 (mostly 120)")] + [TestCase(120, 120.4, null, "120")] + [TestCase(120, 120.6, "DT", "180-182 (mostly 180)")] + [TestCase(120, 120.4, "DT", "180")] + public void TestVaryingBPM(double commonBpm, double otherBpm, string? mod, string expectedDisplay) + { + IBeatmap beatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(new OsuRuleset().RulesetInfo); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); + beatmap.ControlPointInfo.Add(100, new TimingControlPoint { BeatLength = 60 * 1000 / otherBpm }); + beatmap.ControlPointInfo.Add(200, new TimingControlPoint { BeatLength = 60 * 1000 / commonBpm }); + + if (mod != null) + AddStep($"select {mod}", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateModFromAcronym(mod) }); + + selectBeatmap(beatmap); + checkDisplayedBPM(expectedDisplay); + } + + private void setRuleset(RulesetInfo rulesetInfo) + { + AddStep("set ruleset", () => Ruleset.Value = rulesetInfo); + } + + private void selectBeatmap(IBeatmap? b) + { + AddStep($"select {b?.Metadata.Title ?? "null"} beatmap", () => + { + Beatmap.Value = b == null ? Beatmap.Default : CreateWorkingBeatmap(b); + }); + } + + private void checkDisplayedBPM(string target) + { + AddUntilStep($"displayed bpm is {target}", () => + { + var label = titleWedge.ChildrenOfType().Single(l => l.TooltipText == BeatmapsetsStrings.ShowStatsBpm); + return label.Value == target; + }); + } + } +} diff --git a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs index 957ee23e3b..bdb10a477c 100644 --- a/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs +++ b/osu.Game/Overlays/Mods/AdjustedAttributesTooltip.cs @@ -18,6 +18,7 @@ namespace osu.Game.Overlays.Mods { public partial class AdjustedAttributesTooltip : VisibilityContainer, ITooltip { + private readonly OverlayColourProvider? colourProvider; private FillFlowContainer attributesFillFlow = null!; private Container content = null!; @@ -27,6 +28,11 @@ namespace osu.Game.Overlays.Mods [Resolved] private OsuColour colours { get; set; } = null!; + public AdjustedAttributesTooltip(OverlayColourProvider? colourProvider = null) + { + this.colourProvider = colourProvider; + } + [BackgroundDependencyLoader] private void load() { @@ -45,7 +51,7 @@ namespace osu.Game.Overlays.Mods new Box { RelativeSizeAxes = Axes.Both, - Colour = colours.Gray3, + Colour = colourProvider?.Background4 ?? colours.Gray3, }, new FillFlowContainer { diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs new file mode 100644 index 0000000000..9d1be2fc37 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -0,0 +1,324 @@ +// Copyright (c) ppy Pty Ltd . 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Extensions; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge : VisibilityContainer + { + private const float corner_radius = 10; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + private ModSettingChangeTracker? settingChangeTracker; + + private BeatmapSetOnlineStatusPill statusPill = null!; + private Container titleContainer = null!; + private OsuHoverContainer titleLink = null!; + private OsuSpriteText titleLabel = null!; + private Container artistContainer = null!; + private OsuHoverContainer artistLink = null!; + private OsuSpriteText artistLabel = null!; + + internal string DisplayedTitle => titleLabel.Text.ToString(); + internal string DisplayedArtist => artistLabel.Text.ToString(); + + private StatisticPlayCount playCount = null!; + private Statistic favouritesStatistic = null!; + private Statistic lengthStatistic = null!; + private Statistic bpmStatistic = null!; + + [Resolved] + private SongSelect? songSelect { get; set; } + + [Resolved] + private LocalisationManager localisation { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private APIBeatmapSet? currentOnlineBeatmapSet; + private GetBeatmapSetRequest? currentRequest; + + public BeatmapTitleWedge() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load() + { + Shear = OsuGame.SHEAR; + Masking = true; + CornerRadius = corner_radius; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding + { + Top = SongSelect.WEDGE_CONTENT_MARGIN, + Left = SongSelect.WEDGE_CONTENT_MARGIN + }, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new ShearAligningWrapper(statusPill = new BeatmapSetOnlineStatusPill + { + Shear = -OsuGame.SHEAR, + ShowUnknownStatus = true, + TextSize = OsuFont.Style.Caption1.Size, + TextPadding = new MarginPadding { Horizontal = 6, Vertical = 1 }, + }), + new ShearAligningWrapper(titleContainer = new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Title.Size, + Margin = new MarginPadding { Bottom = -4f }, + Child = titleLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = titleLabel = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Title, + }, + } + }), + new ShearAligningWrapper(artistContainer = new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Heading2.Size, + Margin = new MarginPadding { Left = 1f }, + Child = artistLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = artistLabel = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Heading2, + }, + } + }), + new ShearAligningWrapper(new FillFlowContainer + { + Shear = -OsuGame.SHEAR, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2f, 0f), + AutoSizeDuration = 100, + AutoSizeEasing = Easing.OutQuint, + Children = new Drawable[] + { + playCount = new StatisticPlayCount(background: true, leftPadding: SongSelect.WEDGE_CONTENT_MARGIN, minSize: 50f) + { + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + }, + favouritesStatistic = new Statistic(OsuIcon.Heart, background: true, minSize: 25f) + { + TooltipText = BeatmapsStrings.StatusFavourites, + }, + lengthStatistic = new Statistic(OsuIcon.Clock), + bpmStatistic = new Statistic(OsuIcon.Metronome) + { + TooltipText = BeatmapsetsStrings.ShowStatsBpm, + Margin = new MarginPadding { Left = 5f }, + }, + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, + Padding = new MarginPadding { Right = -SongSelect.WEDGE_CONTENT_MARGIN }, + Child = new DifficultyDisplay(), + }), + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateDisplay()); + ruleset.BindValueChanged(_ => updateDisplay()); + + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); + + updateLengthAndBpmStatistics(); + + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => updateLengthAndBpmStatistics(); + }); + + updateDisplay(); + + FinishTransforms(true); + } + + protected override void PopIn() + { + this.MoveToX(0, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeIn(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void PopOut() + { + this.MoveToX(-150, SongSelect.ENTER_DURATION, Easing.OutQuint) + .FadeOut(SongSelect.ENTER_DURATION / 3, Easing.In); + } + + protected override void Update() + { + base.Update(); + titleLabel.MaxWidth = titleContainer.DrawWidth - 20; + artistLabel.MaxWidth = artistContainer.DrawWidth - 20; + } + + private void updateDisplay() + { + var metadata = beatmap.Value.Metadata; + var beatmapInfo = beatmap.Value.BeatmapInfo; + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + statusPill.Status = beatmapInfo.Status; + + var titleText = new RomanisableString(metadata.TitleUnicode, metadata.Title); + titleLabel.Text = titleText; + titleLink.Action = () => songSelect?.Search(titleText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)); + + var artistText = new RomanisableString(metadata.ArtistUnicode, metadata.Artist); + artistLabel.Text = artistText; + artistLink.Action = () => songSelect?.Search(artistText.GetPreferred(localisation.CurrentParameters.Value.PreferOriginalScript)); + + updateLengthAndBpmStatistics(); + + if (currentOnlineBeatmapSet == null || currentOnlineBeatmapSet.OnlineID != beatmapSetInfo.OnlineID) + refetchBeatmapSet(); + + updateOnlineDisplay(); + } + + private void updateLengthAndBpmStatistics() + { + var beatmapInfo = beatmap.Value.BeatmapInfo; + + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + int bpmMax = FormatUtils.RoundBPM(beatmap.Value.Beatmap.ControlPointInfo.BPMMaximum, rate); + int bpmMin = FormatUtils.RoundBPM(beatmap.Value.Beatmap.ControlPointInfo.BPMMinimum, rate); + int mostCommonBPM = FormatUtils.RoundBPM(60000 / beatmap.Value.Beatmap.GetMostCommonBeatLength(), rate); + + double drainLength = Math.Round(beatmap.Value.Beatmap.CalculateDrainLength() / rate); + double hitLength = Math.Round(beatmapInfo.Length / rate); + + lengthStatistic.Value = hitLength.ToFormattedDuration(); + lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); + + bpmStatistic.Value = bpmMin == bpmMax + ? $"{bpmMin}" + : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; + } + + private void refetchBeatmapSet() + { + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + currentRequest?.Cancel(); + currentRequest = null; + currentOnlineBeatmapSet = null; + + if (beatmapSetInfo.OnlineID >= 1) + { + // todo: consider introducing a BeatmapSetLookupCache for caching benefits. + currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID); + currentRequest.Failure += _ => updateOnlineDisplay(); + currentRequest.Success += s => + { + currentOnlineBeatmapSet = s; + updateOnlineDisplay(); + }; + + api.Queue(currentRequest); + } + } + + private void updateOnlineDisplay() + { + if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) + { + playCount.Value = null; + favouritesStatistic.Value = null; + } + else if (currentOnlineBeatmapSet == null) + { + playCount.Value = new StatisticPlayCount.Data(-1, -1); + favouritesStatistic.Value = "-"; + } + else + { + var onlineBeatmapSet = currentOnlineBeatmapSet; + var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmap.Value.BeatmapInfo.OnlineID); + + if (onlineBeatmap != null) + { + playCount.FadeIn(300, Easing.OutQuint); + playCount.Value = new StatisticPlayCount.Data(onlineBeatmap.PlayCount, onlineBeatmap.UserPlayCount); + } + else + { + playCount.FadeOut(300, Easing.OutQuint); + playCount.Value = null; + } + + favouritesStatistic.FadeIn(300, Easing.OutQuint); + favouritesStatistic.Value = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs new file mode 100644 index 0000000000..e8b2ccb04a --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -0,0 +1,392 @@ +// Copyright (c) ppy Pty Ltd . 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.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Online; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Resources.Localisation.Web; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class DifficultyDisplay : CompositeDrawable + { + private const float border_weight = 2; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IBindable ruleset { get; set; } = null!; + + [Resolved] + private IBindable> mods { get; set; } = null!; + + private ModSettingChangeTracker? settingChangeTracker; + + [Resolved] + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private StarRatingDisplay starRatingDisplay = null!; + private FillFlowContainer nameLine = null!; + private OsuSpriteText difficultyText = null!; + private OsuSpriteText mappedByText = null!; + private OsuHoverContainer mapperLink = null!; + private OsuSpriteText mapperText = null!; + + internal LocalisableString DisplayedVersion => difficultyText.Text; + internal LocalisableString DisplayedAuthor => mapperText.Text; + + private GridContainer ratingAndNameContainer = null!; + private DifficultyStatisticsDisplay countStatisticsDisplay = null!; + private AdjustableDifficultyStatisticsDisplay difficultyStatisticsDisplay = null!; + + private CancellationTokenSource? cancellationSource; + + public IBindable DisplayedStars => displayedStars; + + private readonly Bindable displayedStars = new BindableDouble(); + + public DifficultyDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 10; + Shear = OsuGame.SHEAR; + + InternalChildren = new Drawable[] + { + new WedgeBackground(), + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new ShearAligningWrapper(ratingAndNameContainer = new GridContainer + { + Shear = -OsuGame.SHEAR, + AlwaysPresent = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Vertical = 5f }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, 6), + new Dimension(), + }, + Content = new[] + { + new[] + { + starRatingDisplay = new StarRatingDisplay(default, animated: true) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + Empty(), + nameLine = new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Bottom = 2f }, + Children = new Drawable[] + { + difficultyText = new TruncatingSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + mappedByText = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = " mapped by ", + Font = OsuFont.Style.Body, + }, + mapperLink = new MapperLinkContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Child = mapperText = new TruncatingSpriteText + { + Shadow = true, + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }, + }, + }, + }, + } + }, + }), + new ShearAligningWrapper(new Container + { + Shear = -OsuGame.SHEAR, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Bottom = border_weight, Right = border_weight }, + Child = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = 10 - border_weight, + Shear = OsuGame.SHEAR, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5.Opacity(0.8f), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 20f, Vertical = 7.5f }, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 30), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + countStatisticsDisplay = new DifficultyStatisticsDisplay + { + RelativeSizeAxes = Axes.X, + }, + Empty(), + difficultyStatisticsDisplay = new AdjustableDifficultyStatisticsDisplay(autoSize: true), + } + }, + } + }, + } + }), + } + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + beatmap.BindValueChanged(_ => updateDisplay()); + ruleset.BindValueChanged(_ => updateDisplay()); + + mods.BindValueChanged(m => + { + settingChangeTracker?.Dispose(); + + updateDifficultyStatistics(); + + settingChangeTracker = new ModSettingChangeTracker(m.NewValue); + settingChangeTracker.SettingChanged += _ => updateDifficultyStatistics(); + }); + + updateDisplay(); + + displayedStars.BindValueChanged(_ => updateStars(), true); + FinishTransforms(true); + } + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + private void updateDisplay() + { + cancellationSource?.Cancel(); + cancellationSource = new CancellationTokenSource(); + + computeStarDifficulty(cancellationSource.Token); + + if (beatmap.IsDefault) + { + ratingAndNameContainer.FadeOut(300, Easing.OutQuint); + difficultyText.Text = string.Empty; + mapperText.Text = string.Empty; + countStatisticsDisplay.Statistics = Array.Empty(); + } + else + { + ratingAndNameContainer.FadeIn(300, Easing.OutQuint); + difficultyText.Text = beatmap.Value.BeatmapInfo.DifficultyName; + mapperLink.Action = () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, beatmap.Value.Metadata.Author)); + mapperText.Text = beatmap.Value.Metadata.Author.Username; + + var playableBeatmap = beatmap.Value.GetPlayableBeatmap(ruleset.Value); + + countStatisticsDisplay.Statistics = playableBeatmap.GetStatistics() + .Select(s => new StatisticDifficulty.Data(s.Name, s.BarDisplayLength ?? 0, s.BarDisplayLength ?? 0, 1, s.Content)) + .ToList(); + } + + updateDifficultyStatistics(); + } + + private void updateDifficultyStatistics() => Scheduler.AddOnce(() => + { + if (beatmap.IsDefault) + { + difficultyStatisticsDisplay.TooltipContent = null; + difficultyStatisticsDisplay.Statistics = Array.Empty(); + return; + } + + BeatmapDifficulty baseDifficulty = beatmap.Value.BeatmapInfo.Difficulty; + BeatmapDifficulty originalDifficulty = new BeatmapDifficulty(baseDifficulty); + + foreach (var mod in mods.Value.OfType()) + mod.ApplyToDifficulty(originalDifficulty); + + var rateAdjustedDifficulty = originalDifficulty; + + if (ruleset.Value != null) + { + double rate = ModUtils.CalculateRateWithMods(mods.Value); + + rateAdjustedDifficulty = ruleset.Value.CreateInstance().GetRateAdjustedDisplayDifficulty(originalDifficulty, rate); + difficultyStatisticsDisplay.TooltipContent = new AdjustedAttributesTooltip.Data(originalDifficulty, rateAdjustedDifficulty); + } + + StatisticDifficulty.Data firstStatistic; + + switch (ruleset.Value?.OnlineID) + { + case 3: + // Account for mania differences locally for now. + // Eventually this should be handled in a more modular way, allowing rulesets to return arbitrary difficulty attributes. + ILegacyRuleset legacyRuleset = (ILegacyRuleset)ruleset.Value.CreateInstance(); + + // For the time being, the key count is static no matter what, because: + // a) The method doesn't have knowledge of the active keymods. Doing so may require considerations for filtering. + // b) Using the difficulty adjustment mod to adjust OD doesn't have an effect on conversion. + int keyCount = legacyRuleset.GetKeyCount(beatmap.Value.BeatmapInfo, mods.Value); + + firstStatistic = new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsCsMania, keyCount, keyCount, 10); + break; + + default: + firstStatistic = new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsCs, baseDifficulty.CircleSize, rateAdjustedDifficulty.CircleSize, 10); + break; + } + + difficultyStatisticsDisplay.Statistics = new[] + { + firstStatistic, + new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsAccuracy, baseDifficulty.OverallDifficulty, rateAdjustedDifficulty.OverallDifficulty, 10), + new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsDrain, baseDifficulty.DrainRate, rateAdjustedDifficulty.DrainRate, 10), + new StatisticDifficulty.Data(BeatmapsetsStrings.ShowStatsAr, baseDifficulty.ApproachRate, rateAdjustedDifficulty.ApproachRate, 10), + }; + }); + + private void updateStars() + { + starRatingDisplay.Current.Value = new StarDifficulty(displayedStars.Value, 0); + + Color4 colour = displayedStars.Value >= 6.5f ? colours.Orange1 : colours.ForStarDifficulty(displayedStars.Value); + difficultyText.FadeColour(colour, 300, Easing.OutQuint); + mappedByText.FadeColour(colour, 300, Easing.OutQuint); + countStatisticsDisplay.TransformTo(nameof(countStatisticsDisplay.AccentColour), colour, 300, Easing.OutQuint); + difficultyStatisticsDisplay.TransformTo(nameof(difficultyStatisticsDisplay.AccentColour), colour, 300, Easing.OutQuint); + } + + private void computeStarDifficulty(CancellationToken cancellationToken) + { + difficultyCache.GetDifficultyAsync(beatmap.Value.BeatmapInfo, ruleset.Value, mods.Value, cancellationToken) + .ContinueWith(task => + { + Schedule(() => + { + if (cancellationToken.IsCancellationRequested) + return; + + var result = task.GetResultSafely() ?? default; + displayedStars.Value = result.Stars; + }); + }, cancellationToken); + } + + protected override void Update() + { + base.Update(); + difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); + } + + private partial class MapperLinkContainer : OsuHoverContainer + { + [BackgroundDependencyLoader] + private void load(OverlayColourProvider? overlayColourProvider, OsuColour colours) + { + TooltipText = ContextMenuStrings.ViewProfile; + IdleColour = overlayColourProvider?.Light2 ?? colours.Blue; + } + } + + private partial class AdjustableDifficultyStatisticsDisplay : DifficultyStatisticsDisplay, IHasCustomTooltip + { + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ITooltip GetCustomTooltip() => new AdjustedAttributesTooltip(colourProvider); + + public AdjustedAttributesTooltip.Data? TooltipContent { get; set; } + + public AdjustableDifficultyStatisticsDisplay(bool autoSize) + : base(autoSize) + { + } + } + } + } +} From 856f907c864ea2e728c665479db512c89018624a Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 18 Apr 2025 05:27:16 -0400 Subject: [PATCH 09/29] Add beatmap metadata wedge --- .../TestSceneBeatmapMetadataWedge.cs | 165 +++++++++ .../Screens/SelectV2/BeatmapMetadataWedge.cs | 337 ++++++++++++++++++ .../BeatmapMetadataWedge_FailRetryDisplay.cs | 195 ++++++++++ .../BeatmapMetadataWedge_MetadataDisplay.cs | 174 +++++++++ ...eatmapMetadataWedge_RatingSpreadDisplay.cs | 123 +++++++ ...BeatmapMetadataWedge_SuccessRateDisplay.cs | 112 ++++++ .../SelectV2/BeatmapMetadataWedge_TagsLine.cs | 223 ++++++++++++ .../BeatmapMetadataWedge_UserRatingDisplay.cs | 130 +++++++ 8 files changed, 1459 insertions(+) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs create mode 100644 osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs new file mode 100644 index 0000000000..769188eb71 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs @@ -0,0 +1,165 @@ +// Copyright (c) ppy Pty Ltd . 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.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Screens.SelectV2; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + public partial class TestSceneBeatmapMetadataWedge : SongSelectComponentsTestScene + { + private APIBeatmapSet? currentOnlineSet; + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + default: + return false; + } + }; + + Child = new BeatmapMetadataWedge + { + State = { Value = Visibility.Visible }, + }; + } + + [Test] + public void TestDisplay() + { + AddStep("null beatmap", () => Beatmap.SetDefault()); + AddStep("all metrics", () => + { + var (working, onlineSet) = createTestBeatmap(); + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no source", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.Metadata.Source = string.Empty; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no success rate", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Beatmaps.Single().PlayCount = 0; + onlineSet.Beatmaps.Single().PassCount = 0; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no user ratings", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Ratings = Array.Empty(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no fail times", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Beatmaps.Single().FailTimes = null; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("no metrics", () => + { + var (working, onlineSet) = createTestBeatmap(); + + onlineSet.Ratings = Array.Empty(); + onlineSet.Beatmaps.Single().FailTimes = null; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("local beatmap", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.BeatmapInfo.OnlineID = 0; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + } + + [Test] + public void TestTruncation() + { + AddStep("long text", () => + { + var (working, onlineSet) = createTestBeatmap(); + + working.BeatmapInfo.Metadata.Author = new RealmUser { Username = "Verrrrryyyy llooonngggggg author" }; + working.BeatmapInfo.Metadata.Source = "Verrrrryyyy llooonngggggg source"; + working.BeatmapInfo.Metadata.Tags = string.Join(' ', Enumerable.Repeat(working.BeatmapInfo.Metadata.Tags, 3)); + onlineSet.Genre = new BeatmapSetOnlineGenre { Id = 12, Name = "Verrrrryyyy llooonngggggg genre" }; + onlineSet.Language = new BeatmapSetOnlineLanguage { Id = 12, Name = "Verrrrryyyy llooonngggggg language" }; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + } + + private (WorkingBeatmap, APIBeatmapSet) createTestBeatmap() + { + var working = CreateWorkingBeatmap(Ruleset.Value); + var onlineSet = new APIBeatmapSet + { + OnlineID = working.BeatmapSetInfo.OnlineID, + Genre = new BeatmapSetOnlineGenre { Id = 15, Name = "Pop" }, + Language = new BeatmapSetOnlineLanguage { Id = 15, Name = "English" }, + Ratings = Enumerable.Range(0, 11).ToArray(), + Beatmaps = new[] + { + new APIBeatmap + { + OnlineID = working.BeatmapInfo.OnlineID, + PlayCount = 10000, + PassCount = 4567, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + }, + } + }; + + working.BeatmapSetInfo.DateSubmitted = DateTimeOffset.Now; + working.BeatmapSetInfo.DateRanked = DateTimeOffset.Now; + return (working, onlineSet); + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs new file mode 100644 index 0000000000..a83ec51b11 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -0,0 +1,337 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Graphics.Containers; +using osu.Game.Online; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge : VisibilityContainer + { + private MetadataDisplay creator = null!; + private MetadataDisplay source = null!; + private MetadataDisplay genre = null!; + private MetadataDisplay language = null!; + private MetadataDisplay tag = null!; + private MetadataDisplay submitted = null!; + private MetadataDisplay ranked = null!; + + private Drawable ratingsWedge = null!; + private SuccessRateDisplay successRateDisplay = null!; + private UserRatingDisplay userRatingDisplay = null!; + private RatingSpreadDisplay ratingSpreadDisplay = null!; + + private Drawable failRetryWedge = null!; + private FailRetryDisplay failRetryDisplay = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private IBindable apiState = null!; + + [Resolved] + private ILinkHandler? linkHandler { get; set; } + + [Resolved] + private SongSelect? songSelect { get; set; } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Padding = new MarginPadding { Top = 4f }; + + Width = 0.9f; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Shear = OsuGame.SHEAR, + Children = new[] + { + new ShearAligningWrapper(new Container + { + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 35, Vertical = 16 }, + Children = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(), + new Dimension(), + }, + Content = new[] + { + new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + creator = new MetadataDisplay("Creator"), + genre = new MetadataDisplay("Genre"), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + source = new MetadataDisplay("Source"), + language = new MetadataDisplay("Language"), + }, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 10f), + Children = new[] + { + submitted = new MetadataDisplay("Submitted"), + ranked = new MetadataDisplay("Ranked"), + }, + }, + }, + }, + }, + tag = new MetadataDisplay("Tags"), + }, + }, + }, + }, + }, + }), + new ShearAligningWrapper(ratingsWedge = new Container + { + Alpha = 0f, + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + new Dimension(GridSizeMode.Absolute, 10), + new Dimension(), + }, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Content = new[] + { + new[] + { + successRateDisplay = new SuccessRateDisplay(), + Empty(), + userRatingDisplay = new UserRatingDisplay(), + Empty(), + ratingSpreadDisplay = new RatingSpreadDisplay(), + }, + }, + }, + } + }), + new ShearAligningWrapper(failRetryWedge = new Container + { + Alpha = 0f, + CornerRadius = 10, + Masking = true, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + new WedgeBackground(), + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Shear = -OsuGame.SHEAR, + Padding = new MarginPadding { Left = SongSelect.WEDGE_CONTENT_MARGIN, Right = 40f, Vertical = 16 }, + Child = failRetryDisplay = new FailRetryDisplay(), + }, + }, + }), + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + beatmap.BindValueChanged(_ => updateDisplay()); + + apiState = api.State.GetBoundCopy(); + apiState.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay), true); + } + + protected override void PopIn() + { + this.FadeIn(300, Easing.OutQuint) + .MoveToX(0, 300, Easing.OutQuint); + } + + protected override void PopOut() + { + this.FadeOut(300, Easing.OutQuint) + .MoveToX(-100, 300, Easing.OutQuint); + } + + private void updateDisplay() + { + var metadata = beatmap.Value.Metadata; + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + creator.Data = (metadata.Author.Username, () => linkHandler?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, metadata.Author))); + + if (!string.IsNullOrEmpty(metadata.Source)) + source.Data = (metadata.Source, () => songSelect?.Search(metadata.Source)); + else + source.Data = ("-", null); + + tag.Tags = (metadata.Tags.Split(' '), t => songSelect?.Search(t)); + submitted.Date = beatmapSetInfo.DateSubmitted; + ranked.Date = beatmapSetInfo.DateRanked; + + if (currentOnlineBeatmapSet == null || currentOnlineBeatmapSet.OnlineID != beatmapSetInfo.OnlineID) + refetchBeatmapSet(); + + updateOnlineDisplay(); + } + + private APIBeatmapSet? currentOnlineBeatmapSet; + private GetBeatmapSetRequest? currentRequest; + + private void refetchBeatmapSet() + { + var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; + + currentRequest?.Cancel(); + currentRequest = null; + currentOnlineBeatmapSet = null; + + if (beatmapSetInfo.OnlineID >= 1) + { + // todo: consider introducing a BeatmapSetLookupCache for caching benefits. + currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID); + currentRequest.Failure += _ => updateOnlineDisplay(); + currentRequest.Success += s => + { + currentOnlineBeatmapSet = s; + updateOnlineDisplay(); + }; + + api.Queue(currentRequest); + } + } + + private void updateOnlineDisplay() + { + if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) + { + genre.Data = null; + language.Data = null; + } + else if (currentOnlineBeatmapSet == null) + { + genre.Data = ("-", null); + language.Data = ("-", null); + + ratingsWedge.FadeOut(300, Easing.OutQuint); + ratingsWedge.MoveToX(-50, 300, Easing.OutQuint); + failRetryWedge.FadeOut(300, Easing.OutQuint); + failRetryWedge.MoveToX(-50, 300, Easing.OutQuint); + } + else + { + var beatmapInfo = beatmap.Value.BeatmapInfo; + + var onlineBeatmapSet = currentOnlineBeatmapSet; + var onlineBeatmap = onlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + + genre.Data = (onlineBeatmapSet.Genre.Name, () => songSelect?.Search(onlineBeatmapSet.Genre.Name)); + language.Data = (onlineBeatmapSet.Language.Name, () => songSelect?.Search(onlineBeatmapSet.Language.Name)); + + if (onlineBeatmap != null) + { + ratingsWedge.FadeIn(300, Easing.OutQuint); + ratingsWedge.MoveToX(0, 300, Easing.OutQuint); + failRetryWedge.FadeIn(300, Easing.OutQuint); + failRetryWedge.MoveToX(0, 300, Easing.OutQuint); + + userRatingDisplay.Data = onlineBeatmapSet.Ratings; + ratingSpreadDisplay.Data = onlineBeatmapSet.Ratings; + successRateDisplay.Data = (onlineBeatmap.PassCount, onlineBeatmap.PlayCount); + failRetryDisplay.Data = onlineBeatmap.FailTimes ?? new APIFailTimes(); + } + else + { + ratingsWedge.FadeOut(300, Easing.OutQuint); + ratingsWedge.MoveToX(-50, 300, Easing.OutQuint); + failRetryWedge.FadeOut(300, Easing.OutQuint); + failRetryWedge.MoveToX(-50, 300, Easing.OutQuint); + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs new file mode 100644 index 0000000000..048ec3c40d --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_FailRetryDisplay.cs @@ -0,0 +1,195 @@ +// Copyright (c) ppy Pty Ltd . 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class FailRetryDisplay : CompositeDrawable + { + private readonly GraphDrawable retriesGraph; + private readonly GraphDrawable failsGraph; + + public APIFailTimes Data + { + set + { + int[] retries = value.Retries ?? Array.Empty(); + int[] fails = value.Fails ?? Array.Empty(); + int[] total = retries.Zip(fails, (r, f) => r + f).ToArray(); + + int maximum = total.DefaultIfEmpty(0).Max(); + + retriesGraph.Data = total.Select(r => maximum == 0 ? 0 : (float)r / maximum).ToArray(); + failsGraph.Data = fails.Select(r => maximum == 0 ? 0 : (float)r / maximum).ToArray(); + } + } + + public FailRetryDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 4f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowInfoPointsOfFailure, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + Margin = new MarginPadding { Bottom = 4f }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 65f, + Children = new[] + { + retriesGraph = new GraphDrawable { RelativeSizeAxes = Axes.Both, Y = -1f }, + failsGraph = new GraphDrawable { RelativeSizeAxes = Axes.Both }, + }, + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + retriesGraph.Colour = colours.Orange1; + failsGraph.Colour = colours.DarkOrange2; + } + + private partial class GraphDrawable : Drawable + { + private readonly float[] displayedData = new float[100]; + + private float[] data = new float[100]; + + public float[] Data + { + get => data; + set + { + data = value; + Invalidate(Invalidation.DrawNode); + } + } + + protected override void Update() + { + base.Update(); + + bool changed = false; + + for (int i = 0; i < displayedData.Length; i++) + { + float before = displayedData[i]; + float value = data.ElementAtOrDefault(i); + displayedData[i] = (float)Interpolation.DampContinuously(displayedData[i], value, 40, Time.Elapsed); + changed |= displayedData[i] != before; + } + + if (changed) + Invalidate(Invalidation.DrawNode); + } + + protected override DrawNode CreateDrawNode() => new GraphDrawNode(this); + + // todo: consider integrating this with BarGraph + // this is different from BarGraph since this displays each bar with corner radii applied. + private class GraphDrawNode : DrawNode + { + private readonly GraphDrawable source; + + private Vector2 drawSize; + private float[] displayedData = null!; + + public GraphDrawNode(GraphDrawable source) + : base(source) + { + this.source = source; + } + + public override void ApplyState() + { + base.ApplyState(); + + drawSize = source.DrawSize; + displayedData = source.displayedData; + } + + protected override void Draw(IRenderer renderer) + { + base.Draw(renderer); + + const float spacing_constant = 1.5f; + + float position = 0; + float barWidth = drawSize.X / displayedData.Length / spacing_constant; + + float totalSpacing = drawSize.X - barWidth * displayedData.Length; + float spacing = totalSpacing / (displayedData.Length - 1); + + for (int i = 0; i < displayedData.Length; i++) + { + float barHeight = MathF.Max(drawSize.Y * displayedData[i], barWidth); + + drawBar(renderer, position, barWidth, barHeight); + + position += barWidth + spacing; + } + } + + private void drawBar(IRenderer renderer, float position, float width, float height) + { + float cornerRadius = width / 2f; + + Vector3 scale = DrawInfo.MatrixInverse.ExtractScale(); + float blendRange = (scale.X + scale.Y) / 2; + + RectangleF drawRectangle = new RectangleF(new Vector2(position, drawSize.Y - height), new Vector2(width, height)); + Quad screenSpaceDrawQuad = Quad.FromRectangle(drawRectangle) * DrawInfo.Matrix; + + renderer.PushMaskingInfo(new MaskingInfo + { + ScreenSpaceAABB = screenSpaceDrawQuad.AABB, + MaskingRect = drawRectangle.Normalize(), + ConservativeScreenSpaceQuad = screenSpaceDrawQuad, + ToMaskingSpace = DrawInfo.MatrixInverse, + CornerRadius = cornerRadius, + CornerExponent = 2f, + // We are setting the linear blend range to the approximate size of a _pixel_ here. + // This results in the optimal trade-off between crispness and smoothness of the + // edges of the masked region according to sampling theory. + BlendRange = blendRange, + AlphaExponent = 1, + }); + + renderer.DrawQuad(renderer.WhitePixel, screenSpaceDrawQuad, DrawColourInfo.Colour); + renderer.PopMaskingInfo(); + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs new file mode 100644 index 0000000000..897349b9cb --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs @@ -0,0 +1,174 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +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.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class MetadataDisplay : FillFlowContainer + { + private readonly OsuSpriteText labelText; + private readonly OsuSpriteText contentText; + private readonly OsuSpriteText contentLinkText; + private readonly OsuHoverContainer contentLink; + private readonly DrawableDate contentDate; + private readonly TagsLine contentTags; + private readonly LoadingSpinner contentLoading; + + private (LocalisableString value, Action? linkAction)? data; + + public (LocalisableString value, Action? linkAction)? Data + { + get => data; + set + { + data = value; + + if (value?.linkAction != null) + setLink(value.Value.value, value.Value.linkAction); + else if (value.HasValue) + setText(value.Value.value); + else + setLoading(); + } + } + + public DateTimeOffset? Date + { + set + { + if (value != null) + setDate(value.Value); + else + setText("-"); + } + } + + public (string[] tags, Action linkAction) Tags + { + set => setTags(value.tags, value.linkAction); + } + + public MetadataDisplay(LocalisableString label) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Padding = new MarginPadding { Right = 10 }; + + InternalChildren = new Drawable[] + { + labelText = new OsuSpriteText + { + Text = label, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = OsuFont.Style.Caption1.Size, + Children = new Drawable[] + { + contentText = new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Font = OsuFont.Style.Caption1, + }, + contentLink = new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Child = contentLinkText = new TruncatingSpriteText + { + Font = OsuFont.Style.Caption1, + }, + }, + contentDate = new DrawableDate(default, OsuFont.Style.Caption1.Size, false), + contentTags = new TagsLine(), + contentLoading = new LoadingSpinner + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Size = new Vector2(10), + Margin = new MarginPadding { Top = 3f }, + State = { Value = Visibility.Visible }, + } + }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + labelText.Colour = colourProvider.Content1; + contentText.Colour = colourProvider.Content2; + contentLink.IdleColour = colourProvider.Light2; + } + + protected override void Update() + { + base.Update(); + contentLinkText.MaxWidth = ChildSize.X; + } + + private void clear() + { + contentText.Text = string.Empty; + contentLinkText.Text = string.Empty; + contentDate.Hide(); + contentTags.Tags = Array.Empty(); + contentLoading.Hide(); + } + + private void setText(LocalisableString text) + { + clear(); + + contentText.Text = text; + } + + private void setLink(LocalisableString text, Action action) => Schedule(() => + { + clear(); + + contentLinkText.Text = text; + contentLink.Action = action; + }); + + private void setDate(DateTimeOffset date) + { + clear(); + + contentDate.Show(); + contentDate.Date = date; + } + + private void setTags(string[] tags, Action link) + { + clear(); + + contentTags.Tags = tags; + contentTags.Action = link; + } + + private void setLoading() + { + clear(); + + contentLoading.Show(); + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs new file mode 100644 index 0000000000..ee938ecdd9 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_RatingSpreadDisplay.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class RatingSpreadDisplay : CompositeDrawable + { + private const float min_height = 4f; + private const float max_height = 32f; + + private const int rating_range = 10; + + private readonly GraphBar[] graph; + + public int[] Data + { + set + { + if (!value.Any()) + { + foreach (var bar in graph) + bar.ResizeHeightTo(min_height, 300, Easing.OutQuint); + } + else + { + var usableRange = value.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + int maxRating = usableRange.Max(); + + for (int i = 0; i < graph.Length; i++) + graph[i].ResizeHeightTo(min_height + (max_height - min_height) * (maxRating == 0 ? 0 : usableRange.ElementAt(i) / (float)maxRating), 300, Easing.OutQuint); + } + } + } + + public RatingSpreadDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + graph = Enumerable.Range(0, rating_range).Select(_ => new GraphBar()).ToArray(); + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 1f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowStatsRatingSpread, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + RowDimensions = new[] { new Dimension(GridSizeMode.Absolute, max_height) }, + ColumnDimensions = graph.SkipLast(1).Select(_ => new[] + { + new Dimension(), + new Dimension(GridSizeMode.Absolute, 1f), + }).SelectMany(d => d).Append(new Dimension()).ToArray(), + Content = new[] + { + graph.SkipLast(1).Select(g => new[] + { + g, + Empty() + }).SelectMany(g => g).Append(graph[^1]).ToArray() + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + for (int i = 0; i < 10; i++) + { + var left = Interpolation.ValueAt(i, colours.Blue4, colours.Blue0, 0, 10); + var right = Interpolation.ValueAt(i + 1, colours.Blue4, colours.Blue0, 0, 10); + graph[i].Colour = ColourInfo.GradientHorizontal(left, right); + } + } + + private partial class GraphBar : CompositeDrawable + { + [BackgroundDependencyLoader] + private void load() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + RelativeSizeAxes = Axes.X; + CornerRadius = 2f; + Masking = true; + + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs new file mode 100644 index 0000000000..6118547274 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_SuccessRateDisplay.cs @@ -0,0 +1,112 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class SuccessRateDisplay : CompositeDrawable, IHasTooltip + { + private readonly OsuSpriteText valueText; + private readonly Circle backgroundBar; + private readonly Circle valueBar; + + private (int passes, int plays) data; + + public (int passes, int plays) Data + { + get => data; + set + { + data = value; + + float ratio = value.plays == 0 ? 0 : (float)value.passes / value.plays; + + valueText.Text = ratio.ToLocalisableString(@"0.##%"); + valueText.MoveToX(Math.Clamp(ratio, 0.05f, 0.95f), 300, Easing.OutQuint); + valueBar.ResizeWidthTo(ratio, 300, Easing.OutQuint); + } + } + + public LocalisableString TooltipText => $"{data.passes:N0} / {data.plays:N0}"; + + public SuccessRateDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowInfoSuccessRate, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10f }, + Child = valueText = new OsuSpriteText + { + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.X, + Font = OsuFont.Style.Caption1, + } + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + backgroundBar = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 4f, + }, + valueBar = new Circle + { + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 4f, + }, + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + backgroundBar.Colour = colourProvider.Background6; + valueBar.Colour = colours.Lime1; + valueText.Colour = colourProvider.Content2; + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs new file mode 100644 index 0000000000..56b83a2578 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs @@ -0,0 +1,223 @@ +// Copyright (c) ppy Pty Ltd . 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; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Framework.Layout; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class TagsLine : FillFlowContainer + { + private readonly LayoutValue drawSizeLayout = new LayoutValue(Invalidation.DrawSize); + + private string[] tags = Array.Empty(); + + private TagsOverflowButton? overflowButton; + + public string[] Tags + { + get => tags; + set + { + tags = value; + updateTags(); + } + } + + public Action? Action; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public TagsLine() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Horizontal; + Spacing = new Vector2(4, 0); + + AddLayout(drawSizeLayout); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (!drawSizeLayout.IsValid) + { + updateLayout(); + drawSizeLayout.Validate(); + } + } + + private void updateLayout() + { + if (tags.Length == 0) + return; + + Debug.Assert(overflowButton != null); + + float limit = DrawWidth - overflowButton.DrawWidth - 5; + bool showOverflow = false; + + foreach (var text in Children) + { + if (text.X + text.DrawWidth < limit) + text.Show(); + else + { + showOverflow = true; + text.AlwaysPresent = false; + text.Hide(); + } + } + + if (showOverflow) + overflowButton.Show(); + else + overflowButton.Hide(); + } + + private void updateTags() + { + ChildrenEnumerable = tags.Select(t => new OsuHoverContainer + { + AutoSizeAxes = Axes.Both, + Action = () => Action?.Invoke(t), + IdleColour = colourProvider.Light2, + AlwaysPresent = true, + Alpha = 0f, + Child = new OsuSpriteText + { + Text = t, + Font = OsuFont.Style.Caption1, + }, + }); + + Add(overflowButton = new TagsOverflowButton(tags) + { + Alpha = 0f, + }); + + drawSizeLayout.Invalidate(); + } + + private partial class TagsOverflowButton : CompositeDrawable, IHasPopover, IHasLineBaseHeight + { + private readonly string[] tags; + + private Box box = null!; + private OsuSpriteText text = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private SongSelect? songSelect { get; set; } + + public float LineBaseHeight => text.LineBaseHeight; + + public TagsOverflowButton(string[] tags) + { + this.tags = tags; + } + + [BackgroundDependencyLoader] + private void load() + { + Size = new Vector2(OsuFont.Style.Caption1.Size); + CornerRadius = 1.5f; + Masking = true; + + InternalChildren = new Drawable[] + { + box = new Box + { + Colour = colourProvider.Light1, + RelativeSizeAxes = Axes.Both, + }, + text = new OsuSpriteText + { + Y = -2, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "...", + Colour = colourProvider.Background4, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.Bold), + } + }; + } + + protected override bool OnHover(HoverEvent e) + { + box.FadeColour(colourProvider.Content2, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + box.FadeColour(colourProvider.Light1, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnClick(ClickEvent e) + { + box.FlashColour(colourProvider.Content1, 300, Easing.OutQuint); + this.ShowPopover(); + return true; + } + + public Popover GetPopover() => new TagsOverflowPopover(tags, songSelect); + } + + public partial class TagsOverflowPopover : OsuPopover + { + private readonly string[] tags; + private readonly SongSelect? songSelect; + + public TagsOverflowPopover(string[] tags, SongSelect? songSelect) + { + this.tags = tags; + this.songSelect = songSelect; + } + + [BackgroundDependencyLoader] + private void load() + { + LinkFlowContainer textFlow; + + Child = textFlow = new LinkFlowContainer(t => t.Font = OsuFont.Style.Caption1) + { + Width = 200, + AutoSizeAxes = Axes.Y, + }; + + foreach (string tag in tags) + { + textFlow.AddLink(tag, () => songSelect?.Search(tag)); + textFlow.AddText(" "); + } + } + } + } + } +} diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs new file mode 100644 index 0000000000..2f38079577 --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_UserRatingDisplay.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapMetadataWedge + { + private partial class UserRatingDisplay : CompositeDrawable + { + private readonly OsuSpriteText negativeText; + private readonly OsuSpriteText positiveText; + private readonly Circle backgroundBar; + private readonly Circle positiveBar; + + public int[] Data + { + set + { + const int rating_range = 10; + + if (!value.Any()) + { + negativeText.Text = 0.ToLocalisableString(@"N0"); + positiveText.Text = 0.ToLocalisableString(@"N0"); + positiveBar.ResizeWidthTo(0, 300, Easing.OutQuint); + } + else + { + var usableRange = value.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + + int positiveCount = usableRange.Skip(rating_range / 2).Sum(); + int totalCount = usableRange.Sum(); + + negativeText.Text = (totalCount - positiveCount).ToLocalisableString(@"N0"); + positiveText.Text = positiveCount.ToLocalisableString(@"N0"); + positiveBar.ResizeWidthTo(totalCount == 0 ? 0 : (float)positiveCount / totalCount, 300, Easing.OutQuint); + } + } + } + + public UserRatingDisplay() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 2f), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapsetsStrings.ShowStatsUserRating, + Font = OsuFont.Style.Caption1.With(weight: FontWeight.SemiBold), + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = 10f }, + Children = new[] + { + negativeText = new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Style.Caption1, + }, + positiveText = new OsuSpriteText + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Font = OsuFont.Style.Caption1, + }, + }, + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Children = new[] + { + backgroundBar = new Circle + { + RelativeSizeAxes = Axes.X, + Height = 4f, + }, + positiveBar = new Circle + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.X, + Width = 0f, + Height = 4f, + }, + }, + } + }, + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours, OverlayColourProvider colourProvider) + { + backgroundBar.Colour = colours.DarkOrange2; + positiveBar.Colour = colours.Lime1; + negativeText.Colour = colourProvider.Content2; + positiveText.Colour = colourProvider.Content2; + } + } + } +} From 5895a8ac498d995e71650fa54de18295f88172a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Apr 2025 08:52:01 +0200 Subject: [PATCH 10/29] Fix daily challenge marker text spacing Closes https://github.com/ppy/osu/issues/32908. Have you ever been in a situation wherein you find out you fixed a bug that you didn't know existed, but that makes *another* bug appear because it was relying on the other bug? This is where I'm at right now. But, to start from the top. `TextFlowContainer.Text` (the setter) is a convenience property that you use to set the text in one go. Internally it uses `AddText()`: https://github.com/ppy/osu-framework/blob/681900ffb70adfeede4e3fa32a69da66252691ee/osu.Framework/Graphics/Containers/TextFlowContainer.cs#L81-L94 `AddText()`'s xmldoc says: The \n character will create a new paragraph, not just a line break. If you need \n to be a line break, use instead. https://github.com/ppy/osu-framework/blob/681900ffb70adfeede4e3fa32a69da66252691ee/osu.Framework/Graphics/Containers/TextFlowContainer.cs#L226-L239 That's right. This portion of xmldoc was *straight up false* and *silently broken* before https://github.com/ppy/osu-framework/pull/6556. If you want to check that out yourself, apply the following patch to framework: diff --git a/osu.Framework.Tests/Visual/Containers/TestSceneTextFlowContainer.cs b/osu.Framework.Tests/Visual/Containers/TestSceneTextFlowContainer.cs index 464f47c2c..e1ad521a7 100644 --- a/osu.Framework.Tests/Visual/Containers/TestSceneTextFlowContainer.cs +++ b/osu.Framework.Tests/Visual/Containers/TestSceneTextFlowContainer.cs @@ -180,6 +180,22 @@ public void TestAlignmentIsCorrectWhenLineBreaksAtLastWordOfParagraph(Anchor tex }); } + [Test] + public void TestSetTextWithNewLine() + { + AddStep("set text", () => textContainer.Text = "this text\nhas a newline"); + AddStep("clear and add text", () => + { + textContainer.Clear(); + textContainer.AddText("this text\nhas a newline"); + }); + AddStep("clear and add paragraph", () => + { + textContainer.Clear(); + textContainer.AddParagraph("this text\nhas a newline"); + }); + } + private void assertSpriteTextCount(int count) => AddAssert($"text flow has {count} sprite texts", () => textContainer.ChildrenOfType().Count() == count); On `master`, there will be a difference between the first two steps, and the third. On 2025.321.0, *there will be none*. My working theory as to why this was always busted is that the corresponding code that was there before in https://github.com/bdach/osu-framework/blob/c31a48178889ca2f9b4d257d2d64915eee90338a/osu.Framework/Graphics/Containers/TextFlowContainer.cs#L454-L458 just straight up ran too late. *The height of the container is being changed after the flow has laid itself out, without adjusting subsequent children in any way.* There is potentially a discussion to be had as to whether the emergent behaviour of `TextFlowContainer.Text` with respect to `\n` character is correct, but I'm just going to start with this diff and see what the reaction is. --- .../Header/Components/DailyChallengeStatsDisplay.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs index a3dce89ad4..d1be7cecce 100644 --- a/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs +++ b/osu.Game/Overlays/Profile/Header/Components/DailyChallengeStatsDisplay.cs @@ -44,6 +44,8 @@ namespace osu.Game.Overlays.Profile.Header.Components { AutoSizeAxes = Axes.Both; + OsuTextFlowContainer label; + InternalChildren = new Drawable[] { content = new Container @@ -69,12 +71,9 @@ namespace osu.Game.Overlays.Profile.Header.Components Direction = FillDirection.Horizontal, Children = new Drawable[] { - new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + label = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) { AutoSizeAxes = Axes.Both, - // can't use this because osu-web does weird stuff with \\n. - // Text = UsersStrings.ShowDailyChallengeTitle., - Text = "Daily\nChallenge", Margin = new MarginPadding { Horizontal = 5f, Bottom = 2f }, }, new Container @@ -129,6 +128,10 @@ namespace osu.Game.Overlays.Profile.Header.Components } }, }; + + // can't use this because osu-web does weird stuff with \\n. + // Text = UsersStrings.ShowDailyChallengeTitle., + label.AddParagraph("Daily\nChallenge"); } protected override void LoadComplete() From 57e693e0c779076668384a72f3b5526b58ca85b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Apr 2025 11:17:52 +0200 Subject: [PATCH 11/29] Add failing test --- .../TestScenePlaylistsSongSelect.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 7c73fb8321..77fe96310f 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -6,8 +6,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; @@ -16,10 +20,12 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer { @@ -153,10 +159,40 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + [Test] + public void TestFreeModSelectionDisable() + { + FooterButtonFreeMods freeMods = null!; + + AddAssert("freestyle enabled", () => songSelect.Freestyle.Value, () => Is.True); + AddStep("click icon in free mods button", () => + { + freeMods = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mod select not visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("toggle freestyle off", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("freestyle disabled", () => songSelect.Freestyle.Value, () => Is.False); + AddStep("click icon in free mods button", () => + { + InputManager.MoveMouseTo(freeMods.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddAssert("mod select visible", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + private partial class TestPlaylistsSongSelect : PlaylistsSongSelect { public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails; + public new IBindable Freestyle => base.Freestyle; + public TestPlaylistsSongSelect(Room room) : base(room) { From fea1b73c173be0a81ea6f5a07547356b747e0798 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Apr 2025 11:25:09 +0200 Subject: [PATCH 12/29] Fix free mod selection sub-button being clickable even if the main button isn't Noticed in passing when testing https://github.com/ppy/osu/pull/32674. --- osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index 3605412b2b..ad780cd27d 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -80,6 +80,7 @@ namespace osu.Game.Screens.OnlinePlay Origin = Anchor.Centre, Scale = new Vector2(0.8f), Icon = FontAwesome.Solid.Bars, + Enabled = { BindTarget = Enabled }, Action = () => freeModSelectOverlay.ToggleVisibility() } }); From 7e6e082bac2907150f2a47a2519161888f62b47d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 22 Apr 2025 19:13:17 +0900 Subject: [PATCH 13/29] Avoid clearing global cache on entering song select --- osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index f5fefa52b5..61abe3bd86 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -92,6 +92,10 @@ namespace osu.Game.Screens.Select.Leaderboards var fetchBeatmapInfo = BeatmapInfo; var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo?.Ruleset; + // Without this check, an initial fetch will be performed and clear global cache. + if (fetchBeatmapInfo == null) + return null; + leaderboardManager.FetchWithCriteriaAsync(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null)) .ContinueWith(t => { From f7d1809cb7a59d458ab652231aaa7f94ebcb59a4 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 22 Apr 2025 19:00:45 +0900 Subject: [PATCH 14/29] Remove `LeaderboardManager` return value and simplify flow further The rationale for this change is that the return value was mostly useless, and at worst, misleading. When using `LeaderboardManager`, it's assumed that a consumer will bind to the global `Scores` list to ensure they receive updates for things like local score changes via the internal realm subscription. If one decides to instead use the return value of the task, it will be a static snapshot that potentially becomes stale in the future. I fell into this trap when refactoring the new leaderboard component (while attempting to assert correctness that the values we are displaying were in fact from the fetch operation we requested). In the interest of keeping things simple, removing the return value seems to be the best path forward. --- .../Online/Leaderboards/LeaderboardManager.cs | 50 +++++++++++++++---- osu.Game/OsuGame.cs | 7 +-- .../Select/Leaderboards/BeatmapLeaderboard.cs | 44 ++++++++-------- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs index cd77a28893..75f2972f29 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardManager.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; @@ -43,10 +44,14 @@ namespace osu.Game.Online.Leaderboards [Resolved] private RulesetStore rulesets { get; set; } = null!; - public Task FetchWithCriteriaAsync(LeaderboardCriteria newCriteria) + /// + /// Fetch leaderboard content with the new criteria specified in the background. + /// On completion, will be updated with the results from this call (unless a more recent call with a different criteria has completed). + /// + public void FetchWithCriteria(LeaderboardCriteria newCriteria) { if (CurrentCriteria?.Equals(newCriteria) == true && lastFetchCompletionSource?.Task.IsFaulted == false) - return lastFetchCompletionSource?.Task ?? Task.FromResult(Scores.Value); + return; CurrentCriteria = newCriteria; localScoreSubscription?.Dispose(); @@ -55,7 +60,10 @@ namespace osu.Game.Online.Leaderboards scores.Value = null; if (newCriteria.Beatmap == null || newCriteria.Ruleset == null) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected); + return; + } switch (newCriteria.Scope) { @@ -70,25 +78,40 @@ namespace osu.Game.Online.Leaderboards + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + $" AND {nameof(ScoreInfo.DeletePending)} == false" , newCriteria.Beatmap.ID, newCriteria.Ruleset.ShortName), localScoresChanged); - return localFetchCompletionSource.Task; + return; } default: { if (!api.IsLoggedIn) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn); + return; + } if (!newCriteria.Ruleset.IsLegacyRuleset()) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable); + return; + } if (newCriteria.Beatmap.OnlineID <= 0 || newCriteria.Beatmap.Status <= BeatmapOnlineStatus.Pending) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable); + return; + } if ((newCriteria.Scope.RequiresSupporter(newCriteria.ExactMods != null)) && !api.LocalUser.Value.IsSupporter) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter); + return; + } if (newCriteria.Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null) - return Task.FromResult(scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam)); + { + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam); + return; + } var onlineFetchCompletionSource = new TaskCompletionSource(); lastFetchCompletionSource = onlineFetchCompletionSource; @@ -119,9 +142,14 @@ namespace osu.Game.Online.Leaderboards if (onlineFetchCompletionSource.TrySetResult(result)) scores.Value = result; }; - newRequest.Failure += _ => onlineFetchCompletionSource.TrySetResult(LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure)); + newRequest.Failure += ex => + { + Logger.Log($@"Failed to fetch leaderboards when displaying results: {ex}", LoggingTarget.Network); + onlineFetchCompletionSource.TrySetResult(LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure)); + }; + api.Queue(inFlightOnlineRequest = newRequest); - return onlineFetchCompletionSource.Task; + break; } } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 0c6a06a8fc..cbb2d44a9a 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -801,12 +801,7 @@ namespace osu.Game var newLeaderboard = currentLeaderboard != null ? currentLeaderboard with { Beatmap = databasedBeatmap, Ruleset = databasedScore.ScoreInfo.Ruleset } : new LeaderboardCriteria(databasedBeatmap, databasedScore.ScoreInfo.Ruleset, BeatmapLeaderboardScope.Global, null); - LeaderboardManager.FetchWithCriteriaAsync(newLeaderboard) - .ContinueWith(t => - { - if (t.Exception != null) - Logger.Log($@"Failed to fetch leaderboards when displaying results: {t.Exception}", LoggingTarget.Network); - }); + LeaderboardManager.FetchWithCriteria(newLeaderboard); } switch (presentType) diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 61abe3bd86..1c62499162 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -62,7 +62,7 @@ namespace osu.Game.Screens.Select.Leaderboards } } - private readonly Bindable fetchedScores = new Bindable(); + private readonly IBindable fetchedScores = new Bindable(); [Resolved] private IBindable ruleset { get; set; } = null!; @@ -82,9 +82,10 @@ namespace osu.Game.Screens.Select.Leaderboards if (filterMods) RefetchScores(); }; - ((IBindable)fetchedScores).BindTo(leaderboardManager.Scores); } + private bool initialFetchComplete; + protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest? FetchScores(CancellationToken cancellationToken) @@ -96,30 +97,31 @@ namespace osu.Game.Screens.Select.Leaderboards if (fetchBeatmapInfo == null) return null; - leaderboardManager.FetchWithCriteriaAsync(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null)) - .ContinueWith(t => - { - if (t.Exception != null && !t.IsCanceled) - { - Schedule(() => SetErrorState(LeaderboardState.NetworkFailure)); - return; - } + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null)); - fetchedScores.UnbindEvents(); - fetchedScores.BindValueChanged(scores => - { - if (scores.NewValue == null) return; - - if (scores.NewValue.FailState == null) - Schedule(() => SetScores(scores.NewValue.TopScores, scores.NewValue.UserScore)); - else - Schedule(() => SetErrorState((LeaderboardState)scores.NewValue.FailState)); - }, true); - }, cancellationToken); + if (!initialFetchComplete) + { + // only bind this after the first fetch to avoid reading stale scores. + fetchedScores.BindTo(leaderboardManager.Scores); + fetchedScores.BindValueChanged(_ => updateScores(), true); + initialFetchComplete = true; + } return null; } + private void updateScores() + { + var scores = fetchedScores.Value; + + if (scores == null) return; + + if (scores.FailState == null) + Schedule(() => SetScores(scores.TopScores, scores.UserScore)); + else + Schedule(() => SetErrorState((LeaderboardState)scores.FailState)); + } + protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) { Action = () => ScoreSelected?.Invoke(model) From 4979dd86afb7d8d59f92f0e0c33932d08e32281a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Apr 2025 13:19:53 +0200 Subject: [PATCH 15/29] Simplify even further by removing all of the superfluous task completion sources --- .../Online/Leaderboards/LeaderboardManager.cs | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs index 75f2972f29..2144d8e8b3 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardManager.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -31,8 +30,6 @@ namespace osu.Game.Online.Leaderboards public LeaderboardCriteria? CurrentCriteria { get; private set; } private IDisposable? localScoreSubscription; - private TaskCompletionSource? localFetchCompletionSource; - private TaskCompletionSource? lastFetchCompletionSource; private GetScoresRequest? inFlightOnlineRequest; [Resolved] @@ -50,13 +47,12 @@ namespace osu.Game.Online.Leaderboards /// public void FetchWithCriteria(LeaderboardCriteria newCriteria) { - if (CurrentCriteria?.Equals(newCriteria) == true && lastFetchCompletionSource?.Task.IsFaulted == false) + if (CurrentCriteria?.Equals(newCriteria) == true && scores.Value?.FailState == null) return; CurrentCriteria = newCriteria; localScoreSubscription?.Dispose(); inFlightOnlineRequest?.Cancel(); - lastFetchCompletionSource?.TrySetCanceled(); scores.Value = null; if (newCriteria.Beatmap == null || newCriteria.Ruleset == null) @@ -69,9 +65,6 @@ namespace osu.Game.Online.Leaderboards { case BeatmapLeaderboardScope.Local: { - // this task completion source will be marked completed in the `localScoresChanged()` below. - // yes it's twisty, but such are the costs of trying to reconcile data-push / subscription and data-pull / explicit fetch flows. - lastFetchCompletionSource = localFetchCompletionSource = new TaskCompletionSource(); localScoreSubscription = realm.RegisterForNotifications(r => r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" @@ -113,9 +106,6 @@ namespace osu.Game.Online.Leaderboards return; } - var onlineFetchCompletionSource = new TaskCompletionSource(); - lastFetchCompletionSource = onlineFetchCompletionSource; - IReadOnlyList? requestMods = null; if (newCriteria.ExactMods != null) @@ -139,13 +129,13 @@ namespace osu.Game.Online.Leaderboards response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap) ); inFlightOnlineRequest = null; - if (onlineFetchCompletionSource.TrySetResult(result)) - scores.Value = result; + scores.Value = result; }; newRequest.Failure += ex => { Logger.Log($@"Failed to fetch leaderboards when displaying results: {ex}", LoggingTarget.Network); - onlineFetchCompletionSource.TrySetResult(LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure)); + if (ex is not OperationCanceledException) + scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure); }; api.Queue(inFlightOnlineRequest = newRequest); @@ -185,12 +175,6 @@ namespace osu.Game.Online.Leaderboards newScores = newScores.Detach().OrderByTotalScore(); scores.Value = LeaderboardScores.Success(newScores.ToArray(), null); - - if (localFetchCompletionSource != null && localFetchCompletionSource == lastFetchCompletionSource) - { - localFetchCompletionSource.SetResult(scores.Value); - localFetchCompletionSource = lastFetchCompletionSource = null; - } } } From 3b2382ceb0f61589092e7042057ab12b16db1085 Mon Sep 17 00:00:00 2001 From: Shavixinio Date: Tue, 22 Apr 2025 19:49:34 +0200 Subject: [PATCH 16/29] Minor fix to the description text --- osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs | 2 +- osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs | 2 +- osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs index cac5b9aa6a..f2c77d6a05 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModEasy.cs @@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Catch.Mods { public class CatchModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger fruits, more forgiving HP drain, less accuracy required, and extra lives!"; } } diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs index 5c8cd6a5ae..275643ca44 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModEasy.cs @@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Mania.Mods { public class ManiaModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"More forgiving HP drain, less accuracy required, and extra lives!"; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs index 281b36e70e..97fe0d0bf2 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModEasy.cs @@ -8,6 +8,6 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModEasy : ModEasyWithExtraLives { - public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and three lives!"; + public override LocalisableString Description => @"Larger circles, more forgiving HP drain, less accuracy required, and extra lives!"; } } From 83d2189b4f1bceedf890602cef2e8c4b5021f89d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 13:57:36 +0900 Subject: [PATCH 17/29] Remove loading layer --- osu.Game/Users/UserPanel.cs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/osu.Game/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 76b7894a9e..fc261163da 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -86,8 +86,6 @@ namespace osu.Game.Users [Resolved] private INotificationOverlay? notifications { get; set; } - private LoadingLayer loading { get; set; } = null!; - [BackgroundDependencyLoader] private void load() { @@ -104,7 +102,6 @@ namespace osu.Game.Users Add(background); Add(CreateLayout()); - Add(loading = new LoadingLayer(true)); base.Action = ViewProfile = () => { @@ -167,8 +164,8 @@ namespace osu.Game.Users })); items.Add(isUserBlocked() - ? new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => blockUser(false)) - : new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => blockUser(true))); + ? new OsuMenuItem(UsersStrings.BlocksButtonUnblock, MenuItemType.Standard, () => toggleBlock(false)) + : new OsuMenuItem(UsersStrings.BlocksButtonBlock, MenuItemType.Destructive, () => toggleBlock(true))); if (isUserOnline()) { @@ -196,15 +193,13 @@ namespace osu.Game.Users } } - private void blockUser(bool block) + private void toggleBlock(bool block) { - loading.Show(); APIRequest req = block ? new BlockUserRequest(User.OnlineID) : new UnblockUserRequest(User.OnlineID); req.Success += () => { api.UpdateLocalBlocks(); - loading.Hide(); }; req.Failure += e => @@ -214,7 +209,6 @@ namespace osu.Game.Users Text = e.Message, Icon = FontAwesome.Solid.Times, }); - loading.Hide(); }; api.Queue(req); From f945abb72eea310c5e15f0757b9e3ed940cd2b53 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 14:51:00 +0900 Subject: [PATCH 18/29] Always refresh leaderboard for now --- osu.Game/Online/Leaderboards/LeaderboardManager.cs | 4 ++-- osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs index 2144d8e8b3..dd68085103 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardManager.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -45,9 +45,9 @@ namespace osu.Game.Online.Leaderboards /// Fetch leaderboard content with the new criteria specified in the background. /// On completion, will be updated with the results from this call (unless a more recent call with a different criteria has completed). /// - public void FetchWithCriteria(LeaderboardCriteria newCriteria) + public void FetchWithCriteria(LeaderboardCriteria newCriteria, bool forceRefresh = false) { - if (CurrentCriteria?.Equals(newCriteria) == true && scores.Value?.FailState == null) + if (!forceRefresh && CurrentCriteria?.Equals(newCriteria) == true && scores.Value?.FailState == null) return; CurrentCriteria = newCriteria; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 1c62499162..8197319102 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -97,7 +97,10 @@ namespace osu.Game.Screens.Select.Leaderboards if (fetchBeatmapInfo == null) return null; - leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null)); + // For now, we forcefully refresh to keep things simple. + // In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios + // (like returning from gameplay after setting a new score, returning to song select after main menu). + leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null), forceRefresh: true); if (!initialFetchComplete) { From b9fe5079fc074744197de9bbb20d263e96d549c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 23 Apr 2025 08:55:29 +0200 Subject: [PATCH 19/29] Fix fps counter test scene being half broken --- osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs index a91e6e3350..f38fa05218 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneFPSCounter.cs @@ -2,10 +2,12 @@ // 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.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -14,6 +16,9 @@ namespace osu.Game.Tests.Visual.UserInterface { public partial class TestSceneFPSCounter : OsuTestScene { + [Resolved] + private OsuConfigManager config { get; set; } = null!; + [SetUpSteps] public void SetUpSteps() { @@ -41,6 +46,7 @@ namespace osu.Game.Tests.Visual.UserInterface }, }; }); + AddToggleStep("toggle show", b => config.SetValue(OsuSetting.ShowFpsDisplay, b)); } [Test] From 3f98dd93edd5a18233243766f44009ff01a7771f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 23 Apr 2025 08:55:41 +0200 Subject: [PATCH 20/29] Fix increased spacing on fps counter tooltip --- osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs index 17e7be1d8b..e64a4c6c07 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounterTooltip.cs @@ -44,7 +44,8 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.Both, TextAnchor = Anchor.TopRight, Margin = new MarginPadding { Left = 5, Vertical = 10 }, - Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)) + Text = string.Join('\n', gameHost.Threads.Select(t => t.Name)), + ParagraphSpacing = 0, }, textFlow = new OsuTextFlowContainer(cp => { @@ -56,6 +57,7 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Left = 35, Right = 10, Vertical = 10 }, AutoSizeAxes = Axes.Y, TextAnchor = Anchor.TopRight, + ParagraphSpacing = 0, }, }; } From eafb52ffb4767a0a1fff44c384ca17bfc14ba588 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 16:04:38 +0900 Subject: [PATCH 21/29] Add failing test --- .../TestSceneMultiplayerMatchSubScreen.cs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 2def7aeb1c..a94f440a01 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -426,6 +426,31 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("countdown started", () => MultiplayerClient.ServerRoom!.ActiveCountdowns.Any()); } + [Test] + public void TestSettingsRemainsOpenOnRoomUpdate() + { + AddStep("set playlist", () => + { + room.Playlist = + [ + new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + + ClickButtonWhenEnabled(); + + AddUntilStep("wait for room join", () => RoomJoined); + + AddStep("open settings", () => this.ChildrenOfType().Single().Show()); + AddAssert("settings opened", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("trigger room update", () => MultiplayerClient.AddPlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0].Clone())); + AddAssert("settings still open", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen { [Resolved(canBeNull: true)] From 5ea408f3a13aff27675d6dcb879973e4fba15608 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 16:05:26 +0900 Subject: [PATCH 22/29] Keep multiplayer settings open during room updates --- .../Multiplayer/MultiplayerMatchSubScreen.cs | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 6d271a0077..db1b8262b7 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -431,14 +430,25 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// private void onRoomUpdated() => Scheduler.AddOnce(() => { - bool newIsRoomJoined = client.Room != null; + bool wasRoomJoined = isRoomJoined; + isRoomJoined = client.Room != null; - if (newIsRoomJoined) + // Creating a room. + if (!wasRoomJoined && !isRoomJoined) + { + roomContent.Hide(); + settingsOverlay.Show(); + } + + // Joining a room. + if (!wasRoomJoined && isRoomJoined) { roomContent.Show(); settingsOverlay.Hide(); } - else if (isRoomJoined) + + // Leaving a room. + if (wasRoomJoined && !isRoomJoined) { Logger.Log($"{this} exiting due to loss of room or connection"); @@ -447,17 +457,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer else ValidForResume = false; } - else - { - Debug.Assert(!isRoomJoined && !newIsRoomJoined); - - // A new room is being created. - // The main content should be hidden until the settings overlay is hidden, signaling the room is ready to be displayed. - roomContent.Hide(); - settingsOverlay.Show(); - } - - isRoomJoined = newIsRoomJoined; }); /// From 883df07ff6d5fd8880c4fea079f8939a6996c103 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 16:23:37 +0900 Subject: [PATCH 23/29] Adjust tests and transitions --- .../TestSceneBeatmapMetadataWedge.cs | 22 ++++++- .../Screens/SelectV2/BeatmapMetadataWedge.cs | 64 ++++++++++++------- 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs index 769188eb71..be2e6eb9bf 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs @@ -18,6 +18,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { private APIBeatmapSet? currentOnlineSet; + private BeatmapMetadataWedge wedge = null!; + protected override void LoadComplete() { base.LoadComplete(); @@ -40,22 +42,36 @@ namespace osu.Game.Tests.Visual.SongSelectV2 } }; - Child = new BeatmapMetadataWedge + Child = wedge = new BeatmapMetadataWedge { State = { Value = Visibility.Visible }, }; } [Test] - public void TestDisplay() + public void TestShowHide() { - AddStep("null beatmap", () => Beatmap.SetDefault()); AddStep("all metrics", () => { var (working, onlineSet) = createTestBeatmap(); currentOnlineSet = onlineSet; Beatmap.Value = working; }); + + AddStep("hide wedge", () => wedge.Hide()); + AddStep("show wedge", () => wedge.Show()); + } + + [Test] + public void TestVariousMetrics() + { + AddStep("all metrics", () => + { + var (working, onlineSet) = createTestBeatmap(); + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("null beatmap", () => Beatmap.SetDefault()); AddStep("no source", () => { var (working, onlineSet) = createTestBeatmap(); diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index a83ec51b11..816dfc3f95 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -35,6 +35,8 @@ namespace osu.Game.Screens.SelectV2 private Drawable failRetryWedge = null!; private FailRetryDisplay failRetryDisplay = null!; + protected override bool StartHidden => true; + [Resolved] private IBindable beatmap { get; set; } = null!; @@ -225,16 +227,47 @@ namespace osu.Game.Screens.SelectV2 apiState.BindValueChanged(_ => Scheduler.AddOnce(updateDisplay), true); } + private const double transition_duration = 300; + protected override void PopIn() { - this.FadeIn(300, Easing.OutQuint) - .MoveToX(0, 300, Easing.OutQuint); + this.FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + + updateSubWedgeVisibility(); } protected override void PopOut() { - this.FadeOut(300, Easing.OutQuint) - .MoveToX(-100, 300, Easing.OutQuint); + this.FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-100, transition_duration, Easing.OutQuint); + + updateSubWedgeVisibility(); + } + + private void updateSubWedgeVisibility() + { + // We could consider hiding individual wedges based on zero data in the future. + // Needs some experimentation on what looks good. + + if (State.Value == Visibility.Visible && currentOnlineBeatmapSet != null) + { + ratingsWedge.FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + + failRetryWedge.Delay(100) + .FadeIn(transition_duration, Easing.OutQuint) + .MoveToX(0, transition_duration, Easing.OutQuint); + } + else + { + ratingsWedge.FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-50, transition_duration, Easing.OutQuint); + + failRetryWedge.Delay(100) + .FadeOut(transition_duration, Easing.OutQuint) + .MoveToX(-50, transition_duration, Easing.OutQuint); + } } private void updateDisplay() @@ -291,16 +324,13 @@ namespace osu.Game.Screens.SelectV2 { genre.Data = null; language.Data = null; + return; } - else if (currentOnlineBeatmapSet == null) + + if (currentOnlineBeatmapSet == null) { genre.Data = ("-", null); language.Data = ("-", null); - - ratingsWedge.FadeOut(300, Easing.OutQuint); - ratingsWedge.MoveToX(-50, 300, Easing.OutQuint); - failRetryWedge.FadeOut(300, Easing.OutQuint); - failRetryWedge.MoveToX(-50, 300, Easing.OutQuint); } else { @@ -314,24 +344,14 @@ namespace osu.Game.Screens.SelectV2 if (onlineBeatmap != null) { - ratingsWedge.FadeIn(300, Easing.OutQuint); - ratingsWedge.MoveToX(0, 300, Easing.OutQuint); - failRetryWedge.FadeIn(300, Easing.OutQuint); - failRetryWedge.MoveToX(0, 300, Easing.OutQuint); - userRatingDisplay.Data = onlineBeatmapSet.Ratings; ratingSpreadDisplay.Data = onlineBeatmapSet.Ratings; successRateDisplay.Data = (onlineBeatmap.PassCount, onlineBeatmap.PlayCount); failRetryDisplay.Data = onlineBeatmap.FailTimes ?? new APIFailTimes(); } - else - { - ratingsWedge.FadeOut(300, Easing.OutQuint); - ratingsWedge.MoveToX(-50, 300, Easing.OutQuint); - failRetryWedge.FadeOut(300, Easing.OutQuint); - failRetryWedge.MoveToX(-50, 300, Easing.OutQuint); - } } + + updateSubWedgeVisibility(); } } } From 3f719125e6a4a6c1f8b17d7bc52b45c917593c67 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 16:57:41 +0900 Subject: [PATCH 24/29] Define constant for difficulty colour cutoff --- osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs | 4 ++-- osu.Game/Graphics/OsuColour.cs | 5 +++++ osu.Game/Screens/SelectV2/PanelBeatmap.cs | 2 +- osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index 050a78a6b4..eaadf43ad4 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -152,8 +152,8 @@ namespace osu.Game.Beatmaps.Drawables background.Colour = colours.ForStarDifficulty(s.NewValue); - starIcon.Colour = s.NewValue >= 6.5 ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); - starsText.Colour = s.NewValue >= 6.5 ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); + starIcon.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4Extensions.FromHex("303d47"); + starsText.Colour = s.NewValue >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider?.Background5 ?? Color4.Black.Opacity(0.75f); }, true); } } diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index dd5e19e167..ff78e93b5e 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -20,6 +20,11 @@ namespace osu.Game.Graphics public static Color4 Gray(float amt) => new Color4(amt, amt, amt, 1f); public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255); + /// + /// The maximum star rating colour which can be distinguished against a black background. + /// + public const float STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF = 6.5f; + public static readonly (float, Color4)[] STAR_DIFFICULTY_SPECTRUM = { (0.1f, Color4Extensions.FromHex("aaaaaa")), diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index c8ae443364..20c27dba92 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -236,7 +236,7 @@ namespace osu.Game.Screens.SelectV2 starRatingDisplay.Current.Value = starDifficulty; starCounter.Current = (float)starDifficulty.Stars; - difficultyIcon.FadeColour(starDifficulty.Stars > 6.5f ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); + difficultyIcon.FadeColour(starDifficulty.Stars > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); var starRatingColour = colours.ForStarDifficulty(starDifficulty.Stars); starCounter.FadeColour(starRatingColour, duration, Easing.OutQuint); diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index a90a84d115..9a61ce998c 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -270,7 +270,7 @@ namespace osu.Game.Screens.SelectV2 var starDifficulty = starDifficultyBindable?.Value ?? default; AccentColour = colours.ForStarDifficulty(starDifficulty.Stars); - difficultyIcon.FadeColour(starDifficulty.Stars > 6.5f ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); + difficultyIcon.FadeColour(starDifficulty.Stars > OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : colourProvider.Background5, duration, Easing.OutQuint); difficultyStarRating.Current.Value = starDifficulty; } } From f6d7e29396286ae60b200a718bbfdadd54732eb2 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 16:59:15 +0900 Subject: [PATCH 25/29] Improve star rating colour animations to match --- .../TestSceneBeatmapTitleWedge.cs | 32 +++++++++---------- .../Beatmaps/Drawables/StarRatingDisplay.cs | 10 +++++- .../BeatmapTitleWedge_DifficultyDisplay.cs | 25 ++++++++------- ...pTitleWedge_DifficultyStatisticsDisplay.cs | 5 ++- 4 files changed, 42 insertions(+), 30 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 8a674d43a5..8454781e32 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -57,6 +57,22 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }); } + [Test] + public void TestRulesetChange() + { + selectBeatmap(Beatmap.Value.Beatmap); + + AddWaitStep("wait for select", 3); + + foreach (var rulesetInfo in rulesets.AvailableRulesets) + { + var testBeatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(rulesetInfo); + + setRuleset(rulesetInfo); + selectBeatmap(testBeatmap); + } + } + [Test] public void TestNullBeatmap() { @@ -90,22 +106,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkDisplayedBPM($"{bpm * 0.75f}"); } - [Test] - public void TestRulesetChange() - { - selectBeatmap(Beatmap.Value.Beatmap); - - AddWaitStep("wait for select", 3); - - foreach (var rulesetInfo in rulesets.AvailableRulesets) - { - var testBeatmap = TestSceneBeatmapInfoWedge.CreateTestBeatmap(rulesetInfo); - - setRuleset(rulesetInfo); - selectBeatmap(testBeatmap); - } - } - [Test] public void TestWedgeVisibility() { diff --git a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs index eaadf43ad4..93d1f5d5c5 100644 --- a/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/StarRatingDisplay.cs @@ -23,6 +23,8 @@ namespace osu.Game.Beatmaps.Drawables /// public partial class StarRatingDisplay : CompositeDrawable, IHasCurrentValue { + public const double TRANSFORM_DURATION = 750; + private readonly bool animated; private readonly Box background; private readonly SpriteIcon starIcon; @@ -36,6 +38,12 @@ namespace osu.Game.Beatmaps.Drawables set => current.Current = value; } + /// + /// The difficulty colour currently displayed. + /// Can be used to have other components match the spectrum animation. + /// + public Color4 DisplayedDifficultyColour => background.Colour; + private readonly Bindable displayedStars = new BindableDouble(); /// @@ -139,7 +147,7 @@ namespace osu.Game.Beatmaps.Drawables Current.BindValueChanged(c => { if (animated) - this.TransformBindableTo(displayedStars, c.NewValue.Stars, 750, Easing.OutQuint); + this.TransformBindableTo(displayedStars, c.NewValue.Stars, TRANSFORM_DURATION, Easing.OutQuint); else displayedStars.Value = c.NewValue.Stars; }); diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index e8b2ccb04a..7b6fd81267 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -236,7 +236,10 @@ namespace osu.Game.Screens.SelectV2 updateDisplay(); - displayedStars.BindValueChanged(_ => updateStars(), true); + displayedStars.BindValueChanged(_ => + { + starRatingDisplay.Current.Value = new StarDifficulty(displayedStars.Value, 0); + }, true); FinishTransforms(true); } @@ -330,17 +333,6 @@ namespace osu.Game.Screens.SelectV2 }; }); - private void updateStars() - { - starRatingDisplay.Current.Value = new StarDifficulty(displayedStars.Value, 0); - - Color4 colour = displayedStars.Value >= 6.5f ? colours.Orange1 : colours.ForStarDifficulty(displayedStars.Value); - difficultyText.FadeColour(colour, 300, Easing.OutQuint); - mappedByText.FadeColour(colour, 300, Easing.OutQuint); - countStatisticsDisplay.TransformTo(nameof(countStatisticsDisplay.AccentColour), colour, 300, Easing.OutQuint); - difficultyStatisticsDisplay.TransformTo(nameof(difficultyStatisticsDisplay.AccentColour), colour, 300, Easing.OutQuint); - } - private void computeStarDifficulty(CancellationToken cancellationToken) { difficultyCache.GetDifficultyAsync(beatmap.Value.BeatmapInfo, ruleset.Value, mods.Value, cancellationToken) @@ -360,7 +352,16 @@ namespace osu.Game.Screens.SelectV2 protected override void Update() { base.Update(); + difficultyText.MaxWidth = Math.Max(nameLine.DrawWidth - mappedByText.DrawWidth - mapperText.DrawWidth - 20, 0); + + // Use difficulty colour until it gets too dark to be visible against dark backgrounds. + Color4 col = starRatingDisplay.DisplayedStars.Value >= OsuColour.STAR_DIFFICULTY_DEFINED_COLOUR_CUTOFF ? colours.Orange1 : starRatingDisplay.DisplayedDifficultyColour; + + difficultyText.Colour = col; + mappedByText.Colour = col; + countStatisticsDisplay.AccentColour = col; + difficultyStatisticsDisplay.AccentColour = col; } private partial class MapperLinkContainer : OsuHoverContainer diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs index 1cafe1c6db..aaf3d5f9d6 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs @@ -21,7 +21,7 @@ namespace osu.Game.Screens.SelectV2 { public partial class BeatmapTitleWedge { - public partial class DifficultyStatisticsDisplay : CompositeDrawable, IHasAccentColour + public partial class DifficultyStatisticsDisplay : CompositeDrawable { private readonly bool autoSize; private readonly FillFlowContainer statisticsFlow; @@ -51,6 +51,9 @@ namespace osu.Game.Screens.SelectV2 get => accentColour; set { + if (accentColour == value) + return; + accentColour = value; foreach (var statistic in statisticsFlow) From c11220df9b2e84248a84b55dc8c0f973c9ccb5f7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 17:08:48 +0900 Subject: [PATCH 26/29] Remove silly bindable flow that only exists for testing purposes --- .../Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs | 4 ++-- .../SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs | 11 +---------- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 8454781e32..c97af5a835 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -4,12 +4,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Drawables; using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddSliderStep("change star difficulty", 0, 11.9, 4.18, v => { - ((BindableDouble)difficultyDisplay.DisplayedStars).Value = v; + difficultyDisplay.ChildrenOfType().Single().Current.Value = new StarDifficulty(v, 0); }); } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index 7b6fd81267..cb5046b227 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -72,10 +72,6 @@ namespace osu.Game.Screens.SelectV2 private CancellationTokenSource? cancellationSource; - public IBindable DisplayedStars => displayedStars; - - private readonly Bindable displayedStars = new BindableDouble(); - public DifficultyDisplay() { RelativeSizeAxes = Axes.X; @@ -236,10 +232,6 @@ namespace osu.Game.Screens.SelectV2 updateDisplay(); - displayedStars.BindValueChanged(_ => - { - starRatingDisplay.Current.Value = new StarDifficulty(displayedStars.Value, 0); - }, true); FinishTransforms(true); } @@ -343,8 +335,7 @@ namespace osu.Game.Screens.SelectV2 if (cancellationToken.IsCancellationRequested) return; - var result = task.GetResultSafely() ?? default; - displayedStars.Value = result.Stars; + starRatingDisplay.Current.Value = task.GetResultSafely() ?? default; }); }, cancellationToken); } From 9372ba02d1432f3d884f4c8ac1e343a82b233f0c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 17:18:51 +0900 Subject: [PATCH 27/29] Fix animations and alignment of tiny statistics --- osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs | 11 +++++++---- .../SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs | 2 -- .../BeatmapTitleWedge_DifficultyStatisticsDisplay.cs | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 9d1be2fc37..4de896d777 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -41,6 +41,8 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IBindable> mods { get; set; } = null!; + protected override bool StartHidden => true; + private ModSettingChangeTracker? settingChangeTracker; private BeatmapSetOnlineStatusPill statusPill = null!; @@ -71,6 +73,8 @@ namespace osu.Game.Screens.SelectV2 private APIBeatmapSet? currentOnlineBeatmapSet; private GetBeatmapSetRequest? currentRequest; + private FillFlowContainer statisticsFlow = null!; + public BeatmapTitleWedge() { RelativeSizeAxes = Axes.X; @@ -139,14 +143,12 @@ namespace osu.Game.Screens.SelectV2 }, } }), - new ShearAligningWrapper(new FillFlowContainer + new ShearAligningWrapper(statisticsFlow = new FillFlowContainer { Shear = -OsuGame.SHEAR, AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(2f, 0f), - AutoSizeDuration = 100, - AutoSizeEasing = Easing.OutQuint, Children = new Drawable[] { playCount = new StatisticPlayCount(background: true, leftPadding: SongSelect.WEDGE_CONTENT_MARGIN, minSize: 50f) @@ -198,7 +200,8 @@ namespace osu.Game.Screens.SelectV2 updateDisplay(); - FinishTransforms(true); + statisticsFlow.AutoSizeDuration = 100; + statisticsFlow.AutoSizeEasing = Easing.OutQuint; } protected override void PopIn() diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index cb5046b227..07ec1fdade 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -231,8 +231,6 @@ namespace osu.Game.Screens.SelectV2 }); updateDisplay(); - - FinishTransforms(true); } [Resolved] diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs index aaf3d5f9d6..a185448f36 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyStatisticsDisplay.cs @@ -88,6 +88,8 @@ namespace osu.Game.Screens.SelectV2 { Alpha = 0f, AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize), @@ -169,9 +171,8 @@ namespace osu.Game.Screens.SelectV2 private void updateStatistics() { - var oldStatistics = statisticsFlow.Select(s => s.Value).ToArray(); - - if (oldStatistics.Select(s => s.Label).SequenceEqual(statistics.Select(s => s.Label))) + if (statisticsFlow.Select(s => s.Value.Label) + .SequenceEqual(statistics.Select(s => s.Label))) { for (int i = 0; i < statistics.Count; i++) statisticsFlow[i].Value = statistics[i]; From 4290f2d4fd2d74e0fc9cd983f21e94af8b1faee7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 17:29:37 +0900 Subject: [PATCH 28/29] Simplify and fix naming of statistic class --- .../TestSceneBeatmapTitleWedge.cs | 2 +- .../TestSceneBeatmapTitleWedgeStatistic.cs | 22 ++++++---- .../Screens/SelectV2/BeatmapTitleWedge.cs | 10 ++--- .../SelectV2/BeatmapTitleWedge_Statistic.cs | 41 +++++++++++-------- .../BeatmapTitleWedge_StatisticPlayCount.cs | 11 ++++- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index c97af5a835..6a14ddc147 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -154,7 +154,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddUntilStep($"displayed bpm is {target}", () => { var label = titleWedge.ChildrenOfType().Single(l => l.TooltipText == BeatmapsetsStrings.ShowStatsBpm); - return label.Value == target; + return label.Text == target; }); } } diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs index 96eab3e8ec..6bf9469021 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedgeStatistic.cs @@ -29,14 +29,22 @@ namespace osu.Game.Tests.Visual.SongSelectV2 public void TestLoading() { AddStep("setup", () => CreateThemedContent(OverlayColourScheme.Aquamarine)); - AddStep("set loading", () => this.ChildrenOfType().ForEach(s => s.Value = null)); + AddStep("set loading", () => this.ChildrenOfType().ForEach(s => s.Text = null)); AddWaitStep("wait", 3); AddStep("set values", () => { playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(1234, 12); - statistic2.Value = "3,234"; - statistic3.Value = "12:34"; - statistic4.Value = "123"; + statistic2.Text = "3,234"; + statistic3.Text = "12:34"; + statistic4.Text = "123"; + }); + + AddStep("set large values", () => + { + playCount.Value = new BeatmapTitleWedge.StatisticPlayCount.Data(134587921, 502); + statistic2.Text = "1,048,576"; + statistic3.Text = "2:50:23"; + statistic4.Text = "1238014"; }); } @@ -54,18 +62,18 @@ namespace osu.Game.Tests.Visual.SongSelectV2 }, statistic2 = new BeatmapTitleWedge.Statistic(OsuIcon.Clock, true, minSize: 30) { - Value = "3,234", + Text = "3,234", TooltipText = "Statistic 2", }, statistic3 = new BeatmapTitleWedge.Statistic(OsuIcon.Metronome) { - Value = "12:34", + Text = "12:34", Margin = new MarginPadding { Right = 10f }, TooltipText = "Statistic 3", }, statistic4 = new BeatmapTitleWedge.Statistic(OsuIcon.Graphics) { - Value = "123", + Text = "123", TooltipText = "Statistic 4", }, }, diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 4de896d777..d892fcb485 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -260,10 +260,10 @@ namespace osu.Game.Screens.SelectV2 double drainLength = Math.Round(beatmap.Value.Beatmap.CalculateDrainLength() / rate); double hitLength = Math.Round(beatmapInfo.Length / rate); - lengthStatistic.Value = hitLength.ToFormattedDuration(); + lengthStatistic.Text = hitLength.ToFormattedDuration(); lengthStatistic.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(drainLength.ToFormattedDuration()); - bpmStatistic.Value = bpmMin == bpmMax + bpmStatistic.Text = bpmMin == bpmMax ? $"{bpmMin}" : $"{bpmMin}-{bpmMax} (mostly {mostCommonBPM})"; } @@ -296,12 +296,12 @@ namespace osu.Game.Screens.SelectV2 if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) { playCount.Value = null; - favouritesStatistic.Value = null; + favouritesStatistic.Text = null; } else if (currentOnlineBeatmapSet == null) { playCount.Value = new StatisticPlayCount.Data(-1, -1); - favouritesStatistic.Value = "-"; + favouritesStatistic.Text = "-"; } else { @@ -320,7 +320,7 @@ namespace osu.Game.Screens.SelectV2 } favouritesStatistic.FadeIn(300, Easing.OutQuint); - favouritesStatistic.Value = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); + favouritesStatistic.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs index b4ec72761f..85a0382360 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_Statistic.cs @@ -29,27 +29,15 @@ namespace osu.Game.Screens.SelectV2 private OsuSpriteText valueText = null!; private LoadingSpinner loading = null!; - private LocalisableString? value; + private LocalisableString? text; - public LocalisableString? Value + public LocalisableString? Text { - get => value; + get => text; set { - this.value = value; - - Schedule(() => - { - loading.State.Value = value != null ? Visibility.Hidden : Visibility.Visible; - - if (value != null) - { - valueText.Text = value.Value; - valueText.FadeIn(120, Easing.OutQuint); - } - else - valueText.FadeOut(120, Easing.OutQuint); - }); + text = value; + Scheduler.AddOnce(updateDisplay); } } @@ -146,6 +134,25 @@ namespace osu.Game.Screens.SelectV2 } }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + Scheduler.AddOnce(updateDisplay); + } + + private void updateDisplay() + { + loading.State.Value = text != null ? Visibility.Hidden : Visibility.Visible; + + if (text != null) + { + valueText.Text = text.Value; + valueText.FadeIn(120, Easing.OutQuint); + } + else + valueText.FadeOut(120, Easing.OutQuint); + } } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs index 2d480ad5f4..87f7c30d17 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_StatisticPlayCount.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.LocalisationExtensions; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; @@ -21,15 +23,20 @@ namespace osu.Game.Screens.SelectV2 { public partial class StatisticPlayCount : Statistic, IHasCustomTooltip { - public new Data? Value + public Data? Value { set { - base.Value = value?.Total < 0 ? "-" : value?.Total.ToLocalisableString("N0"); + base.Text = value?.Total < 0 ? "-" : value?.Total.ToLocalisableString("N0"); TooltipContent = value; } } + public new LocalisableString? Text + { + set => throw new InvalidOperationException($"Use {nameof(Value)} instead."); + } + public Data? TooltipContent { get; private set; } [Resolved] From 5ad28a792b683191e5d21bbff04299766b4eb3b5 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 17:34:59 +0900 Subject: [PATCH 29/29] Fix "mapped by" line showing stupid display when switching to default beatmap --- .../Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs index 07ec1fdade..7e3589b001 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_DifficultyDisplay.cs @@ -246,8 +246,6 @@ namespace osu.Game.Screens.SelectV2 if (beatmap.IsDefault) { ratingAndNameContainer.FadeOut(300, Easing.OutQuint); - difficultyText.Text = string.Empty; - mapperText.Text = string.Empty; countStatisticsDisplay.Statistics = Array.Empty(); } else