1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-18 10:53:21 +08:00

Merge branch 'master' into beatmap-carousel-v2

This commit is contained in:
Dean Herbert 2025-01-14 19:06:52 +09:00
commit 8d41eda91a
No known key found for this signature in database
25 changed files with 557 additions and 36 deletions

View File

@ -37,7 +37,7 @@ You can also generally download a version for your current device from the [osu!
If your platform is unsupported or not listed above, there is still a chance you can run the release or manually build it by following the instructions below.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores in early 2024.
**For iOS/iPadOS users**: The iOS testflight link fills up very fast (Apple has a hard limit of 10,000 users). We reset it occasionally. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements. Our goal is to get the game on mobile app stores very soon so we don't have to live with this limitation.
## Developing a custom ruleset

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.1224.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.114.1" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -67,7 +67,12 @@ namespace osu.Desktop
{
try
{
stableInstallPath = getStableInstallPathFromRegistry();
stableInstallPath = getStableInstallPathFromRegistry("osustable.File.osz");
if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath))
return stableInstallPath;
stableInstallPath = getStableInstallPathFromRegistry("osu!");
if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath))
return stableInstallPath;
@ -89,9 +94,9 @@ namespace osu.Desktop
}
[SupportedOSPlatform("windows")]
private string? getStableInstallPathFromRegistry()
private string? getStableInstallPathFromRegistry(string progId)
{
using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey("osu!"))
using (RegistryKey? key = Registry.ClassesRoot.OpenSubKey(progId))
return key?.OpenSubKey(WindowsAssociationManager.SHELL_OPEN_COMMAND)?.GetValue(string.Empty)?.ToString()?.Split('"')[1].Replace("osu!.exe", "");
}

View File

@ -0,0 +1,129 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Metadata;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Tests.Visual.Metadata;
using osu.Game.Users;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Components
{
public partial class TestSceneFriendPresenceNotifier : OsuManualInputManagerTestScene
{
private ChannelManager channelManager = null!;
private NotificationOverlay notificationOverlay = null!;
private ChatOverlay chatOverlay = null!;
private TestMetadataClient metadataClient = null!;
[SetUp]
public void Setup() => Schedule(() =>
{
Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(ChannelManager), channelManager = new ChannelManager(API)),
(typeof(INotificationOverlay), notificationOverlay = new NotificationOverlay()),
(typeof(ChatOverlay), chatOverlay = new ChatOverlay()),
(typeof(MetadataClient), metadataClient = new TestMetadataClient()),
],
Children = new Drawable[]
{
channelManager,
notificationOverlay,
chatOverlay,
metadataClient,
new FriendPresenceNotifier()
}
};
for (int i = 1; i <= 100; i++)
((DummyAPIAccess)API).Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } });
});
[Test]
public void TestNotifications()
{
AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
AddStep("bring friend 1 offline", () => metadataClient.FriendPresenceUpdated(1, null));
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
}
[Test]
public void TestSingleUserNotificationOpensChat()
{
AddStep("bring friend 1 online", () => metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online }));
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
AddStep("click notification", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Notification>().First());
InputManager.Click(MouseButton.Left);
});
AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible));
AddUntilStep("user channel selected", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo(((DummyAPIAccess)API).Friends[0].TargetUser!.Username));
}
[Test]
public void TestMultipleUserNotificationDoesNotOpenChat()
{
AddStep("bring friends 1 & 2 online", () =>
{
metadataClient.FriendPresenceUpdated(1, new UserPresence { Status = UserStatus.Online });
metadataClient.FriendPresenceUpdated(2, new UserPresence { Status = UserStatus.Online });
});
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
AddStep("click notification", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Notification>().First());
InputManager.Click(MouseButton.Left);
});
AddAssert("chat overlay not opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Hidden));
}
[Test]
public void TestNonFriendsDoNotNotify()
{
AddStep("bring non-friend 1000 online", () => metadataClient.UserPresenceUpdated(1000, new UserPresence { Status = UserStatus.Online }));
AddWaitStep("wait for possible notification", 10);
AddAssert("no notification", () => notificationOverlay.AllNotifications.Count(), () => Is.Zero);
}
[Test]
public void TestPostManyDebounced()
{
AddStep("bring friends 1-10 online", () =>
{
for (int i = 1; i <= 10; i++)
metadataClient.FriendPresenceUpdated(i, new UserPresence { Status = UserStatus.Online });
});
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(1));
AddStep("bring friends 1-10 offline", () =>
{
for (int i = 1; i <= 10; i++)
metadataClient.FriendPresenceUpdated(i, null);
});
AddUntilStep("wait for notification", () => notificationOverlay.AllNotifications.Count(), () => Is.EqualTo(2));
}
}
}

