1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-15 06:13:03 +08:00

Merge pull request #19430 from peppy/realm-collections

Move beatmap collections to realm
This commit is contained in:
Dan Balasescu 2022-07-28 22:05:10 +09:00 committed by GitHub
commit 5003eb5629
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 857 additions and 986 deletions

View File

@ -5,12 +5,15 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Collections.IO
@ -29,7 +32,11 @@ namespace osu.Game.Tests.Collections.IO
await importCollectionsFromStream(osu, new MemoryStream());
Assert.That(osu.CollectionManager.Collections.Count, Is.Zero);
osu.Realm.Run(realm =>
{
var collections = realm.All<BeatmapCollection>().ToList();
Assert.That(collections.Count, Is.Zero);
});
}
finally
{
@ -49,18 +56,22 @@ namespace osu.Game.Tests.Collections.IO
await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db"));
Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
osu.Realm.Run(realm =>
{
var collections = realm.All<BeatmapCollection>().ToList();
Assert.That(collections.Count, Is.EqualTo(2));
// Even with no beatmaps imported, collections are tracking the hashes and will continue to.
// In the future this whole mechanism will be replaced with having the collections in realm,
// but until that happens it makes rough sense that we want to track not-yet-imported beatmaps
// and have them associate with collections if/when they become available.
// Even with no beatmaps imported, collections are tracking the hashes and will continue to.
// In the future this whole mechanism will be replaced with having the collections in realm,
// but until that happens it makes rough sense that we want to track not-yet-imported beatmaps
// and have them associate with collections if/when they become available.
Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First"));
Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(1));
Assert.That(collections[0].Name, Is.EqualTo("First"));
Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(1));
Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second"));
Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(12));
Assert.That(collections[1].Name, Is.EqualTo("Second"));
Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(12));
});
}
finally
{
@ -80,13 +91,18 @@ namespace osu.Game.Tests.Collections.IO
await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db"));
Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
osu.Realm.Run(realm =>
{
var collections = realm.All<BeatmapCollection>().ToList();
Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First"));
Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(1));
Assert.That(collections.Count, Is.EqualTo(2));
Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second"));
Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(12));
Assert.That(collections[0].Name, Is.EqualTo("First"));
Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(1));
Assert.That(collections[1].Name, Is.EqualTo("Second"));
Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(12));
});
}
finally
{
@ -123,7 +139,11 @@ namespace osu.Game.Tests.Collections.IO
}
Assert.That(exceptionThrown, Is.False);
Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0));
osu.Realm.Run(realm =>
{
var collections = realm.All<BeatmapCollection>().ToList();
Assert.That(collections.Count, Is.EqualTo(0));
});
}
finally
{
@ -148,12 +168,18 @@ namespace osu.Game.Tests.Collections.IO
await importCollectionsFromStream(osu, TestResources.OpenResource("Collections/collections.db"));
// Move first beatmap from second collection into the first.
osu.CollectionManager.Collections[0].BeatmapHashes.Add(osu.CollectionManager.Collections[1].BeatmapHashes[0]);
osu.CollectionManager.Collections[1].BeatmapHashes.RemoveAt(0);
// ReSharper disable once MethodHasAsyncOverload
osu.Realm.Write(realm =>
{
var collections = realm.All<BeatmapCollection>().ToList();
// Rename the second collecction.
osu.CollectionManager.Collections[1].Name.Value = "Another";
// Move first beatmap from second collection into the first.
collections[0].BeatmapMD5Hashes.Add(collections[1].BeatmapMD5Hashes[0]);
collections[1].BeatmapMD5Hashes.RemoveAt(0);
// Rename the second collecction.
collections[1].Name = "Another";
});
}
finally
{
@ -168,13 +194,17 @@ namespace osu.Game.Tests.Collections.IO
{
var osu = LoadOsuIntoHost(host, true);
Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
osu.Realm.Run(realm =>
{
var collections = realm.All<BeatmapCollection>().ToList();
Assert.That(collections.Count, Is.EqualTo(2));
Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First"));
Assert.That(osu.CollectionManager.Collections[0].BeatmapHashes.Count, Is.EqualTo(2));
Assert.That(collections[0].Name, Is.EqualTo("First"));
Assert.That(collections[0].BeatmapMD5Hashes.Count, Is.EqualTo(2));
Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another"));
Assert.That(osu.CollectionManager.Collections[1].BeatmapHashes.Count, Is.EqualTo(11));
Assert.That(collections[1].Name, Is.EqualTo("Another"));
Assert.That(collections[1].BeatmapMD5Hashes.Count, Is.EqualTo(11));
});
}
finally
{
@ -187,7 +217,7 @@ namespace osu.Game.Tests.Collections.IO
{
// intentionally spin this up on a separate task to avoid disposal deadlocks.
// see https://github.com/EventStore/EventStore/issues/1179
await Task.Factory.StartNew(() => osu.CollectionManager.Import(stream).WaitSafely(), TaskCreationOptions.LongRunning);
await Task.Factory.StartNew(() => new LegacyCollectionImporter(osu.Realm).Import(stream).WaitSafely(), TaskCreationOptions.LongRunning);
}
}
}

View File

@ -10,7 +10,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Platform;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Tests.Resources;
namespace osu.Game.Tests
@ -47,7 +47,7 @@ namespace osu.Game.Tests
public class TestOsuGameBase : OsuGameBase
{
public CollectionManager CollectionManager { get; private set; }
public RealmAccess Realm => Dependencies.Get<RealmAccess>();
private readonly bool withBeatmap;
@ -62,8 +62,6 @@ namespace osu.Game.Tests
// Beatmap must be imported before the collection manager is loaded.
if (withBeatmap)
BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely();
AddInternal(CollectionManager = new CollectionManager(Storage));
}
}
}

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
@ -27,12 +25,9 @@ namespace osu.Game.Tests.Visual.Collections
{
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
private DialogOverlay dialogOverlay;
private CollectionManager manager;
private BeatmapManager beatmapManager;
private ManageCollectionsDialog dialog;
private DialogOverlay dialogOverlay = null!;
private BeatmapManager beatmapManager = null!;
private ManageCollectionsDialog dialog = null!;
[BackgroundDependencyLoader]
private void load(GameHost host)
@ -45,19 +40,17 @@ namespace osu.Game.Tests.Visual.Collections
base.Content.AddRange(new Drawable[]
{
manager = new CollectionManager(LocalStorage),
Content,
dialogOverlay = new DialogOverlay(),
});
Dependencies.Cache(manager);
Dependencies.CacheAs<IDialogOverlay>(dialogOverlay);
}
[SetUp]
public void SetUp() => Schedule(() =>
{
manager.Collections.Clear();
Realm.Write(r => r.RemoveAll<BeatmapCollection>());
Child = dialog = new ManageCollectionsDialog();
});
@ -77,17 +70,17 @@ namespace osu.Game.Tests.Visual.Collections
[Test]
public void TestLastItemIsPlaceholder()
{
AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType<DrawableCollectionListItem>().Last().Model));
AddAssert("last item is placeholder", () => !dialog.ChildrenOfType<DrawableCollectionListItem>().Last().Model.IsManaged);
}
[Test]
public void TestAddCollectionExternal()
{
AddStep("add collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "First collection" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "First collection"))));
assertCollectionCount(1);
assertCollectionName(0, "First collection");
AddStep("add another collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "Second collection" } }));
AddStep("add another collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "Second collection"))));
assertCollectionCount(2);
assertCollectionName(1, "Second collection");
}
@ -107,7 +100,7 @@ namespace osu.Game.Tests.Visual.Collections
[Test]
public void TestAddCollectionViaPlaceholder()
{
DrawableCollectionListItem placeholderItem = null;
DrawableCollectionListItem placeholderItem = null!;
AddStep("focus placeholder", () =>
{
@ -115,24 +108,37 @@ namespace osu.Game.Tests.Visual.Collections
InputManager.Click(MouseButton.Left);
});
// Done directly via the collection since InputManager methods cannot add text to textbox...
AddStep("change collection name", () => placeholderItem.Model.Name.Value = "a");
assertCollectionCount(1);
AddAssert("collection now exists", () => manager.Collections.Contains(placeholderItem.Model));
assertCollectionCount(0);
AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType<DrawableCollectionListItem>().Last().Model));
AddStep("change collection name", () =>
{
placeholderItem.ChildrenOfType<TextBox>().First().Text = "test text";
InputManager.Key(Key.Enter);
});
assertCollectionCount(1);
AddAssert("last item is placeholder", () => !dialog.ChildrenOfType<DrawableCollectionListItem>().Last().Model.IsManaged);
}
[Test]
public void TestRemoveCollectionExternal()
{
AddStep("add two collections", () => manager.Collections.AddRange(new[]
{
new BeatmapCollection { Name = { Value = "1" } },
new BeatmapCollection { Name = { Value = "2" } },
}));
BeatmapCollection first = null!;
AddStep("remove first collection", () => manager.Collections.RemoveAt(0));
AddStep("add two collections", () =>
{
Realm.Write(r =>
{
r.Add(new[]
{
first = new BeatmapCollection(name: "1"),
new BeatmapCollection(name: "2"),
});
});
});
AddStep("remove first collection", () => Realm.Write(r => r.Remove(first)));
assertCollectionCount(1);
assertCollectionName(0, "2");
}
@ -142,7 +148,7 @@ namespace osu.Game.Tests.Visual.Collections
{
AddStep("add dropdown", () =>
{
Add(new CollectionFilterDropdown
Add(new CollectionDropdown
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
@ -150,21 +156,27 @@ namespace osu.Game.Tests.Visual.Collections
Width = 0.4f,
});
});
AddStep("add two collections with same name", () => manager.Collections.AddRange(new[]
AddStep("add two collections with same name", () => Realm.Write(r => r.Add(new[]
{
new BeatmapCollection { Name = { Value = "1" } },
new BeatmapCollection { Name = { Value = "1" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } },
}));
new BeatmapCollection(name: "1"),
new BeatmapCollection(name: "1")
{
BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash }
},
})));
}
[Test]
public void TestRemoveCollectionViaButton()
{
AddStep("add two collections", () => manager.Collections.AddRange(new[]
AddStep("add two collections", () => Realm.Write(r => r.Add(new[]
{
new BeatmapCollection { Name = { Value = "1" } },
new BeatmapCollection { Name = { Value = "2" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } },
}));
new BeatmapCollection(name: "1"),
new BeatmapCollection(name: "2")
{
BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash }
},
})));
assertCollectionCount(2);
@ -197,10 +209,13 @@ namespace osu.Game.Tests.Visual.Collections
[Test]
public void TestCollectionNotRemovedWhenDialogCancelled()
{
AddStep("add two collections", () => manager.Collections.AddRange(new[]
AddStep("add collection", () => Realm.Write(r => r.Add(new[]
{
new BeatmapCollection { Name = { Value = "1" }, BeatmapHashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash } },
}));
new BeatmapCollection(name: "1")
{
BeatmapMD5Hashes = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0].MD5Hash }
},
})));
assertCollectionCount(1);
@ -223,34 +238,67 @@ namespace osu.Game.Tests.Visual.Collections
[Test]
public void TestCollectionRenamedExternal()
{
AddStep("add two collections", () => manager.Collections.AddRange(new[]
BeatmapCollection first = null!;
AddStep("add two collections", () =>
{
new BeatmapCollection { Name = { Value = "1" } },
new BeatmapCollection { Name = { Value = "2" } },
}));
Realm.Write(r =>
{
r.Add(new[]
{
first = new BeatmapCollection(name: "1"),
new BeatmapCollection(name: "2"),
});
});
});
AddStep("change first collection name", () => manager.Collections[0].Name.Value = "First");
assertCollectionName(0, "1");
assertCollectionName(1, "2");
assertCollectionName(0, "First");
AddStep("change first collection name", () => Realm.Write(_ => first.Name = "First"));
// Item will have moved due to alphabetical sorting.
assertCollectionName(0, "2");
assertCollectionName(1, "First");
}
[Test]
public void TestCollectionRenamedOnTextChange()
{
AddStep("add two collections", () => manager.Collections.AddRange(new[]
BeatmapCollection first = null!;
DrawableCollectionListItem firstItem = null!;
AddStep("add two collections", () =>
{
new BeatmapCollection { Name = { Value = "1" } },
new BeatmapCollection { Name = { Value = "2" } },
}));
Realm.Write(r =>
{
r.Add(new[]
{
first = new BeatmapCollection(name: "1"),
new BeatmapCollection(name: "2"),
});
});
});
assertCollectionCount(2);
AddStep("change first collection name", () => dialog.ChildrenOfType<TextBox>().First().Text = "First");
AddAssert("collection has new name", () => manager.Collections[0].Name.Value == "First");
AddStep("focus first collection", () =>
{
InputManager.MoveMouseTo(firstItem = dialog.ChildrenOfType<DrawableCollectionListItem>().First());
InputManager.Click(MouseButton.Left);
});
AddStep("change first collection name", () =>
{
firstItem.ChildrenOfType<TextBox>().First().Text = "First";
InputManager.Key(Key.Enter);
});
AddUntilStep("collection has new name", () => first.Name == "First");
}
private void assertCollectionCount(int count)
=> AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType<DrawableCollectionListItem>().Count(i => i.IsCreated.Value) == count);
=> AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType<DrawableCollectionListItem>().Count() == count + 1); // +1 for placeholder
private void assertCollectionName(int index, string name)
=> AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType<DrawableCollectionListItem>().ElementAt(index).ChildrenOfType<TextBox>().First().Text == name);

