diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8645d728e..a88f1320cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: build-only-android: name: Build only (Android) - runs-on: windows-latest + runs-on: windows-2019 timeout-minutes: 60 steps: - name: Checkout diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index 7dd9250ab6..6afb3e319d 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -82,7 +82,7 @@ namespace osu.Desktop }; client.OnReady += onReady; - client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Message} ({e.Code})", LoggingTarget.Network, LogLevel.Error); + client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Message} ({e.Code})", LoggingTarget.Network); try { diff --git a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs index 0bed9830df..4b6ebc9b56 100644 --- a/osu.Desktop/Security/ElevatedPrivilegesChecker.cs +++ b/osu.Desktop/Security/ElevatedPrivilegesChecker.cs @@ -30,8 +30,6 @@ namespace osu.Desktop.Security private partial class ElevatedPrivilegesNotification : SimpleNotification { - public override bool IsImportant => true; - public ElevatedPrivilegesNotification() { Text = $"Running osu! as {(RuntimeInfo.IsUnix ? "root" : "administrator")} does not improve performance, may break integrations and poses a security risk. Please run the game as a normal user."; diff --git a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs index 69efb7fbca..11649da2f1 100644 --- a/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/Legacy/CatchLegacySkinTransformer.cs @@ -4,6 +4,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -47,6 +48,7 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy return new DefaultSkinComponentsContainer(container => { var keyCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); if (keyCounter != null) { @@ -55,11 +57,19 @@ namespace osu.Game.Rulesets.Catch.Skinning.Legacy keyCounter.Origin = Anchor.TopRight; keyCounter.Position = new Vector2(0, -40) * 1.6f; } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = new Vector2(10, -10); + } }) { Children = new Drawable[] { new LegacyKeyCounterDisplay(), + new SpectatorList(), } }; } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index c37c18081a..6f010ffe48 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -9,7 +9,9 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Mania.Skinning.Argon @@ -39,6 +41,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon return new DefaultSkinComponentsContainer(container => { var combo = container.ChildrenOfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); if (combo != null) { @@ -47,9 +50,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon combo.Origin = Anchor.Centre; combo.Y = 200; } + + if (spectatorList != null) + spectatorList.Position = new Vector2(36, -66); }) { new ArgonManiaComboCounter(), + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } }; } diff --git a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs index 8f425edc44..76af569b95 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Legacy/ManiaLegacySkinTransformer.cs @@ -15,7 +15,9 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK; namespace osu.Game.Rulesets.Mania.Skinning.Legacy { @@ -95,6 +97,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy return new DefaultSkinComponentsContainer(container => { var combo = container.ChildrenOfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); if (combo != null) { @@ -102,9 +105,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy combo.Origin = Anchor.Centre; combo.Y = this.GetManiaSkinConfig(LegacyManiaSkinConfigurationLookups.ComboPosition)?.Value ?? 0; } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = new Vector2(10, -10); + } }) { new LegacyManiaComboCounter(), + new SpectatorList(), }; } diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index 636a9ecb21..d39e05b262 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; using osuTK; @@ -70,12 +71,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy } var combo = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(); if (combo != null) { combo.Anchor = Anchor.BottomLeft; combo.Origin = Anchor.BottomLeft; combo.Scale = new Vector2(1.28f); + + pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X); + } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = pos; } }) { @@ -83,6 +96,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { new LegacyDefaultComboCounter(), new LegacyKeyCounterDisplay(), + new SpectatorList(), } }; } diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk new file mode 100644 index 0000000000..23322e7373 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-20250116.osk differ diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 962a9b2a7a..55836302e6 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -71,6 +71,8 @@ namespace osu.Game.Tests.Skins "Archives/modified-classic-20240724.osk", // Covers skinnable mod display "Archives/modified-default-20241207.osk", + // Covers skinnable spectator list + "Archives/modified-argon-20250116.osk", }; /// diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs index a766b253aa..ce9dbd5fb1 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboard.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("is one object", () => EditorBeatmap.HitObjects.Count == 1); - AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == newTime); + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(newTime, null)); } [Test] @@ -122,6 +122,8 @@ namespace osu.Game.Tests.Visual.Editing [TestCase(true)] public void TestCopyPaste(bool deselectAfterCopy) { + const int paste_time = 2000; + var addedObject = new HitCircle { StartTime = 1000 }; AddStep("add hitobject", () => EditorBeatmap.Add(addedObject)); @@ -130,7 +132,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("copy hitobject", () => Editor.Copy()); - AddStep("move forward in time", () => EditorClock.Seek(2000)); + AddStep("move forward in time", () => EditorClock.Seek(paste_time)); if (deselectAfterCopy) { @@ -144,7 +146,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("are two objects", () => EditorBeatmap.HitObjects.Count == 2); - AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == 2000); + AddAssert("new object selected", () => EditorBeatmap.SelectedHitObjects.Single().StartTime == EditorBeatmap.SnapTime(paste_time, null)); AddUntilStep("timeline selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); AddUntilStep("composer selection box is visible", () => Editor.ChildrenOfType().First().ChildrenOfType().First().Alpha > 0); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs new file mode 100644 index 0000000000..edaba67591 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorClipboardSnapping.cs @@ -0,0 +1,82 @@ +// 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.Utils; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Screens.Edit; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestSceneEditorClipboardSnapping : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + private const double beat_length = 60_000 / 180.0; // 180 bpm + private const double timing_point_time = 1500; + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) + { + var controlPointInfo = new ControlPointInfo(); + controlPointInfo.Add(timing_point_time, new TimingControlPoint { BeatLength = beat_length }); + return new TestBeatmap(ruleset, false) + { + ControlPointInfo = controlPointInfo + }; + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(3)] + [TestCase(4)] + [TestCase(6)] + [TestCase(8)] + [TestCase(12)] + [TestCase(16)] + public void TestPasteSnapping(int divisor) + { + const double paste_time = timing_point_time + 1271; // arbitrary timestamp that doesn't snap to the timing point at any divisor + + var addedObjects = new HitObject[] + { + new HitCircle { StartTime = 1000 }, + new HitCircle { StartTime = 1200 }, + }; + + AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects)); + AddStep("select added objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects)); + AddStep("copy hitobjects", () => Editor.Copy()); + + AddStep($"set beat divisor to 1/{divisor}", () => + { + var beatDivisor = (BindableBeatDivisor)Editor.Dependencies.Get(typeof(BindableBeatDivisor)); + beatDivisor.SetArbitraryDivisor(divisor); + }); + + AddStep("move forward in time", () => EditorClock.Seek(paste_time)); + AddAssert("not at snapped time", () => EditorClock.CurrentTime != EditorBeatmap.SnapTime(EditorClock.CurrentTime, null)); + + AddStep("paste hitobjects", () => Editor.Paste()); + + AddAssert("first object is snapped", () => Precision.AlmostEquals( + EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!.StartTime, + EditorBeatmap.ControlPointInfo.GetClosestSnappedTime(paste_time, divisor) + )); + + AddAssert("duration between pasted objects is same", () => + { + var firstObject = EditorBeatmap.SelectedHitObjects.MinBy(h => h.StartTime)!; + var secondObject = EditorBeatmap.SelectedHitObjects.MaxBy(h => h.StartTime)!; + + return Precision.AlmostEquals(secondObject.StartTime - firstObject.StartTime, addedObjects[1].StartTime - addedObjects[0].StartTime); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs index 9a54de1459..66a87c0715 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs @@ -6,48 +6,74 @@ using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Utils; +using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; +using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Gameplay { [TestFixture] public partial class TestSceneSpectatorList : OsuTestScene { - private readonly BindableList spectators = new BindableList(); - private readonly Bindable localUserPlayingState = new Bindable(); - private int counter; [Test] public void TestBasics() { SpectatorList list = null!; - AddStep("create spectator list", () => Child = list = new SpectatorList + Bindable playingState = new Bindable(); + GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), healthProcessor: new OsuHealthProcessor(0), localUserPlayingState: playingState); + TestSpectatorClient client = new TestSpectatorClient(); + + AddStep("create spectator list", () => { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Spectators = { BindTarget = spectators }, - UserPlayingState = { BindTarget = localUserPlayingState } + Children = new Drawable[] + { + client, + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = + [ + (typeof(GameplayState), gameplayState), + (typeof(SpectatorClient), client) + ], + Child = list = new SpectatorList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; }); - AddStep("start playing", () => localUserPlayingState.Value = LocalUserPlayingState.Playing); + AddStep("start playing", () => playingState.Value = LocalUserPlayingState.Playing); AddRepeatStep("add a user", () => { int id = Interlocked.Increment(ref counter); - spectators.Add(new SpectatorList.Spectator(id, $"User {id}")); + ((ISpectatorClient)client).UserStartedWatching([ + new SpectatorUser + { + OnlineID = id, + Username = $"User {id}" + } + ]); }, 10); - AddRepeatStep("remove random user", () => spectators.RemoveAt(RNG.Next(0, spectators.Count)), 5); + AddRepeatStep("remove random user", () => ((ISpectatorClient)client).UserEndedWatching(client.WatchingUsers[RNG.Next(client.WatchingUsers.Count)].OnlineID), 5); AddStep("change font to venera", () => list.Font.Value = Typeface.Venera); AddStep("change font to torus", () => list.Font.Value = Typeface.Torus); AddStep("change header colour", () => list.HeaderColour.Value = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1)); - AddStep("enter break", () => localUserPlayingState.Value = LocalUserPlayingState.Break); - AddStep("stop playing", () => localUserPlayingState.Value = LocalUserPlayingState.NotPlaying); + AddStep("enter break", () => playingState.Value = LocalUserPlayingState.Break); + AddStep("stop playing", () => playingState.Value = LocalUserPlayingState.NotPlaying); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 521d097fb9..88b482ab4c 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -48,6 +48,7 @@ using osu.Game.Screens.Select.Carousel; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.Select.Options; using osu.Game.Tests.Beatmaps.IO; +using osu.Game.Tests.Resources; using osu.Game.Utils; using osuTK; using osuTK.Input; @@ -202,6 +203,38 @@ namespace osu.Game.Tests.Visual.Navigation TextBox filterControlTextBox() => songSelect.ChildrenOfType().Single(); } + [Test] + public void TestSongSelectRandomRewindButton() + { + Guid? originalSelection = null; + TestPlaySongSelect songSelect = null; + + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("Add two beatmaps", () => + { + Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8)); + Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo(8)); + }); + + AddUntilStep("wait for selected", () => + { + originalSelection = Game.Beatmap.Value.BeatmapInfo.ID; + return !Game.Beatmap.IsDefault; + }); + + AddStep("hit random", () => + { + InputManager.MoveMouseTo(Game.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for selection changed", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.Not.EqualTo(originalSelection)); + + AddStep("hit random rewind", () => InputManager.Click(MouseButton.Right)); + AddUntilStep("wait for selection reverted", () => Game.Beatmap.Value.BeatmapInfo.ID, () => Is.EqualTo(originalSelection)); + } + [Test] public void TestSongSelectScrollHandling() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs index b13d450c32..984352b2f5 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs @@ -16,6 +16,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; @@ -117,12 +118,11 @@ namespace osu.Game.Tests.Visual.SongSelect } } }, - stats = new OsuTextFlowContainer(cp => cp.Font = FrameworkFont.Regular.With()) + stats = new OsuTextFlowContainer { + AutoSizeAxes = Axes.Both, Padding = new MarginPadding(10), TextAnchor = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, }, }; }); @@ -258,16 +258,29 @@ namespace osu.Game.Tests.Visual.SongSelect if (carousel.IsNull()) return; - stats.Text = $""" - store - sets: {beatmapSets.Count} - beatmaps: {beatmapCount} - carousel: - sorting: {carousel.IsFiltering} - tracked: {carousel.ItemsTracked} - displayable: {carousel.DisplayableItems} - displayed: {carousel.VisibleItems} - """; + stats.Clear(); + createHeader("beatmap store"); + stats.AddParagraph($""" + sets: {beatmapSets.Count} + beatmaps: {beatmapCount} + """); + createHeader("carousel"); + stats.AddParagraph($""" + sorting: {carousel.IsFiltering} + tracked: {carousel.ItemsTracked} + displayable: {carousel.DisplayableItems} + displayed: {carousel.VisibleItems} + selected: {carousel.CurrentSelection} + """); + + void createHeader(string text) + { + stats.AddParagraph(string.Empty); + stats.AddParagraph(text, cp => + { + cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold); + }); + } } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs index c584c7dba0..65c8b913d3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNotificationOverlay.cs @@ -83,6 +83,40 @@ namespace osu.Game.Tests.Visual.UserInterface waitForCompletion(); } + [Test] + public void TestNormalDoesForwardToOverlay() + { + SimpleNotification notification = null!; + + AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"This shouldn't annoy you too much", + Transient = false, + })); + + AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True); + AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False); + + checkDisplayedCount(1); + } + + [Test] + public void TestTransientDoesNotForwardToOverlay() + { + SimpleNotification notification = null!; + + AddStep(@"simple #1", () => notificationOverlay.Post(notification = new SimpleNotification + { + Text = @"This shouldn't annoy you too much", + Transient = true, + })); + + AddAssert("notification in toast tray", () => notification.IsInToastTray, () => Is.True); + AddUntilStep("wait for dismissed", () => notification.IsInToastTray, () => Is.False); + + checkDisplayedCount(0); + } + [Test] public void TestForwardWithFlingRight() { @@ -634,12 +668,18 @@ namespace osu.Game.Tests.Visual.UserInterface private partial class BackgroundNotification : SimpleNotification { - public override bool IsImportant => false; + public BackgroundNotification() + { + IsImportant = false; + } } private partial class BackgroundProgressNotification : ProgressNotification { - public override bool IsImportant => false; + public BackgroundProgressNotification() + { + IsImportant = false; + } } } } diff --git a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs index 66fad6c8d8..a1744f74b3 100644 --- a/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs +++ b/osu.Game/Beatmaps/LocalCachedBeatmapMetadataSource.cs @@ -113,6 +113,31 @@ namespace osu.Game.Beatmaps return queryCacheVersion2(db, beatmapInfo, out onlineMetadata); } } + + onlineMetadata = null; + return false; + } + catch (SqliteException sqliteException) + { + onlineMetadata = null; + + // There have been cases where the user's local database is corrupt. + // Let's attempt to identify these cases and re-initialise the local cache. + switch (sqliteException.SqliteErrorCode) + { + case 26: // SQLITE_NOTADB + case 11: // SQLITE_CORRUPT + // only attempt purge & re-download if there is no other refetch in progress + if (cacheDownloadRequest != null) + return false; + + tryPurgeCache(); + prepareLocalCache(); + return false; + } + + logForModel(beatmapInfo.BeatmapSet, $@"Cached local retrieval for {beatmapInfo} failed with unhandled sqlite error {sqliteException}."); + return false; } catch (Exception ex) { @@ -120,9 +145,22 @@ namespace osu.Game.Beatmaps onlineMetadata = null; return false; } + } - onlineMetadata = null; - return false; + private void tryPurgeCache() + { + log(@"Local metadata cache is corrupted; attempting purge."); + + try + { + File.Delete(storage.GetFullPath(cache_database_name)); + } + catch (Exception ex) + { + log($@"Failed to purge local metadata cache: {ex}"); + } + + log(@"Local metadata cache purged due to corruption."); } private SqliteConnection getConnection() => diff --git a/osu.Game/Database/ModelDownloader.cs b/osu.Game/Database/ModelDownloader.cs index dfeec259fe..8e89db4d06 100644 --- a/osu.Game/Database/ModelDownloader.cs +++ b/osu.Game/Database/ModelDownloader.cs @@ -131,8 +131,6 @@ namespace osu.Game.Database private partial class DownloadNotification : ProgressNotification { - public override bool IsImportant => false; - protected override Notification CreateCompletionNotification() => new SilencedProgressCompletionNotification { Activated = CompletionClickAction, @@ -141,7 +139,10 @@ namespace osu.Game.Database private partial class SilencedProgressCompletionNotification : ProgressCompletionNotification { - public override bool IsImportant => false; + public SilencedProgressCompletionNotification() + { + IsImportant = false; + } } } } diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index e1b8de89fa..5cc143f4e2 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -11,7 +11,6 @@ using System.Linq.Expressions; using System.Reflection; using System.Threading; using System.Threading.Tasks; -using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Extensions; @@ -97,8 +96,9 @@ namespace osu.Game.Database /// 44 2024-11-22 Removed several properties from BeatmapInfo which did not need to be persisted to realm. /// 45 2024-12-23 Change beat snap divisor adjust defaults to be Ctrl+Scroll instead of Ctrl+Shift+Scroll, if not already changed by user. /// 46 2024-12-26 Change beat snap divisor bindings to match stable directionality ¯\_(ツ)_/¯. + /// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions. /// - private const int schema_version = 46; + private const int schema_version = 47; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -413,18 +413,7 @@ namespace osu.Game.Database /// Compact this realm. /// /// - public bool Compact() - { - try - { - return Realm.Compact(getConfiguration()); - } - // Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator). - catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException) - { - return true; - } - } + public bool Compact() => Realm.Compact(getConfiguration()); /// /// Run work on realm with a return value. @@ -720,11 +709,6 @@ namespace osu.Game.Database return Realm.GetInstance(getConfiguration()); } - // Catch can be removed along with entity framework. Is specifically to allow a failure message to arrive to the user (see similar catches in EFToRealmMigrator). - catch (AggregateException ae) when (RuntimeInfo.OS == RuntimeInfo.Platform.macOS && ae.Flatten().InnerException is TypeInitializationException) - { - return Realm.GetInstance(); - } finally { if (tookSemaphoreLock) @@ -1239,6 +1223,17 @@ namespace osu.Game.Database break; } + + case 47: + { + var keyBindings = migration.NewRealm.All(); + + var existingBinding = keyBindings.FirstOrDefault(k => k.ActionInt == (int)GlobalAction.AbsoluteScrollSongList); + if (existingBinding != null && existingBinding.KeyCombination.Keys.SequenceEqual(new[] { InputKey.MouseRight })) + migration.NewRealm.Remove(existingBinding); + + break; + } } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs index 9b4689958c..7abaca4092 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs @@ -14,7 +14,6 @@ using osu.Framework.Input.Events; using osu.Game.Input.Bindings; using osu.Game.Overlays; using osuTK; -using osuTK.Input; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -75,14 +74,6 @@ namespace osu.Game.Graphics.UserInterfaceV2 samplePopOut?.Play(); } - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Key == Key.Escape) - return false; // disable the framework-level handling of escape key for conformity (we use GlobalAction.Back). - - return base.OnKeyDown(e); - } - public virtual bool OnPressed(KeyBindingPressEvent e) { if (e.Repeat) diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 6c130ff309..599ca6d6c1 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -205,7 +205,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.BackSpace, GlobalAction.DeselectAllMods), new KeyBinding(new[] { InputKey.Control, InputKey.Up }, GlobalAction.IncreaseModSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Down }, GlobalAction.DecreaseModSpeed), - new KeyBinding(new[] { InputKey.MouseRight }, GlobalAction.AbsoluteScrollSongList), + new KeyBinding(InputKey.None, GlobalAction.AbsoluteScrollSongList), }; private static IEnumerable audioControlKeyBindings => new[] diff --git a/osu.Game/Online/FriendPresenceNotifier.cs b/osu.Game/Online/FriendPresenceNotifier.cs index dd141b756b..75b487384a 100644 --- a/osu.Game/Online/FriendPresenceNotifier.cs +++ b/osu.Game/Online/FriendPresenceNotifier.cs @@ -169,6 +169,8 @@ namespace osu.Game.Online notifications.Post(new SimpleNotification { + Transient = true, + IsImportant = false, Icon = FontAwesome.Solid.UserPlus, Text = $"Online: {string.Join(@", ", onlineAlertQueue.Select(u => u.Username))}", IconColour = colours.Green, @@ -204,6 +206,8 @@ namespace osu.Game.Online notifications.Post(new SimpleNotification { + Transient = true, + IsImportant = false, Icon = FontAwesome.Solid.UserMinus, Text = $"Offline: {string.Join(@", ", offlineAlertQueue.Select(u => u.Username))}", IconColour = colours.Red diff --git a/osu.Game/Online/Spectator/ISpectatorClient.cs b/osu.Game/Online/Spectator/ISpectatorClient.cs index 2dc2283c23..2b73037cb8 100644 --- a/osu.Game/Online/Spectator/ISpectatorClient.cs +++ b/osu.Game/Online/Spectator/ISpectatorClient.cs @@ -37,5 +37,17 @@ namespace osu.Game.Online.Spectator /// The ID of the user who achieved the score. /// The ID of the score. Task UserScoreProcessed(int userId, long scoreId); + + /// + /// Signals that another user has started watching this client. + /// + /// The information about the user who started watching. + Task UserStartedWatching(SpectatorUser[] user); + + /// + /// Signals that another user has ended watching this client + /// + /// The ID of the user who ended watching. + Task UserEndedWatching(int userId); } } diff --git a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs index 036cfa1d76..645d7054dc 100644 --- a/osu.Game/Online/Spectator/OnlineSpectatorClient.cs +++ b/osu.Game/Online/Spectator/OnlineSpectatorClient.cs @@ -42,6 +42,8 @@ namespace osu.Game.Online.Spectator connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); connection.On(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed); + connection.On(nameof(ISpectatorClient.UserStartedWatching), ((ISpectatorClient)this).UserStartedWatching); + connection.On(nameof(ISpectatorClient.UserEndedWatching), ((ISpectatorClient)this).UserEndedWatching); connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested); }; diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index fb7a3d13ca..91f009b76f 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; @@ -36,10 +37,16 @@ namespace osu.Game.Online.Spectator public abstract IBindable IsConnected { get; } /// - /// The states of all users currently being watched. + /// The states of all users currently being watched by the local user. /// + [UsedImplicitly] // Marked virtual due to mock use in testing public virtual IBindableDictionary WatchedUserStates => watchedUserStates; + /// + /// All users who are currently watching the local user. + /// + public IBindableList WatchingUsers => watchingUsers; + /// /// A global list of all players currently playing. /// @@ -53,6 +60,7 @@ namespace osu.Game.Online.Spectator /// /// Called whenever new frames arrive from the server. /// + [UsedImplicitly] // Marked virtual due to mock use in testing public virtual event Action? OnNewFrames; /// @@ -82,6 +90,7 @@ namespace osu.Game.Online.Spectator private readonly BindableDictionary watchedUserStates = new BindableDictionary(); + private readonly BindableList watchingUsers = new BindableList(); private readonly BindableList playingUsers = new BindableList(); private readonly SpectatorState currentState = new SpectatorState(); @@ -127,6 +136,7 @@ namespace osu.Game.Online.Spectator { playingUsers.Clear(); watchedUserStates.Clear(); + watchingUsers.Clear(); } }), true); } @@ -179,6 +189,30 @@ namespace osu.Game.Online.Spectator return Task.CompletedTask; } + Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users) + { + Schedule(() => + { + foreach (var user in users) + { + if (!watchingUsers.Contains(user)) + watchingUsers.Add(user); + } + }); + + return Task.CompletedTask; + } + + Task ISpectatorClient.UserEndedWatching(int userId) + { + Schedule(() => + { + watchingUsers.RemoveAll(u => u.OnlineID == userId); + }); + + return Task.CompletedTask; + } + Task IStatefulUserHubClient.DisconnectRequested() { Schedule(() => DisconnectInternal()); diff --git a/osu.Game/Online/Spectator/SpectatorUser.cs b/osu.Game/Online/Spectator/SpectatorUser.cs new file mode 100644 index 0000000000..9c9563be70 --- /dev/null +++ b/osu.Game/Online/Spectator/SpectatorUser.cs @@ -0,0 +1,39 @@ +// 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 MessagePack; +using osu.Game.Users; + +namespace osu.Game.Online.Spectator +{ + [Serializable] + [MessagePackObject] + public class SpectatorUser : IUser, IEquatable + { + [Key(0)] + public int OnlineID { get; set; } + + [Key(1)] + public string Username { get; set; } = string.Empty; + + [IgnoreMember] + public CountryCode CountryCode => CountryCode.Unknown; + + [IgnoreMember] + public bool IsBot => false; + + public bool Equals(SpectatorUser? other) + { + if (other is null) return false; + if (ReferenceEquals(this, other)) return true; + + return OnlineID == other.OnlineID; + } + + public override bool Equals(object? obj) => Equals(obj as SpectatorUser); + + // ReSharper disable once NonReadonlyMemberInGetHashCode + public override int GetHashCode() => OnlineID; + } +} diff --git a/osu.Game/Overlays/NotificationOverlayToastTray.cs b/osu.Game/Overlays/NotificationOverlayToastTray.cs index df07b4f138..ddb2e02fb8 100644 --- a/osu.Game/Overlays/NotificationOverlayToastTray.cs +++ b/osu.Game/Overlays/NotificationOverlayToastTray.cs @@ -41,7 +41,7 @@ namespace osu.Game.Overlays [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public Action? ForwardNotificationToPermanentStore { get; set; } + public required Action ForwardNotificationToPermanentStore { get; init; } public int UnreadCount => Notifications.Count(n => !n.WasClosed && !n.Read); @@ -142,8 +142,15 @@ namespace osu.Game.Overlays notification.MoveToOffset(new Vector2(400, 0), NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint); notification.FadeOut(NotificationOverlay.TRANSITION_LENGTH, Easing.OutQuint).OnComplete(_ => { + if (notification.Transient) + { + notification.IsInToastTray = false; + notification.Close(false); + return; + } + RemoveInternal(notification, false); - ForwardNotificationToPermanentStore?.Invoke(notification); + ForwardNotificationToPermanentStore(notification); notification.FadeIn(300, Easing.OutQuint); }); diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index d48524d8b0..ccfd1adb39 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -34,9 +34,15 @@ namespace osu.Game.Overlays.Notifications public abstract LocalisableString Text { get; set; } /// - /// Whether this notification should forcefully display itself. + /// Important notifications display for longer, and announce themselves at an OS level (ie flashing the taskbar). + /// This defaults to true. /// - public virtual bool IsImportant => true; + public bool IsImportant { get; init; } = true; + + /// + /// Transient notifications only show as a toast, and do not linger in notification history. + /// + public bool Transient { get; init; } /// /// Run on user activating the notification. Return true to close. diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 2362cb11f6..0b42188252 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -191,8 +191,6 @@ namespace osu.Game.Overlays.Notifications public override bool DisplayOnTop => false; - public override bool IsImportant => false; - private readonly ProgressBar progressBar; private Color4 colourQueued; private Color4 colourActive; @@ -206,6 +204,8 @@ namespace osu.Game.Overlays.Notifications public ProgressNotification() { + IsImportant = false; + Content.Add(textDrawable = new OsuTextFlowContainer(t => t.Font = t.Font.With(size: 14, weight: FontWeight.Medium)) { AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs index 1f95d5f239..c6ecee5f45 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -149,7 +149,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { Enabled.Value = SelectedHitObject.Value != null; - if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0) + if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1) { BackgroundColour = colourProvider.Background3; icon.Colour = BackgroundColour.Darken(0.5f); diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 5d93c4ea9d..15bbddd97e 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -111,25 +111,26 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override bool OnKeyDown(KeyDownEvent e) { + // Until the keys below are global actions, this will prevent conflicts with "seek between sample points" + // which has a default of ctrl+shift+arrows. + if (e.ShiftPressed) + return false; + if (e.ControlPressed) { switch (e.Key) { case Key.Left: - nudgeSelection(new Vector2(-1, 0)); - return true; + return nudgeSelection(new Vector2(-1, 0)); case Key.Right: - nudgeSelection(new Vector2(1, 0)); - return true; + return nudgeSelection(new Vector2(1, 0)); case Key.Up: - nudgeSelection(new Vector2(0, -1)); - return true; + return nudgeSelection(new Vector2(0, -1)); case Key.Down: - nudgeSelection(new Vector2(0, 1)); - return true; + return nudgeSelection(new Vector2(0, 1)); } } @@ -151,7 +152,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Move the current selection spatially by the specified delta, in gamefield coordinates (ie. the same coordinates as the blueprints). /// /// - private void nudgeSelection(Vector2 delta) + private bool nudgeSelection(Vector2 delta) { if (!nudgeMovementActive) { @@ -162,12 +163,13 @@ namespace osu.Game.Screens.Edit.Compose.Components var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); if (firstBlueprint == null) - return; + return false; // convert to game space coordinates delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); + return true; } private void updatePlacementNewCombo() diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index f7e523db25..195625dcde 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -31,6 +31,9 @@ namespace osu.Game.Screens.Edit.Compose [Resolved] private IGameplaySettings globalGameplaySettings { get; set; } + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } + private Bindable clipboard { get; set; } private HitObjectComposer composer; @@ -150,7 +153,7 @@ namespace osu.Game.Screens.Edit.Compose Debug.Assert(objects.Any()); - double timeOffset = clock.CurrentTime - objects.Min(o => o.StartTime); + double timeOffset = beatSnapProvider.SnapTime(clock.CurrentTime) - objects.Min(o => o.StartTime); foreach (var h in objects) h.StartTime += timeOffset; diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs index 41920605b0..25fa689d4c 100644 --- a/osu.Game/Screens/Menu/ButtonSystem.cs +++ b/osu.Game/Screens/Menu/ButtonSystem.cs @@ -245,6 +245,15 @@ namespace osu.Game.Screens.Menu if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed || e.SuperPressed) return false; + if (e.Key >= Key.F1 && e.Key <= Key.F35) + return false; + + switch (e.Key) + { + case Key.Escape: + return false; + } + if (triggerInitialOsuLogo()) return true; diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 2b961278d5..ffd7845356 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -166,11 +166,6 @@ namespace osu.Game.Screens.Play protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); - // Don't let mouse down events through the overlay or people can click circles while paused. - protected override bool OnMouseDown(MouseDownEvent e) => true; - - protected override bool OnMouseMove(MouseMoveEvent e) => true; - protected void AddButton(LocalisableString text, Color4 colour, Action? action) { var button = new Button diff --git a/osu.Game/Screens/Play/GameplayState.cs b/osu.Game/Screens/Play/GameplayState.cs index 478acd7229..851e95495f 100644 --- a/osu.Game/Screens/Play/GameplayState.cs +++ b/osu.Game/Screens/Play/GameplayState.cs @@ -69,6 +69,11 @@ namespace osu.Game.Screens.Play private readonly Bindable lastJudgementResult = new Bindable(); + /// + /// The local user's playing state (whether actively playing, paused, or not playing due to watching a replay or similar). + /// + public IBindable PlayingState { get; } = new Bindable(); + public GameplayState( IBeatmap beatmap, Ruleset ruleset, @@ -76,7 +81,8 @@ namespace osu.Game.Screens.Play Score? score = null, ScoreProcessor? scoreProcessor = null, HealthProcessor? healthProcessor = null, - Storyboard? storyboard = null) + Storyboard? storyboard = null, + IBindable? localUserPlayingState = null) { Beatmap = beatmap; Ruleset = ruleset; @@ -92,6 +98,9 @@ namespace osu.Game.Screens.Play ScoreProcessor = scoreProcessor ?? ruleset.CreateScoreProcessor(); HealthProcessor = healthProcessor ?? ruleset.CreateHealthProcessor(beatmap.HitObjects[0].StartTime); Storyboard = storyboard ?? new Storyboard(); + + if (localUserPlayingState != null) + PlayingState.BindTo(localUserPlayingState); } /// diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs index 438aa61d9d..c784fc298a 100644 --- a/osu.Game/Screens/Play/HUD/SpectatorList.cs +++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs @@ -15,18 +15,20 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; -using osu.Game.Users; using osu.Game.Localisation.HUD; using osu.Game.Localisation.SkinComponents; +using osu.Game.Online.Spectator; +using osu.Game.Skinning; +using osuTK; using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { - public partial class SpectatorList : CompositeDrawable + public partial class SpectatorList : CompositeDrawable, ISerialisableDrawable { private const int max_spectators_displayed = 10; - public BindableList Spectators { get; } = new BindableList(); + public BindableList Spectators { get; } = new BindableList(); public Bindable UserPlayingState { get; } = new Bindable(); [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))] @@ -41,13 +43,20 @@ namespace osu.Game.Screens.Play.HUD private FillFlowContainer spectatorsFlow = null!; private DrawablePool pool = null!; + [Resolved] + private SpectatorClient client { get; set; } = null!; + + [Resolved] + private GameplayState gameplayState { get; set; } = null!; + [BackgroundDependencyLoader] private void load(OsuColour colours) { AutoSizeAxes = Axes.Y; - InternalChildren = new Drawable[] + InternalChildren = new[] { + Empty().With(t => t.Size = new Vector2(100, 50)), mainFlow = new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -76,6 +85,9 @@ namespace osu.Game.Screens.Play.HUD { base.LoadComplete(); + ((IBindableList)Spectators).BindTo(client.WatchingUsers); + ((IBindable)UserPlayingState).BindTo(gameplayState.PlayingState); + Spectators.BindCollectionChanged(onSpectatorsChanged, true); UserPlayingState.BindValueChanged(_ => updateVisibility()); @@ -94,7 +106,7 @@ namespace osu.Game.Screens.Play.HUD { for (int i = 0; i < e.NewItems!.Count; i++) { - var spectator = (Spectator)e.NewItems![i]!; + var spectator = (SpectatorUser)e.NewItems![i]!; int index = Math.Max(e.NewStartingIndex, 0) + i; if (index >= max_spectators_displayed) @@ -143,7 +155,7 @@ namespace osu.Game.Screens.Play.HUD } } - private void addNewSpectatorToList(int i, Spectator spectator) + private void addNewSpectatorToList(int i, SpectatorUser spectator) { var entry = pool.Get(entry => { @@ -156,6 +168,7 @@ namespace osu.Game.Screens.Play.HUD private void updateVisibility() { + // We don't want to show spectators when we are watching a replay. mainFlow.FadeTo(Spectators.Count > 0 && UserPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint); } @@ -169,7 +182,7 @@ namespace osu.Game.Screens.Play.HUD private partial class SpectatorListEntry : PoolableDrawable { - public Bindable Current { get; } = new Bindable(); + public Bindable Current { get; } = new Bindable(); private readonly BindableWithCurrent current = new BindableWithCurrent(); @@ -233,10 +246,6 @@ namespace osu.Game.Screens.Play.HUD } } - public record Spectator(int OnlineID, string Username) : IUser - { - public CountryCode CountryCode => CountryCode.Unknown; - public bool IsBot => false; - } + public bool UsesFixedAnchor { get; set; } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 513f4854ad..efa55d65c2 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -261,7 +261,7 @@ namespace osu.Game.Screens.Play Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; Score.ScoreInfo.Mods = gameplayMods; - dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard)); + dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor, HealthProcessor, Beatmap.Value.Storyboard, PlayingState)); var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); GameplayClockContainer.Add(new GameplayScrollWheelHandling()); diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 06086c1004..fc956e15fd 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -663,8 +663,6 @@ namespace osu.Game.Screens.Play private partial class MutedNotification : SimpleNotification { - public override bool IsImportant => true; - public MutedNotification() { Text = NotificationsStrings.GameVolumeTooLow; @@ -716,8 +714,6 @@ namespace osu.Game.Screens.Play private partial class BatteryWarningNotification : SimpleNotification { - public override bool IsImportant => true; - public BatteryWarningNotification() { Text = NotificationsStrings.BatteryLow; diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 7e3c26a1ba..a807fc6a34 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -1181,14 +1181,7 @@ namespace osu.Game.Screens.Select switch (e.Action) { case GlobalAction.AbsoluteScrollSongList: - // The default binding for absolute scroll is right mouse button. - // To avoid conflicts with context menus, disallow absolute scroll completely if it looks like things will fall over. - if (e.CurrentState.Mouse.Buttons.Contains(MouseButton.Right) - && GetContainingInputManager()!.HoveredDrawables.OfType().Any()) - return false; - - ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); - absoluteScrolling = true; + beginAbsoluteScrolling(e); return true; } @@ -1200,11 +1193,32 @@ namespace osu.Game.Screens.Select switch (e.Action) { case GlobalAction.AbsoluteScrollSongList: - absoluteScrolling = false; + endAbsoluteScrolling(); break; } } + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) + return false; + + beginAbsoluteScrolling(e); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Right) + endAbsoluteScrolling(); + base.OnMouseUp(e); + } + protected override bool OnMouseMove(MouseMoveEvent e) { if (absoluteScrolling) @@ -1216,6 +1230,14 @@ namespace osu.Game.Screens.Select return base.OnMouseMove(e); } + private void beginAbsoluteScrolling(UIEvent e) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + absoluteScrolling = true; + } + + private void endAbsoluteScrolling() => absoluteScrolling = false; + #endregion protected override ScrollbarContainer CreateScrollbar(Direction direction) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 93d4c90be0..d9c049bbae 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using System.Threading; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,7 +13,6 @@ using osu.Framework.Graphics.Pooling; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Multiplayer; using osu.Game.Screens.Select; namespace osu.Game.Screens.SelectV2 @@ -93,14 +91,8 @@ namespace osu.Game.Screens.SelectV2 public void Filter(FilterCriteria criteria) { Criteria = criteria; - FilterAsync().FireAndForget(); - } - - protected override async Task FilterAsync() - { loading.Show(); - await base.FilterAsync().ConfigureAwait(true); - loading.Hide(); + FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide())); } } } diff --git a/osu.Game/Screens/SelectV2/Carousel.cs b/osu.Game/Screens/SelectV2/Carousel.cs index a07022b32f..c042da167e 100644 --- a/osu.Game/Screens/SelectV2/Carousel.cs +++ b/osu.Game/Screens/SelectV2/Carousel.cs @@ -30,10 +30,7 @@ namespace osu.Game.Screens.SelectV2 /// public abstract partial class Carousel : CompositeDrawable { - /// - /// A collection of filters which should be run each time a is executed. - /// - protected IEnumerable Filters { get; init; } = Enumerable.Empty(); + #region Properties and methods for external usage /// /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. @@ -75,22 +72,13 @@ namespace osu.Game.Screens.SelectV2 /// /// The number of carousel items currently in rotation for display. /// - public int DisplayableItems => displayedCarouselItems?.Count ?? 0; + public int DisplayableItems => carouselItems?.Count ?? 0; /// /// The number of items currently actualised into drawables. /// public int VisibleItems => scroll.Panels.Count; - /// - /// All items which are to be considered for display in this carousel. - /// Mutating this list will automatically queue a . - /// - /// - /// Note that an may add new items which are displayed but not tracked in this list. - /// - protected readonly BindableList Items = new BindableList(); - /// /// The currently selected model. /// @@ -114,20 +102,31 @@ namespace osu.Game.Screens.SelectV2 } } - private List? displayedCarouselItems; + #endregion - private readonly CarouselScrollContainer scroll; + #region Properties and methods concerning implementations - protected Carousel() - { - InternalChild = scroll = new CarouselScrollContainer - { - RelativeSizeAxes = Axes.Both, - Masking = false, - }; + /// + /// A collection of filters which should be run each time a is executed. + /// + /// + /// Implementations should add all required filters as part of their initialisation. + /// + /// Importantly, each filter is sequentially run in the order provided. + /// Each filter receives the output of the previous filter. + /// + /// A filter may add, mutate or remove items. + /// + protected IEnumerable Filters { get; init; } = Enumerable.Empty(); - Items.BindCollectionChanged((_, _) => FilterAsync()); - } + /// + /// All items which are to be considered for display in this carousel. + /// Mutating this list will automatically queue a . + /// + /// + /// Note that an may add new items which are displayed but not tracked in this list. + /// + protected readonly BindableList Items = new BindableList(); /// /// Queue an asynchronous filter operation. @@ -151,8 +150,29 @@ namespace osu.Game.Screens.SelectV2 /// A representing the model. protected abstract CarouselItem CreateCarouselItemForModel(T model); + #endregion + + #region Initialisation + + private readonly CarouselScrollContainer scroll; + + protected Carousel() + { + InternalChild = scroll = new CarouselScrollContainer + { + RelativeSizeAxes = Axes.Both, + Masking = false, + }; + + Items.BindCollectionChanged((_, _) => FilterAsync()); + } + + #endregion + #region Filtering and display preparation + private List? carouselItems; + private Task filterTask = Task.CompletedTask; private CancellationTokenSource cancellationSource = new CancellationTokenSource(); @@ -202,7 +222,7 @@ namespace osu.Game.Screens.SelectV2 return; log("Items ready for display"); - displayedCarouselItems = items.ToList(); + carouselItems = items.ToList(); displayedRange = null; updateSelection(); @@ -233,9 +253,9 @@ namespace osu.Game.Screens.SelectV2 { currentSelectionCarouselItem = null; - if (displayedCarouselItems == null) return; + if (carouselItems == null) return; - foreach (var item in displayedCarouselItems) + foreach (var item in carouselItems) { bool isSelected = item.Model == currentSelection; @@ -286,7 +306,7 @@ namespace osu.Game.Screens.SelectV2 { base.Update(); - if (displayedCarouselItems == null) + if (carouselItems == null) return; var range = getDisplayRange(); @@ -336,15 +356,15 @@ namespace osu.Game.Screens.SelectV2 private DisplayRange getDisplayRange() { - Debug.Assert(displayedCarouselItems != null); + Debug.Assert(carouselItems != null); // Find index range of all items that should be on-screen carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload; - int firstIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem); + int firstIndex = carouselItems.BinarySearch(carouselBoundsItem); if (firstIndex < 0) firstIndex = ~firstIndex; carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload; - int lastIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem); + int lastIndex = carouselItems.BinarySearch(carouselBoundsItem); if (lastIndex < 0) lastIndex = ~lastIndex; firstIndex = Math.Max(0, firstIndex - 1); @@ -355,11 +375,11 @@ namespace osu.Game.Screens.SelectV2 private void updateDisplayedRange(DisplayRange range) { - Debug.Assert(displayedCarouselItems != null); + Debug.Assert(carouselItems != null); List toDisplay = range.Last - range.First == 0 ? new List() - : displayedCarouselItems.GetRange(range.First, range.Last - range.First + 1); + : carouselItems.GetRange(range.First, range.Last - range.First + 1); // Iterate over all panels which are already displayed and figure which need to be displayed / removed. foreach (var panel in scroll.Panels) @@ -395,9 +415,9 @@ namespace osu.Game.Screens.SelectV2 // Update the total height of all items (to make the scroll container scrollable through the full height even though // most items are not displayed / loaded). - if (displayedCarouselItems.Count > 0) + if (carouselItems.Count > 0) { - var lastItem = displayedCarouselItems[^1]; + var lastItem = carouselItems[^1]; scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight + visibleHalfHeight)); } else @@ -493,15 +513,7 @@ namespace osu.Game.Screens.SelectV2 switch (e.Action) { case GlobalAction.AbsoluteScrollSongList: - - // The default binding for absolute scroll is right mouse button. - // To avoid conflicts with context menus, disallow absolute scroll completely if it looks like things will fall over. - if (e.CurrentState.Mouse.Buttons.Contains(MouseButton.Right) - && GetContainingInputManager()!.HoveredDrawables.OfType().Any()) - return false; - - ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); - absoluteScrolling = true; + beginAbsoluteScrolling(e); return true; } @@ -513,11 +525,32 @@ namespace osu.Game.Screens.SelectV2 switch (e.Action) { case GlobalAction.AbsoluteScrollSongList: - absoluteScrolling = false; + endAbsoluteScrolling(); break; } } + protected override bool OnMouseDown(MouseDownEvent e) + { + if (e.Button == MouseButton.Right) + { + // To avoid conflicts with context menus, disallow absolute scroll if it looks like things will fall over. + if (GetContainingInputManager()!.HoveredDrawables.OfType().Any()) + return false; + + beginAbsoluteScrolling(e); + } + + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (e.Button == MouseButton.Right) + endAbsoluteScrolling(); + base.OnMouseUp(e); + } + protected override bool OnMouseMove(MouseMoveEvent e) { if (absoluteScrolling) @@ -529,6 +562,14 @@ namespace osu.Game.Screens.SelectV2 return base.OnMouseMove(e); } + private void beginAbsoluteScrolling(UIEvent e) + { + ScrollToAbsolutePosition(e.CurrentState.Mouse.Position); + absoluteScrolling = true; + } + + private void endAbsoluteScrolling() => absoluteScrolling = false; + #endregion } diff --git a/osu.Game/Skinning/ArgonSkin.cs b/osu.Game/Skinning/ArgonSkin.cs index 771d10d73b..bd31ccd5c9 100644 --- a/osu.Game/Skinning/ArgonSkin.cs +++ b/osu.Game/Skinning/ArgonSkin.cs @@ -7,7 +7,6 @@ using JetBrains.Annotations; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; @@ -110,15 +109,37 @@ namespace osu.Game.Skinning case GlobalSkinnableContainers.MainHUDComponents: if (containerLookup.Ruleset != null) { - return new Container + return new DefaultSkinComponentsContainer(container => + { + var comboCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(36, -66); + + if (comboCounter != null) + { + comboCounter.Position = pos; + pos -= new Vector2(0, comboCounter.DrawHeight * 1.4f + 20); + } + + if (spectatorList != null) + spectatorList.Position = pos; + }) { RelativeSizeAxes = Axes.Both, - Child = new ArgonComboCounter + Children = new Drawable[] { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Position = new Vector2(36, -66), - Scale = new Vector2(1.3f), + new ArgonComboCounter + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Scale = new Vector2(1.3f), + }, + new SpectatorList + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + } }, }; } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 6faadfba9b..08fa068830 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -367,16 +367,29 @@ namespace osu.Game.Skinning return new DefaultSkinComponentsContainer(container => { var combo = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); + + Vector2 pos = new Vector2(); if (combo != null) { combo.Anchor = Anchor.BottomLeft; combo.Origin = Anchor.BottomLeft; combo.Scale = new Vector2(1.28f); + + pos += new Vector2(10, -(combo.DrawHeight * 1.56f + 20) * combo.Scale.X); + } + + if (spectatorList != null) + { + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = pos; } }) { - new LegacyDefaultComboCounter() + new LegacyDefaultComboCounter(), + new SpectatorList(), }; } diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index d562fd3256..06fe1c80ee 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; +using osu.Game.Graphics; using osu.Game.IO; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.HUD.HitErrorMeters; @@ -90,6 +91,7 @@ namespace osu.Game.Skinning var ppCounter = container.OfType().FirstOrDefault(); var songProgress = container.OfType().FirstOrDefault(); var keyCounter = container.OfType().FirstOrDefault(); + var spectatorList = container.OfType().FirstOrDefault(); if (score != null) { @@ -142,17 +144,26 @@ namespace osu.Game.Skinning } } + const float padding = 10; + + // Hard to find this at runtime, so taken from the most expanded state during replay. + const float song_progress_offset_height = 73; + if (songProgress != null && keyCounter != null) { - const float padding = 10; - - // Hard to find this at runtime, so taken from the most expanded state during replay. - const float song_progress_offset_height = 73; - keyCounter.Anchor = Anchor.BottomRight; keyCounter.Origin = Anchor.BottomRight; keyCounter.Position = new Vector2(-padding, -(song_progress_offset_height + padding)); } + + if (spectatorList != null) + { + spectatorList.Font.Value = Typeface.Venera; + spectatorList.HeaderColour.Value = new OsuColour().BlueLighter; + spectatorList.Anchor = Anchor.BottomLeft; + spectatorList.Origin = Anchor.BottomLeft; + spectatorList.Position = new Vector2(padding, -(song_progress_offset_height + padding)); + } }) { Children = new Drawable[] @@ -165,7 +176,8 @@ namespace osu.Game.Skinning new DefaultKeyCounterDisplay(), new BarHitErrorMeter(), new BarHitErrorMeter(), - new TrianglesPerformancePointsCounter() + new TrianglesPerformancePointsCounter(), + new SpectatorList(), } };