1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 08:02:38 +08:00

Merge pull request #34459 from peppy/collection-group-sane

Add support for grouping beatmaps by collections
This commit is contained in:
Bartłomiej Dach
2025-08-01 10:32:57 +02:00
committed by GitHub
Unverified
11 changed files with 172 additions and 41 deletions
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.Carousel;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
@@ -363,7 +364,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
private static async Task<List<CarouselItem>> runGrouping(GroupMode group, List<BeatmapSetInfo> beatmapSets)
{
var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group });
var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }, () => new List<BeatmapCollection>());
return await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None);
}
@@ -159,6 +159,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
});
}
protected void WaitForFiltering() => AddUntilStep("wait for filtering", () => !SongSelect.IsFiltering);
protected void ImportBeatmapForRuleset(params int[] rulesetIds)
{
int beatmapsCount = 0;
@@ -197,8 +197,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
[Test]
public void TestManageCollectionsFilterIsNotSelected()
{
bool received = false;
addExpandHeaderStep();
AddStep("add collection", () => writeAndRefresh(r => r.Add(new BeatmapCollection(name: "1", new List<string> { "abc" }))));
@@ -212,12 +210,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
addExpandHeaderStep();
AddStep("watch for filter requests", () =>
{
received = false;
dropdown.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
});
AddStep("click manage collections filter", () =>
{
int lastItemIndex = dropdown.ChildrenOfType<CollectionDropdown>().Single().Items.Count() - 1;
@@ -226,8 +218,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2
});
AddAssert("collection filter still selected", () => dropdown.Current.Value.CollectionName == "1");
AddAssert("filter request not fired", () => !received);
}
private void writeAndRefresh(Action<Realm> action) => Realm.Write(r =>
@@ -0,0 +1,115 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Extensions;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
namespace osu.Game.Tests.Visual.SongSelectV2
{
public partial class TestSceneSongSelectGrouping : SongSelectTestScene
{
private BeatmapCarouselFilterGrouping grouping => Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single();
[Test]
public void TestCollectionGrouping()
{
ImportBeatmapForRuleset(0);
ImportBeatmapForRuleset(0);
ImportBeatmapForRuleset(0);
BeatmapSetInfo[] beatmapSets = null!;
AddStep("add collections", () =>
{
beatmapSets = Beatmaps.GetAllUsableBeatmapSets().OrderBy(b => b.OnlineID).ToArray();
Realm.Write(r =>
{
r.RemoveAll<BeatmapCollection>();
r.Add(new BeatmapCollection("My Collection #1", beatmapSets[0].Beatmaps.Select(b => b.MD5Hash).ToList()));
r.Add(new BeatmapCollection("My Collection #2", beatmapSets[1].Beatmaps.Select(b => b.MD5Hash).ToList()));
r.Add(new BeatmapCollection("My Collection #3"));
});
});
LoadSongSelect();
GroupBy(GroupMode.Collections);
WaitForFiltering();
AddAssert("first collection present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #1");
return group.Value.Select(i => i.Model).OfType<BeatmapSetInfo>().Single().Equals(beatmapSets[0]);
});
AddAssert("second collection present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #2");
return group.Value.Select(i => i.Model).OfType<BeatmapSetInfo>().Single().Equals(beatmapSets[1]);
});
AddAssert("third collection not present", () => grouping.GroupItems.All(g => g.Key.Title != "My Collection #3"));
AddAssert("no-collection group present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == "Not in collection");
return group.Value.Select(i => i.Model).OfType<BeatmapSetInfo>().Single().Equals(beatmapSets[2]);
});
}
[Test]
public void TestCollectionGroupingUpdatesOnChange()
{
ImportBeatmapForRuleset(0);
BeatmapSetInfo beatmapSet = null!;
AddStep("add collections", () =>
{
beatmapSet = Beatmaps.GetAllUsableBeatmapSets().Single();
Realm.Write(r =>
{
r.RemoveAll<BeatmapCollection>();
r.Add(new BeatmapCollection("My Collection #4"));
});
});
LoadSongSelect();
GroupBy(GroupMode.Collections);
WaitForFiltering();
AddAssert("collection not present", () => grouping.GroupItems.All(g => g.Key.Title != "My Collection #4"));
AddAssert("no-collection group present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == "Not in collection");
return group.Value.Select(i => i.Model).OfType<BeatmapSetInfo>().Single().Equals(beatmapSet);
});
AddStep("add beatmap to collection", () =>
{
Realm.Write(r =>
{
var collection = r.All<BeatmapCollection>().Single();
collection.BeatmapMD5Hashes.AddRange(beatmapSet.Beatmaps.Select(b => b.MD5Hash));
});
});
WaitForFiltering();
AddAssert("collection present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #4");
return group.Value.Select(i => i.Model).OfType<BeatmapSetInfo>().Single().Equals(beatmapSet);
});
AddAssert("no-collection group not present", () => grouping.GroupItems.All(g => g.Key.Title != "Not in collection"));
}
}
}
@@ -10,6 +10,7 @@ using AutoMapper;
using AutoMapper.Internal;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Input.Bindings;
using osu.Game.Models;
using osu.Game.Rulesets;
@@ -170,6 +171,7 @@ namespace osu.Game.Database
});
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
c.CreateMap<BeatmapCollection, BeatmapCollection>();
c.CreateMap<BeatmapMetadata, BeatmapMetadata>();
c.CreateMap<BeatmapUserSettings, BeatmapUserSettings>();
c.CreateMap<BeatmapDifficulty, BeatmapDifficulty>();
+2 -2
View File
@@ -20,8 +20,8 @@ namespace osu.Game.Screens.Select.Filter
[LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.BPM))]
BPM,
// [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Collections))]
// Collections,
[LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.Collections))]
Collections,
[LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.DateAdded))]
DateAdded,
+6 -2
View File
@@ -18,6 +18,7 @@ using osu.Framework.Graphics.Pooling;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
@@ -98,17 +99,18 @@ namespace osu.Game.Screens.SelectV2
{
matching = new BeatmapCarouselFilterMatching(() => Criteria!),
new BeatmapCarouselFilterSorting(() => Criteria!),
grouping = new BeatmapCarouselFilterGrouping(() => Criteria!),
grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, () => detachedCollections())
};
AddInternal(loading = new LoadingLayer());
}
[BackgroundDependencyLoader]
private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken)
private void load(BeatmapStore beatmapStore, RealmAccess realm, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken)
{
setupPools();
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
detachedCollections = () => realm.Run(r => r.All<BeatmapCollection>().AsEnumerable().Detach());
loadSamples(audio);
config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm);
@@ -696,6 +698,8 @@ namespace osu.Game.Screens.SelectV2
private Sample? spinSample;
private Sample? randomSelectSample;
private Func<List<BeatmapCollection>> detachedCollections = null!;
public bool NextRandom()
{
var carouselItems = GetCarouselItems();
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Extensions;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.Carousel;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
@@ -33,10 +34,12 @@ namespace osu.Game.Screens.SelectV2
private readonly Dictionary<GroupDefinition, HashSet<CarouselItem>> groupMap = new Dictionary<GroupDefinition, HashSet<CarouselItem>>();
private readonly Func<FilterCriteria> getCriteria;
private readonly Func<List<BeatmapCollection>>? getCollections;
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria, Func<List<BeatmapCollection>>? getCollections)
{
this.getCriteria = getCriteria;
this.getCollections = getCollections;
}
public async Task<List<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken)
@@ -206,11 +209,11 @@ namespace osu.Game.Screens.SelectV2
case GroupMode.Source:
return getGroupsBy(b => defineGroupBySource(b.BeatmapSet!.Metadata.Source), items);
case GroupMode.Collections:
var collections = getCollections?.Invoke() ?? Enumerable.Empty<BeatmapCollection>();
return getGroupsBy(b => defineGroupByCollection(b, collections), items);
// TODO: need implementation
//
// case GroupMode.Collections:
// goto case GroupMode.None;
//
// case GroupMode.Favourites:
// goto case GroupMode.None;
//
@@ -374,6 +377,17 @@ namespace osu.Game.Screens.SelectV2
return new GroupDefinition(0, source);
}
private GroupDefinition defineGroupByCollection(BeatmapInfo beatmap, IEnumerable<BeatmapCollection> collections)
{
foreach (var collection in collections)
{
if (collection.BeatmapMD5Hashes.Contains(beatmap.MD5Hash))
return new GroupDefinition(0, collection.Name);
}
return new GroupDefinition(1, "Not in collection");
}
private static T? aggregateMax<T>(BeatmapInfo b, Func<BeatmapInfo, T> func)
{
var beatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden);
@@ -34,8 +34,6 @@ namespace osu.Game.Screens.SelectV2
/// </summary>
protected virtual bool ShowManageCollectionsItem => true;
public Action? RequestFilter { private get; set; }
private readonly BindableList<CollectionFilterMenuItem> filters = new BindableList<CollectionFilterMenuItem>();
[Resolved]
@@ -110,16 +108,12 @@ namespace osu.Game.Screens.SelectV2
Current.Value = filters.SingleOrDefault(f => f.Collection?.ID == selectedItem.Collection?.ID) ?? filters[0];
});
// Trigger an external re-filter if the current item was in the change set.
RequestFilter?.Invoke();
break;
}
}
}
}
private Live<BeatmapCollection>? lastFiltered;
private void selectionChanged(ValueChangedEvent<CollectionFilterMenuItem> filter)
{
// May be null during .Clear().
@@ -132,17 +126,6 @@ namespace osu.Game.Screens.SelectV2
{
Current.Value = filter.OldValue;
manageCollectionsDialog?.Show();
return;
}
var newCollection = filter.NewValue.Collection;
// This dropdown be weird.
// We only care about filtering if the actual collection has changed.
if (newCollection != lastFiltered)
{
RequestFilter?.Invoke();
lastFiltered = newCollection;
}
}
+17 -1
View File
@@ -14,6 +14,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
@@ -50,6 +51,9 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private OsuConfigManager config { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
public LocalisableString StatusText
{
get => searchTextBox.StatusText;
@@ -60,6 +64,8 @@ namespace osu.Game.Screens.SelectV2
private FilterCriteria currentCriteria = null!;
private IDisposable? collectionsSubscription;
[BackgroundDependencyLoader]
private void load()
{
@@ -218,10 +224,20 @@ namespace osu.Game.Screens.SelectV2
updateCriteria();
});
collectionsSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>(), (collections, changeSet) =>
{
if (changeSet != null && groupDropdown.Current.Value == GroupMode.Collections)
updateCriteria();
});
updateCriteria();
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
collectionsSubscription?.Dispose();
}
/// <summary>
/// Creates a <see cref="FilterCriteria"/> based on the current state of the controls.
/// </summary>
+7 -3
View File
@@ -509,8 +509,7 @@ namespace osu.Game.Screens.SelectV2
// While filtering, let's not ever attempt to change selection.
// This will be resolved after the filter completes, see `newItemsPresented`.
bool carouselStateIsValid = filterDebounce?.State != ScheduledDelegate.RunState.Waiting && !carousel.IsFiltering;
if (!carouselStateIsValid)
if (IsFiltering)
return false;
// Refetch to be confident that the current selection is still valid. It may have been deleted or hidden.
@@ -738,6 +737,11 @@ namespace osu.Game.Screens.SelectV2
/// </summary>
public bool CarouselItemsPresented { get; private set; }
/// <summary>
/// Whether the carousel is or will be undergoing a filter operation.
/// </summary>
public bool IsFiltering => carousel.IsFiltering || filterDebounce?.State == ScheduledDelegate.RunState.Waiting;
private const double filter_delay = 250;
private ScheduledDelegate? filterDebounce;
@@ -752,7 +756,7 @@ namespace osu.Game.Screens.SelectV2
// Criteria change may have included a ruleset change which made the current selection invalid.
bool isSelectionValid = checkBeatmapValidForSelection(Beatmap.Value.BeatmapInfo, criteria);
filterDebounce = Scheduler.AddDelayed(() => { carousel.Filter(criteria, !isSelectionValid); }, isFirstFilter || !isSelectionValid ? 0 : filter_delay);
filterDebounce = Scheduler.AddDelayed(() => carousel.Filter(criteria, !isSelectionValid), isFirstFilter || !isSelectionValid ? 0 : filter_delay);
}
private void newItemsPresented(IEnumerable<CarouselItem> carouselItems)