View File

@ -74,14 +74,14 @@ namespace osu.Game.Tests.Visual.Navigation
AddStep("set filter again", () => songSelect.ChildrenOfType<SearchTextBox>().Single().Current.Value = "test");
AddStep("open collections dropdown", () =>
{
InputManager.MoveMouseTo(songSelect.ChildrenOfType<CollectionFilterDropdown>().Single());
InputManager.MoveMouseTo(songSelect.ChildrenOfType<CollectionDropdown>().Single());
InputManager.Click(MouseButton.Left);
});
AddStep("press back once", () => InputManager.Click(MouseButton.Button1));
AddAssert("still at song select", () => Game.ScreenStack.CurrentScreen == songSelect);
AddAssert("collections dropdown closed", () => songSelect
.ChildrenOfType<CollectionFilterDropdown>().Single()
.ChildrenOfType<CollectionDropdown>().Single()
.ChildrenOfType<Dropdown<CollectionFilterMenuItem>.DropdownMenu>().Single().State == MenuState.Closed);
AddStep("press back a second time", () => InputManager.Click(MouseButton.Button1));

View File

@ -1,8 +1,6 @@
// 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.
#nullable disable
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
@ -28,11 +26,8 @@ namespace osu.Game.Tests.Visual.SongSelect
{
protected override Container<Drawable> Content { get; } = new Container { RelativeSizeAxes = Axes.Both };
private CollectionManager collectionManager;
private BeatmapManager beatmapManager;
private FilterControl control;
private BeatmapManager beatmapManager = null!;
private FilterControl control = null!;
[BackgroundDependencyLoader]
private void load(GameHost host)
@ -45,17 +40,14 @@ namespace osu.Game.Tests.Visual.SongSelect
base.Content.AddRange(new Drawable[]
{
collectionManager = new CollectionManager(LocalStorage),
Content
});
Dependencies.Cache(collectionManager);
}
[SetUp]
public void SetUp() => Schedule(() =>
{
collectionManager.Collections.Clear();
Realm.Write(r => r.RemoveAll<BeatmapCollection>());
Child = control = new FilterControl
{
@ -76,8 +68,8 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestCollectionAddedToDropdown()
{
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "2"))));
assertCollectionDropdownContains("1");
assertCollectionDropdownContains("2");
}
@ -85,9 +77,11 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestCollectionRemovedFromDropdown()
{
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } }));
AddStep("remove collection", () => collectionManager.Collections.RemoveAt(0));
BeatmapCollection first = null!;
AddStep("add collection", () => Realm.Write(r => r.Add(first = new BeatmapCollection(name: "1"))));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "2"))));
AddStep("remove collection", () => Realm.Write(r => r.Remove(first)));
assertCollectionDropdownContains("1", false);
assertCollectionDropdownContains("2");
@ -96,16 +90,16 @@ namespace osu.Game.Tests.Visual.SongSelect
[Test]
public void TestCollectionRenamed()
{
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("select collection", () =>
{
var dropdown = control.ChildrenOfType<CollectionFilterDropdown>().Single();
var dropdown = control.ChildrenOfType<CollectionDropdown>().Single();
dropdown.Current.Value = dropdown.ItemSource.ElementAt(1);
});
addExpandHeaderStep();
AddStep("change name", () => collectionManager.Collections[0].Name.Value = "First");
AddStep("change name", () => Realm.Write(_ => getFirstCollection().Name = "First"));
assertCollectionDropdownContains("First");
assertCollectionHeaderDisplays("First");
@ -123,7 +117,7 @@ namespace osu.Game.Tests.Visual.SongSelect
public void TestCollectionFilterHasAddButton()
{
addExpandHeaderStep();
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1)));
AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent);
}
@ -133,7 +127,7 @@ namespace osu.Game.Tests.Visual.SongSelect
{
addExpandHeaderStep();
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1"))));
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value);
@ -149,13 +143,13 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1"))));
AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
AddStep("add beatmap to collection", () => collectionManager.Collections[0].BeatmapHashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash));
AddStep("add beatmap to collection", () => Realm.Write(r => getFirstCollection().BeatmapMD5Hashes.Add(Beatmap.Value.BeatmapInfo.MD5Hash)));
AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare));
AddStep("remove beatmap from collection", () => collectionManager.Collections[0].BeatmapHashes.Clear());
AddStep("remove beatmap from collection", () => Realm.Write(r => getFirstCollection().BeatmapMD5Hashes.Clear()));
AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
}
@ -166,24 +160,26 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1"))));
AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
addClickAddOrRemoveButtonStep(1);
AddAssert("collection contains beatmap", () => collectionManager.Collections[0].BeatmapHashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddAssert("collection contains beatmap", () => getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare));
addClickAddOrRemoveButtonStep(1);
AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].BeatmapHashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddAssert("collection does not contain beatmap", () => !getFirstCollection().BeatmapMD5Hashes.Contains(Beatmap.Value.BeatmapInfo.MD5Hash));
AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
}
[Test]
public void TestManageCollectionsFilterIsNotSelected()
{
bool received = false;
addExpandHeaderStep();
AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
AddStep("add collection", () => Realm.Write(r => r.Add(new BeatmapCollection(name: "1", new List<string> { "abc" }))));
AddStep("select collection", () =>
{
InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1));
@ -192,18 +188,28 @@ namespace osu.Game.Tests.Visual.SongSelect
addExpandHeaderStep();
AddStep("watch for filter requests", () =>
{
received = false;
control.ChildrenOfType<CollectionDropdown>().First().RequestFilter = () => received = true;
});
AddStep("click manage collections filter", () =>
{
InputManager.MoveMouseTo(getCollectionDropdownItems().Last());
InputManager.Click(MouseButton.Left);
});
AddAssert("collection filter still selected", () => control.CreateCriteria().Collection?.Name.Value == "1");
AddAssert("collection filter still selected", () => control.CreateCriteria().CollectionBeatmapMD5Hashes.Any());
AddAssert("filter request not fired", () => !received);
}
private BeatmapCollection getFirstCollection() => Realm.Run(r => r.All<BeatmapCollection>().First());
private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true)
=> AddAssert($"collection dropdown header displays '{collectionName}'",
() => shouldDisplay == (control.ChildrenOfType<CollectionFilterDropdown.CollectionDropdownHeader>().Single().ChildrenOfType<SpriteText>().First().Text == collectionName));
() => shouldDisplay == (control.ChildrenOfType<CollectionDropdown.CollectionDropdownHeader>().Single().ChildrenOfType<SpriteText>().First().Text == collectionName));
private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) =>
AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'",
@ -215,7 +221,7 @@ namespace osu.Game.Tests.Visual.SongSelect
private void addExpandHeaderStep() => AddStep("expand header", () =>
{
InputManager.MoveMouseTo(control.ChildrenOfType<CollectionFilterDropdown.CollectionDropdownHeader>().Single());
InputManager.MoveMouseTo(control.ChildrenOfType<CollectionDropdown.CollectionDropdownHeader>().Single());
InputManager.Click(MouseButton.Left);
});
@ -226,6 +232,6 @@ namespace osu.Game.Tests.Visual.SongSelect
});
private IEnumerable<Dropdown<CollectionFilterMenuItem>.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems()
=> control.ChildrenOfType<CollectionFilterDropdown>().Single().ChildrenOfType<Dropdown<CollectionFilterMenuItem>.DropdownMenu.DrawableDropdownMenuItem>();
=> control.ChildrenOfType<CollectionDropdown>().Single().ChildrenOfType<Dropdown<CollectionFilterMenuItem>.DropdownMenu.DrawableDropdownMenuItem>();
}
}