View File

@ -5,6 +5,7 @@
using System;
using System.Linq;
using Newtonsoft.Json;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
@ -102,6 +103,77 @@ namespace osu.Game.Tests.Visual.Navigation
AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get<SkinManager>().CurrentSkin.Value.SkinInfo.Value.Protected);
}
[Test]
public void TestMutateProtectedSkinFromMainMenu_UndoToInitialStateIsCorrect()
{
AddStep("set default skin", () => Game.Dependencies.Get<SkinManager>().CurrentSkinInfo.SetDefault());
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
openSkinEditor();
AddUntilStep("current skin is mutable", () => !Game.Dependencies.Get<SkinManager>().CurrentSkin.Value.SkinInfo.Value.Protected);
AddUntilStep("wait for player", () =>
{
DismissAnyNotifications();
return Game.ScreenStack.CurrentScreen is Player;
});
string state = string.Empty;
AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType<ArgonAccuracyCounter>().Any(counter => counter.Position != new Vector2()));
AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType<ArgonAccuracyCounter>().First().CreateSerialisedInfo()));
AddStep("add any component", () => Game.ChildrenOfType<SkinComponentToolbox.ToolboxComponentButton>().First().TriggerClick());
AddStep("undo", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Z);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddUntilStep("only one accuracy meter left",
() => Game.ChildrenOfType<Player>().Single().ChildrenOfType<ArgonAccuracyCounter>().Count(),
() => Is.EqualTo(1));
AddAssert("accuracy meter state unchanged",
() => JsonConvert.SerializeObject(Game.ChildrenOfType<ArgonAccuracyCounter>().First().CreateSerialisedInfo()),
() => Is.EqualTo(state));
}
[Test]
public void TestMutateProtectedSkinFromPlayer_UndoToInitialStateIsCorrect()
{
AddStep("set default skin", () => Game.Dependencies.Get<SkinManager>().CurrentSkinInfo.SetDefault());
AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely());
advanceToSongSelect();
AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
AddStep("enable NF", () => Game.SelectedMods.Value = new[] { new OsuModNoFail() });
AddStep("enter gameplay", () => InputManager.Key(Key.Enter));
AddUntilStep("wait for player", () =>
{
DismissAnyNotifications();
return Game.ScreenStack.CurrentScreen is Player;
});
openSkinEditor();
string state = string.Empty;
AddUntilStep("wait for accuracy counter", () => Game.ChildrenOfType<ArgonAccuracyCounter>().Any(counter => counter.Position != new Vector2()));
AddStep("dump state of accuracy meter", () => state = JsonConvert.SerializeObject(Game.ChildrenOfType<ArgonAccuracyCounter>().First().CreateSerialisedInfo()));
AddStep("add any component", () => Game.ChildrenOfType<SkinComponentToolbox.ToolboxComponentButton>().First().TriggerClick());
AddStep("undo", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Z);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddUntilStep("only one accuracy meter left",
() => Game.ChildrenOfType<Player>().Single().ChildrenOfType<ArgonAccuracyCounter>().Count(),
() => Is.EqualTo(1));
AddAssert("accuracy meter state unchanged",
() => JsonConvert.SerializeObject(Game.ChildrenOfType<ArgonAccuracyCounter>().First().CreateSerialisedInfo()),
() => Is.EqualTo(state));
}
[Test]
public void TestComponentsDeselectedOnSkinEditorHide()
{

View File

@ -96,6 +96,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.NotifyOnUsernameMentioned, true);
SetDefault(OsuSetting.NotifyOnPrivateMessage, true);
SetDefault(OsuSetting.NotifyOnFriendPresenceChange, true);
// Audio
SetDefault(OsuSetting.VolumeInactive, 0.25, 0, 1, 0.01);
@ -418,6 +419,7 @@ namespace osu.Game.Configuration
IntroSequence,
NotifyOnUsernameMentioned,
NotifyOnPrivateMessage,
NotifyOnFriendPresenceChange,
UIHoldActivationDelay,
HitLighting,
StarFountains,

