更换为最新版批量收藏功能

This commit is contained in:
LA
2026-03-03 09:50:06 +08:00
parent 99b36342d1
commit e16460d947
5 changed files with 348 additions and 222 deletions

View File

@@ -0,0 +1,138 @@
// 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.Linq;
using System.Threading.Tasks;
using osu.Framework.Graphics.Sprites;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.LAsEzExtensions.Localization;
using osu.Game.Overlays.Dialog;
namespace osu.Game.Collections
{
internal static partial class BatchAddToCollectionHandler
{
public static void RequestSaveToCollection(
Live<BeatmapCollection> collection,
Func<IEnumerable<BeatmapInfo>>? filteredBeatmapsProvider,
Action<PopupDialog> showDialog)
{
if (filteredBeatmapsProvider == null)
return;
var hashes = filteredBeatmapsProvider().Select(b => b.MD5Hash)
.Where(h => !string.IsNullOrEmpty(h))
.Distinct()
.ToList();
if (hashes.Count == 0)
return;
var existing = collection.PerformRead(c => c.BeatmapMD5Hashes.ToList());
var intersection = existing.Intersect(hashes).ToList();
int overlapCount = intersection.Count;
if (overlapCount == hashes.Count)
{
showDialog(new RemoveFilteredResultsDialog(
onRemove: () => runHashRemoval(collection, intersection)));
return;
}
if (overlapCount > 0)
{
var toAdd = hashes.Except(existing).ToList();
var toRemove = intersection;
showDialog(new PartialOverlapFilteredResultsDialog(
overlapCount,
onAddDifference: () => runHashAddition(collection, toAdd),
onRemoveIntersection: () => runHashRemoval(collection, toRemove)));
return;
}
string collectionName = collection.PerformRead(c => c.Name);
showDialog(new AddFilteredResultsDialog(
collectionName,
hashes.Count,
onAddAll: () => runHashAddition(collection, hashes)));
}
private static void runHashAddition(Live<BeatmapCollection> collection, IReadOnlyList<string> hashes)
{
if (hashes.Count == 0)
return;
Task.Run(() => collection.PerformWrite(c =>
{
foreach (string hash in hashes)
{
if (!c.BeatmapMD5Hashes.Contains(hash))
c.BeatmapMD5Hashes.Add(hash);
}
}));
}
private static void runHashRemoval(Live<BeatmapCollection> collection, IReadOnlyList<string> hashes)
{
Task.Run(() => collection.PerformWrite(c =>
{
foreach (string hash in hashes)
c.BeatmapMD5Hashes.Remove(hash);
}));
}
private partial class AddFilteredResultsDialog : DangerousActionDialog
{
public AddFilteredResultsDialog(string collectionName, int beatmapCount, Action onAddAll)
{
Icon = FontAwesome.Solid.Check;
HeaderText = EzSongSelectStrings.SAVE_TO_COLLECTION;
BodyText = $"Add {beatmapCount:#,0} beatmaps to \"{collectionName}\"?";
DangerousAction = onAddAll;
}
}
private partial class RemoveFilteredResultsDialog : DangerousActionDialog
{
public RemoveFilteredResultsDialog(Action onRemove)
{
Icon = FontAwesome.Solid.Trash;
HeaderText = EzSongSelectStrings.REMOVE_FROM_COLLECTION;
BodyText = EzSongSelectStrings.REMOVE_FROM_COLLECTION_TOOLTIP;
DangerousAction = onRemove;
}
}
private partial class PartialOverlapFilteredResultsDialog : DangerousActionDialog
{
public PartialOverlapFilteredResultsDialog(int overlapCount, Action onAddDifference, Action onRemoveIntersection)
{
Icon = FontAwesome.Solid.Question;
HeaderText = EzSongSelectStrings.PARTIALLY_OVERLAPPED;
BodyText = $"{overlapCount} {EzSongSelectStrings.SELECT_ACTION_FOR_OVERLAP}";
Buttons = new PopupDialogButton[]
{
new PopupDialogDangerousButton
{
Text = EzSongSelectStrings.ADD_DIFFERENCE,
Action = onAddDifference,
},
new PopupDialogDangerousButton
{
Text = EzSongSelectStrings.REMOVE_FROM_COLLECTION,
Action = onRemoveIntersection,
},
new PopupDialogCancelButton
{
Text = "Cancel"
}
};
}
}
}
}

