1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-18 07:22:57 +08:00
osu-lazer/osu.Game/Screens/Select/BeatmapCarousel.cs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1324 lines
52 KiB
C#
Raw Normal View History

// 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.
2018-04-13 17:19:50 +08:00
using System;
using System.Collections.Generic;
using System.Diagnostics;
2020-10-13 17:18:22 +08:00
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
2019-02-21 18:04:31 +08:00
using osu.Framework.Bindables;
2017-12-12 16:48:38 +08:00
using osu.Framework.Caching;
2020-10-13 17:18:22 +08:00
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
2020-10-12 14:36:03 +08:00
using osu.Framework.Graphics.Pooling;
using osu.Framework.Input.Bindings;
2018-10-02 11:02:47 +08:00
using osu.Framework.Input.Events;
using osu.Framework.Layout;
2020-10-13 17:18:22 +08:00
using osu.Framework.Threading;
using osu.Framework.Utils;
2017-07-26 12:22:46 +08:00
using osu.Game.Beatmaps;
2020-10-13 17:18:22 +08:00
using osu.Game.Configuration;
2021-11-08 16:41:42 +08:00
using osu.Game.Database;
using osu.Game.Graphics.Containers;
2017-09-14 14:41:32 +08:00
using osu.Game.Graphics.Cursor;
using osu.Game.Input.Bindings;
2017-12-12 16:48:38 +08:00
using osu.Game.Screens.Select.Carousel;
2020-10-13 17:18:22 +08:00
using osuTK;
using osuTK.Input;
2021-11-08 16:41:42 +08:00
using Realms;
2018-04-13 17:19:50 +08:00
namespace osu.Game.Screens.Select
{
public partial class BeatmapCarousel : CompositeDrawable, IKeyBindingHandler<GlobalAction>
{
2020-04-21 03:42:43 +08:00
/// <summary>
/// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
/// </summary>
2020-04-21 03:43:07 +08:00
public float BleedTop { get; set; }
2020-04-21 03:42:43 +08:00
/// <summary>
/// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
/// </summary>
2020-04-21 03:43:07 +08:00
public float BleedBottom { get; set; }
2017-12-13 18:56:16 +08:00
/// <summary>
/// Triggered when <see cref="BeatmapSets"/> finish loading, or are subsequently changed.
2017-12-13 18:56:16 +08:00
/// </summary>
2022-09-07 13:04:51 +08:00
public Action? BeatmapSetsChanged;
2018-04-13 17:19:50 +08:00
/// <summary>
/// Triggered after filter conditions have finished being applied to the model hierarchy.
/// </summary>
public Action? FilterApplied;
2017-12-13 18:56:16 +08:00
/// <summary>
/// The currently selected beatmap.
/// </summary>
2022-09-07 13:04:51 +08:00
public BeatmapInfo? SelectedBeatmapInfo => selectedBeatmap?.BeatmapInfo;
2018-04-13 17:19:50 +08:00
2022-09-07 13:04:51 +08:00
private CarouselBeatmap? selectedBeatmap => selectedBeatmapSet?.Beatmaps.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected);
2018-04-13 17:19:50 +08:00
/// <summary>
/// The total count of non-filtered beatmaps displayed.
/// </summary>
public int CountDisplayed => beatmapSets.Where(s => !s.Filtered.Value).Sum(s => s.TotalItemsNotFiltered);
/// <summary>
/// The currently selected beatmap set.
/// </summary>
2022-09-07 13:04:51 +08:00
public BeatmapSetInfo? SelectedBeatmapSet => selectedBeatmapSet?.BeatmapSet;
2018-04-13 17:19:50 +08:00
/// <summary>
/// A function to optionally decide on a recommended difficulty from a beatmap set.
/// </summary>
2023-01-09 02:02:48 +08:00
public Func<IEnumerable<BeatmapInfo>, BeatmapInfo?>? GetRecommendedBeatmap;
2022-09-07 13:04:51 +08:00
private CarouselBeatmapSet? selectedBeatmapSet;
2018-04-13 17:19:50 +08:00
private List<BeatmapSetInfo> originalBeatmapSetsDetached = new List<BeatmapSetInfo>();
2017-12-13 18:56:16 +08:00
/// <summary>
/// Raised when the <see cref="SelectedBeatmapInfo"/> is changed.
2017-12-13 18:56:16 +08:00
/// </summary>
2022-09-07 13:04:51 +08:00
public Action<BeatmapInfo?>? SelectionChanged;
2018-04-13 17:19:50 +08:00
public override bool HandleNonPositionalInput => AllowSelection;
public override bool HandlePositionalInput => AllowSelection;
2018-04-13 17:19:50 +08:00
2020-01-27 13:55:47 +08:00
public override bool PropagatePositionalInputSubTree => AllowSelection;
public override bool PropagateNonPositionalInputSubTree => AllowSelection;
2020-10-13 18:10:35 +08:00
private (int first, int last) displayedRange;
/// <summary>
/// Extend the range to retain already loaded pooled drawables.
/// </summary>
private const float distance_offscreen_before_unload = 2048;
2020-10-13 18:10:35 +08:00
/// <summary>
/// Extend the range to update positions / retrieve pooled drawables outside of visible range.
/// </summary>
private const float distance_offscreen_to_preload = 768;
2020-10-13 18:10:35 +08:00
/// <summary>
2019-03-21 19:51:06 +08:00
/// Whether carousel items have completed asynchronously loaded.
/// </summary>
2019-03-21 19:51:06 +08:00
public bool BeatmapSetsLoaded { get; private set; }
2018-04-13 17:19:50 +08:00
[Cached]
protected readonly CarouselScrollContainer Scroll;
private readonly NoResultsPlaceholder noResultsPlaceholder;
private IEnumerable<CarouselBeatmapSet> beatmapSets => root.Items.OfType<CarouselBeatmapSet>();
2018-04-13 17:19:50 +08:00
// todo: only used for testing, maybe remove.
2022-01-10 13:52:59 +08:00
private bool loadedTestBeatmaps;
public IEnumerable<BeatmapSetInfo> BeatmapSets
{
get => beatmapSets.Select(g => g.BeatmapSet);
2022-01-10 13:52:59 +08:00
set
{
loadedTestBeatmaps = true;
Schedule(() => loadBeatmapSets(value));
2022-01-10 13:52:59 +08:00
}
}
private void loadBeatmapSets(IEnumerable<BeatmapSetInfo> beatmapSets)
{
originalBeatmapSetsDetached = beatmapSets.Detach();
if (selectedBeatmapSet != null && !originalBeatmapSetsDetached.Contains(selectedBeatmapSet.BeatmapSet))
selectedBeatmapSet = null;
2023-08-24 23:52:54 +08:00
var selectedBeatmapBefore = selectedBeatmap?.BeatmapInfo;
CarouselRoot newRoot = new CarouselRoot(this);
if (beatmapsSplitOut)
{
var carouselBeatmapSets = originalBeatmapSetsDetached.SelectMany(s => s.Beatmaps).Select(b =>
{
return createCarouselSet(new BeatmapSetInfo(new[] { b })
{
ID = b.BeatmapSet!.ID,
OnlineID = b.BeatmapSet!.OnlineID,
Status = b.BeatmapSet!.Status,
});
}).OfType<CarouselBeatmapSet>();
newRoot.AddItems(carouselBeatmapSets);
}
else
{
var carouselBeatmapSets = originalBeatmapSetsDetached.Select(createCarouselSet).OfType<CarouselBeatmapSet>();
newRoot.AddItems(carouselBeatmapSets);
}
2018-04-13 17:19:50 +08:00
root = newRoot;
Scroll.Clear(false);
itemsCache.Invalidate();
ScrollToSelected();
applyActiveCriteria(false);
if (loadedTestBeatmaps)
2023-12-18 21:24:57 +08:00
{
invalidateAfterChange();
2023-12-18 21:24:57 +08:00
BeatmapSetsLoaded = true;
}
// Restore selection
2023-08-24 23:52:54 +08:00
if (selectedBeatmapBefore != null && newRoot.BeatmapSetsByID.TryGetValue(selectedBeatmapBefore.BeatmapSet!.ID, out var newSelectionCandidates))
{
2023-08-24 23:52:54 +08:00
CarouselBeatmap? found = newSelectionCandidates.SelectMany(s => s.Beatmaps).SingleOrDefault(b => b.BeatmapInfo.ID == selectedBeatmapBefore.ID);
if (found != null)
found.State.Value = CarouselItemState.Selected;
}
}
2018-04-13 17:19:50 +08:00
private readonly List<CarouselItem> visibleItems = new List<CarouselItem>();
private readonly Cached itemsCache = new Cached();
private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None;
2018-04-13 17:19:50 +08:00
public Bindable<bool> RightClickScrollingEnabled = new Bindable<bool>();
public Bindable<RandomSelectAlgorithm> RandomAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly List<CarouselBeatmapSet> previouslyVisitedRandomSets = new List<CarouselBeatmapSet>();
private readonly List<CarouselBeatmap> randomSelectedBeatmaps = new List<CarouselBeatmap>();
2018-04-13 17:19:50 +08:00
private CarouselRoot root;
2020-04-11 15:41:11 +08:00
2022-09-07 13:04:51 +08:00
private IDisposable? subscriptionSets;
private IDisposable? subscriptionBeatmaps;
2020-05-19 15:44:22 +08:00
2020-10-12 14:36:03 +08:00
private readonly DrawablePool<DrawableCarouselBeatmapSet> setPool = new DrawablePool<DrawableCarouselBeatmapSet>(100);
2022-09-07 13:04:51 +08:00
private Sample? spinSample;
private Sample? randomSelectSample;
private int visibleSetsCount;
2017-03-17 18:12:54 +08:00
public BeatmapCarousel()
2017-01-18 08:18:15 +08:00
{
root = new CarouselRoot(this);
InternalChild = new OsuContextMenuContainer
2017-01-18 08:18:15 +08:00
{
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding
{
// Avoid clash between scrollbar and osu! logo.
Top = 10,
Bottom = 100,
},
Children = new Drawable[]
2017-09-14 14:41:32 +08:00
{
setPool,
Scroll = new CarouselScrollContainer
{
RelativeSizeAxes = Axes.Both,
},
noResultsPlaceholder = new NoResultsPlaceholder()
2017-09-14 14:41:32 +08:00
}
2017-12-14 12:49:43 +08:00
};
2017-01-18 08:18:15 +08:00
}
2018-04-13 17:19:50 +08:00
[BackgroundDependencyLoader]
private void load(OsuConfigManager config, AudioManager audio)
{
spinSample = audio.Samples.Get("SongSelect/random-spin");
randomSelectSample = audio.Samples.Get(@"SongSelect/select-random");
config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm);
2018-04-18 18:26:54 +08:00
config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled);
RightClickScrollingEnabled.ValueChanged += enabled => Scroll.RightMouseScrollbar = enabled.NewValue;
RightClickScrollingEnabled.TriggerChange();
if (!loadedTestBeatmaps)
{
2024-07-08 18:29:03 +08:00
// This is performing an unnecessary second lookup on realm (in addition to the subscription), but for performance reasons
// we require it to be separate: the subscription's initial callback (with `ChangeSet` of `null`) will run on the update
// thread. If we attempt to detach beatmaps in this callback the game will fall over (it takes time).
realm.Run(r => loadBeatmapSets(getBeatmapSets(r)));
}
2021-11-08 16:41:42 +08:00
}
[Resolved]
2022-09-07 13:04:51 +08:00
private RealmAccess realm { get; set; } = null!;
/// <summary>
/// Track GUIDs of all sets in realm to allow handling deletions.
/// </summary>
private readonly List<Guid> realmBeatmapSets = new List<Guid>();
2021-11-08 16:41:42 +08:00
protected override void LoadComplete()
{
base.LoadComplete();
subscriptionSets = realm.RegisterForNotifications(getBeatmapSets, beatmapSetsChanged);
subscriptionBeatmaps = realm.RegisterForNotifications(r => r.All<BeatmapInfo>().Where(b => !b.Hidden), beatmapsChanged);
}
2018-04-13 17:19:50 +08:00
private readonly HashSet<Guid> setsRequiringUpdate = new HashSet<Guid>();
2024-07-08 18:29:03 +08:00
private readonly HashSet<Guid> setsRequiringRemoval = new HashSet<Guid>();
2023-07-06 12:37:42 +08:00
private void beatmapSetsChanged(IRealmCollection<BeatmapSetInfo> sender, ChangeSet? changes)
{
2022-01-10 13:52:59 +08:00
// If loading test beatmaps, avoid overwriting with realm subscription callbacks.
if (loadedTestBeatmaps)
return;
2018-04-13 17:19:50 +08:00
2021-11-08 16:41:42 +08:00
if (changes == null)
{
realmBeatmapSets.Clear();
realmBeatmapSets.AddRange(sender.Select(r => r.ID));
if (originalBeatmapSetsDetached.Count > 0 && sender.Count == 0)
{
// Usually we'd reset stuff here, but doing so triggers a silly flow which ends up deadlocking realm.
// Additionally, user should not be at song select when realm is blocking all operations in the first place.
//
// Note that due to the catch-up logic below, once operations are restored we will still be in a roughly
// correct state. The only things that this return will change is the carousel will not empty *during* the blocking
// operation.
return;
}
// Do a full two-way check for missing (or incorrectly present) beatmaps.
// Let's assume that the worst that can happen is deletions or additions.
setsRequiringRemoval.Clear();
setsRequiringUpdate.Clear();
foreach (Guid id in realmBeatmapSets)
{
if (!root.BeatmapSetsByID.ContainsKey(id))
setsRequiringUpdate.Add(id);
}
foreach (Guid id in root.BeatmapSetsByID.Keys)
{
if (!realmBeatmapSets.Contains(id))
setsRequiringRemoval.Add(id);
}
2021-11-08 16:41:42 +08:00
}
else
{
foreach (int i in changes.DeletedIndices.OrderDescending())
{
Guid id = realmBeatmapSets[i];
setsRequiringRemoval.Add(id);
setsRequiringUpdate.Remove(id);
realmBeatmapSets.RemoveAt(i);
}
2018-04-13 17:19:50 +08:00
foreach (int i in changes.InsertedIndices)
{
Guid id = sender[i].ID;
setsRequiringRemoval.Remove(id);
setsRequiringUpdate.Add(id);
realmBeatmapSets.Insert(i, id);
}
foreach (int i in changes.NewModifiedIndices)
setsRequiringUpdate.Add(sender[i].ID);
}
2024-07-08 18:29:03 +08:00
Scheduler.AddOnce(processBeatmapChanges);
}
// All local operations must be scheduled.
//
// If we don't schedule, beatmaps getting changed while song select is suspended (ie. last played being updated)
// will cause unexpected sounds and operations to occur in the background.
private void processBeatmapChanges()
{
try
{
2024-07-08 18:29:03 +08:00
foreach (var set in setsRequiringRemoval) removeBeatmapSet(set);
foreach (var set in setsRequiringUpdate) updateBeatmapSet(fetchFromID(set)!);
2024-07-08 18:29:03 +08:00
if (setsRequiringRemoval.Count > 0 && SelectedBeatmapInfo != null)
{
// If SelectedBeatmapInfo is non-null, the set should also be non-null.
Debug.Assert(SelectedBeatmapSet != null);
2024-07-08 18:29:03 +08:00
// To handle the beatmap update flow, attempt to track selection changes across delete-insert transactions.
// When an update occurs, the previous beatmap set is either soft or hard deleted.
// Check if the current selection was potentially deleted by re-querying its validity.
bool selectedSetMarkedDeleted = fetchFromID(SelectedBeatmapSet.ID)?.DeletePending != false;
2024-07-08 18:29:03 +08:00
if (selectedSetMarkedDeleted && setsRequiringUpdate.Any())
{
// If it is no longer valid, make the bold assumption that an updated version will be available in the modified/inserted indices.
// This relies on the full update operation being in a single transaction, so please don't change that.
foreach (var set in setsRequiringUpdate)
{
foreach (var beatmapInfo in fetchFromID(set)!.Beatmaps)
{
2024-07-08 18:29:03 +08:00
if (!((IBeatmapMetadataInfo)beatmapInfo.Metadata).Equals(SelectedBeatmapInfo.Metadata)) continue;
// Best effort matching. We can't use ID because in the update flow a new version will get its own GUID.
if (beatmapInfo.DifficultyName == SelectedBeatmapInfo.DifficultyName)
{
2024-07-08 18:29:03 +08:00
SelectBeatmap(beatmapInfo);
return;
}
}
}
2024-07-08 18:29:03 +08:00
// If a direct selection couldn't be made, it's feasible that the difficulty name (or beatmap metadata) changed.
// Let's attempt to follow set-level selection anyway.
SelectBeatmap(fetchFromID(setsRequiringUpdate.First())!.Beatmaps.First());
}
}
2024-07-08 18:29:03 +08:00
}
finally
{
BeatmapSetsLoaded = true;
invalidateAfterChange();
}
setsRequiringRemoval.Clear();
setsRequiringUpdate.Clear();
BeatmapSetInfo? fetchFromID(Guid id) => realm.Realm.Find<BeatmapSetInfo>(id);
2021-11-08 16:41:42 +08:00
}
2023-07-06 12:37:42 +08:00
private void beatmapsChanged(IRealmCollection<BeatmapInfo> sender, ChangeSet? changes)
2021-11-08 16:41:42 +08:00
{
// we only care about actual changes in hidden status.
if (changes == null)
return;
bool changed = false;
2022-01-12 00:03:59 +08:00
foreach (int i in changes.InsertedIndices)
{
var beatmapInfo = sender[i];
var beatmapSet = beatmapInfo.BeatmapSet;
Debug.Assert(beatmapSet != null);
// Only require to action here if the beatmap is missing.
// This avoids processing these events unnecessarily when new beatmaps are imported, for example.
if (root.BeatmapSetsByID.TryGetValue(beatmapSet.ID, out var existingSets)
&& existingSets.SelectMany(s => s.Beatmaps).All(b => b.BeatmapInfo.ID != beatmapInfo.ID))
{
updateBeatmapSet(beatmapSet.Detach());
changed = true;
}
}
if (changed)
invalidateAfterChange();
2021-11-08 16:41:42 +08:00
}
private IQueryable<BeatmapSetInfo> getBeatmapSets(Realm realm) => realm.All<BeatmapSetInfo>().Where(s => !s.DeletePending && !s.Protected);
public void RemoveBeatmapSet(BeatmapSetInfo beatmapSet) => Schedule(() =>
{
removeBeatmapSet(beatmapSet.ID);
invalidateAfterChange();
});
2018-04-13 17:19:50 +08:00
private void removeBeatmapSet(Guid beatmapSetID)
{
if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSets))
return;
2018-04-13 17:19:50 +08:00
originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSetID);
foreach (var set in existingSets)
{
foreach (var beatmap in set.Beatmaps)
randomSelectedBeatmaps.Remove(beatmap);
previouslyVisitedRandomSets.Remove(set);
root.RemoveItem(set);
}
}
public void UpdateBeatmapSet(BeatmapSetInfo beatmapSet)
{
beatmapSet = beatmapSet.Detach();
Schedule(() =>
{
updateBeatmapSet(beatmapSet);
invalidateAfterChange();
});
}
2018-04-13 17:19:50 +08:00
private void updateBeatmapSet(BeatmapSetInfo beatmapSet)
{
beatmapSet = beatmapSet.Detach();
originalBeatmapSetsDetached.RemoveAll(set => set.ID == beatmapSet.ID);
originalBeatmapSetsDetached.Add(beatmapSet);
var newSets = new List<CarouselBeatmapSet>();
2018-04-13 17:19:50 +08:00
if (beatmapsSplitOut)
{
foreach (var beatmap in beatmapSet.Beatmaps)
{
var newSet = createCarouselSet(new BeatmapSetInfo(new[] { beatmap })
{
ID = beatmapSet.ID,
OnlineID = beatmapSet.OnlineID,
Status = beatmapSet.Status,
});
2018-04-13 17:19:50 +08:00
if (newSet != null)
newSets.Add(newSet);
}
}
else
{
var newSet = createCarouselSet(beatmapSet);
if (newSet != null)
newSets.Add(newSet);
}
var removedSets = root.ReplaceItem(beatmapSet, newSets);
// If we don't remove these here, it may remain in a hidden state until scrolled off screen.
// Doesn't really affect anything during actual user interaction, but makes testing annoying.
foreach (var removedSet in removedSets)
{
var removedDrawable = Scroll.FirstOrDefault(c => c.Item == removedSet);
if (removedDrawable != null)
expirePanelImmediately(removedDrawable);
}
}
2018-04-13 17:19:50 +08:00
/// <summary>
/// Selects a given beatmap on the carousel.
/// </summary>
2021-10-02 23:55:29 +08:00
/// <param name="beatmapInfo">The beatmap to select.</param>
/// <param name="bypassFilters">Whether to select the beatmap even if it is filtered (i.e., not visible on carousel).</param>
/// <returns>True if a selection was made, False if it wasn't.</returns>
2022-09-07 13:04:51 +08:00
public bool SelectBeatmap(BeatmapInfo? beatmapInfo, bool bypassFilters = true)
{
// ensure that any pending events from BeatmapManager have been run before attempting a selection.
Scheduler.Update();
2021-10-02 23:55:29 +08:00
if (beatmapInfo?.Hidden != false)
return false;
2018-04-13 17:19:50 +08:00
foreach (CarouselBeatmapSet set in beatmapSets)
{
2019-02-21 17:56:34 +08:00
if (!bypassFilters && set.Filtered.Value)
continue;
2018-04-13 17:19:50 +08:00
2021-10-02 23:55:29 +08:00
var item = set.Beatmaps.FirstOrDefault(p => p.BeatmapInfo.Equals(beatmapInfo));
2018-04-13 17:19:50 +08:00
if (item == null)
// The beatmap that needs to be selected doesn't exist in this set
continue;
2018-04-13 17:19:50 +08:00
2019-02-21 17:56:34 +08:00
if (!bypassFilters && item.Filtered.Value)
return false;
2018-04-13 17:19:50 +08:00
select(item);
// if we got here and the set is filtered, it means we were bypassing filters.
// in this case, reapplying the filter is necessary to ensure the panel is in the correct place
// (since it is forcefully being included in the carousel).
if (set.Filtered.Value)
{
Debug.Assert(bypassFilters);
applyActiveCriteria(false);
}
return true;
}
2018-04-13 17:19:50 +08:00
return false;
}
2018-04-13 17:19:50 +08:00
/// <summary>
/// Increment selection in the carousel in a chosen direction.
/// </summary>
/// <param name="direction">The direction to increment. Negative is backwards.</param>
/// <param name="skipDifficulties">Whether to skip individual difficulties and only increment over full groups.</param>
public void SelectNext(int direction = 1, bool skipDifficulties = true)
{
2020-03-29 02:21:21 +08:00
if (beatmapSets.All(s => s.Filtered.Value))
return;
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
if (skipDifficulties)
selectNextSet(direction, true);
else
selectNextDifficulty(direction);
}
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
private void selectNextSet(int direction, bool skipDifficulties)
{
if (selectedBeatmap == null || selectedBeatmapSet == null)
return;
2020-03-29 23:07:48 +08:00
var unfilteredSets = beatmapSets.Where(s => !s.Filtered.Value).ToList();
2018-04-13 17:19:50 +08:00
2020-03-29 23:07:48 +08:00
var nextSet = unfilteredSets[(unfilteredSets.IndexOf(selectedBeatmapSet) + direction + unfilteredSets.Count) % unfilteredSets.Count];
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
if (skipDifficulties)
2020-03-28 19:23:31 +08:00
select(nextSet);
2020-03-28 18:54:48 +08:00
else
2020-03-28 19:23:31 +08:00
select(direction > 0 ? nextSet.Beatmaps.First(b => !b.Filtered.Value) : nextSet.Beatmaps.Last(b => !b.Filtered.Value));
2020-03-28 18:54:48 +08:00
}
2018-04-13 17:19:50 +08:00
2020-03-28 18:54:48 +08:00
private void selectNextDifficulty(int direction)
{
if (selectedBeatmap == null || selectedBeatmapSet == null)
return;
var unfilteredDifficulties = selectedBeatmapSet.Items.Where(s => !s.Filtered.Value).ToList();
2018-04-13 17:19:50 +08:00
2020-03-29 23:07:48 +08:00
int index = unfilteredDifficulties.IndexOf(selectedBeatmap);
2018-04-13 17:19:50 +08:00
2020-03-29 23:07:48 +08:00
if (index + direction < 0 || index + direction >= unfilteredDifficulties.Count)
2020-03-28 18:54:48 +08:00
selectNextSet(direction, false);
else
2020-03-29 23:07:48 +08:00
select(unfilteredDifficulties[index + direction]);
}
2018-04-13 17:19:50 +08:00
/// <summary>
/// Select the next beatmap in the random sequence.
/// </summary>
/// <returns>True if a selection could be made, else False.</returns>
public bool SelectNextRandom()
{
if (!AllowSelection)
return false;
2019-02-21 17:56:34 +08:00
var visibleSets = beatmapSets.Where(s => !s.Filtered.Value).ToList();
visibleSetsCount = visibleSets.Count;
if (!visibleSets.Any())
return false;
2018-04-13 17:19:50 +08:00
if (selectedBeatmap != null && selectedBeatmapSet != null)
{
randomSelectedBeatmaps.Add(selectedBeatmap);
2018-04-13 17:19:50 +08:00
// when performing a random, we want to add the current set to the previously visited list
// else the user may be "randomised" to the existing selection.
if (previouslyVisitedRandomSets.LastOrDefault() != selectedBeatmapSet)
previouslyVisitedRandomSets.Add(selectedBeatmapSet);
}
2018-04-13 17:19:50 +08:00
CarouselBeatmapSet set;
2018-04-13 17:19:50 +08:00
2019-02-21 17:56:34 +08:00
if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
{
var notYetVisitedSets = visibleSets.Except(previouslyVisitedRandomSets).ToList();
2019-04-01 11:16:05 +08:00
if (!notYetVisitedSets.Any())
2017-06-01 02:31:05 +08:00
{
previouslyVisitedRandomSets.RemoveAll(s => visibleSets.Contains(s));
notYetVisitedSets = visibleSets;
}
2018-04-13 17:19:50 +08:00
set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count));
previouslyVisitedRandomSets.Add(set);
}
else
set = visibleSets.ElementAt(RNG.Next(visibleSets.Count));
2018-04-13 17:19:50 +08:00
if (selectedBeatmapSet != null)
playSpinSample(distanceBetween(set, selectedBeatmapSet));
select(set);
return true;
}
2018-04-13 17:19:50 +08:00
2017-06-05 17:24:28 +08:00
public void SelectPreviousRandom()
2017-06-02 01:54:42 +08:00
{
2017-06-05 17:24:28 +08:00
while (randomSelectedBeatmaps.Any())
2017-06-02 01:54:42 +08:00
{
var beatmap = randomSelectedBeatmaps[^1];
2023-12-26 07:09:39 +08:00
randomSelectedBeatmaps.RemoveAt(randomSelectedBeatmaps.Count - 1);
2018-04-13 17:19:50 +08:00
if (!beatmap.Filtered.Value && beatmap.BeatmapInfo.BeatmapSet?.DeletePending != true)
2017-06-02 01:54:42 +08:00
{
if (selectedBeatmapSet != null)
{
if (RandomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
previouslyVisitedRandomSets.Remove(selectedBeatmapSet);
playSpinSample(distanceBetween(beatmap, selectedBeatmapSet));
}
2017-12-12 16:48:38 +08:00
select(beatmap);
2017-06-02 01:54:42 +08:00
break;
}
}
}
2018-04-13 17:19:50 +08:00
private double distanceBetween(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / DrawableCarouselItem.MAX_HEIGHT);
private void playSpinSample(double distance)
{
var chan = spinSample?.GetChannel();
if (chan != null)
{
chan.Frequency.Value = 1f + Math.Min(1f, distance / visibleSetsCount);
chan.Play();
}
randomSelectSample?.Play();
}
2022-09-07 13:04:51 +08:00
private void select(CarouselItem? item)
{
if (!AllowSelection)
return;
if (item == null) return;
2019-02-28 12:31:40 +08:00
item.State.Value = CarouselItemState.Selected;
}
2018-04-13 17:19:50 +08:00
2017-12-16 15:27:39 +08:00
private FilterCriteria activeCriteria = new FilterCriteria();
2018-04-13 17:19:50 +08:00
2022-09-07 13:04:51 +08:00
protected ScheduledDelegate? PendingFilter;
2018-04-13 17:19:50 +08:00
public bool AllowSelection = true;
2018-04-13 17:19:50 +08:00
/// <summary>
2019-07-26 14:22:29 +08:00
/// Half the height of the visible content.
/// <remarks>
/// This is different from the height of <see cref="ScrollContainer{T}"/>.displayableContent, since
/// the beatmap carousel bleeds into the <see cref="FilterControl"/> and the <see cref="Footer"/>
/// </remarks>
/// </summary>
2020-04-19 23:29:06 +08:00
private float visibleHalfHeight => (DrawHeight + BleedBottom + BleedTop) / 2;
/// <summary>
/// The position of the lower visible bound with respect to the current scroll position.
/// </summary>
private float visibleBottomBound => Scroll.Current + DrawHeight + BleedBottom;
/// <summary>
/// The position of the upper visible bound with respect to the current scroll position.
/// </summary>
private float visibleUpperBound => Scroll.Current - BleedTop;
2017-12-16 15:27:39 +08:00
public void FlushPendingFilterOperations()
2017-07-21 16:20:52 +08:00
{
if (!IsLoaded)
return;
2018-07-18 09:12:14 +08:00
if (PendingFilter?.Completed == false)
{
applyActiveCriteria(false);
Update();
}
2017-07-21 16:20:52 +08:00
}
2018-04-13 17:19:50 +08:00
2022-09-07 13:04:51 +08:00
public void Filter(FilterCriteria? newCriteria, bool debounce = true)
{
if (newCriteria != null)
2017-12-16 15:27:39 +08:00
activeCriteria = newCriteria;
2018-04-13 17:19:50 +08:00
applyActiveCriteria(debounce);
2017-12-16 15:27:39 +08:00
}
2018-04-13 17:19:50 +08:00
private bool beatmapsSplitOut;
private void applyActiveCriteria(bool debounce, bool alwaysResetScrollPosition = true)
2017-12-16 15:27:39 +08:00
{
PendingFilter?.Cancel();
PendingFilter = null;
if (debounce)
PendingFilter = Scheduler.AddDelayed(perform, 250);
else
{
// if initial load is not yet finished, this will be run inline in loadBeatmapSets to ensure correct order of operation.
if (!BeatmapSetsLoaded)
PendingFilter = Schedule(perform);
else
perform();
}
2018-04-13 17:19:50 +08:00
void perform()
2016-11-25 17:14:56 +08:00
{
2018-07-18 09:12:14 +08:00
PendingFilter = null;
2018-04-13 17:19:50 +08:00
if (activeCriteria.SplitOutDifficulties != beatmapsSplitOut)
{
beatmapsSplitOut = activeCriteria.SplitOutDifficulties;
loadBeatmapSets(originalBeatmapSetsDetached);
return;
}
2017-12-16 15:27:39 +08:00
root.Filter(activeCriteria);
itemsCache.Invalidate();
if (alwaysResetScrollPosition || !Scroll.UserScrolling)
ScrollToSelected(true);
FilterApplied?.Invoke();
}
}
2018-04-13 17:19:50 +08:00
private void invalidateAfterChange()
{
itemsCache.Invalidate();
if (!Scroll.UserScrolling)
ScrollToSelected(true);
BeatmapSetsChanged?.Invoke();
}
2017-12-17 01:33:01 +08:00
private float? scrollTarget;
2018-04-13 17:19:50 +08:00
/// <summary>
/// Scroll to the current <see cref="SelectedBeatmapInfo"/>.
/// </summary>
/// <param name="immediate">
/// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels.
/// This should be true for operations like filtering - where panels are changing visibility state - to avoid large jumps in animation.
/// </param>
public void ScrollToSelected(bool immediate = false) =>
pendingScrollOperation = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard;
2018-04-13 17:19:50 +08:00
#region Button selection logic
2021-09-16 17:26:12 +08:00
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
2021-09-16 17:26:12 +08:00
switch (e.Action)
{
case GlobalAction.SelectNext:
case GlobalAction.SelectNextGroup:
SelectNext(1, e.Action == GlobalAction.SelectNextGroup);
return true;
case GlobalAction.SelectPrevious:
case GlobalAction.SelectPreviousGroup:
SelectNext(-1, e.Action == GlobalAction.SelectPreviousGroup);
return true;
}
2018-04-13 17:19:50 +08:00
return false;
}
2018-04-13 17:19:50 +08:00
2021-09-16 17:26:12 +08:00
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
{
}
#endregion
protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
{
// handles the vertical size of the carousel changing (ie. on window resize when aspect ratio has changed).
2024-07-02 23:19:04 +08:00
if (invalidation.HasFlag(Invalidation.DrawSize))
itemsCache.Invalidate();
return base.OnInvalidate(invalidation, source);
}
protected override void Update()
{
base.Update();
2018-04-13 17:19:50 +08:00
2020-10-12 13:46:51 +08:00
bool revalidateItems = !itemsCache.IsValid;
// First we iterate over all non-filtered carousel items and populate their
// vertical position data.
2020-10-12 13:46:51 +08:00
if (revalidateItems)
{
updateYPositions();
2018-04-13 17:19:50 +08:00
if (visibleItems.Count == 0)
{
noResultsPlaceholder.Filter = activeCriteria;
noResultsPlaceholder.Show();
}
else
noResultsPlaceholder.Hide();
}
// if there is a pending scroll action we apply it without animation and transfer the difference in position to the panels.
// this is intentionally applied before updating the visible range below, to avoid animating new items (sourced from pool) from locations off-screen, as it looks bad.
if (pendingScrollOperation != PendingScrollOperation.None)
updateScrollPosition();
// This data is consumed to find the currently displayable range.
// This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn.
var newDisplayRange = getDisplayRange();
// If the filtered items or visible range has changed, pooling requirements need to be checked.
// This involves fetching new items from the pool, returning no-longer required items.
if (revalidateItems || newDisplayRange != displayedRange)
2020-10-12 13:46:51 +08:00
{
displayedRange = newDisplayRange;
2018-04-13 17:19:50 +08:00
if (visibleItems.Count > 0)
{
var toDisplay = visibleItems.GetRange(displayedRange.first, displayedRange.last - displayedRange.first + 1);
2020-10-12 14:36:03 +08:00
2024-01-22 14:56:16 +08:00
foreach (var panel in Scroll)
2020-10-13 13:23:29 +08:00
{
2023-01-10 03:59:28 +08:00
Debug.Assert(panel.Item != null);
if (toDisplay.Remove(panel.Item))
{
// panel already displayed.
continue;
}
// panel loaded as drawable but not required by visible range.
// remove but only if too far off-screen
if (panel.Y + panel.DrawHeight < visibleUpperBound - distance_offscreen_before_unload || panel.Y > visibleBottomBound + distance_offscreen_before_unload)
expirePanelImmediately(panel);
2020-10-13 13:23:29 +08:00
}
2018-04-13 17:19:50 +08:00
// Add those items within the previously found index range that should be displayed.
foreach (var item in toDisplay)
{
var panel = setPool.Get(p => p.Item = item);
panel.Y = item.CarouselYPosition;
Scroll.Add(panel);
}
}
}
2020-10-12 13:46:51 +08:00
// Update externally controlled state of currently visible items (e.g. x-offset and opacity).
// This is a per-frame update on all drawable panels.
foreach (DrawableCarouselItem item in Scroll)
{
updateItem(item);
Debug.Assert(item.Item != null);
if (item.Item.Visible)
{
bool isSelected = item.Item.State.Value == CarouselItemState.Selected;
bool hasPassedSelection = item.Item.CarouselYPosition < selectedBeatmapSet?.CarouselYPosition;
// Cheap way of doing animations when entering / exiting song select.
const double half_time = 50;
const float panel_x_offset_when_inactive = 200;
if (isSelected || AllowSelection)
{
item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 1, half_time, Clock.ElapsedFrameTime);
item.X = (float)Interpolation.DampContinuously(item.X, 0, half_time, Clock.ElapsedFrameTime);
}
else
{
item.Alpha = (float)Interpolation.DampContinuously(item.Alpha, 0, half_time, Clock.ElapsedFrameTime);
item.X = (float)Interpolation.DampContinuously(item.X, panel_x_offset_when_inactive, half_time, Clock.ElapsedFrameTime);
}
Scroll.ChangeChildDepth(item, hasPassedSelection ? -item.Item.CarouselYPosition : item.Item.CarouselYPosition);
}
if (item is DrawableCarouselBeatmapSet set)
{
foreach (var diff in set.DrawableBeatmaps)
updateItem(diff, item);
}
}
}
2018-04-13 17:19:50 +08:00
private static void expirePanelImmediately(DrawableCarouselItem panel)
{
// may want a fade effect here (could be seen if a huge change happens, like a set with 20 difficulties becomes selected).
panel.ClearTransforms();
panel.Expire();
}
private readonly CarouselBoundsItem carouselBoundsItem = new CarouselBoundsItem();
private (int firstIndex, int lastIndex) getDisplayRange()
{
// Find index range of all items that should be on-screen
carouselBoundsItem.CarouselYPosition = visibleUpperBound - distance_offscreen_to_preload;
int firstIndex = visibleItems.BinarySearch(carouselBoundsItem);
if (firstIndex < 0) firstIndex = ~firstIndex;
carouselBoundsItem.CarouselYPosition = visibleBottomBound + distance_offscreen_to_preload;
int lastIndex = visibleItems.BinarySearch(carouselBoundsItem);
if (lastIndex < 0) lastIndex = ~lastIndex;
// as we can't be 100% sure on the size of individual carousel drawables,
// always play it safe and extend bounds by one.
firstIndex = Math.Max(0, firstIndex - 1);
lastIndex = Math.Clamp(lastIndex + 1, firstIndex, Math.Max(0, visibleItems.Count - 1));
return (firstIndex, lastIndex);
}
2022-09-07 13:04:51 +08:00
private CarouselBeatmapSet? createCarouselSet(BeatmapSetInfo beatmapSet)
{
2022-01-20 16:50:17 +08:00
// This can be moved to the realm query if required using:
2022-01-20 17:36:20 +08:00
// .Filter("DeletePending == false && Protected == false && ANY Beatmaps.Hidden == false")
2022-01-20 16:50:17 +08:00
//
// As long as we are detaching though, it makes more sense to do it here as adding to the realm query has an overhead
// as seen at https://github.com/realm/realm-dotnet/discussions/2773#discussioncomment-2004275.
if (beatmapSet.Beatmaps.All(b => b.Hidden))
return null;
2018-04-13 17:19:50 +08:00
var set = new CarouselBeatmapSet(beatmapSet)
{
GetRecommendedBeatmap = beatmaps => GetRecommendedBeatmap?.Invoke(beatmaps)
};
2018-04-13 17:19:50 +08:00
2017-12-12 16:48:38 +08:00
foreach (var c in set.Beatmaps)
{
c.State.ValueChanged += state =>
2017-12-12 16:48:38 +08:00
{
if (state.NewValue == CarouselItemState.Selected)
2017-12-12 16:48:38 +08:00
{
selectedBeatmapSet = set;
SelectionChanged?.Invoke(c.BeatmapInfo);
2018-04-13 17:19:50 +08:00
itemsCache.Invalidate();
ScrollToSelected();
2017-12-12 16:48:38 +08:00
}
};
}
2018-04-13 17:19:50 +08:00
2017-12-12 16:48:38 +08:00
return set;
}
2018-04-13 17:19:50 +08:00
/// <summary>
2017-12-12 16:48:38 +08:00
/// Computes the target Y positions for every item in the carousel.
/// </summary>
2017-12-12 16:48:38 +08:00
/// <returns>The Y position of the currently selected item.</returns>
private void updateYPositions()
{
visibleItems.Clear();
2018-04-13 17:19:50 +08:00
2019-07-26 14:22:29 +08:00
float currentY = visibleHalfHeight;
2018-04-13 17:19:50 +08:00
2017-12-17 01:33:01 +08:00
scrollTarget = null;
2018-04-13 17:19:50 +08:00
foreach (CarouselItem item in root.Items)
2017-12-12 16:48:38 +08:00
{
if (item.Filtered.Value)
continue;
switch (item)
{
case CarouselBeatmapSet set:
2017-12-17 01:33:01 +08:00
{
bool isSelected = item.State.Value == CarouselItemState.Selected;
float padding = isSelected ? 5 : -5;
if (isSelected)
// double padding because we want to cancel the negative padding from the last item.
currentY += padding * 2;
visibleItems.Add(set);
set.CarouselYPosition = currentY;
if (isSelected)
2020-10-12 17:32:29 +08:00
{
// scroll position at currentY makes the set panel appear at the very top of the carousel's screen space
// move down by half of visible height (height of the carousel's visible extent, including semi-transparent areas)
// then reapply the top semi-transparent area (because carousel's screen space starts below it)
scrollTarget = currentY + DrawableCarouselBeatmapSet.HEIGHT - visibleHalfHeight + BleedTop;
foreach (var b in set.Beatmaps)
{
if (!b.Visible)
continue;
2020-10-12 17:32:29 +08:00
if (b.State.Value == CarouselItemState.Selected)
{
scrollTarget += b.TotalHeight / 2;
break;
}
scrollTarget += b.TotalHeight;
2020-10-12 17:32:29 +08:00
}
}
currentY += set.TotalHeight + padding;
break;
2017-12-17 01:33:01 +08:00
}
}
}
2018-04-13 17:19:50 +08:00
2019-07-26 14:22:29 +08:00
currentY += visibleHalfHeight;
Scroll.ScrollContent.Height = currentY;
2018-04-13 17:19:50 +08:00
itemsCache.Validate();
// update and let external consumers know about selection loss.
if (BeatmapSetsLoaded)
{
bool selectionLost = selectedBeatmapSet != null && selectedBeatmapSet.State.Value != CarouselItemState.Selected;
2018-04-13 17:19:50 +08:00
if (selectionLost)
{
selectedBeatmapSet = null;
SelectionChanged?.Invoke(null);
}
}
2016-11-21 14:59:46 +08:00
}
2018-04-13 17:19:50 +08:00
private bool firstScroll = true;
private void updateScrollPosition()
{
if (scrollTarget != null)
{
if (firstScroll)
{
// reduce movement when first displaying the carousel.
Scroll.ScrollTo(scrollTarget.Value - 200, false);
firstScroll = false;
}
switch (pendingScrollOperation)
{
case PendingScrollOperation.Standard:
Scroll.ScrollTo(scrollTarget.Value);
break;
case PendingScrollOperation.Immediate:
// in order to simplify animation logic, rather than using the animated version of ScrollTo,
// we take the difference in scroll height and apply to all visible panels.
// this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer
// to enter clamp-special-case mode where it animates completely differently to normal.
float scrollChange = scrollTarget.Value - Scroll.Current;
Scroll.ScrollTo(scrollTarget.Value, false);
2024-01-22 14:56:16 +08:00
foreach (var i in Scroll)
i.Y += scrollChange;
break;
}
pendingScrollOperation = PendingScrollOperation.None;
}
}
2018-04-13 17:19:50 +08:00
/// <summary>
2017-12-12 16:48:38 +08:00
/// Computes the x-offset of currently visible items. Makes the carousel appear round.
/// </summary>
/// <param name="dist">
/// Vertical distance from the center of the carousel container
/// ranging from -1 to 1.
/// </param>
/// <param name="halfHeight">Half the height of the carousel container.</param>
private static float offsetX(float dist, float halfHeight)
{
// The radius of the circle the carousel moves on.
const float circle_radius = 3;
float discriminant = MathF.Max(0, circle_radius * circle_radius - dist * dist);
float x = (circle_radius - MathF.Sqrt(discriminant)) * halfHeight;
2018-04-13 17:19:50 +08:00
return 125 + x;
}
2018-04-13 17:19:50 +08:00
/// <summary>
2017-12-12 16:48:38 +08:00
/// Update a item's x position and multiplicative alpha based on its y position and
/// the current scroll position.
/// </summary>
/// <param name="item">The item to be updated.</param>
/// <param name="parent">For nested items, the parent of the item to be updated.</param>
2022-09-07 13:04:51 +08:00
private void updateItem(DrawableCarouselItem item, DrawableCarouselItem? parent = null)
{
Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre);
float itemDrawY = posInScroll.Y - visibleUpperBound;
2019-07-26 14:22:29 +08:00
float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight);
2018-04-13 17:19:50 +08:00
// adjusting the item's overall X position can cause it to become masked away when
// child items (difficulties) are still visible.
item.Header.X = offsetX(dist, visibleHalfHeight) - (parent?.X ?? 0);
2018-04-13 17:19:50 +08:00
// We are applying a multiplicative alpha (which is internally done by nesting an
// additional container and setting that container's alpha) such that we can
// layer alpha transformations on top.
item.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1));
}
2018-04-13 17:19:50 +08:00
private enum PendingScrollOperation
{
None,
Standard,
Immediate,
}
/// <summary>
/// A carousel item strictly used for binary search purposes.
/// </summary>
private class CarouselBoundsItem : CarouselItem
{
2022-09-07 13:04:51 +08:00
public override DrawableCarouselItem CreateDrawableRepresentation() => throw new NotImplementedException();
}
2018-04-02 14:23:58 +08:00
private class CarouselRoot : CarouselGroupEagerSelect
{
// May only be null during construction (State.Value set causes PerformSelection to be triggered).
private readonly BeatmapCarousel? carousel;
2018-04-13 17:19:50 +08:00
public readonly Dictionary<Guid, List<CarouselBeatmapSet>> BeatmapSetsByID = new Dictionary<Guid, List<CarouselBeatmapSet>>();
2018-04-02 14:23:58 +08:00
public CarouselRoot(BeatmapCarousel carousel)
{
// root should always remain selected. if not, PerformSelection will not be called.
State.Value = CarouselItemState.Selected;
2022-06-24 20:25:23 +08:00
State.ValueChanged += _ => State.Value = CarouselItemState.Selected;
2018-04-02 14:23:58 +08:00
this.carousel = carousel;
}
2018-04-13 17:19:50 +08:00
public override void AddItem(CarouselItem i)
{
CarouselBeatmapSet set = (CarouselBeatmapSet)i;
if (BeatmapSetsByID.TryGetValue(set.BeatmapSet.ID, out var sets))
sets.Add(set);
else
BeatmapSetsByID.Add(set.BeatmapSet.ID, new List<CarouselBeatmapSet> { set });
base.AddItem(i);
}
/// <summary>
/// A special method to handle replace operations (general for updating a beatmap).
/// Avoids event-driven selection flip-flopping during the remove/add process.
/// </summary>
/// <param name="oldItem">The beatmap set to be replaced.</param>
/// <param name="newItems">All new items to replace the removed beatmap set.</param>
/// <returns>All removed items, for any further processing.</returns>
public IEnumerable<CarouselBeatmapSet> ReplaceItem(BeatmapSetInfo oldItem, List<CarouselBeatmapSet> newItems)
{
var previousSelection = (LastSelected as CarouselBeatmapSet)?.Beatmaps
.FirstOrDefault(s => s.State.Value == CarouselItemState.Selected)
?.BeatmapInfo;
bool wasSelected = previousSelection?.BeatmapSet?.ID == oldItem.ID;
// Without doing this, the removal of the old beatmap will cause carousel's eager selection
// logic to invoke, causing one unnecessary selection.
DisableSelection = true;
var removedSets = RemoveItemsByID(oldItem.ID);
DisableSelection = false;
foreach (var set in newItems)
AddItem(set);
// Check if we can/need to maintain our current selection.
if (wasSelected)
{
CarouselBeatmap? matchingBeatmap = newItems.SelectMany(s => s.Beatmaps)
.FirstOrDefault(b => b.BeatmapInfo.ID == previousSelection?.ID);
if (matchingBeatmap != null)
matchingBeatmap.State.Value = CarouselItemState.Selected;
}
return removedSets;
}
public IEnumerable<CarouselBeatmapSet> RemoveItemsByID(Guid beatmapSetID)
{
if (BeatmapSetsByID.TryGetValue(beatmapSetID, out var carouselBeatmapSets))
{
foreach (var set in carouselBeatmapSets)
RemoveItem(set);
return carouselBeatmapSets;
}
return Enumerable.Empty<CarouselBeatmapSet>();
}
public override void RemoveItem(CarouselItem i)
{
CarouselBeatmapSet set = (CarouselBeatmapSet)i;
BeatmapSetsByID.Remove(set.BeatmapSet.ID);
base.RemoveItem(i);
}
2018-04-02 14:23:58 +08:00
protected override void PerformSelection()
{
if (LastSelected == null)
carousel?.SelectNextRandom();
2018-04-02 14:23:58 +08:00
else
base.PerformSelection();
}
}
public partial class CarouselScrollContainer : UserTrackingScrollContainer<DrawableCarouselItem>
{
private bool rightMouseScrollBlocked;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
public CarouselScrollContainer()
{
// size is determined by the carousel itself, due to not all content necessarily being loaded.
ScrollContent.AutoSizeAxes = Axes.None;
// the scroll container may get pushed off-screen by global screen changes, but we still want panels to display outside of the bounds.
Masking = false;
}
protected override bool OnMouseDown(MouseDownEvent e)
{
if (e.Button == MouseButton.Right)
{
// we need to block right click absolute scrolling when hovering a carousel item so context menus can display.
// this can be reconsidered when we have an alternative to right click scrolling.
if (GetContainingInputManager()!.HoveredDrawables.OfType<DrawableCarouselItem>().Any())
{
rightMouseScrollBlocked = true;
return false;
}
}
rightMouseScrollBlocked = false;
return base.OnMouseDown(e);
}
protected override bool OnDragStart(DragStartEvent e)
{
if (rightMouseScrollBlocked)
return false;
return base.OnDragStart(e);
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
subscriptionSets?.Dispose();
subscriptionBeatmaps?.Dispose();
}
}
}