diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs b/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs
index 2d1b7cd1b2..d91e7283d1 100644
--- a/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs
+++ b/osu.Game.Tests/Visual/SongSelectV2/TestScenePanelGroup.cs
@@ -56,21 +56,21 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
new PanelGroupStarDifficulty
{
- Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)"))
+ Item = new CarouselItem(new GroupDefinition(0, $"{star} Star(s)", new StarDifficulty(star, 0)))
},
new PanelGroupStarDifficulty
{
- Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")),
+ Item = new CarouselItem(new GroupDefinition(1, $"{star} Star(s)", new StarDifficulty(star, 0))),
KeyboardSelected = { Value = true },
},
new PanelGroupStarDifficulty
{
- Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")),
+ Item = new CarouselItem(new GroupDefinition(2, $"{star} Star(s)", new StarDifficulty(star, 0))),
Expanded = { Value = true },
},
new PanelGroupStarDifficulty
{
- Item = new CarouselItem(new GroupDefinition(new StarDifficulty(star, 0), $"{star} Star(s)")),
+ Item = new CarouselItem(new GroupDefinition(3, $"{star} Star(s)", new StarDifficulty(star, 0))),
Expanded = { Value = true },
KeyboardSelected = { Value = true },
},
diff --git a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs
index d489aeda3f..bc1438d7c7 100644
--- a/osu.Game/Beatmaps/BeatmapOnlineStatus.cs
+++ b/osu.Game/Beatmaps/BeatmapOnlineStatus.cs
@@ -15,6 +15,7 @@ namespace osu.Game.Beatmaps
/// Once in this state, online status changes should be ignored unless the beatmap is reverted or submitted.
///
[LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.LocallyModified))]
+ [Description("Local")]
LocallyModified = -4,
[Description("Unknown")]
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
index 4c70b8c58f..fa5224b387 100644
--- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
+++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
@@ -423,5 +423,11 @@ namespace osu.Game.Screens.SelectV2
#endregion
}
- public record GroupDefinition(object Data, string Title);
+ ///
+ /// Defines a grouping header for a set of carousel items.
+ ///
+ /// The order of this group in the carousel, sorted using ascending order.
+ /// The title of this group.
+ /// Additional data. Provide a for difficulty groups, or null for any other group.
+ public record GroupDefinition(int Order, string Title, object? Data = null);
}
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
index f8004282db..0286eadbcc 100644
--- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
+++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
@@ -3,8 +3,10 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Carousel;
using osu.Game.Screens.Select;
@@ -19,15 +21,15 @@ namespace osu.Game.Screens.SelectV2
///
/// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection.
///
- public IDictionary> SetItems => setItems;
+ public IDictionary> SetItems => setMap;
///
/// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection.
///
- public IDictionary> GroupItems => groupItems;
+ public IDictionary> GroupItems => groupMap;
- private readonly Dictionary> setItems = new Dictionary>();
- private readonly Dictionary> groupItems = new Dictionary>();
+ private readonly Dictionary> setMap = new Dictionary>();
+ private readonly Dictionary> groupMap = new Dictionary>();
private readonly Func getCriteria;
@@ -40,72 +42,70 @@ namespace osu.Game.Screens.SelectV2
{
return await Task.Run(() =>
{
- setItems.Clear();
- groupItems.Clear();
+ setMap.Clear();
+ groupMap.Clear();
var criteria = getCriteria();
var newItems = new List();
- BeatmapInfo? lastBeatmap = null;
-
- GroupDefinition? lastGroup = null;
- CarouselItem? lastGroupItem = null;
-
- HashSet? currentGroupItems = null;
- HashSet? currentSetItems = null;
-
BeatmapSetsGroupedTogether = criteria.Sort != SortMode.Difficulty;
- foreach (var item in items)
+ var groups = getGroups((List)items, criteria);
+
+ foreach (var (group, itemsInGroup) in groups)
{
cancellationToken.ThrowIfCancellationRequested();
- var beatmap = (BeatmapInfo)item.Model;
+ CarouselItem? groupItem = null;
+ HashSet? currentGroupItems = null;
+ HashSet? currentSetItems = null;
+ BeatmapInfo? lastBeatmap = null;
- if (createGroupIfRequired(criteria, beatmap, lastGroup) is GroupDefinition newGroup)
+ if (group != null)
{
- // When reaching a new group, ensure we reset any beatmap set tracking.
- currentSetItems = null;
- lastBeatmap = null;
+ groupMap[group] = currentGroupItems = new HashSet();
- groupItems[newGroup] = currentGroupItems = new HashSet();
- lastGroup = newGroup;
-
- addItem(lastGroupItem = new CarouselItem(newGroup)
+ addItem(groupItem = new CarouselItem(group)
{
DrawHeight = PanelGroup.HEIGHT,
DepthLayer = -2,
});
}
- if (BeatmapSetsGroupedTogether)
+ foreach (var item in itemsInGroup)
{
- bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID;
+ var beatmap = (BeatmapInfo)item.Model;
- if (newBeatmapSet)
+ if (BeatmapSetsGroupedTogether)
{
- setItems[beatmap.BeatmapSet!] = currentSetItems = new HashSet();
+ bool newBeatmapSet = lastBeatmap?.BeatmapSet!.ID != beatmap.BeatmapSet!.ID;
- if (lastGroupItem != null)
- lastGroupItem.NestedItemCount++;
-
- addItem(new CarouselItem(beatmap.BeatmapSet!)
+ if (newBeatmapSet)
{
- DrawHeight = PanelBeatmapSet.HEIGHT,
- DepthLayer = -1
- });
+ if (!setMap.TryGetValue(beatmap.BeatmapSet!, out currentSetItems))
+ setMap[beatmap.BeatmapSet!] = currentSetItems = new HashSet();
+
+ if (groupItem != null)
+ groupItem.NestedItemCount++;
+
+ addItem(new CarouselItem(beatmap.BeatmapSet!)
+ {
+ DrawHeight = PanelBeatmapSet.HEIGHT,
+ DepthLayer = -1
+ });
+ }
}
- }
- else
- {
- if (lastGroupItem != null)
- lastGroupItem.NestedItemCount++;
+ else
+ {
+ if (groupItem != null)
+ groupItem.NestedItemCount++;
- item.DrawHeight = PanelBeatmapStandalone.HEIGHT;
- }
+ item.DrawHeight = PanelBeatmapStandalone.HEIGHT;
+ }
- addItem(item);
- lastBeatmap = beatmap;
+ addItem(item);
+ lastBeatmap = beatmap;
+ }
void addItem(CarouselItem i)
{
@@ -114,7 +114,7 @@ namespace osu.Game.Screens.SelectV2
currentGroupItems?.Add(i);
currentSetItems?.Add(i);
- i.IsVisible = i.Model is GroupDefinition || (lastGroup == null && (i.Model is BeatmapSetInfo || currentSetItems == null));
+ i.IsVisible = i.Model is GroupDefinition || (group == null && (i.Model is BeatmapSetInfo || currentSetItems == null));
}
}
@@ -122,40 +122,211 @@ namespace osu.Game.Screens.SelectV2
}, cancellationToken).ConfigureAwait(false);
}
- private GroupDefinition? createGroupIfRequired(FilterCriteria criteria, BeatmapInfo beatmap, GroupDefinition? lastGroup)
+ private List getGroups(List items, FilterCriteria criteria)
{
switch (criteria.Group)
{
+#pragma warning disable CS0618 // Type or member is obsolete
+ case GroupMode.All:
+#pragma warning restore CS0618 // Type or member is obsolete
+ case GroupMode.NoGrouping:
+ return new List { new GroupMapping(null, items) };
+
case GroupMode.Artist:
- char groupChar = lastGroup?.Data as char? ?? (char)0;
- char beatmapFirstChar = char.ToUpperInvariant(beatmap.Metadata.Artist[0]);
+ return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Artist), items);
- if (beatmapFirstChar > groupChar)
- return new GroupDefinition(beatmapFirstChar, $"{beatmapFirstChar}");
+ case GroupMode.Author:
+ return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Author.Username), items);
- break;
+ case GroupMode.Title:
+ return getGroupsBy(b => defineGroupAlphabetically(b.BeatmapSet!.Metadata.Title), items);
+
+ case GroupMode.DateAdded:
+ return getGroupsBy(b => defineGroupByDate(b.BeatmapSet!.DateAdded), items);
+
+ case GroupMode.LastPlayed:
+ return getGroupsBy(b =>
+ {
+ DateTimeOffset? maxLastPlayed = aggregateMax(b, items, bb => bb.LastPlayed);
+
+ if (maxLastPlayed == null)
+ return new GroupDefinition(int.MaxValue, "Never");
+
+ return defineGroupByDate(maxLastPlayed.Value);
+ }, items);
+
+ case GroupMode.RankedStatus:
+ return getGroupsBy(b => defineGroupByStatus(b.BeatmapSet!.Status), items);
+
+ case GroupMode.BPM:
+ return getGroupsBy(b =>
+ {
+ double maxBPM = aggregateMax(b, items, bb => bb.BPM);
+ return defineGroupByBPM(maxBPM);
+ }, items);
case GroupMode.Difficulty:
- var starGroup = lastGroup?.Data as StarDifficulty? ?? new StarDifficulty(-1, 0);
- double beatmapStarRating = Math.Round(beatmap.StarRating, 2);
+ return getGroupsBy(b => defineGroupByStars(b.StarRating), items);
- if (beatmapStarRating >= starGroup.Stars + 1)
+ case GroupMode.Length:
+ return getGroupsBy(b =>
{
- starGroup = new StarDifficulty((int)Math.Floor(beatmapStarRating), 0);
+ double maxLength = aggregateMax(b, items, bb => bb.Length);
+ return defineGroupByLength(maxLength);
+ }, items);
- if (starGroup.Stars == 0)
- return new GroupDefinition(starGroup, "Below 1 Star");
+ case GroupMode.Collections:
+ // todo: unsupported.
+ goto case GroupMode.NoGrouping;
- if (starGroup.Stars == 1)
- return new GroupDefinition(starGroup, "1 Star");
+ case GroupMode.Favourites:
+ // todo: unsupported.
+ goto case GroupMode.NoGrouping;
- return new GroupDefinition(starGroup, $"{starGroup.Stars} Stars");
- }
+ case GroupMode.MyMaps:
+ // todo: unsupported.
+ goto case GroupMode.NoGrouping;
- break;
+ case GroupMode.RankAchieved:
+ // todo: unsupported.
+ goto case GroupMode.NoGrouping;
+
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ }
+
+ private List getGroupsBy(Func getGroup, List items)
+ {
+ return items.GroupBy(i => getGroup((BeatmapInfo)i.Model))
+ .OrderBy(s => s.Key.Order)
+ .Select(g => new GroupMapping(g.Key, g.ToList()))
+ .ToList();
+ }
+
+ private GroupDefinition defineGroupAlphabetically(string name)
+ {
+ char firstChar = name.FirstOrDefault();
+
+ if (char.IsAsciiDigit(firstChar))
+ return new GroupDefinition(int.MinValue, "0-9");
+
+ if (char.IsAsciiLetter(firstChar))
+ return new GroupDefinition(char.ToUpperInvariant(firstChar) - 'A', char.ToUpperInvariant(firstChar).ToString());
+
+ return new GroupDefinition(int.MaxValue, "Other");
+ }
+
+ private GroupDefinition defineGroupByDate(DateTimeOffset date)
+ {
+ var now = DateTimeOffset.Now;
+ var elapsed = now - date;
+
+ if (elapsed.TotalDays < 1)
+ return new GroupDefinition(0, "Today");
+
+ if (elapsed.TotalDays < 2)
+ return new GroupDefinition(1, "Yesterday");
+
+ if (elapsed.TotalDays < 7)
+ return new GroupDefinition(2, "Last week");
+
+ if (elapsed.TotalDays < 30)
+ return new GroupDefinition(3, "1 month ago");
+
+ for (int i = 60; i <= 150; i += 30)
+ {
+ if (elapsed.TotalDays < i)
+ return new GroupDefinition(i, $"{i / 30} months ago");
}
- return null;
+ return new GroupDefinition(int.MaxValue, "Over 5 months ago");
}
+
+ private GroupDefinition defineGroupByStatus(BeatmapOnlineStatus status)
+ {
+ switch (status)
+ {
+ case BeatmapOnlineStatus.Ranked:
+ case BeatmapOnlineStatus.Approved:
+ return new GroupDefinition(0, BeatmapOnlineStatus.Ranked.GetDescription());
+
+ case BeatmapOnlineStatus.Qualified:
+ return new GroupDefinition(1, status.GetDescription());
+
+ case BeatmapOnlineStatus.WIP:
+ return new GroupDefinition(2, status.GetDescription());
+
+ case BeatmapOnlineStatus.Pending:
+ return new GroupDefinition(3, status.GetDescription());
+
+ case BeatmapOnlineStatus.Graveyard:
+ return new GroupDefinition(4, status.GetDescription());
+
+ case BeatmapOnlineStatus.LocallyModified:
+ return new GroupDefinition(5, status.GetDescription());
+
+ case BeatmapOnlineStatus.None:
+ return new GroupDefinition(6, status.GetDescription());
+
+ case BeatmapOnlineStatus.Loved:
+ return new GroupDefinition(7, status.GetDescription());
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(status), status, null);
+ }
+ }
+
+ private GroupDefinition defineGroupByBPM(double bpm)
+ {
+ for (int i = 1; i < 6; i++)
+ {
+ if (bpm < i * 60)
+ return new GroupDefinition(i, $"Under {i * 60} BPM");
+ }
+
+ return new GroupDefinition(6, "Over 300 BPM");
+ }
+
+ private GroupDefinition defineGroupByStars(double stars)
+ {
+ int starInt = (int)Math.Round(stars, 2);
+ var groupData = new StarDifficulty(starInt, 0);
+
+ if (starInt == 0)
+ return new GroupDefinition(0, "Below 1 Star", groupData);
+
+ if (starInt == 1)
+ return new GroupDefinition(1, "1 Star", groupData);
+
+ return new GroupDefinition(starInt, $"{starInt} Stars", groupData);
+ }
+
+ private GroupDefinition defineGroupByLength(double length)
+ {
+ for (int i = 1; i < 6; i++)
+ {
+ if (length <= i * 60_000)
+ {
+ if (i == 1)
+ return new GroupDefinition(1, "1 minute or less");
+
+ return new GroupDefinition(i, $"{i} minutes or less");
+ }
+ }
+
+ if (length <= 10 * 60_000)
+ return new GroupDefinition(10, "10 minutes or less");
+
+ return new GroupDefinition(11, "Over 10 minutes");
+ }
+
+ private static T? aggregateMax(BeatmapInfo b, IEnumerable items, Func func)
+ {
+ var matchedBeatmaps = items.Select(i => i.Model).Cast().Where(beatmap => beatmap.BeatmapSet!.Equals(b.BeatmapSet));
+ return matchedBeatmaps.Max(func);
+ }
+
+ private record GroupMapping(GroupDefinition? Group, List ItemsInGroup);
}
}
diff --git a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs
index b042f34d22..f4d5bca1e2 100644
--- a/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs
+++ b/osu.Game/Screens/SelectV2/PanelGroupStarDifficulty.cs
@@ -140,7 +140,7 @@ namespace osu.Game.Screens.SelectV2
Debug.Assert(Item != null);
var group = (GroupDefinition)Item.Model;
- var stars = (StarDifficulty)group.Data;
+ var stars = (StarDifficulty)group.Data!;
int starNumber = (int)stars.Stars;
ratingColour = starNumber >= 9 ? OsuColour.Gray(0.2f) : colours.ForStarDifficulty(starNumber);