View File

@ -1,17 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Input;
namespace osu.Game.Graphics.UserInterface
{
public partial class OsuNumberBox : OsuTextBox
{
protected override bool AllowIme => false;
public OsuNumberBox()
{
InputProperties = new TextInputProperties(TextInputType.Number, false);
SelectAllOnFocus = true;
}
protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character);
}
}

View File

@ -18,7 +18,7 @@ using osu.Game.Localisation;
namespace osu.Game.Graphics.UserInterface
{
public partial class OsuPasswordTextBox : OsuTextBox, ISuppressKeyEventLogging
public partial class OsuPasswordTextBox : OsuTextBox
{
protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer
{
@ -28,12 +28,6 @@ namespace osu.Game.Graphics.UserInterface
protected override bool AllowUniqueCharacterSamples => false;
protected override bool AllowClipboardExport => false;
protected override bool AllowWordNavigation => false;
protected override bool AllowIme => false;
private readonly CapsWarning warning;
[Resolved]
@ -41,6 +35,8 @@ namespace osu.Game.Graphics.UserInterface
public OsuPasswordTextBox()
{
InputProperties = new TextInputProperties(TextInputType.Password, false);
Add(warning = new CapsWarning
{
Size = new Vector2(20),

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Globalization;
using osu.Framework.Input;
namespace osu.Game.Graphics.UserInterfaceV2
{
@ -19,6 +20,11 @@ namespace osu.Game.Graphics.UserInterfaceV2
{
public bool AllowDecimals { get; init; }
public InnerNumberBox()
{
InputProperties = new TextInputProperties(TextInputType.Number, false);
}
protected override bool CanAddCharacter(char character)
=> char.IsAsciiDigit(character) || (AllowDecimals && CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator.Contains(character));
}

View File

@ -29,6 +29,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString NotifyOnPrivateMessage => new TranslatableString(getKey(@"notify_on_private_message"), @"Show a notification when you receive a private message");
/// <summary>
/// "Show notification popups when friends change status"
/// </summary>
public static LocalisableString NotifyOnFriendPresenceChange => new TranslatableString(getKey(@"notify_on_friend_presence_change"), @"Show notification popups when friends change status");
/// <summary>
/// "Notifications will be shown when friends go online/offline."
/// </summary>
public static LocalisableString NotifyOnFriendPresenceChangeTooltip => new TranslatableString(getKey(@"notify_on_friend_presence_change_tooltip"), @"Notifications will be shown when friends go online/offline.");
/// <summary>
/// "Integrations"
/// </summary>
@ -84,6 +94,6 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString HideCountryFlags => new TranslatableString(getKey(@"hide_country_flags"), @"Hide country flags");
private static string getKey(string key) => $"{prefix}:{key}";
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
@ -610,8 +611,14 @@ namespace osu.Game.Online.API
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res =>
{
friends.Clear();
friends.AddRange(res);
var existingFriends = friends.Select(f => f.TargetID).ToHashSet();
var updatedFriends = res.Select(f => f.TargetID).ToHashSet();
// Add new friends into local list.
friends.AddRange(res.Where(r => !existingFriends.Contains(r.TargetID)));
// Remove non-friends from local list.
friends.RemoveAll(f => !updatedFriends.Contains(f.TargetID));
};
Queue(friendsReq);

View File

@ -0,0 +1,216 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Chat;
using osu.Game.Online.Metadata;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Users;
namespace osu.Game.Online
{
public partial class FriendPresenceNotifier : Component
{
[Resolved]
private INotificationOverlay notifications { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private MetadataClient metadataClient { get; set; } = null!;
[Resolved]
private ChannelManager channelManager { get; set; } = null!;
[Resolved]
private ChatOverlay chatOverlay { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private OsuConfigManager config { get; set; } = null!;
private readonly Bindable<bool> notifyOnFriendPresenceChange = new BindableBool();
private readonly IBindableList<APIRelation> friends = new BindableList<APIRelation>();
private readonly IBindableDictionary<int, UserPresence> friendStates = new BindableDictionary<int, UserPresence>();
private readonly HashSet<APIUser> onlineAlertQueue = new HashSet<APIUser>();
private readonly HashSet<APIUser> offlineAlertQueue = new HashSet<APIUser>();
private double? lastOnlineAlertTime;
private double? lastOfflineAlertTime;
protected override void LoadComplete()
{
base.LoadComplete();
config.BindWith(OsuSetting.NotifyOnFriendPresenceChange, notifyOnFriendPresenceChange);
friends.BindTo(api.Friends);
friends.BindCollectionChanged(onFriendsChanged, true);
friendStates.BindTo(metadataClient.FriendStates);
friendStates.BindCollectionChanged(onFriendStatesChanged, true);
}
protected override void Update()
{
base.Update();
alertOnlineUsers();
alertOfflineUsers();
}
private void onFriendsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (APIRelation friend in e.NewItems!.Cast<APIRelation>())
{
if (friend.TargetUser is not APIUser user)
continue;
if (friendStates.TryGetValue(friend.TargetID, out _))
markUserOnline(user);
}
break;
case NotifyCollectionChangedAction.Remove:
foreach (APIRelation friend in e.OldItems!.Cast<APIRelation>())
{
if (friend.TargetUser is not APIUser user)
continue;
onlineAlertQueue.Remove(user);
offlineAlertQueue.Remove(user);
}
break;
}
}
private void onFriendStatesChanged(object? sender, NotifyDictionaryChangedEventArgs<int, UserPresence> e)
{
switch (e.Action)
{
case NotifyDictionaryChangedAction.Add:
foreach ((int friendId, _) in e.NewItems!)
{
APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId);
if (friend?.TargetUser is APIUser user)
markUserOnline(user);
}
break;
case NotifyDictionaryChangedAction.Remove:
foreach ((int friendId, _) in e.OldItems!)
{
APIRelation? friend = friends.FirstOrDefault(f => f.TargetID == friendId);
if (friend?.TargetUser is APIUser user)
markUserOffline(user);
}
break;
}
}
private void markUserOnline(APIUser user)
{
if (!offlineAlertQueue.Remove(user))
{
onlineAlertQueue.Add(user);
lastOnlineAlertTime ??= Time.Current;
}
}
private void markUserOffline(APIUser user)
{
if (!onlineAlertQueue.Remove(user))
{
offlineAlertQueue.Add(user);
lastOfflineAlertTime ??= Time.Current;
}
}
private void alertOnlineUsers()
{
if (onlineAlertQueue.Count == 0)
return;
if (lastOnlineAlertTime == null || Time.Current - lastOnlineAlertTime < 1000)
return;
if (!notifyOnFriendPresenceChange.Value)
{
lastOnlineAlertTime = null;
return;
}
APIUser? singleUser = onlineAlertQueue.Count == 1 ? onlineAlertQueue.Single() : null;
notifications.Post(new SimpleNotification
{
Icon = FontAwesome.Solid.UserPlus,
Text = $"Online: {string.Join(@", ", onlineAlertQueue.Select(u => u.Username))}",
IconColour = colours.Green,
Activated = () =>
{
if (singleUser != null)
{
channelManager.OpenPrivateChannel(singleUser);
chatOverlay.Show();
}
return true;
}
});
onlineAlertQueue.Clear();
lastOnlineAlertTime = null;
}
private void alertOfflineUsers()
{
if (offlineAlertQueue.Count == 0)
return;
if (lastOfflineAlertTime == null || Time.Current - lastOfflineAlertTime < 1000)
return;
if (!notifyOnFriendPresenceChange.Value)
{
lastOfflineAlertTime = null;
return;
}
notifications.Post(new SimpleNotification
{
Icon = FontAwesome.Solid.UserMinus,
Text = $"Offline: {string.Join(@", ", offlineAlertQueue.Select(u => u.Username))}",
IconColour = colours.Red
});
offlineAlertQueue.Clear();
lastOfflineAlertTime = null;
}
}
}

View File

@ -21,6 +21,11 @@ namespace osu.Game.Online.Metadata
/// </summary>
Task UserPresenceUpdated(int userId, UserPresence? status);
/// <summary>
/// Delivers and update of the <see cref="UserPresence"/> of a friend with the supplied <paramref name="userId"/>.
/// </summary>
Task FriendPresenceUpdated(int userId, UserPresence? presence);
/// <summary>
/// Delivers an update of the current "daily challenge" status.
/// Null value means there is no "daily challenge" currently active.

View File

@ -42,6 +42,11 @@ namespace osu.Game.Online.Metadata
/// </summary>
public abstract IBindableDictionary<int, UserPresence> UserStates { get; }
/// <summary>
/// Dictionary keyed by user ID containing all of the <see cref="UserPresence"/> information about currently online friends received from the server.
/// </summary>
public abstract IBindableDictionary<int, UserPresence> FriendStates { get; }
/// <inheritdoc/>
public abstract Task UpdateActivity(UserActivity? activity);
@ -57,6 +62,9 @@ namespace osu.Game.Online.Metadata
/// <inheritdoc/>
public abstract Task UserPresenceUpdated(int userId, UserPresence? presence);
/// <inheritdoc/>
public abstract Task FriendPresenceUpdated(int userId, UserPresence? presence);
#endregion
#region Daily Challenge

View File

@ -26,15 +26,16 @@ namespace osu.Game.Online.Metadata
public override IBindableDictionary<int, UserPresence> UserStates => userStates;
private readonly BindableDictionary<int, UserPresence> userStates = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> FriendStates => friendStates;
private readonly BindableDictionary<int, UserPresence> friendStates = new BindableDictionary<int, UserPresence>();
public override IBindable<DailyChallengeInfo?> DailyChallengeInfo => dailyChallengeInfo;
private readonly Bindable<DailyChallengeInfo?> dailyChallengeInfo = new Bindable<DailyChallengeInfo?>();
private readonly string endpoint;
private IHubClientConnector? connector;
private Bindable<int> lastQueueId = null!;
private IBindable<APIUser> localUser = null!;
private IBindable<UserActivity?> userActivity = null!;
private IBindable<UserStatus?>? userStatus;
@ -61,6 +62,7 @@ namespace osu.Game.Online.Metadata
// https://github.com/dotnet/aspnetcore/issues/15198
connection.On<BeatmapUpdates>(nameof(IMetadataClient.BeatmapSetsUpdated), ((IMetadataClient)this).BeatmapSetsUpdated);
connection.On<int, UserPresence?>(nameof(IMetadataClient.UserPresenceUpdated), ((IMetadataClient)this).UserPresenceUpdated);
connection.On<int, UserPresence?>(nameof(IMetadataClient.FriendPresenceUpdated), ((IMetadataClient)this).FriendPresenceUpdated);
connection.On<DailyChallengeInfo?>(nameof(IMetadataClient.DailyChallengeUpdated), ((IMetadataClient)this).DailyChallengeUpdated);
connection.On<MultiplayerRoomScoreSetEvent>(nameof(IMetadataClient.MultiplayerRoomScoreSet), ((IMetadataClient)this).MultiplayerRoomScoreSet);
connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IMetadataClient)this).DisconnectRequested);
@ -106,6 +108,7 @@ namespace osu.Game.Online.Metadata
{
isWatchingUserPresence.Value = false;
userStates.Clear();
friendStates.Clear();
dailyChallengeInfo.Value = null;
});
return;
@ -207,6 +210,19 @@ namespace osu.Game.Online.Metadata
return Task.CompletedTask;
}
public override Task FriendPresenceUpdated(int userId, UserPresence? presence)
{
Schedule(() =>
{
if (presence?.Status != null)
friendStates[userId] = presence.Value;
else
friendStates.Remove(userId);
});
return Task.CompletedTask;
}
public override async Task BeginWatchingUserPresence()
{
if (connector?.IsConnected.Value != true)

View File

@ -1125,6 +1125,7 @@ namespace osu.Game
Add(externalLinkOpener = new ExternalLinkOpener());
Add(new MusicKeyBindingHandler());
Add(new OnlineStatusNotifier(() => ScreenStack.CurrentScreen));
Add(new FriendPresenceNotifier());
// side overlays which cancel each other.
var singleDisplaySideOverlays = new OverlayContainer[] { Settings, Notifications, FirstRunOverlay };

View File

@ -7,6 +7,7 @@ using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Game.Configuration;
using osu.Game.Graphics;
@ -63,6 +64,7 @@ namespace osu.Game.Overlays.Login
},
username = new OsuTextBox
{
InputProperties = new TextInputProperties(TextInputType.Username, false),
PlaceholderText = UsersStrings.LoginUsername.ToLower(),
RelativeSizeAxes = Axes.X,
Text = api.ProvidedUsername,

View File

@ -29,6 +29,12 @@ namespace osu.Game.Overlays.Settings.Sections.Online
Current = config.GetBindable<bool>(OsuSetting.NotifyOnPrivateMessage)
},
new SettingsCheckbox
{
LabelText = OnlineSettingsStrings.NotifyOnFriendPresenceChange,
TooltipText = OnlineSettingsStrings.NotifyOnFriendPresenceChangeTooltip,
Current = config.GetBindable<bool>(OsuSetting.NotifyOnFriendPresenceChange),
},
new SettingsCheckbox
{
LabelText = OnlineSettingsStrings.HideCountryFlags,
Current = config.GetBindable<bool>(OsuSetting.HideCountryFlags)

View File

@ -5,6 +5,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
namespace osu.Game.Overlays.Settings
{
@ -66,7 +67,10 @@ namespace osu.Game.Overlays.Settings
private partial class OutlinedNumberBox : OutlinedTextBox
{
protected override bool AllowIme => false;
public OutlinedNumberBox()
{
InputProperties = new TextInputProperties(TextInputType.Number, false);
}
protected override bool CanAddCharacter(char character) => char.IsAsciiDigit(character);

View File

@ -374,9 +374,10 @@ namespace osu.Game.Overlays.SkinEditor
return;
}
changeHandler = new SkinEditorChangeHandler(skinComponentsContainer);
changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
if (skinComponentsContainer.IsLoaded)
bindChangeHandler(skinComponentsContainer);
else
skinComponentsContainer.OnLoadComplete += d => Schedule(() => bindChangeHandler((SkinnableContainer)d));
content.Child = new SkinBlueprintContainer(skinComponentsContainer);
@ -418,10 +419,21 @@ namespace osu.Game.Overlays.SkinEditor
SelectedComponents.Clear();
placeComponent(component);
}
void bindChangeHandler(SkinnableContainer skinnableContainer)
{
changeHandler = new SkinEditorChangeHandler(skinnableContainer);
changeHandler.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true);
changeHandler.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true);
}
}
private void skinChanged()
{
if (skins.EnsureMutableSkin())
// Another skin changed event will arrive which will complete the process.
return;
headerText.Clear();
headerText.AddParagraph(SkinEditorStrings.SkinEditor, cp => cp.Font = OsuFont.Default.With(size: 16));
@ -439,17 +451,24 @@ namespace osu.Game.Overlays.SkinEditor
});
changeHandler?.Dispose();
changeHandler = null;
skins.EnsureMutableSkin();
// Schedule is required to ensure that all layout in `LoadComplete` methods has been completed
// before storing an undo state.
//
// See https://github.com/ppy/osu/blob/8e6a4559e3ae8c9892866cf9cf8d4e8d1b72afd0/osu.Game/Skinning/SkinReloadableDrawable.cs#L76.
Schedule(() =>
{
var targetContainer = getTarget(selectedTarget.Value);
var targetContainer = getTarget(selectedTarget.Value);
if (targetContainer != null)
changeHandler = new SkinEditorChangeHandler(targetContainer);
if (targetContainer != null)
changeHandler = new SkinEditorChangeHandler(targetContainer);
hasBegunMutating = true;
hasBegunMutating = true;
// Reload sidebar components.
selectedTarget.TriggerChange();
// Reload sidebar components.
selectedTarget.TriggerChange();
});
}
/// <summary>