View File

@ -1,49 +1,57 @@
// 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.
#nullable disable
using System;
using osu.Framework.Bindables;
using System.Collections.Generic;
using JetBrains.Annotations;
using osu.Game.Beatmaps;
using osu.Game.Database;
using Realms;
namespace osu.Game.Collections
{
/// <summary>
/// A collection of beatmaps grouped by a name.
/// </summary>
public class BeatmapCollection
public class BeatmapCollection : RealmObject, IHasGuidPrimaryKey
{
/// <summary>
/// Invoked whenever any change occurs on this <see cref="BeatmapCollection"/>.
/// </summary>
public event Action Changed;
[PrimaryKey]
public Guid ID { get; set; }
/// <summary>
/// The collection's name.
/// </summary>
public readonly Bindable<string> Name = new Bindable<string>();
public string Name { get; set; } = string.Empty;
/// <summary>
/// The <see cref="BeatmapInfo.MD5Hash"/>es of beatmaps contained by the collection.
/// </summary>
public readonly BindableList<string> BeatmapHashes = new BindableList<string>();
/// <remarks>
/// We store as hashes rather than references to <see cref="BeatmapInfo"/>s to allow collections to maintain
/// references to beatmaps even if they are removed. This helps with cases like importing collections before
/// importing the beatmaps they contain, or when sharing collections between users.
///
/// This can probably change in the future as we build the system up.
/// </remarks>
public IList<string> BeatmapMD5Hashes { get; } = null!;
/// <summary>
/// The date when this collection was last modified.
/// </summary>
public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastModified { get; set; }
public BeatmapCollection()
public BeatmapCollection(string? name = null, IList<string>? beatmapMD5Hashes = null)
{
BeatmapHashes.CollectionChanged += (_, _) => onChange();
Name.ValueChanged += _ => onChange();
ID = Guid.NewGuid();
Name = name ?? string.Empty;
BeatmapMD5Hashes = beatmapMD5Hashes ?? new List<string>();
LastModified = DateTimeOffset.UtcNow;
}
private void onChange()
[UsedImplicitly]
private BeatmapCollection()
{
LastModifyDate = DateTimeOffset.Now;
Changed?.Invoke();
}
}
}

View File

@ -0,0 +1,250 @@
// 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;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
using Realms;
namespace osu.Game.Collections
{
/// <summary>
/// A dropdown to select the collection to be used to filter results.
/// </summary>
public class CollectionDropdown : OsuDropdown<CollectionFilterMenuItem>
{
/// <summary>
/// Whether to show the "manage collections..." menu item in the dropdown.
/// </summary>
protected virtual bool ShowManageCollectionsItem => true;
public Action? RequestFilter { private get; set; }
private readonly BindableList<CollectionFilterMenuItem> filters = new BindableList<CollectionFilterMenuItem>();
[Resolved]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
[Resolved]
private RealmAccess realm { get; set; } = null!;
private IDisposable? realmSubscription;
public CollectionDropdown()
{
ItemSource = filters;
Current.Value = new AllBeatmapsCollectionFilterMenuItem();
}
protected override void LoadComplete()
{
base.LoadComplete();
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>().OrderBy(c => c.Name), collectionsChanged);
Current.BindValueChanged(selectionChanged);
}
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes, Exception error)
{
var selectedItem = SelectedItem?.Value?.Collection;
var allBeatmaps = new AllBeatmapsCollectionFilterMenuItem();
filters.Clear();
filters.Add(allBeatmaps);
filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm))));
if (ShowManageCollectionsItem)
filters.Add(new ManageCollectionsFilterMenuItem());
// This current update and schedule is required to work around dropdown headers not updating text even when the selected item
// changes. It's not great but honestly the whole dropdown menu structure isn't great. This needs to be fixed, but I'll issue
// a warning that it's going to be a frustrating journey.
Current.Value = allBeatmaps;
Schedule(() => Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection.ID == selectedItem?.ID) ?? filters[0]);
// Trigger a re-filter if the current item was in the change set.
if (selectedItem != null && changes != null)
{
foreach (int index in changes.ModifiedIndices)
{
if (collections[index].ID == selectedItem.ID)
RequestFilter?.Invoke();
}
}
}
private Live<BeatmapCollection>? lastFiltered;
private void selectionChanged(ValueChangedEvent<CollectionFilterMenuItem> filter)
{
// May be null during .Clear().
if (filter.NewValue == null)
return;
// Never select the manage collection filter - rollback to the previous filter.
// This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value.
if (filter.NewValue is ManageCollectionsFilterMenuItem)
{
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;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
realmSubscription?.Dispose();
}
protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName;
protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader();
protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu();
protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader();
protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu();
public class CollectionDropdownHeader : OsuDropdownHeader
{
public CollectionDropdownHeader()
{
Height = 25;
Icon.Size = new Vector2(16);
Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 };
}
}
protected class CollectionDropdownMenu : OsuDropdownMenu
{
public CollectionDropdownMenu()
{
MaxHeight = 200;
}
protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownDrawableMenuItem(item)
{
BackgroundColourHover = HoverColour,
BackgroundColourSelected = SelectionColour
};
}
protected class CollectionDropdownDrawableMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem
{
private IconButton addOrRemoveButton = null!;
private bool beatmapInCollection;
private readonly Live<BeatmapCollection>? collection;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
public CollectionDropdownDrawableMenuItem(MenuItem item)
: base(item)
{
collection = ((DropdownMenuItem<CollectionFilterMenuItem>)item).Value.Collection;
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(addOrRemoveButton = new IconButton
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
X = -OsuScrollContainer.SCROLL_BAR_HEIGHT,
Scale = new Vector2(0.65f),
Action = addOrRemove,
});
}
protected override void LoadComplete()
{
base.LoadComplete();
if (collection != null)
{
beatmap.BindValueChanged(_ =>
{
beatmapInCollection = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.Value.BeatmapInfo.MD5Hash));
addOrRemoveButton.Enabled.Value = !beatmap.IsDefault;
addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare;
addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap";
updateButtonVisibility();
}, true);
}
updateButtonVisibility();
}
protected override bool OnHover(HoverEvent e)
{
updateButtonVisibility();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateButtonVisibility();
base.OnHoverLost(e);
}
protected override void OnSelectChange()
{
base.OnSelectChange();
updateButtonVisibility();
}
private void updateButtonVisibility()
{
if (collection == null)
addOrRemoveButton.Alpha = 0;
else
addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0;
}
private void addOrRemove()
{
Debug.Assert(collection != null);
collection.PerformWrite(c =>
{
if (!c.BeatmapMD5Hashes.Remove(beatmap.Value.BeatmapInfo.MD5Hash))
c.BeatmapMD5Hashes.Add(beatmap.Value.BeatmapInfo.MD5Hash);
});
}
protected override Drawable CreateContent() => (Content)base.CreateContent();
}
}
}

View File