View File

@@ -92,10 +92,19 @@ namespace osu.Game.Collections
IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v)
}
: Empty(),
collection.IsManaged
? new BatchAddToCollectionButton(collection)
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
X = -button_width,
IsTextBoxHovered = v => TextBox.ReceivePositionalInputAt(v),
}
: Empty(),
new Container
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = collection.IsManaged ? button_width : 0 },
Padding = new MarginPadding { Right = collection.IsManaged ? button_width * 2 : 0 },
Children = new Drawable[]
{
TextBox = new ItemTextBox(collection)
@@ -201,7 +210,7 @@ namespace osu.Game.Collections
this.collection = collection;
RelativeSizeAxes = Axes.Y;
Width = button_width + item_height / 2; // add corner radius to cover with fill
Width = button_width + item_height / 4; // add corner radius to cover with fill
}
[BackgroundDependencyLoader]
@@ -238,7 +247,14 @@ namespace osu.Game.Collections
};
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos);
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{
if (!base.ReceivePositionalInputAt(screenSpacePos) || IsTextBoxHovered(screenSpacePos))
return false;
float localX = ToLocalSpace(screenSpacePos).X;
return localX > item_height / 4;
}
protected override bool OnHover(HoverEvent e)
{
@@ -261,6 +277,77 @@ namespace osu.Game.Collections
private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c));
}
public partial class BatchAddToCollectionButton : OsuClickableContainer
{
public Func<Vector2, bool> IsTextBoxHovered = null!;
private readonly Live<BeatmapCollection> collection;
private Color4 darkenedColour;
private Color4 normalColour;
private Drawable background = null!;
public BatchAddToCollectionButton(Live<BeatmapCollection> collection)
{
this.collection = collection;
RelativeSizeAxes = Axes.Y;
Width = button_width + item_height / 4;
TooltipText = "Add all visible beatmaps to collection";
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
normalColour = colours.Yellow;
darkenedColour = normalColour.Darken(0.9f);
Child = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = 10,
Children = new[]
{
background = new Box
{
RelativeSizeAxes = Axes.Both,
Colour = darkenedColour,
},
new SpriteIcon
{
Anchor = Anchor.CentreRight,
Origin = Anchor.Centre,
X = -button_width * 0.6f,
Size = new Vector2(10),
Icon = FontAwesome.Solid.Plus,
}
}
};
Action = () => this.FindClosestParent<ManageCollectionsDialog>()?.RequestBatchAddToCollection(collection);
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos);
protected override bool OnHover(HoverEvent e)
{
background.FadeColour(normalColour, 100, Easing.Out);
return false;
}
protected override void OnHoverLost(HoverLostEvent e)
{
background.FadeColour(darkenedColour, 100, Easing.Out);
}
protected override bool OnClick(ClickEvent e)
{
background.FlashColour(Color4.White, 150);
return base.OnClick(e);
}
}
public IEnumerable<LocalisableString> FilterTerms => Model.PerformRead(m => m.IsValid ? new[] { (LocalisableString)m.Name } : []);
private bool matchingFilter = true;

View File