View File

@ -34,7 +34,7 @@ namespace osu.Game.Overlays.SkinEditor
return;
components = new BindableList<ISerialisableDrawable> { BindTarget = firstTarget.Components };
components.BindCollectionChanged((_, _) => SaveState());
components.BindCollectionChanged((_, _) => SaveState(), true);
}
protected override void WriteCurrentStateToStream(MemoryStream stream)

View File

@ -4,6 +4,7 @@
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
@ -136,7 +137,10 @@ namespace osu.Game.Screens.Edit.Setup
private partial class RomanisedTextBox : InnerTextBox
{
protected override bool AllowIme => false;
public RomanisedTextBox()
{
InputProperties = new TextInputProperties(TextInputType.Text, false);
}
protected override bool CanAddCharacter(char character)
=> MetadataUtils.IsRomanised(character);

View File

@ -22,6 +22,9 @@ namespace osu.Game.Tests.Visual.Metadata
public override IBindableDictionary<int, UserPresence> UserStates => userStates;
private readonly BindableDictionary<int, UserPresence> userStates = new BindableDictionary<int, UserPresence>();
public override IBindableDictionary<int, UserPresence> FriendStates => friendStates;
private readonly BindableDictionary<int, UserPresence> friendStates = new BindableDictionary<int, UserPresence>();
public override Bindable<DailyChallengeInfo?> DailyChallengeInfo => dailyChallengeInfo;
private readonly Bindable<DailyChallengeInfo?> dailyChallengeInfo = new Bindable<DailyChallengeInfo?>();
@ -77,6 +80,16 @@ namespace osu.Game.Tests.Visual.Metadata
return Task.CompletedTask;
}
public override Task FriendPresenceUpdated(int userId, UserPresence? presence)
{
if (presence.HasValue)
friendStates[userId] = presence.Value;
else
friendStates.Remove(userId);
return Task.CompletedTask;
}
public override Task<BeatmapUpdates> GetChangesSince(int queueId)
=> Task.FromResult(new BeatmapUpdates(Array.Empty<int>(), queueId));

View File

@ -35,7 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.1224.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.114.1" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.1224.0" />
<PackageReference Include="Sentry" Version="5.0.0" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->

View File

@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2024.1224.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.114.1" />
</ItemGroup>
</Project>