diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs index 0f991abcfc..f92abd1063 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselTestScene.cs @@ -107,8 +107,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { Carousel = new TestBeatmapCarousel { - NewItemsPresented = () => NewItemsPresentedInvocationCount++, - ChooseRecommendedBeatmap = beatmaps => BeatmapRecommendationFunction?.Invoke(beatmaps) ?? beatmaps.First(), + NewItemsPresented = _ => NewItemsPresentedInvocationCount++, + RequestSelection = b => Carousel.CurrentSelection = b, + RequestRecommendedSelection = beatmaps => Carousel.CurrentSelection = BeatmapRecommendationFunction?.Invoke(beatmaps) ?? beatmaps.First(), BleedTop = 50, BleedBottom = 50, Anchor = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs index fcb74e539b..1534b1174b 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelect.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Overlays.Dialog; using osu.Game.Overlays.Mods; @@ -36,10 +37,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("beatmap imported", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.True); - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); AddAssert("beatmap selected", () => !Beatmap.IsDefault); AddStep("import score", () => @@ -90,11 +87,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ImportBeatmapForRuleset(0); AddAssert("beatmap imported", () => Beatmaps.GetAllUsableBeatmapSets().Any(), () => Is.True); - - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); AddAssert("beatmap selected", () => !Beatmap.IsDefault); AddStep("press shift-delete", () => @@ -253,11 +245,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ImportBeatmapForRuleset(0); LoadSongSelect(); - - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove. AddAssert("beatmap selected", () => !Beatmap.IsDefault); @@ -283,11 +270,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ImportBeatmapForRuleset(0); LoadSongSelect(); - - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove. AddAssert("beatmap selected", () => !Beatmap.IsDefault); @@ -315,11 +297,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ImportBeatmapForRuleset(0); LoadSongSelect(); - - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); AddStep("press right", () => InputManager.Key(Key.Right)); // press right to select in carousel, also remove. AddAssert("beatmap selected", () => !Beatmap.IsDefault); @@ -460,17 +437,56 @@ namespace osu.Game.Tests.Visual.SongSelectV2 LoadSongSelect(); ImportBeatmapForRuleset(0); - - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); AddAssert("options enabled", () => this.ChildrenOfType().Single().Enabled.Value); AddStep("click", () => this.ChildrenOfType().Single().TriggerClick()); AddUntilStep("popover displayed", () => this.ChildrenOfType().Any(p => p.IsPresent)); } + [Test] + public void TestSelectionChangedFromProtectedToNone() + { + ImportBeatmapForRuleset(0); + AddStep("set protected on import", () => Realm.Write(r => r.All().First(s => !s.DeletePending).Protected = true)); + + AddStep("selected protected", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().First(s => s.Protected).Beatmaps.First())); + + LoadSongSelect(); + + AddUntilStep("beatmap deselected", () => Beatmap.IsDefault); + } + + [Test] + public void TestSelectionChangedFromProtectedToSomething() + { + ImportBeatmapForRuleset(0); + AddStep("set protected on import", () => Realm.Write(r => r.All().First(s => !s.DeletePending).Protected = true)); + + AddStep("selected protected", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().First(s => s.Protected).Beatmaps.First())); + + ImportBeatmapForRuleset(0); + + LoadSongSelect(); + + AddUntilStep("beatmap selected", () => !Beatmap.IsDefault); + AddUntilStep("selection not protected", () => !Beatmap.Value.BeatmapSetInfo.Protected); + } + + [Test] + public void TestSelectAfterDeletion() + { + LoadSongSelect(); + + ImportBeatmapForRuleset(0); + AddUntilStep("beatmap selected", () => !Beatmap.IsDefault); + + AddStep("delete all beatmaps", () => Beatmaps.Delete()); + AddUntilStep("beatmap not selected", () => Beatmap.IsDefault); + + AddStep("restore deleted", () => Beatmaps.UndeleteAll()); + AddUntilStep("beatmap selected", () => !Beatmap.IsDefault); + } + [Test] public void TestFooterOptionsState() { @@ -478,16 +494,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 ImportBeatmapForRuleset(0); - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); - AddAssert("options enabled", () => this.ChildrenOfType().Single().Enabled.Value); AddStep("delete all beatmaps", () => Beatmaps.Delete()); - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. AddAssert("beatmap selected", () => !Beatmap.IsDefault); AddStep("select no beatmap", () => Beatmap.SetDefault()); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs index c0c80e0bc3..9532895edd 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectFiltering.cs @@ -254,11 +254,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 checkMatchedBeatmaps(3); - // song select should automatically select the beatmap for us but this is not implemented yet. - // todo: remove when that's the case. - AddAssert("no beatmap selected", () => Beatmap.IsDefault); - AddStep("select beatmap", () => Beatmap.Value = Beatmaps.GetWorkingBeatmap(Beatmaps.GetAllUsableBeatmapSets().Single().Beatmaps.First())); - AddStep("hide", () => Beatmaps.Hide(Beatmap.Value.BeatmapInfo)); checkMatchedBeatmaps(2); diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 59e413d935..0aad55b26d 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -90,6 +90,12 @@ namespace osu.Game.Beatmaps return ID == other.ID; } + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return ID.GetHashCode(); + } + public override string ToString() => Metadata.GetDisplayString(); public bool Equals(IBeatmapSetInfo? other) => other is BeatmapSetInfo b && Equals(b); diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 37d69dab89..31c95f6930 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -39,7 +39,7 @@ namespace osu.Game.Graphics.Carousel /// /// Called after a filter operation or change in items results in the visible carousel items changing. /// - public Action? NewItemsPresented { private get; init; } + public Action>? NewItemsPresented { private get; init; } /// /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it. @@ -318,7 +318,7 @@ namespace osu.Game.Graphics.Carousel if (!Scroll.UserScrolling) scrollToSelection(); - NewItemsPresented?.Invoke(); + NewItemsPresented?.Invoke(carouselItems); }); return items; diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 0d84dea605..9e7ac00375 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -27,9 +27,14 @@ namespace osu.Game.Screens.SelectV2 public Action? RequestPresentBeatmap { private get; init; } /// - /// From the provided beatmaps, return the most appropriate one for the user's skill. + /// From the provided beatmaps, select the most appropriate one for the user's skill. /// - public Func, BeatmapInfo>? ChooseRecommendedBeatmap { private get; init; } + public required Action> RequestRecommendedSelection { private get; init; } + + /// + /// Selection requested for the provided beatmap. + /// + public required Action RequestSelection { private get; init; } public const float SPACING = 3f; @@ -139,7 +144,7 @@ namespace osu.Game.Screens.SelectV2 // TODO: should this exist in song select instead of here? // we need to ensure the global beatmap is also updated alongside changes. if (CurrentSelection != null && CheckModelEquality(beatmap, CurrentSelection)) - CurrentSelection = matchingNewBeatmap; + RequestSelection(matchingNewBeatmap); Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); newSetBeatmaps.Remove(matchingNewBeatmap); @@ -190,7 +195,7 @@ namespace osu.Game.Screens.SelectV2 if (grouping.SetItems.TryGetValue(setInfo, out var items)) { var beatmaps = items.Select(i => i.Model).OfType(); - CurrentSelection = ChooseRecommendedBeatmap?.Invoke(beatmaps) ?? beatmaps.First(); + RequestRecommendedSelection(beatmaps); } return; @@ -202,7 +207,7 @@ namespace osu.Game.Screens.SelectV2 return; } - CurrentSelection = beatmapInfo; + RequestSelection(beatmapInfo); return; } } diff --git a/osu.Game/Screens/SelectV2/PanelBeatmap.cs b/osu.Game/Screens/SelectV2/PanelBeatmap.cs index 497fce17ca..b129cd683f 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmap.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmap.cs @@ -209,7 +209,7 @@ namespace osu.Game.Screens.SelectV2 var beatmap = (BeatmapInfo)Item.Model; - starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, 200); + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.SELECTION_DEBOUNCE); starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true); } diff --git a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs index 6c0779bab6..96e8fa47ff 100644 --- a/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs +++ b/osu.Game/Screens/SelectV2/PanelBeatmapStandalone.cs @@ -245,7 +245,7 @@ namespace osu.Game.Screens.SelectV2 var beatmap = (BeatmapInfo)Item.Model; - starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, 200); + starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.SELECTION_DEBOUNCE); starDifficultyBindable.BindValueChanged(_ => updateDisplay(), true); } diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 4e85d0a3eb..93986d5a77 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -15,10 +16,12 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Graphics.Carousel; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; @@ -32,6 +35,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Footer; using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Skinning; @@ -47,8 +51,12 @@ namespace osu.Game.Screens.SelectV2 /// This will be gradually built upon and ultimately replace once everything is in place. /// [Cached(typeof(ISongSelect))] - public abstract partial class SongSelect : OsuScreen, IKeyBindingHandler, ISongSelect + public abstract partial class SongSelect : ScreenWithBeatmapBackground, IKeyBindingHandler, ISongSelect { + // this is intentionally slightly higher than key repeat, but low enough to not impede user experience. + // this avoids rapid churn loading when iterating the carousel using keyboard. + public const int SELECTION_DEBOUNCE = 100; + private const float logo_scale = 0.4f; private const double fade_duration = 300; @@ -56,6 +64,12 @@ namespace osu.Game.Screens.SelectV2 public const float CORNER_RADIUS_HIDE_OFFSET = 20f; public const float ENTER_DURATION = 600; + /// + /// Whether this song select instance should take control of the global track, + /// applying looping and preview offsets. + /// + protected bool ControlGlobalMusic { get; init; } = true; + private readonly ModSelectOverlay modSelectOverlay = new UserModSelectOverlay(OverlayColourScheme.Aquamarine) { ShowPresets = true, @@ -177,9 +191,11 @@ namespace osu.Game.Screens.SelectV2 { BleedTop = FilterControl.HEIGHT_FROM_SCREEN_TOP + 5, BleedBottom = ScreenFooter.HEIGHT + 5, - RequestPresentBeatmap = SelectAndStart, - NewItemsPresented = newItemsPresented, RelativeSizeAxes = Axes.Both, + RequestPresentBeatmap = _ => OnStart(), + RequestSelection = selectBeatmap, + RequestRecommendedSelection = selectRecommendedBeatmap, + NewItemsPresented = newItemsPresented, }, noResultsPlaceholder = new NoResultsPlaceholder(), } @@ -245,10 +261,107 @@ namespace osu.Game.Screens.SelectV2 detailsArea.Height = wedgesContainer.DrawHeight - titleWedge.LayoutSize.Y - 4; } + #region Audio + + [Resolved] + private MusicController music { get; set; } = null!; + + private readonly WeakReference lastTrack = new WeakReference(null); + + /// + /// Ensures some music is playing for the current track. + /// Will resume playback from a manual user pause if the track has changed. + /// + private void ensurePlayingSelected() + { + if (!ControlGlobalMusic) + return; + + ITrack track = music.CurrentTrack; + + bool isNewTrack = !lastTrack.TryGetTarget(out var last) || last != track; + + if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) + { + Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); + music.Play(true); + } + + lastTrack.SetTarget(track); + } + + private bool isHandlingLooping; + + private void beginLooping() + { + if (!ControlGlobalMusic) + return; + + Debug.Assert(!isHandlingLooping); + + isHandlingLooping = true; + + ensureTrackLooping(Beatmap.Value, TrackChangeDirection.None); + + music.TrackChanged += ensureTrackLooping; + } + + private void endLooping() + { + // may be called multiple times during screen exit process. + if (!isHandlingLooping) + return; + + music.CurrentTrack.Looping = isHandlingLooping = false; + + music.TrackChanged -= ensureTrackLooping; + } + + private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection) + => beatmap.PrepareTrackForPreview(true); + + #endregion + #region Selection handling - private BeatmapInfo getRecommendedBeatmap(IEnumerable beatmaps) - => difficultyRecommender?.GetRecommendedBeatmap(beatmaps) ?? beatmaps.First(); + private ScheduledDelegate? selectionDebounce; + + private void selectRecommendedBeatmap(IEnumerable beatmaps) + { + selectBeatmap(difficultyRecommender?.GetRecommendedBeatmap(beatmaps) ?? beatmaps.First()); + } + + private void selectBeatmap(BeatmapInfo beatmap) + { + carousel.CurrentSelection = beatmap; + + selectionDebounce?.Cancel(); + selectionDebounce = Scheduler.AddDelayed(() => selectBeatmap(beatmaps.GetWorkingBeatmap(beatmap)), SELECTION_DEBOUNCE); + } + + private void selectBeatmap(WorkingBeatmap beatmap) + { + if (beatmap.BeatmapInfo.BeatmapSet!.Protected) + return; + + carousel.CurrentSelection = beatmap.BeatmapInfo; + + Beatmap.Value = beatmap; + + if (this.IsCurrentScreen()) + ensurePlayingSelected(); + + // If not the current screen, this will be applied in OnResuming. + if (this.IsCurrentScreen()) + { + ApplyToBackground(backgroundModeBeatmap => + { + backgroundModeBeatmap.Beatmap = beatmap; + backgroundModeBeatmap.IgnoreUserSettings.Value = true; + backgroundModeBeatmap.FadeColour(Color4.White, 250); + }); + } + } #endregion @@ -266,6 +379,14 @@ namespace osu.Game.Screens.SelectV2 modSelectOverlay.Beatmap.BindTo(Beatmap); modSelectOverlay.SelectedMods.BindTo(Mods); + + beginLooping(); + + // force reselection if entering song select with a protected beatmap + if (Beatmap.Value.BeatmapInfo.BeatmapSet!.Protected) + Beatmap.SetDefault(); + else + selectBeatmap(Beatmap.Value); } public override void OnResuming(ScreenTransitionEvent e) @@ -285,6 +406,13 @@ namespace osu.Game.Screens.SelectV2 // required due to https://github.com/ppy/osu-framework/issues/3218 modSelectOverlay.SelectedMods.Disabled = false; modSelectOverlay.SelectedMods.BindTo(Mods); + + beginLooping(); + + if (Beatmap.Value.BeatmapInfo.BeatmapSet!.Protected) + Beatmap.SetDefault(); + else + selectBeatmap(Beatmap.Value); } public override void OnSuspending(ScreenTransitionEvent e) @@ -300,6 +428,8 @@ namespace osu.Game.Screens.SelectV2 carousel.VisuallyFocusSelected = true; + endLooping(); + base.OnSuspending(e); } @@ -311,6 +441,8 @@ namespace osu.Game.Screens.SelectV2 detailsArea.Hide(); filterControl.Hide(); + endLooping(); + return base.OnExiting(e); } @@ -368,13 +500,10 @@ namespace osu.Game.Screens.SelectV2 private void criteriaChanged(FilterCriteria criteria) { filterDebounce?.Cancel(); - filterDebounce = Scheduler.AddDelayed(() => - { - carousel.Filter(criteria); - }, filter_delay); + filterDebounce = Scheduler.AddDelayed(() => { carousel.Filter(criteria); }, filter_delay); } - private void newItemsPresented() + private void newItemsPresented(IEnumerable carouselItems) { int count = carousel.MatchedBeatmapsCount; @@ -389,6 +518,16 @@ namespace osu.Game.Screens.SelectV2 // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 // but also in this case we want support for formatting a number within a string). filterControl.StatusText = count != 1 ? $"{count:#,0} matches" : $"{count:#,0} match"; + + if (!carouselItems.Any()) + { + Beatmap.SetDefault(); + return; + } + + if (Beatmap.IsDefault || Beatmap.Value.BeatmapSetInfo?.DeletePending == true) + // TODO: this should probably use random, not recommended like this. + selectRecommendedBeatmap(carouselItems.Select(i => i.Model).OfType()); } #endregion