@@ -3,30 +3,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Database;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.LAsEzExtensions.Localization;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Dialog;
using osu.Game.Resources.Localisation.Web;
using osuTK;
using Realms;
using CommonStrings = osu.Game.Resources.Localisation.Web.CommonStrings;
namespace osu.Game.Collections
{
@@ -42,27 +33,8 @@ namespace osu.Game.Collections
private BasicSearchTextBox searchTextBox = null!;
private DrawableCollectionList list = null!;
private SaveToCollectionDropdown saveToCollectionDropdown = null!;
private RoundedButton saveFilteredResultsButton = null!;
private readonly BindableList<CollectionFilterMenuItem> saveToCollectionItems = new BindableList<CollectionFilterMenuItem>();
private IDisposable? saveCollectionsSubscription;
private Func<IEnumerable<BeatmapInfo>>? filteredBeatmapsProvider;
public Func<IEnumerable<BeatmapInfo>>? FilteredBeatmapsProvider
{
get => filteredBeatmapsProvider;
set
{
filteredBeatmapsProvider = value;
updateSaveButtonState();
}
}
[Resolved]
private RealmAccess realm { get; set; } = null!;
public Func<IEnumerable<BeatmapInfo>>? FilteredBeatmapsProvider { get; set; }
[Resolved]
private MusicController? musicController { get; set; }
@@ -154,13 +126,12 @@ namespace osu.Game.Collections
{
list = new DrawableCollectionList
{
Padding = new MarginPadding { Top = 90, Bottom = 50 },
Padding = new MarginPadding { Vertical = 50 },
RelativeSizeAxes = Axes.Both,
},
searchTextBox = new BasicSearchTextBox
{
RelativeSizeAxes = Axes.X,
Y = 45,
Height = 40,
ReleaseFocusOnCommit = false,
HoldFocus = true,
@@ -177,45 +148,6 @@ namespace osu.Game.Collections
new NewCollectionEntryItem()
}
},
new GridContainer
{
RelativeSizeAxes = Axes.X,
Height = 40,
ColumnDimensions = new[]
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(GridSizeMode.Absolute, 10),
new Dimension(),
new Dimension(GridSizeMode.Absolute, 10),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
new[]
{
new OsuSpriteText
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Text = EzSongSelectStrings.SAVE_TO_COLLECTION,
Font = OsuFont.GetFont(size: 16),
},
Empty(),
saveToCollectionDropdown = new SaveToCollectionDropdown
{
RelativeSizeAxes = Axes.X,
},
Empty(),
saveFilteredResultsButton = new RoundedButton
{
Width = 90,
Height = 40,
Text = "Save",
Action = saveFilteredResults,
},
}
}
}
}
},
}
@@ -235,18 +167,12 @@ namespace osu.Game.Collections
{
list.SearchTerm = searchTextBox.Current.Value;
});
saveToCollectionDropdown.ItemSource = saveToCollectionItems;
saveToCollectionDropdown.Current.BindValueChanged(_ => updateSaveButtonState(), true);
saveCollectionsSubscription = realm.RegisterForNotifications(r => r.All<BeatmapCollection>().OrderBy(c => c.Name), saveCollectionsChanged);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
duckOperation?.Dispose();
saveCollectionsSubscription?.Dispose();
}
protected override void PopIn()
@@ -300,145 +226,12 @@ namespace osu.Game.Collections
}
}
private void saveCollectionsChanged(IRealmCollection<BeatmapCollection> collections, ChangeSet? changes)
internal void RequestBatchAddToCollection(Live<BeatmapCollection> collection)
{
var selectedId = saveToCollectionDropdown.Current.Value?.Collection?.ID;
saveToCollectionItems.Clear();
saveToCollectionItems.AddRange(collections.Select(c => new CollectionFilterMenuItem(c.ToLive(realm))));
if (saveToCollectionItems.Count > 0)
{
var selectedItem = saveToCollectionItems.FirstOrDefault(i => i.Collection?.ID == selectedId) ?? saveToCollectionItems[0];
saveToCollectionDropdown.Current.Value = selectedItem;
}
updateSaveButtonState();
}
private void updateSaveButtonState()
{
var currentId = saveToCollectionDropdown.Current.Value?.Collection?.ID;
bool hasTarget = currentId != null && saveToCollectionItems.Any(item => item.Collection?.ID == currentId);
bool hasProvider = FilteredBeatmapsProvider != null;
saveFilteredResultsButton.Enabled.Value = hasTarget && hasProvider;
}
private void saveFilteredResults()
{
var provider = FilteredBeatmapsProvider;
var collection = saveToCollectionDropdown.Current.Value?.Collection;
if (provider == null || collection == null)
return;
var hashes = provider().Select(b => b.MD5Hash)
.Where(h => !string.IsNullOrEmpty(h))
.Distinct()
.ToList();
if (hashes.Count == 0)
return;
// Read existing entries from the collection to determine overlap.
var existing = collection.PerformRead(c => c.BeatmapMD5Hashes.ToList());
var intersection = existing.Intersect(hashes).ToList();
// Case 1: collection already contains all filtered results -> offer bulk remove.
if (intersection.Count == hashes.Count)
{
Schedule(() => dialogOverlay?.Push(new SimplePopupDialog(
FontAwesome.Solid.Trash,
EzSongSelectStrings.REMOVE_FROM_COLLECTION,
EzSongSelectStrings.REMOVE_FROM_COLLECTION_TOOLTIP,
new PopupDialogButton[]
{
new PopupDialogCancelButton { Text = CommonStrings.ButtonsCancel },
new PopupDialogDangerousButton
{
Text = AccountsStrings.UserTotpButtonRemove,
Action = () => Task.Run(() => collection.PerformWrite(c =>
{
foreach (string h in intersection)
c.BeatmapMD5Hashes.Remove(h);
}))
}
})));
return;
}
// Case 2: partial overlap -> let user choose to add remaining or remove overlapping parts.
if (intersection.Count > 0)
{
var toAdd = hashes.Except(existing).ToList();
var toRemove = intersection;
Schedule(() => dialogOverlay?.Push(new SimplePopupDialog(
FontAwesome.Solid.Question,
EzSongSelectStrings.PARTIALLY_OVERLAPPED,
$"{intersection.Count}{EzSongSelectStrings.SELECT_ACTION_FOR_OVERLAP}",
new PopupDialogButton[]
{
new PopupDialogCancelButton { Text = CommonStrings.ButtonsCancel },
new PopupDialogOkButton
{
Text = EzSongSelectStrings.ADD_DIFFERENCE,
Action = () =>
{
if (toAdd.Count == 0) return;
Task.Run(() => collection.PerformWrite(c =>
{
foreach (string h in toAdd)
{
if (!c.BeatmapMD5Hashes.Contains(h))
c.BeatmapMD5Hashes.Add(h);
}
}));
}
},
new PopupDialogDangerousButton
{
Text = EzSongSelectStrings.REMOVE_INTERSECTION,
Action = () => Task.Run(() => collection.PerformWrite(c =>
{
foreach (string h in toRemove)
c.BeatmapMD5Hashes.Remove(h);
}))
}
})));
return;
}
Task.Run(() => collection.PerformWrite(c =>
{
foreach (string hash in hashes)
{
if (!c.BeatmapMD5Hashes.Contains(hash))
c.BeatmapMD5Hashes.Add(hash);
}
}));
}
private partial class SaveToCollectionDropdown : OsuDropdown<CollectionFilterMenuItem>
{
protected override LocalisableString GenerateItemText(CollectionFilterMenuItem item) => item.CollectionName;
}
/// <summary>
/// A minimal concrete <see cref="PopupDialog"/> used for inline confirmation prompts in this dialog.
/// </summary>
private partial class SimplePopupDialog : PopupDialog
{
public SimplePopupDialog(IconUsage icon, LocalisableString header, LocalisableString body, PopupDialogButton[] buttons)
{
Icon = icon;
HeaderText = header;
BodyText = body;
Buttons = buttons;
}
BatchAddToCollectionHandler.RequestSaveToCollection(
collection,
FilteredBeatmapsProvider,
dialog => Schedule(() => dialogOverlay?.Push(dialog)));
}
}
}