@ -1,297 +0,0 @@
// 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.
#nullable disable
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osuTK;
namespace osu.Game.Collections
{
/// <summary>
/// A dropdown to select the <see cref="CollectionFilterMenuItem"/> to filter beatmaps using.
/// </summary>
public class CollectionFilterDropdown : OsuDropdown<CollectionFilterMenuItem>
{
/// <summary>
/// Whether to show the "manage collections..." menu item in the dropdown.
/// </summary>
protected virtual bool ShowManageCollectionsItem => true;
private readonly BindableWithCurrent<CollectionFilterMenuItem> current = new BindableWithCurrent<CollectionFilterMenuItem>();
public new Bindable<CollectionFilterMenuItem> Current
{
get => current.Current;
set => current.Current = value;
}
private readonly IBindableList<BeatmapCollection> collections = new BindableList<BeatmapCollection>();
private readonly IBindableList<string> beatmaps = new BindableList<string>();
private readonly BindableList<CollectionFilterMenuItem> filters = new BindableList<CollectionFilterMenuItem>();
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog manageCollectionsDialog { get; set; }
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
public CollectionFilterDropdown()
{
ItemSource = filters;
Current.Value = new AllBeatmapsCollectionFilterMenuItem();
}
protected override void LoadComplete()
{
base.LoadComplete();
if (collectionManager != null)
collections.BindTo(collectionManager.Collections);
// Dropdown has logic which triggers a change on the bindable with every change to the contained items.
// This is not desirable here, as it leads to multiple filter operations running even though nothing has changed.
// An extra bindable is enough to subvert this behaviour.
base.Current = Current;
collections.BindCollectionChanged((_, _) => collectionsChanged(), true);
Current.BindValueChanged(filterChanged, true);
}
/// <summary>
/// Occurs when a collection has been added or removed.
/// </summary>
private void collectionsChanged()
{
var selectedItem = SelectedItem?.Value?.Collection;
filters.Clear();
filters.Add(new AllBeatmapsCollectionFilterMenuItem());
filters.AddRange(collections.Select(c => new CollectionFilterMenuItem(c)));
if (ShowManageCollectionsItem)
filters.Add(new ManageCollectionsFilterMenuItem());
Current.Value = filters.SingleOrDefault(f => f.Collection != null && f.Collection == selectedItem) ?? filters[0];
}
/// <summary>
/// Occurs when the <see cref="CollectionFilterMenuItem"/> selection has changed.
/// </summary>
private void filterChanged(ValueChangedEvent<CollectionFilterMenuItem> filter)
{
// Binding the beatmaps will trigger a collection change event, which results in an infinite-loop. This is rebound later, when it's safe to do so.
beatmaps.CollectionChanged -= filterBeatmapsChanged;
if (filter.OldValue?.Collection != null)
beatmaps.UnbindFrom(filter.OldValue.Collection.BeatmapHashes);
if (filter.NewValue?.Collection != null)
beatmaps.BindTo(filter.NewValue.Collection.BeatmapHashes);
beatmaps.CollectionChanged += filterBeatmapsChanged;
// Never select the manage collection filter - rollback to the previous filter.
// This is done after the above since it is important that bindable is unbound from OldValue, which is lost after forcing it back to the old value.
if (filter.NewValue is ManageCollectionsFilterMenuItem)
{
Current.Value = filter.OldValue;
manageCollectionsDialog?.Show();
}
}
/// <summary>
/// Occurs when the beatmaps contained by a <see cref="BeatmapCollection"/> have changed.
/// </summary>
private void filterBeatmapsChanged(object sender, NotifyCollectionChangedEventArgs e)
{
// The filtered beatmaps have changed, without the filter having changed itself. So a change in filter must be notified.
// Note that this does NOT propagate to bound bindables, so the FilterControl must bind directly to the value change event of this bindable.
Current.TriggerChange();
}
protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName.Value;
protected sealed override DropdownHeader CreateHeader() => CreateCollectionHeader().With(d =>
{
d.SelectedItem.BindTarget = Current;
});
protected sealed override DropdownMenu CreateMenu() => CreateCollectionMenu();
protected virtual CollectionDropdownHeader CreateCollectionHeader() => new CollectionDropdownHeader();
protected virtual CollectionDropdownMenu CreateCollectionMenu() => new CollectionDropdownMenu();
public class CollectionDropdownHeader : OsuDropdownHeader
{
public readonly Bindable<CollectionFilterMenuItem> SelectedItem = new Bindable<CollectionFilterMenuItem>();
private readonly Bindable<string> collectionName = new Bindable<string>();
protected override LocalisableString Label
{
get => base.Label;
set { } // See updateText().
}
public CollectionDropdownHeader()
{
Height = 25;
Icon.Size = new Vector2(16);
Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 };
}
protected override void LoadComplete()
{
base.LoadComplete();
SelectedItem.BindValueChanged(_ => updateBindable(), true);
}
private void updateBindable()
{
collectionName.UnbindAll();
if (SelectedItem.Value != null)
collectionName.BindTo(SelectedItem.Value.CollectionName);
collectionName.BindValueChanged(_ => updateText(), true);
}
// Dropdowns don't bind to value changes, so the real name is copied directly from the selected item here.
private void updateText() => base.Label = collectionName.Value;
}
protected class CollectionDropdownMenu : OsuDropdownMenu
{
public CollectionDropdownMenu()
{
MaxHeight = 200;
}
protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item)
{
BackgroundColourHover = HoverColour,
BackgroundColourSelected = SelectionColour
};
}
protected class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem
{
[NotNull]
protected new CollectionFilterMenuItem Item => ((DropdownMenuItem<CollectionFilterMenuItem>)base.Item).Value;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; }
[CanBeNull]
private readonly BindableList<string> collectionBeatmaps;
[NotNull]
private readonly Bindable<string> collectionName;
private IconButton addOrRemoveButton;
private Content content;
private bool beatmapInCollection;
public CollectionDropdownMenuItem(MenuItem item)
: base(item)
{
collectionBeatmaps = Item.Collection?.BeatmapHashes.GetBoundCopy();
collectionName = Item.CollectionName.GetBoundCopy();
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(addOrRemoveButton = new IconButton
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
X = -OsuScrollContainer.SCROLL_BAR_HEIGHT,
Scale = new Vector2(0.65f),
Action = addOrRemove,
});
}
protected override void LoadComplete()
{
base.LoadComplete();
if (collectionBeatmaps != null)
{
collectionBeatmaps.CollectionChanged += (_, _) => collectionChanged();
beatmap.BindValueChanged(_ => collectionChanged(), true);
}
// Although the DrawableMenuItem binds to value changes of the item's text, the item is an internal implementation detail of Dropdown that has no knowledge
// of the underlying CollectionFilter value and its accompanying name, so the real name has to be copied here. Without this, the collection name wouldn't update when changed.
collectionName.BindValueChanged(name => content.Text = name.NewValue, true);
updateButtonVisibility();
}
protected override bool OnHover(HoverEvent e)
{
updateButtonVisibility();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateButtonVisibility();
base.OnHoverLost(e);
}
private void collectionChanged()
{
Debug.Assert(collectionBeatmaps != null);
beatmapInCollection = collectionBeatmaps.Contains(beatmap.Value.BeatmapInfo.MD5Hash);
addOrRemoveButton.Enabled.Value = !beatmap.IsDefault;
addOrRemoveButton.Icon = beatmapInCollection ? FontAwesome.Solid.MinusSquare : FontAwesome.Solid.PlusSquare;
addOrRemoveButton.TooltipText = beatmapInCollection ? "Remove selected beatmap" : "Add selected beatmap";
updateButtonVisibility();
}
protected override void OnSelectChange()
{
base.OnSelectChange();
updateButtonVisibility();
}
private void updateButtonVisibility()
{
if (collectionBeatmaps == null)
addOrRemoveButton.Alpha = 0;
else
addOrRemoveButton.Alpha = IsHovered || IsPreSelected || beatmapInCollection ? 1 : 0;
}
private void addOrRemove()
{
Debug.Assert(collectionBeatmaps != null);
if (!collectionBeatmaps.Remove(beatmap.Value.BeatmapInfo.MD5Hash))
collectionBeatmaps.Add(beatmap.Value.BeatmapInfo.MD5Hash);
}
protected override Drawable CreateContent() => content = (Content)base.CreateContent();
}
}
}

View File

@ -1,11 +1,8 @@
// 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.
#nullable disable
using System;
using JetBrains.Annotations;
using osu.Framework.Bindables;
using osu.Game.Database;
namespace osu.Game.Collections
{
@ -18,26 +15,29 @@ namespace osu.Game.Collections
/// The collection to filter beatmaps from.
/// May be null to not filter by collection (include all beatmaps).
/// </summary>
[CanBeNull]
public readonly BeatmapCollection Collection;
public readonly Live<BeatmapCollection>? Collection;
/// <summary>
/// The name of the collection.
/// </summary>
[NotNull]
public readonly Bindable<string> CollectionName;
public string CollectionName { get; }
/// <summary>
/// Creates a new <see cref="CollectionFilterMenuItem"/>.
/// </summary>
/// <param name="collection">The collection to filter beatmaps from.</param>
public CollectionFilterMenuItem([CanBeNull] BeatmapCollection collection)
public CollectionFilterMenuItem(Live<BeatmapCollection> collection)
: this(collection.PerformRead(c => c.Name))
{
Collection = collection;
CollectionName = Collection?.Name.GetBoundCopy() ?? new Bindable<string>("All beatmaps");
}
public bool Equals(CollectionFilterMenuItem other)
protected CollectionFilterMenuItem(string name)
{
CollectionName = name;
}
public bool Equals(CollectionFilterMenuItem? other)
{
if (other == null)
return false;
@ -45,20 +45,20 @@ namespace osu.Game.Collections
// collections may have the same name, so compare first on reference equality.
// this relies on the assumption that only one instance of the BeatmapCollection exists game-wide, managed by CollectionManager.
if (Collection != null)
return Collection == other.Collection;
return Collection.ID == other.Collection?.ID;
// fallback to name-based comparison.
// this is required for special dropdown items which don't have a collection (all beatmaps / manage collections items below).
return CollectionName.Value == other.CollectionName.Value;
return CollectionName == other.CollectionName;
}
public override int GetHashCode() => CollectionName.Value.GetHashCode();
public override int GetHashCode() => CollectionName.GetHashCode();
}
public class AllBeatmapsCollectionFilterMenuItem : CollectionFilterMenuItem
{
public AllBeatmapsCollectionFilterMenuItem()
: base(null)
: base("All beatmaps")
{
}
}
@ -66,9 +66,8 @@ namespace osu.Game.Collections
public class ManageCollectionsFilterMenuItem : CollectionFilterMenuItem
{
public ManageCollectionsFilterMenuItem()
: base(null)
: base("Manage collections...")
{
CollectionName.Value = "Manage collections...";
}
}
}

