diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs index 03b3b94bd8..4454501a96 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs @@ -34,6 +34,8 @@ namespace osu.Game.Tests.Visual.Menus { Queue<(IWorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null!; + AddStep("disable shuffle", () => Game.MusicController.Shuffle.Value = false); + // ensure we have at least two beatmaps available to identify the direction the music controller navigated to. AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(TestResources.CreateTestBeatmapSetInfo()), 5); diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 63efdd5381..a7bca536df 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -13,8 +14,10 @@ using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Rulesets.Mods; @@ -43,6 +46,8 @@ namespace osu.Game.Overlays /// public readonly BindableBool AllowTrackControl = new BindableBool(true); + public readonly BindableBool Shuffle = new BindableBool(true); + /// /// Fired when the global has changed. /// Includes direction information for display purposes. @@ -66,12 +71,18 @@ namespace osu.Game.Overlays private AudioFilter audioDuckFilter = null!; + private readonly Bindable randomSelectAlgorithm = new Bindable(); + private readonly List previousRandomSets = new List(); + private int randomHistoryDirection; + [BackgroundDependencyLoader] - private void load(AudioManager audio) + private void load(AudioManager audio, OsuConfigManager configManager) { AddInternal(audioDuckFilter = new AudioFilter(audio.TrackMixer)); audio.Tracks.AddAdjustment(AdjustableProperty.Volume, audioDuckVolume); sampleVolume = audio.VolumeSample.GetBoundCopy(); + + configManager.BindWith(OsuSetting.RandomSelectAlgorithm, randomSelectAlgorithm); } protected override void LoadComplete() @@ -238,8 +249,15 @@ namespace osu.Game.Overlays queuedDirection = TrackChangeDirection.Prev; - var playableSet = getBeatmapSets().AsEnumerable().TakeWhile(i => !i.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Protected || allowProtectedTracks) + BeatmapSetInfo? playableSet; + + if (Shuffle.Value) + playableSet = getNextRandom(-1, allowProtectedTracks); + else + { + playableSet = getBeatmapSets().AsEnumerable().TakeWhile(i => !i.Equals(current?.BeatmapSetInfo)).LastOrDefault(s => !s.Protected || allowProtectedTracks) ?? getBeatmapSets().AsEnumerable().LastOrDefault(s => !s.Protected || allowProtectedTracks); + } if (playableSet != null) { @@ -327,8 +345,15 @@ namespace osu.Game.Overlays queuedDirection = TrackChangeDirection.Next; - var playableSet = getBeatmapSets().AsEnumerable().SkipWhile(i => !i.Equals(current?.BeatmapSetInfo) || (i.Protected && !allowProtectedTracks)).ElementAtOrDefault(1) + BeatmapSetInfo? playableSet; + + if (Shuffle.Value) + playableSet = getNextRandom(1, allowProtectedTracks); + else + { + playableSet = getBeatmapSets().AsEnumerable().SkipWhile(i => !i.Equals(current?.BeatmapSetInfo) || (i.Protected && !allowProtectedTracks)).ElementAtOrDefault(1) ?? getBeatmapSets().AsEnumerable().FirstOrDefault(i => !i.Protected || allowProtectedTracks); + } var playableBeatmap = playableSet?.Beatmaps.FirstOrDefault(); @@ -342,6 +367,58 @@ namespace osu.Game.Overlays return false; } + private BeatmapSetInfo? getNextRandom(int direction, bool allowProtectedTracks) + { + BeatmapSetInfo result; + + var possibleSets = getBeatmapSets().AsEnumerable().Where(s => !s.Protected || allowProtectedTracks).ToArray(); + + if (possibleSets.Length == 0) + return null; + + // condition below checks if the signs of `randomHistoryDirection` and `direction` are opposite and not zero. + // if that is the case, it means that the user had previously chosen next track `randomHistoryDirection` times and wants to go back, + // or that the user had previously chosen previous track `randomHistoryDirection` times and wants to go forward. + // in both cases, it means that we have a history of previous random selections that we can rewind. + if (randomHistoryDirection * direction < 0) + { + Debug.Assert(Math.Abs(randomHistoryDirection) == previousRandomSets.Count); + result = previousRandomSets[^1]; + previousRandomSets.RemoveAt(previousRandomSets.Count - 1); + randomHistoryDirection += direction; + return result; + } + + // if the early-return above didn't cover it, it means that we have no history to fall back on + // and need to actually choose something random. + switch (randomSelectAlgorithm.Value) + { + case RandomSelectAlgorithm.Random: + result = possibleSets[RNG.Next(possibleSets.Length)]; + break; + + case RandomSelectAlgorithm.RandomPermutation: + var notYetPlayedSets = possibleSets.Except(previousRandomSets).ToArray(); + + if (notYetPlayedSets.Length == 0) + { + notYetPlayedSets = possibleSets; + previousRandomSets.Clear(); + randomHistoryDirection = 0; + } + + result = notYetPlayedSets[RNG.Next(notYetPlayedSets.Length)]; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(randomSelectAlgorithm), randomSelectAlgorithm.Value, "Unsupported random select algorithm"); + } + + previousRandomSets.Add(result); + randomHistoryDirection += direction; + return result; + } + private void restartTrack() { // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase). diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 76c8c237d5..bcd15a2b7e 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -47,6 +47,7 @@ namespace osu.Game.Overlays private IconButton prevButton = null!; private IconButton playButton = null!; private IconButton nextButton = null!; + private MusicIconButton shuffleButton = null!; private IconButton playlistButton = null!; private ScrollingTextContainer title = null!, artist = null!; @@ -69,6 +70,7 @@ namespace osu.Game.Overlays private OsuColour colours { get; set; } = null!; private Bindable allowTrackControl = null!; + private BindableBool shuffle = new BindableBool(true); public NowPlayingOverlay() { @@ -162,6 +164,13 @@ namespace osu.Game.Overlays Action = () => musicController.NextTrack(), Icon = FontAwesome.Solid.StepForward, }, + shuffleButton = new MusicIconButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = shuffle.Toggle, + Icon = FontAwesome.Solid.Random, + } } }, playlistButton = new MusicIconButton @@ -227,6 +236,9 @@ namespace osu.Game.Overlays allowTrackControl = musicController.AllowTrackControl.GetBoundCopy(); allowTrackControl.BindValueChanged(_ => Scheduler.AddOnce(updateEnabledStates), true); + shuffle.BindTo(musicController.Shuffle); + shuffle.BindValueChanged(s => shuffleButton.FadeColour(s.NewValue ? colours.Yellow : Color4.White, 200, Easing.OutQuint), true); + musicController.TrackChanged += trackChanged; trackChanged(beatmap.Value); }