View File

@@ -8,8 +8,8 @@ namespace osu.Game.LAsEzExtensions.Localization
public static class EzSongSelectStrings
{
public static readonly EzLocalizationManager.EzLocalisableString SAVE_TO_COLLECTION = new EzLocalizationManager.EzLocalisableString(
"将当前过滤结果保存到",
"Save current filter result to");
"将当前过滤结果保存到收藏夹",
"Add all visible beatmaps to collection");
public static readonly EzLocalizationManager.EzLocalisableString REMOVE_FROM_COLLECTION = new EzLocalizationManager.EzLocalisableString(
"从收藏夹移除当前过滤结果",
@@ -21,7 +21,7 @@ namespace osu.Game.LAsEzExtensions.Localization
public static readonly EzLocalizationManager.EzLocalisableString PARTIALLY_OVERLAPPED = new EzLocalizationManager.EzLocalisableString(
"筛选结果与收藏夹部分重",
"The filter result is partially overlapped with the collection");
"The visible beatmaps is partially overlapped with the collection");
public static readonly EzLocalizationManager.EzLocalisableString SELECT_ACTION_FOR_OVERLAP = new EzLocalizationManager.EzLocalisableString(
" 个谱面已存在。请选择要执行的操作:",

View File

@@ -7,8 +7,11 @@ using System.Collections.Immutable;
using System.Linq;
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.Input;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
@@ -16,6 +19,7 @@ using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
@@ -31,6 +35,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Screens.SelectV2
@@ -66,6 +71,9 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private ISongSelect? songSelect { get; set; }
[Resolved(CanBeNull = true)]
private ManageCollectionsDialog? manageCollectionsDialog { get; set; }
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
@@ -316,7 +324,19 @@ namespace osu.Game.Screens.SelectV2
updateCriteria();
});
searchTextBox.Current.BindValueChanged(_ => updateCriteria());
searchTextBox.Current.BindValueChanged(_ =>
{
updateCriteria();
updateVisibleResultsActionAvailability();
});
searchTextBox.VisibleResultsAction = () =>
{
if (manageCollectionsDialog == null)
return;
manageCollectionsDialog.Show();
};
difficultyRangeSlider.LowerBound.BindValueChanged(_ => updateCriteria());
difficultyRangeSlider.UpperBound.BindValueChanged(_ => updateCriteria());
showConvertedBeatmapsButton.Active.BindValueChanged(_ => updateCriteria());
@@ -344,9 +364,16 @@ namespace osu.Game.Screens.SelectV2
csSelector.Current.BindValueChanged(_ => updateCriteria());
xxySrFilterButton.Active.BindValueChanged(_ => updateCriteria());
updateVisibleResultsActionAvailability();
updateCriteria();
}
private void updateVisibleResultsActionAvailability()
{
bool available = !string.IsNullOrWhiteSpace(searchTextBox.Current.Value);
searchTextBox.UpdateVisibleResultsActionAvailability(available);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
@@ -460,6 +487,29 @@ namespace osu.Game.Screens.SelectV2
{
public IBindable<BeatmapSetInfo?> ScopedBeatmapSet { get; } = new Bindable<BeatmapSetInfo?>();
public Action? VisibleResultsAction { get; set; }
private readonly BatchAddToCollectionButton batchAddToCollectionButton;
public SongSelectSearchTextBox()
{
AddInternal(batchAddToCollectionButton = new BatchAddToCollectionButton
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
X = -50,
Alpha = 0,
TooltipText = "Add all visible beatmaps to collection",
Action = () => VisibleResultsAction?.Invoke(),
});
}
public void UpdateVisibleResultsActionAvailability(bool available)
{
batchAddToCollectionButton.Enabled.Value = available;
batchAddToCollectionButton.FadeTo(available ? 1 : 0, 200, Easing.OutQuint);
}
protected override InnerSearchTextBox CreateInnerTextBox() => new InnerTextBox
{
ScopedBeatmapSet = { BindTarget = ScopedBeatmapSet },
@@ -492,6 +542,64 @@ namespace osu.Game.Screens.SelectV2
return base.OnPressed(e);
}
}
private partial class BatchAddToCollectionButton : OsuClickableContainer
{
private readonly Box background;
private Color4 normalColour;
private Color4 hoverColour;
public BatchAddToCollectionButton()
{
RelativeSizeAxes = Axes.Y;
Width = 50;
CornerRadius = 7;
Masking = true;
Child = background = new Box
{
RelativeSizeAxes = Axes.Both
};
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
normalColour = colours.Yellow.Darken(0.45f);
hoverColour = colours.Yellow;
background.Colour = normalColour;
AddInternal(new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Icon = FontAwesome.Solid.Plus,
Size = new Vector2(10),
Shear = -OsuGame.SHEAR,
}
}
});
}
protected override bool OnHover(HoverEvent e)
{
background.FadeColour(hoverColour, 100, Easing.Out);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
background.FadeColour(normalColour, 100, Easing.Out);
base.OnHoverLost(e);
}
}
}
}
}