View File

@ -1,349 +0,0 @@
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.IO;
using osu.Game.IO.Legacy;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Collections
{
/// <summary>
/// Handles user-defined collections of beatmaps.
/// </summary>
/// <remarks>
/// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the
/// database backing the game. Going forward writing should be done in a similar way to other model stores.
/// </remarks>
public class CollectionManager : Component, IPostNotifications
{
/// <summary>
/// Database version in stable-compatible YYYYMMDD format.
/// </summary>
private const int database_version = 30000000;
private const string database_name = "collection.db";
private const string database_backup_name = "collection.db.bak";
public readonly BindableList<BeatmapCollection> Collections = new BindableList<BeatmapCollection>();
private readonly Storage storage;
public CollectionManager(Storage storage)
{
this.storage = storage;
}
[Resolved(canBeNull: true)]
private DatabaseContextFactory efContextFactory { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
efContextFactory?.WaitForMigrationCompletion();
Collections.CollectionChanged += collectionsChanged;
if (storage.Exists(database_backup_name))
{
// If a backup file exists, it means the previous write operation didn't run to completion.
// Always prefer the backup file in such a case as it's the most recent copy that is guaranteed to not be malformed.
//
// The database is saved 100ms after any change, and again when the game is closed, so there shouldn't be a large diff between the two files in the worst case.
if (storage.Exists(database_name))
storage.Delete(database_name);
File.Copy(storage.GetFullPath(database_backup_name), storage.GetFullPath(database_name));
}
if (storage.Exists(database_name))
{
List<BeatmapCollection> beatmapCollections;
using (var stream = storage.GetStream(database_name))
beatmapCollections = readCollections(stream);
// intentionally fire-and-forget async.
importCollections(beatmapCollections);
}
}
private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e) => Schedule(() =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
foreach (var c in e.NewItems.Cast<BeatmapCollection>())
c.Changed += backgroundSave;
break;
case NotifyCollectionChangedAction.Remove:
foreach (var c in e.OldItems.Cast<BeatmapCollection>())
c.Changed -= backgroundSave;
break;
case NotifyCollectionChangedAction.Replace:
foreach (var c in e.OldItems.Cast<BeatmapCollection>())
c.Changed -= backgroundSave;
foreach (var c in e.NewItems.Cast<BeatmapCollection>())
c.Changed += backgroundSave;
break;
}
backgroundSave();
});
public Action<Notification> PostNotification { protected get; set; }
public Task<int> GetAvailableCount(StableStorage stableStorage)
{
if (!stableStorage.Exists(database_name))
return Task.FromResult(0);
return Task.Run(() =>
{
using (var stream = stableStorage.GetStream(database_name))
return readCollections(stream).Count;
});
}
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
public Task ImportFromStableAsync(StableStorage stableStorage)
{
if (!stableStorage.Exists(database_name))
{
// This handles situations like when the user does not have a collections.db file
Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
return Task.Run(async () =>
{
using (var stream = stableStorage.GetStream(database_name))
await Import(stream).ConfigureAwait(false);
});
}
public async Task Import(Stream stream)
{
var notification = new ProgressNotification
{
State = ProgressNotificationState.Active,
Text = "Collections import is initialising..."
};
PostNotification?.Invoke(notification);
var collections = readCollections(stream, notification);
await importCollections(collections).ConfigureAwait(false);
notification.CompletionText = $"Imported {collections.Count} collections";
notification.State = ProgressNotificationState.Completed;
}
private Task importCollections(List<BeatmapCollection> newCollections)
{
var tcs = new TaskCompletionSource<bool>();
Schedule(() =>
{
try
{
foreach (var newCol in newCollections)
{
var existing = Collections.FirstOrDefault(c => c.Name.Value == newCol.Name.Value);
if (existing == null)
Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } });
foreach (string newBeatmap in newCol.BeatmapHashes)
{
if (!existing.BeatmapHashes.Contains(newBeatmap))
existing.BeatmapHashes.Add(newBeatmap);
}
}
tcs.SetResult(true);
}
catch (Exception e)
{
Logger.Error(e, "Failed to import collection.");
tcs.SetException(e);
}
});
return tcs.Task;
}
private List<BeatmapCollection> readCollections(Stream stream, ProgressNotification notification = null)
{
if (notification != null)
{
notification.Text = "Reading collections...";
notification.Progress = 0;
}
var result = new List<BeatmapCollection>();
try
{
using (var sr = new SerializationReader(stream))
{
sr.ReadInt32(); // Version
int collectionCount = sr.ReadInt32();
result.Capacity = collectionCount;
for (int i = 0; i < collectionCount; i++)
{
if (notification?.CancellationToken.IsCancellationRequested == true)
return result;
var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } };
int mapCount = sr.ReadInt32();
for (int j = 0; j < mapCount; j++)
{
if (notification?.CancellationToken.IsCancellationRequested == true)
return result;
string checksum = sr.ReadString();
collection.BeatmapHashes.Add(checksum);
}
if (notification != null)
{
notification.Text = $"Imported {i + 1} of {collectionCount} collections";
notification.Progress = (float)(i + 1) / collectionCount;
}
result.Add(collection);
}
}
}
catch (Exception e)
{
Logger.Error(e, "Failed to read collection database.");
}
return result;
}
public void DeleteAll()
{
Collections.Clear();
PostNotification?.Invoke(new ProgressCompletionNotification { Text = "Deleted all collections!" });
}
private readonly object saveLock = new object();
private int lastSave;
private int saveFailures;
/// <summary>
/// Perform a save with debounce.
/// </summary>
private void backgroundSave()
{
int current = Interlocked.Increment(ref lastSave);
Task.Delay(100).ContinueWith(_ =>
{
if (current != lastSave)
return;
if (!save())
backgroundSave();
});
}
private bool save()
{
lock (saveLock)
{
Interlocked.Increment(ref lastSave);
// This is NOT thread-safe!!
try
{
string tempPath = Path.GetTempFileName();
using (var ms = new MemoryStream())
{
using (var sw = new SerializationWriter(ms, true))
{
sw.Write(database_version);
var collectionsCopy = Collections.ToArray();
sw.Write(collectionsCopy.Length);
foreach (var c in collectionsCopy)
{
sw.Write(c.Name.Value);
string[] beatmapsCopy = c.BeatmapHashes.ToArray();
sw.Write(beatmapsCopy.Length);
foreach (string b in beatmapsCopy)
sw.Write(b);
}
}
using (var fs = File.OpenWrite(tempPath))
ms.WriteTo(fs);
string databasePath = storage.GetFullPath(database_name);
string databaseBackupPath = storage.GetFullPath(database_backup_name);
// Back up the existing database, clearing any existing backup.
if (File.Exists(databaseBackupPath))
File.Delete(databaseBackupPath);
if (File.Exists(databasePath))
File.Move(databasePath, databaseBackupPath);
// Move the new database in-place of the existing one.
File.Move(tempPath, databasePath);
// If everything succeeded up to this point, remove the backup file.
if (File.Exists(databaseBackupPath))
File.Delete(databaseBackupPath);
}
if (saveFailures < 10)
saveFailures = 0;
return true;
}
catch (Exception e)
{
// Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing).
// Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred.
if (++saveFailures == 10)
Logger.Error(e, "Failed to save collection database!");
}
return false;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
save();
}
}
}

View File

@ -2,22 +2,26 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
namespace osu.Game.Collections
{
public class CollectionToggleMenuItem : ToggleMenuItem
{
public CollectionToggleMenuItem(BeatmapCollection collection, IBeatmapInfo beatmap)
: base(collection.Name.Value, MenuItemType.Standard, state =>
public CollectionToggleMenuItem(Live<BeatmapCollection> collection, IBeatmapInfo beatmap)
: base(collection.PerformRead(c => c.Name), MenuItemType.Standard, state =>
{
if (state)
collection.BeatmapHashes.Add(beatmap.MD5Hash);
else
collection.BeatmapHashes.Remove(beatmap.MD5Hash);
collection.PerformWrite(c =>
{
if (state)
c.BeatmapMD5Hashes.Add(beatmap.MD5Hash);
else
c.BeatmapMD5Hashes.Remove(beatmap.MD5Hash);
});
})
{
State.Value = collection.BeatmapHashes.Contains(beatmap.MD5Hash);
State.Value = collection.PerformRead(c => c.BeatmapMD5Hashes.Contains(beatmap.MD5Hash));
}
}
}

View File

@ -1,21 +1,20 @@
// 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.
#nullable disable
using System;
using Humanizer;
using osu.Framework.Graphics.Sprites;
using osu.Game.Database;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Collections
{
public class DeleteCollectionDialog : PopupDialog
{
public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction)
public DeleteCollectionDialog(Live<BeatmapCollection> collection, Action deleteAction)
{
HeaderText = "Confirm deletion of";
BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.BeatmapHashes.Count)})";
BodyText = collection.PerformRead(c => $"{c.Name} ({"beatmap".ToQuantity(c.BeatmapMD5Hashes.Count)})");
Icon = FontAwesome.Regular.TrashAlt;

View File

@ -1,39 +1,66 @@
// 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.
#nullable disable
using System;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Database;
using osu.Game.Graphics.Containers;
using osuTK;
using Realms;
namespace osu.Game.Collections
{
/// <summary>
/// Visualises a list of <see cref="BeatmapCollection"/>s.
/// </summary>
public class DrawableCollectionList : OsuRearrangeableListContainer<BeatmapCollection>
public class DrawableCollectionList : OsuRearrangeableListContainer<Live<BeatmapCollection>>
{
private Scroll scroll;
protected override ScrollContainer<Drawable> CreateScrollContainer() => scroll = new Scroll();
protected override FillFlowContainer<RearrangeableListItem<BeatmapCollection>> CreateListFillFlowContainer() => new Flow
[Resolved]
private RealmAccess realm { get; set; } = null!;
private Scroll scroll = null!;
private IDisposable? realmSubscription;
protected override FillFlowContainer<RearrangeableListItem<Live<BeatmapCollection>>> CreateListFillFlowContainer() => new Flow
{
DragActive = { BindTarget = DragActive }
};
protected override OsuRearrangeableListItem<BeatmapCollection> CreateOsuDrawable(BeatmapCollection item)
protected override void LoadComplete()
{
if (item == scroll.PlaceholderItem.Model)
base.LoadComplete();
realmSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>().OrderBy(c => c.Name), collectionsChanged);
}
private void collectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes, Exception error)
{
Items.Clear();
Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm)));
}
protected override OsuRearrangeableListItem<Live<BeatmapCollection>> CreateOsuDrawable(Live<BeatmapCollection> item)
{
if (item.ID == scroll.PlaceholderItem.Model.ID)
return scroll.ReplacePlaceholder();
return new DrawableCollectionListItem(item, true);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
realmSubscription?.Dispose();
}
/// <summary>
/// The scroll container for this <see cref="DrawableCollectionList"/>.
/// Contains the main flow of <see cref="DrawableCollectionListItem"/> and attaches a placeholder item to the end of the list.
@ -46,7 +73,7 @@ namespace osu.Game.Collections
/// <summary>
/// The currently-displayed placeholder item.
/// </summary>
public DrawableCollectionListItem PlaceholderItem { get; private set; }
public DrawableCollectionListItem PlaceholderItem { get; private set; } = null!;
protected override Container<Drawable> Content => content;
private readonly Container content;
@ -76,6 +103,7 @@ namespace osu.Game.Collections
});
ReplacePlaceholder();
Debug.Assert(PlaceholderItem != null);
}
protected override void Update()
@ -95,7 +123,7 @@ namespace osu.Game.Collections
var previous = PlaceholderItem;
placeholderContainer.Clear(false);
placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false));
placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection().ToLiveUnmanaged(), false));
return previous;
}
@ -104,7 +132,7 @@ namespace osu.Game.Collections
/// <summary>
/// The flow of <see cref="DrawableCollectionListItem"/>. Disables layout easing unless a drag is in progress.
/// </summary>
private class Flow : FillFlowContainer<RearrangeableListItem<BeatmapCollection>>
private class Flow : FillFlowContainer<RearrangeableListItem<Live<BeatmapCollection>>>
{
public readonly IBindable<bool> DragActive = new Bindable<bool>();

View File

@ -1,17 +1,16 @@
// 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.
#nullable disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
@ -24,79 +23,62 @@ namespace osu.Game.Collections
/// <summary>
/// Visualises a <see cref="BeatmapCollection"/> inside a <see cref="DrawableCollectionList"/>.
/// </summary>
public class DrawableCollectionListItem : OsuRearrangeableListItem<BeatmapCollection>
public class DrawableCollectionListItem : OsuRearrangeableListItem<Live<BeatmapCollection>>
{
private const float item_height = 35;
private const float button_width = item_height * 0.75f;
/// <summary>
/// Whether the <see cref="BeatmapCollection"/> currently exists inside the <see cref="CollectionManager"/>.
/// </summary>
public IBindable<bool> IsCreated => isCreated;
private readonly Bindable<bool> isCreated = new Bindable<bool>();
/// <summary>
/// Creates a new <see cref="DrawableCollectionListItem"/>.
/// </summary>
/// <param name="item">The <see cref="BeatmapCollection"/>.</param>
/// <param name="isCreated">Whether <paramref name="item"/> currently exists inside the <see cref="CollectionManager"/>.</param>
public DrawableCollectionListItem(BeatmapCollection item, bool isCreated)
/// <param name="isCreated">Whether <paramref name="item"/> currently exists inside realm.</param>
public DrawableCollectionListItem(Live<BeatmapCollection> item, bool isCreated)
: base(item)
{
this.isCreated.Value = isCreated;
ShowDragHandle.BindTo(this.isCreated);
ShowDragHandle.Value = item.IsManaged;
}
protected override Drawable CreateContent() => new ItemContent(Model)
{
IsCreated = { BindTarget = isCreated }
};
protected override Drawable CreateContent() => new ItemContent(Model);
/// <summary>
/// The main content of the <see cref="DrawableCollectionListItem"/>.
/// </summary>
private class ItemContent : CircularContainer
{
public readonly Bindable<bool> IsCreated = new Bindable<bool>();
private readonly Live<BeatmapCollection> collection;
private readonly IBindable<string> collectionName;
private readonly BeatmapCollection collection;
private ItemTextBox textBox = null!;
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
[Resolved]
private RealmAccess realm { get; set; } = null!;
private Container textBoxPaddingContainer;
private ItemTextBox textBox;
public ItemContent(BeatmapCollection collection)
public ItemContent(Live<BeatmapCollection> collection)
{
this.collection = collection;
RelativeSizeAxes = Axes.X;
Height = item_height;
Masking = true;
collectionName = collection.Name.GetBoundCopy();
}
[BackgroundDependencyLoader]
private void load()
{
Children = new Drawable[]
Children = new[]
{
new DeleteButton(collection)
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
IsCreated = { BindTarget = IsCreated },
IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v)
},
textBoxPaddingContainer = new Container
collection.IsManaged
? new DeleteButton(collection)
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v)
}
: Empty(),
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = button_width },
Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 },
Children = new Drawable[]
{
textBox = new ItemTextBox
@ -104,7 +86,7 @@ namespace osu.Game.Collections
RelativeSizeAxes = Axes.Both,
Size = Vector2.One,
CornerRadius = item_height / 2,
PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection"
PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection"
},
}
},
@ -116,28 +98,18 @@ namespace osu.Game.Collections
base.LoadComplete();
// Bind late, as the collection name may change externally while still loading.
textBox.Current = collection.Name;
collectionName.BindValueChanged(_ => createNewCollection(), true);
IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true);
textBox.Current.Value = collection.PerformRead(c => c.IsValid ? c.Name : string.Empty);
textBox.OnCommit += onCommit;
}
private void createNewCollection()
private void onCommit(TextBox sender, bool newText)
{
if (IsCreated.Value)
return;
if (collection.IsManaged)
collection.PerformWrite(c => c.Name = textBox.Current.Value);
else if (!string.IsNullOrEmpty(textBox.Current.Value))
realm.Write(r => r.Add(new BeatmapCollection(textBox.Current.Value)));
if (string.IsNullOrEmpty(collectionName.Value))
return;
// Add the new collection and disable our placeholder. If all text is removed, the placeholder should not show back again.
collectionManager?.Collections.Add(collection);
textBox.PlaceholderText = string.Empty;
// When this item changes from placeholder to non-placeholder (via changing containers), its textbox will lose focus, so it needs to be re-focused.
Schedule(() => GetContainingInputManager().ChangeFocus(textBox));
IsCreated.Value = true;
textBox.Text = string.Empty;
}
}
@ -155,22 +127,17 @@ namespace osu.Game.Collections
public class DeleteButton : CompositeDrawable
{
public readonly IBindable<bool> IsCreated = new Bindable<bool>();
public Func<Vector2, bool> IsTextBoxHovered = null!;
public Func<Vector2, bool> IsTextBoxHovered;
[Resolved]
private IDialogOverlay? dialogOverlay { get; set; }
[Resolved(CanBeNull = true)]
private IDialogOverlay dialogOverlay { get; set; }
private readonly Live<BeatmapCollection> collection;
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
private Drawable fadeContainer = null!;
private Drawable background = null!;
private readonly BeatmapCollection collection;
private Drawable fadeContainer;
private Drawable background;
public DeleteButton(BeatmapCollection collection)
public DeleteButton(Live<BeatmapCollection> collection)
{
this.collection = collection;
RelativeSizeAxes = Axes.Y;
@ -204,12 +171,6 @@ namespace osu.Game.Collections
};
}
protected override void LoadComplete()
{
base.LoadComplete();
IsCreated.BindValueChanged(created => Alpha = created.NewValue ? 1 : 0, true);
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos);
protected override bool OnHover(HoverEvent e)
@ -227,7 +188,7 @@ namespace osu.Game.Collections
{
background.FlashColour(Color4.White, 150);
if (collection.BeatmapHashes.Count == 0)
if (collection.PerformRead(c => c.BeatmapMD5Hashes.Count) == 0)
deleteCollection();
else
dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection));
@ -235,7 +196,7 @@ namespace osu.Game.Collections
return true;
}
private void deleteCollection() => collectionManager?.Collections.Remove(collection);
private void deleteCollection() => collection.PerformWrite(c => c.Realm.Remove(c));
}
}
}

View File

@ -1,11 +1,8 @@
// 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.
#nullable disable
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -24,10 +21,7 @@ namespace osu.Game.Collections
private const double enter_duration = 500;
private const double exit_duration = 200;
private AudioFilter lowPassFilter;
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
private AudioFilter lowPassFilter = null!;
public ManageCollectionsDialog()
{
@ -107,7 +101,6 @@ namespace osu.Game.Collections
new DrawableCollectionList
{
RelativeSizeAxes = Axes.Both,
Items = { BindTarget = collectionManager?.Collections ?? new BindableList<BeatmapCollection>() }
}
}
}

View File

@ -0,0 +1,169 @@
// 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;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Collections;
using osu.Game.IO.Legacy;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Database
{
public class LegacyCollectionImporter
{
public Action<Notification>? PostNotification { protected get; set; }
private readonly RealmAccess realm;
private const string database_name = "collection.db";
public LegacyCollectionImporter(RealmAccess realm)
{
this.realm = realm;
}
public Task<int> GetAvailableCount(Storage storage)
{
if (!storage.Exists(database_name))
return Task.FromResult(0);
return Task.Run(() =>
{
using (var stream = storage.GetStream(database_name))
return readCollections(stream).Count;
});
}
/// <summary>
/// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
/// </summary>
public Task ImportFromStorage(Storage storage)
{
if (!storage.Exists(database_name))
{
// This handles situations like when the user does not have a collections.db file
Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
return Task.CompletedTask;
}
return Task.Run(async () =>
{
using (var stream = storage.GetStream(database_name))
await Import(stream).ConfigureAwait(false);
});
}
public async Task Import(Stream stream)
{
var notification = new ProgressNotification
{
State = ProgressNotificationState.Active,
Text = "Collections import is initialising..."
};
PostNotification?.Invoke(notification);
var importedCollections = readCollections(stream, notification);
await importCollections(importedCollections).ConfigureAwait(false);
notification.CompletionText = $"Imported {importedCollections.Count} collections";
notification.State = ProgressNotificationState.Completed;
}
private Task importCollections(List<BeatmapCollection> newCollections)
{
var tcs = new TaskCompletionSource<bool>();
try
{
realm.Write(r =>
{
foreach (var collection in newCollections)
{
var existing = r.All<BeatmapCollection>().FirstOrDefault(c => c.Name == collection.Name);
if (existing != null)
{
foreach (string newBeatmap in existing.BeatmapMD5Hashes)
{
if (!existing.BeatmapMD5Hashes.Contains(newBeatmap))
existing.BeatmapMD5Hashes.Add(newBeatmap);
}
}
else
r.Add(collection);
}
});
tcs.SetResult(true);
}
catch (Exception e)
{
Logger.Error(e, "Failed to import collection.");
tcs.SetException(e);
}
return tcs.Task;
}
private List<BeatmapCollection> readCollections(Stream stream, ProgressNotification? notification = null)
{
if (notification != null)
{
notification.Text = "Reading collections...";
notification.Progress = 0;
}
var result = new List<BeatmapCollection>();
try
{
using (var sr = new SerializationReader(stream))
{
sr.ReadInt32(); // Version
int collectionCount = sr.ReadInt32();
result.Capacity = collectionCount;
for (int i = 0; i < collectionCount; i++)
{
if (notification?.CancellationToken.IsCancellationRequested == true)
return result;
var collection = new BeatmapCollection(sr.ReadString());
int mapCount = sr.ReadInt32();
for (int j = 0; j < mapCount; j++)
{
if (notification?.CancellationToken.IsCancellationRequested == true)
return result;
string checksum = sr.ReadString();
collection.BeatmapMD5Hashes.Add(checksum);
}
if (notification != null)
{
notification.Text = $"Imported {i + 1} of {collectionCount} collections";
notification.Progress = (float)(i + 1) / collectionCount;
}
result.Add(collection);
}
}
}
catch (Exception e)
{
Logger.Error(e, "Failed to read collection database.");
}
return result;
}
}
}

View File

@ -13,7 +13,6 @@ using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.IO;
using osu.Game.Overlays;
using osu.Game.Overlays.Settings.Sections.Maintenance;
@ -36,15 +35,15 @@ namespace osu.Game.Database
[Resolved]
private ScoreManager scores { get; set; }
[Resolved]
private CollectionManager collections { get; set; }
[Resolved(canBeNull: true)]
private OsuGame game { get; set; }
[Resolved]
private IDialogOverlay dialogOverlay { get; set; }
[Resolved]
private RealmAccess realmAccess { get; set; }
[Resolved(canBeNull: true)]
private DesktopGameHost desktopGameHost { get; set; }
@ -72,7 +71,7 @@ namespace osu.Game.Database
return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage);
case StableContent.Collections:
return await collections.GetAvailableCount(stableStorage);
return await new LegacyCollectionImporter(realmAccess).GetAvailableCount(stableStorage);
case StableContent.Scores:
return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage);
@ -109,7 +108,7 @@ namespace osu.Game.Database
importTasks.Add(new LegacySkinImporter(skins).ImportFromStableAsync(stableStorage));
if (content.HasFlagFast(StableContent.Collections))
importTasks.Add(beatmapImportTask.ContinueWith(_ => collections.ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyCollectionImporter(realmAccess).ImportFromStorage(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));
if (content.HasFlagFast(StableContent.Scores))
importTasks.Add(beatmapImportTask.ContinueWith(_ => new LegacyScoreImporter(scores).ImportFromStableAsync(stableStorage), TaskContinuationOptions.OnlyOnRanToCompletion));

View File

@ -14,6 +14,7 @@ using System.Threading.Tasks;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Input.Bindings;
using osu.Framework.Logging;
using osu.Framework.Platform;
@ -64,8 +65,9 @@ namespace osu.Game.Database
/// 18 2022-07-19 Added OnlineMD5Hash and LastOnlineUpdate to BeatmapInfo.
/// 19 2022-07-19 Added DateSubmitted and DateRanked to BeatmapSetInfo.
/// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1.
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
/// </summary>
private const int schema_version = 20;
private const int schema_version = 21;
/// <summary>
/// Lock object which is held during <see cref="BlockAllOperations"/> sections, blocking realm retrieval during blocking periods.
@ -790,6 +792,27 @@ namespace osu.Game.Database
beatmap.StarRating = -1;
break;
case 21:
try
{
// Migrate collections from external file to inside realm.
// We use the "legacy" importer because that is how things were actually being saved out until now.
var legacyCollectionImporter = new LegacyCollectionImporter(this);
if (legacyCollectionImporter.GetAvailableCount(storage).GetResultSafely() > 0)
{
legacyCollectionImporter.ImportFromStorage(storage);
storage.Delete("collection.db");
}
}
catch (Exception e)
{
// can be removed 20221027 (just for initial safety).
Logger.Error(e, "Collections could not be migrated to realm. Please provide your \"collection.db\" to the dev team.");
}
break;
}
}

View File

@ -858,11 +858,6 @@ namespace osu.Game
d.Origin = Anchor.TopRight;
}), rightFloatingOverlayContent.Add, true);
loadComponentSingleFile(new CollectionManager(Storage)
{
PostNotification = n => Notifications.Post(n),
}, Add, true);
loadComponentSingleFile(legacyImportManager, Add);
loadComponentSingleFile(screenshotManager, Add);

View File

@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Music
public Action<FilterCriteria> FilterChanged;
public readonly FilterTextBox Search;
private readonly CollectionDropdown collectionDropdown;
private readonly NowPlayingCollectionDropdown collectionDropdown;
public FilterControl()
{
@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Music
RelativeSizeAxes = Axes.X,
Height = 40,
},
collectionDropdown = new CollectionDropdown { RelativeSizeAxes = Axes.X }
collectionDropdown = new NowPlayingCollectionDropdown { RelativeSizeAxes = Axes.X }
},
},
};

View File

@ -5,6 +5,7 @@
using JetBrains.Annotations;
using osu.Game.Collections;
using osu.Game.Database;
namespace osu.Game.Overlays.Music
{
@ -19,6 +20,6 @@ namespace osu.Game.Overlays.Music
/// The collection to filter beatmaps from.
/// </summary>
[CanBeNull]
public BeatmapCollection Collection;
public Live<BeatmapCollection> Collection;
}
}

View File

@ -15,9 +15,9 @@ using osu.Game.Graphics;
namespace osu.Game.Overlays.Music
{
/// <summary>
/// A <see cref="CollectionFilterDropdown"/> for use in the <see cref="NowPlayingOverlay"/>.
/// A <see cref="CollectionDropdown"/> for use in the <see cref="NowPlayingOverlay"/>.
/// </summary>
public class CollectionDropdown : CollectionFilterDropdown
public class NowPlayingCollectionDropdown : CollectionDropdown
{
protected override bool ShowManageCollectionsItem => false;

View File

@ -31,14 +31,16 @@ namespace osu.Game.Overlays.Music
{
var items = (SearchContainer<RearrangeableListItem<Live<BeatmapSetInfo>>>)ListContainer;
string[] currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
foreach (var item in items.OfType<PlaylistItem>())
{
if (criteria.Collection == null)
if (currentCollectionHashes == null)
item.InSelectedCollection = true;
else
{
item.InSelectedCollection = item.Model.Value.Beatmaps.Select(b => b.MD5Hash)
.Any(criteria.Collection.BeatmapHashes.Contains);
.Any(currentCollectionHashes.Contains);
}
}

View File

@ -6,6 +6,7 @@ using osu.Framework.Localisation;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Localisation;
using osu.Game.Overlays.Notifications;
namespace osu.Game.Overlays.Settings.Sections.Maintenance
{
@ -15,11 +16,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
private SettingsButton importCollectionsButton = null!;
[BackgroundDependencyLoader]
private void load(CollectionManager? collectionManager, LegacyImportManager? legacyImportManager, IDialogOverlay? dialogOverlay)
{
if (collectionManager == null) return;
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved]
private INotificationOverlay? notificationOverlay { get; set; }
[BackgroundDependencyLoader]
private void load(LegacyImportManager? legacyImportManager, IDialogOverlay? dialogOverlay)
{
if (legacyImportManager?.SupportsImportFromStable == true)
{
Add(importCollectionsButton = new SettingsButton
@ -38,9 +43,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
Text = MaintenanceSettingsStrings.DeleteAllCollections,
Action = () =>
{
dialogOverlay?.Push(new MassDeleteConfirmationDialog(collectionManager.DeleteAll));
dialogOverlay?.Push(new MassDeleteConfirmationDialog(deleteAllCollections));
}
});
}
private void deleteAllCollections()
{
realm.Write(r => r.RemoveAll<BeatmapCollection>());
notificationOverlay?.Post(new ProgressCompletionNotification { Text = "Deleted all collections!" });
}
}
}

View File

@ -94,6 +94,9 @@ namespace osu.Game.Screens.OnlinePlay
private PanelBackground panelBackground;
private FillFlowContainer mainFillFlow;
[Resolved]
private RealmAccess realm { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
@ -112,9 +115,6 @@ namespace osu.Game.Screens.OnlinePlay
[Resolved(CanBeNull = true)]
private BeatmapSetOverlay beatmapOverlay { get; set; }
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog manageCollectionsDialog { get; set; }
@ -495,11 +495,11 @@ namespace osu.Game.Screens.OnlinePlay
if (beatmapOverlay != null)
items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(Item.Beatmap.OnlineID)));
if (collectionManager != null && beatmap != null)
if (beatmap != null)
{
if (beatmaps.QueryBeatmap(b => b.OnlineID == beatmap.OnlineID) is BeatmapInfo local && !local.BeatmapSet.AsNonNull().DeletePending)
{
var collectionItems = collectionManager.Collections.Select(c => new CollectionToggleMenuItem(c, beatmap)).Cast<OsuMenuItem>().ToList();
var collectionItems = realm.Realm.All<BeatmapCollection>().AsEnumerable().Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmap)).Cast<OsuMenuItem>().ToList();
if (manageCollectionsDialog != null)
collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show));

View File

@ -76,7 +76,7 @@ namespace osu.Game.Screens.Select.Carousel
}
if (match)
match &= criteria.Collection?.BeatmapHashes.Contains(BeatmapInfo.MD5Hash) ?? true;
match &= criteria.CollectionBeatmapMD5Hashes?.Contains(BeatmapInfo.MD5Hash) ?? true;
if (match && criteria.RulesetCriteria != null)
match &= criteria.RulesetCriteria.Matches(BeatmapInfo);

View File

@ -22,6 +22,7 @@ using osu.Framework.Input.Events;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Sprites;
@ -63,12 +64,12 @@ namespace osu.Game.Screens.Select.Carousel
[Resolved]
private BeatmapDifficultyCache difficultyCache { get; set; }
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog manageCollectionsDialog { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
private IBindable<StarDifficulty?> starDifficultyBindable;
private CancellationTokenSource starDifficultyCancellationSource;
@ -237,14 +238,11 @@ namespace osu.Game.Screens.Select.Carousel
if (beatmapInfo.OnlineID > 0 && beatmapOverlay != null)
items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => beatmapOverlay.FetchAndShowBeatmap(beatmapInfo.OnlineID)));
if (collectionManager != null)
{
var collectionItems = collectionManager.Collections.Select(c => new CollectionToggleMenuItem(c, beatmapInfo)).Cast<OsuMenuItem>().ToList();
if (manageCollectionsDialog != null)
collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show));
var collectionItems = realm.Realm.All<BeatmapCollection>().AsEnumerable().Select(c => new CollectionToggleMenuItem(c.ToLive(realm), beatmapInfo)).Cast<OsuMenuItem>().ToList();
if (manageCollectionsDialog != null)
collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show));
items.Add(new OsuMenuItem("Collections") { Items = collectionItems });
}
items.Add(new OsuMenuItem("Collections") { Items = collectionItems });
if (hideRequested != null)
items.Add(new OsuMenuItem(CommonStrings.ButtonsHide.ToSentence(), MenuItemType.Destructive, () => hideRequested(beatmapInfo)));

View File

@ -17,6 +17,7 @@ using osu.Framework.Graphics.UserInterface;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Overlays;
@ -32,12 +33,12 @@ namespace osu.Game.Screens.Select.Carousel
[Resolved(CanBeNull = true)]
private IDialogOverlay dialogOverlay { get; set; }
[Resolved(CanBeNull = true)]
private CollectionManager collectionManager { get; set; }
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog manageCollectionsDialog { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
public IEnumerable<DrawableCarouselItem> DrawableBeatmaps => beatmapContainer?.IsLoaded != true ? Enumerable.Empty<DrawableCarouselItem>() : beatmapContainer.AliveChildren;
[CanBeNull]
@ -223,14 +224,11 @@ namespace osu.Game.Screens.Select.Carousel
if (beatmapSet.OnlineID > 0 && viewDetails != null)
items.Add(new OsuMenuItem("Details...", MenuItemType.Standard, () => viewDetails(beatmapSet.OnlineID)));
if (collectionManager != null)
{
var collectionItems = collectionManager.Collections.Select(createCollectionMenuItem).ToList();
if (manageCollectionsDialog != null)
collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show));
var collectionItems = realm.Realm.All<BeatmapCollection>().AsEnumerable().Select(createCollectionMenuItem).ToList();
if (manageCollectionsDialog != null)
collectionItems.Add(new OsuMenuItem("Manage...", MenuItemType.Standard, manageCollectionsDialog.Show));
items.Add(new OsuMenuItem("Collections") { Items = collectionItems });
}
items.Add(new OsuMenuItem("Collections") { Items = collectionItems });
if (beatmapSet.Beatmaps.Any(b => b.Hidden))
items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested(beatmapSet)));
@ -247,7 +245,7 @@ namespace osu.Game.Screens.Select.Carousel
TernaryState state;
int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapHashes.Contains(b.MD5Hash));
int countExisting = beatmapSet.Beatmaps.Count(b => collection.BeatmapMD5Hashes.Contains(b.MD5Hash));
if (countExisting == beatmapSet.Beatmaps.Count)
state = TernaryState.True;
@ -256,24 +254,29 @@ namespace osu.Game.Screens.Select.Carousel
else
state = TernaryState.False;
return new TernaryStateToggleMenuItem(collection.Name.Value, MenuItemType.Standard, s =>
var liveCollection = collection.ToLive(realm);
return new TernaryStateToggleMenuItem(collection.Name, MenuItemType.Standard, s =>
{
foreach (var b in beatmapSet.Beatmaps)
liveCollection.PerformWrite(c =>
{
switch (s)
foreach (var b in beatmapSet.Beatmaps)
{
case TernaryState.True:
if (collection.BeatmapHashes.Contains(b.MD5Hash))
continue;
switch (s)
{
case TernaryState.True:
if (c.BeatmapMD5Hashes.Contains(b.MD5Hash))
continue;
collection.BeatmapHashes.Add(b.MD5Hash);
break;
c.BeatmapMD5Hashes.Add(b.MD5Hash);
break;
case TernaryState.False:
collection.BeatmapHashes.Remove(b.MD5Hash);
break;
case TernaryState.False:
c.BeatmapMD5Hashes.Remove(b.MD5Hash);
break;
}
}
}
});
})
{
State = { Value = state }

View File

@ -39,6 +39,10 @@ namespace osu.Game.Screens.Select
private Bindable<GroupMode> groupMode;
private SeekLimitedSearchTextBox searchTextBox;
private CollectionDropdown collectionDropdown;
public FilterCriteria CreateCriteria()
{
string query = searchTextBox.Text;
@ -49,7 +53,7 @@ namespace osu.Game.Screens.Select
Sort = sortMode.Value,
AllowConvertedBeatmaps = showConverted.Value,
Ruleset = ruleset.Value,
Collection = collectionDropdown?.Current.Value?.Collection
CollectionBeatmapMD5Hashes = collectionDropdown.Current.Value?.Collection?.PerformRead(c => c.BeatmapMD5Hashes)
};
if (!minimumStars.IsDefault)
@ -64,10 +68,6 @@ namespace osu.Game.Screens.Select
return criteria;
}
private SeekLimitedSearchTextBox searchTextBox;
private CollectionFilterDropdown collectionDropdown;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
base.ReceivePositionalInputAt(screenSpacePos) || sortTabs.ReceivePositionalInputAt(screenSpacePos);
@ -179,10 +179,11 @@ namespace osu.Game.Screens.Select
RelativeSizeAxes = Axes.Both,
Width = 0.48f,
},
collectionDropdown = new CollectionFilterDropdown
collectionDropdown = new CollectionDropdown
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RequestFilter = updateCriteria,
RelativeSizeAxes = Axes.X,
Y = 4,
Width = 0.5f,
@ -209,15 +210,6 @@ namespace osu.Game.Screens.Select
groupMode.BindValueChanged(_ => updateCriteria());
sortMode.BindValueChanged(_ => updateCriteria());
collectionDropdown.Current.ValueChanged += val =>
{
if (val.NewValue == null)
// may be null briefly while menu is repopulated.
return;
updateCriteria();
};
searchTextBox.Current.ValueChanged += _ => updateCriteria();
updateCriteria();

View File

@ -68,10 +68,10 @@ namespace osu.Game.Screens.Select
}
/// <summary>
/// The collection to filter beatmaps from.
/// Hashes from the <see cref="BeatmapCollection"/> to filter to.
/// </summary>
[CanBeNull]
public BeatmapCollection Collection;
public IEnumerable<string> CollectionBeatmapMD5Hashes { get; set; }
[CanBeNull]
public IRulesetFilterCriteria RulesetCriteria { get; set; }