Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ppy
GitHub Repository: ppy/osu
Path: blob/master/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
2264 views
// Copyright (c) ppy Pty Ltd <[email protected]>. 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.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Threading;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Carousel;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Screens.Select;
using Realms;

namespace osu.Game.Screens.SelectV2
{
    [Cached]
    public partial class BeatmapCarousel : Carousel<BeatmapInfo>
    {
        public Action<BeatmapInfo>? RequestPresentBeatmap { private get; init; }

        /// <summary>
        /// From the provided beatmaps, select the most appropriate one for the user's skill.
        /// </summary>
        public required Action<IEnumerable<GroupedBeatmap>> RequestRecommendedSelection { private get; init; }

        /// <summary>
        /// Selection requested for the provided beatmap.
        /// </summary>
        public required Action<GroupedBeatmap> RequestSelection { private get; init; }

        public const float SPACING = 3f;

        private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;

        private readonly LoadingLayer loading;

        private readonly BeatmapCarouselFilterGrouping grouping;

        /// <summary>
        /// Total number of beatmap difficulties displayed with the filter.
        /// </summary>
        public int MatchedBeatmapsCount => Filters.Last().BeatmapItemsCount;

        protected override float GetSpacingBetweenPanels(CarouselItem top, CarouselItem bottom)
        {
            // Group panels do not overlap with any other panel but should overlap with themselves.
            if ((top.Model is GroupDefinition) ^ (bottom.Model is GroupDefinition))
                return SPACING * 2;

            if (grouping.BeatmapSetsGroupedTogether)
            {
                // Give some space around the expanded beatmap set, at the top..
                if (bottom.Model is GroupedBeatmapSet && bottom.IsExpanded)
                    return SPACING * 2;

                // ..and the bottom.
                if (top.Model is GroupedBeatmap && bottom.Model is GroupedBeatmapSet)
                    return SPACING * 2;

                // Beatmap difficulty panels do not overlap with themselves or any other panel.
                if (top.Model is GroupedBeatmap || bottom.Model is GroupedBeatmap)
                    return SPACING;
            }
            else
            {
                // `CurrentSelectionItem` cannot be used here because it may not be correctly set yet.
                if (CurrentSelection != null && (CheckModelEquality(top.Model, CurrentSelection) || CheckModelEquality(bottom.Model, CurrentSelection)))
                    return SPACING * 2;
            }

            return -SPACING;
        }

        public BeatmapCarousel()
        {
            DebounceDelay = 100;
            DistanceOffscreenToPreload = 100;

            // Account for the osu! logo being in the way.
            Scroll.ScrollbarPaddingBottom = 70;

            Filters = new ICarouselFilter[]
            {
                new BeatmapCarouselFilterMatching(() => Criteria!),
                new BeatmapCarouselFilterSorting(() => Criteria!),
                grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, GetAllCollections, GetBeatmapInfoGuidToTopRankMapping)
            };

            AddInternal(loading = new LoadingLayer());
        }

        [BackgroundDependencyLoader]
        private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken)
        {
            setupPools();
            detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
            loadSamples(audio);

            config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm);
        }

        protected override void LoadComplete()
        {
            base.LoadComplete();
            detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
        }

        #region Beatmap source hookup

        private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed) => Schedule(() =>
        {
            // This callback is scheduled to ensure there's no added overhead during gameplay.
            // If this ever becomes an issue, it's important to note that the actual carousel filtering is already
            // implemented in a way it will only run when at song select.
            //
            // The overhead we are avoiding here is that of this method directly – things like Items.IndexOf calls
            // that can be slow for very large beatmap libraries. There are definitely ways to optimise this further.

            // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
            // right now we are managing this locally which is a bit of added overhead.
            IEnumerable<BeatmapSetInfo>? newItems = changed.NewItems?.Cast<BeatmapSetInfo>();
            IEnumerable<BeatmapSetInfo>? oldItems = changed.OldItems?.Cast<BeatmapSetInfo>();

            switch (changed.Action)
            {
                case NotifyCollectionChangedAction.Add:
                    if (!newItems!.Any())
                        return;

                    Items.AddRange(newItems!.SelectMany(s => s.Beatmaps));
                    break;

                case NotifyCollectionChangedAction.Remove:
                    bool selectedSetDeleted = false;

                    foreach (var set in oldItems!)
                    {
                        foreach (var beatmap in set.Beatmaps)
                        {
                            Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
                            selectedSetDeleted |= CheckModelEquality((CurrentSelection as GroupedBeatmap)?.Beatmap, beatmap);
                        }
                    }

                    // After removing all items in this batch, we want to make an immediate reselection
                    // based on adjacency to the previous selection if it was deleted.
                    //
                    // This needs to be done immediately to avoid song select making a random selection.
                    // This needs to be done in this class because we need to know final display order.
                    // This needs to be done with attention to detail of which beatmaps have not been deleted.
                    if (selectedSetDeleted && CurrentSelectionIndex != null)
                    {
                        var items = GetCarouselItems()!;
                        if (items.Count == 0)
                            break;

                        bool success = false;

                        // Try selecting forwards first
                        for (int i = CurrentSelectionIndex.Value + 1; i < items.Count; i++)
                        {
                            if (attemptSelection(items[i]))
                            {
                                success = true;
                                break;
                            }
                        }

                        if (success)
                            break;

                        // Then try backwards (we might be at the end of available items).
                        for (int i = Math.Min(items.Count - 1, CurrentSelectionIndex.Value); i >= 0; i--)
                        {
                            if (attemptSelection(items[i]))
                                break;
                        }

                        bool attemptSelection(CarouselItem item)
                        {
                            if (CheckValidForSetSelection(item))
                            {
                                if (item.Model is GroupedBeatmap groupedBeatmap)
                                {
                                    // check the new selection wasn't deleted above
                                    if (!Items.Contains(groupedBeatmap.Beatmap))
                                        return false;

                                    RequestSelection(groupedBeatmap);
                                    return true;
                                }

                                if (item.Model is GroupedBeatmapSet groupedSet)
                                {
                                    if (oldItems.Contains(groupedSet.BeatmapSet))
                                        return false;

                                    selectRecommendedDifficultyForBeatmapSet(groupedSet);
                                    return true;
                                }
                            }

                            return false;
                        }
                    }

                    break;

                case NotifyCollectionChangedAction.Move:
                    // We can ignore move operations as we are applying our own sort in all cases.
                    break;

                case NotifyCollectionChangedAction.Replace:
                    var oldSetBeatmaps = oldItems!.Single().Beatmaps;
                    var newSetBeatmaps = newItems!.Single().Beatmaps.ToList();

                    // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set.
                    // Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered
                    // by users editing the beatmap or by difficulty/metadata recomputation).
                    //
                    // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update.
                    // We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties
                    // have been processed) if it becomes an issue for animation or performance reasons.
                    foreach (var beatmap in oldSetBeatmaps)
                    {
                        int previousIndex = Items.IndexOf(beatmap);
                        Debug.Assert(previousIndex >= 0);

                        // we're intentionally being lenient with there being two difficulties with equal online ID or difficulty name.
                        // this can be the case when the user modifies the beatmap using the editor's "external edit" feature.
                        BeatmapInfo? matchingNewBeatmap =
                            newSetBeatmaps.FirstOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ??
                            newSetBeatmaps.FirstOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset));

                        // The matching beatmap may have been deleted or invalidated in some way since this event was fired.
                        // Let's make sure we have the most up-to-date realm state.
                        if (matchingNewBeatmap?.ID is Guid matchingID)
                            matchingNewBeatmap = realm.Run(r => r.FindWithRefresh<BeatmapInfo>(matchingID)?.Detach());

                        if (matchingNewBeatmap != null)
                        {
                            // TODO: should this exist in song select instead of here?
                            // we need to ensure the global beatmap is also updated alongside changes.
                            if (CurrentBeatmap != null && beatmap.Equals(CurrentBeatmap))
                                // we don't know in which group the matching new beatmap is, but that's fine - we can leave it null for now.
                                // we are about to modify `Items`, which will trigger a re-filter, which will pick a correct group - if one is present - via `HandleFilterCompleted()`.
                                RequestSelection(new GroupedBeatmap(null, matchingNewBeatmap));

                            Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]);
                            newSetBeatmaps.Remove(matchingNewBeatmap);
                        }
                        else
                        {
                            Items.RemoveAt(previousIndex);
                        }
                    }

                    // Add any items which weren't found in the previous pass (difficulty names didn't match).
                    foreach (var beatmap in newSetBeatmaps)
                        Items.Add(beatmap);

                    break;

                case NotifyCollectionChangedAction.Reset:
                    Items.Clear();
                    break;
            }
        });

        #endregion

        #region Selection handling

        protected GroupDefinition? ExpandedGroup { get; private set; }

        protected GroupedBeatmapSet? ExpandedBeatmapSet { get; private set; }

        protected override bool ShouldActivateOnKeyboardSelection(CarouselItem item) =>
            grouping.BeatmapSetsGroupedTogether && item.Model is GroupedBeatmap;

        /// <summary>
        /// The currently selected <see cref="GroupedBeatmap"/>.
        /// </summary>
        /// <remarks>
        /// The selection is never reset due to not existing. It can be set to anything.
        /// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
        /// </remarks>
        public GroupedBeatmap? CurrentGroupedBeatmap
        {
            get => CurrentSelection as GroupedBeatmap;
            set => CurrentSelection = value;
        }

        /// <summary>
        /// The currently selected <see cref="BeatmapInfo"/>.
        /// </summary>
        /// <remarks>
        /// This is a property mostly dedicated to external consumers who only care about showing some particular copy of a beatmap
        /// (there could be multiple panels for one beatmap due to grouping).
        /// Through this property, the carousel basically figures out what group to use internally.
        /// </remarks>
        public BeatmapInfo? CurrentBeatmap
        {
            get => CurrentGroupedBeatmap?.Beatmap;
            set
            {
                if (value == null)
                {
                    CurrentGroupedBeatmap = null;
                    return;
                }

                if (CurrentGroupedBeatmap != null && value.Equals(CurrentGroupedBeatmap.Beatmap))
                    return;

                // it is not universally guaranteed that the carousel items will be materialised at the time this is set.
                // therefore, in cases where it is known that they will not be, default to a null group.
                // even if grouping is active, this will be rectified to a correct group on the next invocation of `HandleFilterCompleted()`.
                CurrentGroupedBeatmap = IsLoaded && !IsFiltering
                    ? GetCarouselItems()?.Select(item => item.Model).OfType<GroupedBeatmap>().FirstOrDefault(gb => gb.Beatmap.Equals(value))
                    : new GroupedBeatmap(null, value);
            }
        }

        protected override void HandleItemActivated(CarouselItem item)
        {
            try
            {
                switch (item.Model)
                {
                    case GroupDefinition group:
                        // Special case – collapsing an open group.
                        if (ExpandedGroup == group)
                        {
                            setExpansionStateOfGroup(ExpandedGroup, false);
                            ExpandedGroup = null;
                            return;
                        }

                        setExpandedGroup(group);

                        // If the active selection is within this group, it should get keyboard focus immediately.
                        if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is GroupedBeatmap gb)
                            RequestSelection(gb);

                        return;

                    case GroupedBeatmapSet groupedSet:
                        selectRecommendedDifficultyForBeatmapSet(groupedSet);
                        return;

                    case GroupedBeatmap groupedBeatmap:
                        if (CurrentSelection != null && CheckModelEquality(CurrentSelection, groupedBeatmap))
                        {
                            RequestPresentBeatmap?.Invoke(groupedBeatmap.Beatmap);
                            return;
                        }

                        RequestSelection(groupedBeatmap);
                        return;
                }
            }
            finally
            {
                playActivationSound(item);
            }
        }

        protected override void HandleItemSelected(object? model)
        {
            base.HandleItemSelected(model);

            switch (model)
            {
                case GroupedBeatmapSet:
                case GroupDefinition:
                    throw new InvalidOperationException("Groups should never become selected");

                case GroupedBeatmap groupedBeatmap:
                    setExpandedGroup(groupedBeatmap.Group);

                    if (grouping.BeatmapSetsGroupedTogether)
                        setExpandedSet(new GroupedBeatmapSet(groupedBeatmap.Group, groupedBeatmap.Beatmap.BeatmapSet!));
                    break;
            }
        }

        protected override bool HandleItemsChanged(NotifyCollectionChangedEventArgs args)
        {
            switch (args.Action)
            {
                case NotifyCollectionChangedAction.Add:
                case NotifyCollectionChangedAction.Remove:
                case NotifyCollectionChangedAction.Move:
                case NotifyCollectionChangedAction.Reset:
                    return true;

                case NotifyCollectionChangedAction.Replace:
                    var oldBeatmaps = args.OldItems!.OfType<BeatmapInfo>().ToList();
                    var newBeatmaps = args.NewItems!.OfType<BeatmapInfo>().ToList();

                    for (int i = 0; i < oldBeatmaps.Count; i++)
                    {
                        var oldBeatmap = oldBeatmaps[i];
                        var newBeatmap = newBeatmaps[i];

                        // Ignore changes which don't concern us.
                        //
                        // Here are some examples of things that can go wrong:
                        // - Background difficulty calculation runs and causes a realm update.
                        //   We use `BeatmapDifficultyCache` and don't want to know about these.
                        // - Background user tag population runs and causes a realm update.
                        //   We don't display user tags so want to ignore this.
                        bool equalForDisplayPurposes =
                            // covers metadata changes
                            oldBeatmap.Hash == newBeatmap.Hash &&
                            // sanity check
                            oldBeatmap.OnlineID == newBeatmap.OnlineID &&
                            // displayed on panel
                            oldBeatmap.Status == newBeatmap.Status &&
                            // displayed on panel
                            oldBeatmap.DifficultyName == newBeatmap.DifficultyName &&
                            // hidden changed, needs re-filter
                            oldBeatmap.Hidden == newBeatmap.Hidden &&
                            // might be used for grouping, returning from gameplay
                            oldBeatmap.LastPlayed == newBeatmap.LastPlayed;

                        if (equalForDisplayPurposes)
                            return false;
                    }

                    return true;

                default:
                    throw new ArgumentOutOfRangeException();
            }
        }

        protected override void HandleFilterCompleted()
        {
            base.HandleFilterCompleted();

            attemptSelectSingleFilteredResult();

            // Store selected group before handling selection (it may implicitly change the expanded group).
            var groupForReselection = ExpandedGroup;

            var currentGroupedBeatmap = CurrentSelection as GroupedBeatmap;

            // The filter might have changed the set of available groups, which means that the current selection may point to a stale group.
            // Check whether that is the case.
            bool groupingRemainsOff = currentGroupedBeatmap?.Group == null && grouping.GroupItems.Count == 0;
            bool groupStillExists = currentGroupedBeatmap?.Group != null && grouping.GroupItems.ContainsKey(currentGroupedBeatmap.Group);

            if (groupingRemainsOff || groupStillExists)
            {
                // Only update the visual state of the selected item.
                HandleItemSelected(currentGroupedBeatmap);
            }
            else if (currentGroupedBeatmap != null)
            {
                // If the group no longer exists, grab an arbitrary other instance of the beatmap under the first group encountered.
                var newSelection = GetCarouselItems()?.Select(i => i.Model).OfType<GroupedBeatmap>().FirstOrDefault(gb => gb.Beatmap.Equals(currentGroupedBeatmap.Beatmap));
                // Only change the selection if we actually got a positive hit.
                // This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place.
                if (newSelection != null)
                    CurrentSelection = newSelection;
            }

            // If a group was selected that is not the one containing the selection, attempt to reselect it.
            // If the original group was not found, ExpandedGroup will already have been updated to a valid value in `HandleItemSelected` above.
            if (groupForReselection != null && grouping.GroupItems.TryGetValue(groupForReselection, out _))
                setExpandedGroup(groupForReselection);
        }

        private void selectRecommendedDifficultyForBeatmapSet(GroupedBeatmapSet set)
        {
            // Selecting a set isn't valid – let's re-select the first visible difficulty.
            if (grouping.SetItems.TryGetValue(set, out var items))
            {
                var beatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>();
                RequestRecommendedSelection(beatmaps);
            }
        }

        /// <summary>
        /// If we don't have a selection and there's a single beatmap set returned, select it for the user.
        /// </summary>
        private void attemptSelectSingleFilteredResult()
        {
            var items = GetCarouselItems();

            if (items == null || items.Count == 0) return;

            BeatmapSetInfo? beatmapSetInfo = null;

            foreach (var item in items)
            {
                if (item.Model is GroupedBeatmap groupedBeatmap)
                {
                    var beatmapInfo = groupedBeatmap.Beatmap;

                    if (beatmapSetInfo == null)
                    {
                        beatmapSetInfo = beatmapInfo.BeatmapSet!;
                        continue;
                    }

                    // Found a beatmap with a different beatmap set, abort.
                    if (!beatmapSetInfo.Equals(beatmapInfo.BeatmapSet))
                        return;
                }
            }

            var beatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>();

            if (beatmaps.Any(b => b.Equals(CurrentSelection as GroupedBeatmap)))
                return;

            RequestRecommendedSelection(beatmaps);
        }

        protected override bool CheckValidForGroupSelection(CarouselItem item) => item.Model is GroupDefinition;

        protected override bool CheckValidForSetSelection(CarouselItem item)
        {
            switch (item.Model)
            {
                case GroupedBeatmapSet:
                    return true;

                case GroupedBeatmap:
                    return !grouping.BeatmapSetsGroupedTogether;

                case GroupDefinition:
                    return false;

                default:
                    throw new ArgumentException($"Unsupported model type {item.Model}");
            }
        }

        private void setExpandedGroup(GroupDefinition? group)
        {
            if (ExpandedGroup != null)
                setExpansionStateOfGroup(ExpandedGroup, false);

            ExpandedGroup = group;

            if (ExpandedGroup != null)
                setExpansionStateOfGroup(ExpandedGroup, true);
        }

        private void setExpansionStateOfGroup(GroupDefinition group, bool expanded)
        {
            if (grouping.GroupItems.TryGetValue(group, out var items))
            {
                if (expanded)
                {
                    foreach (var i in items)
                    {
                        switch (i.Model)
                        {
                            case GroupDefinition:
                                i.IsExpanded = true;
                                break;

                            case GroupedBeatmapSet groupedSet:
                                // Case where there are set headers, header should be visible
                                // and items should use the set's expanded state.
                                i.IsVisible = true;
                                setExpansionStateOfSetItems(groupedSet, i.IsExpanded);
                                break;

                            default:
                                // Case where there are no set headers, all items should be visible.
                                if (!grouping.BeatmapSetsGroupedTogether)
                                    i.IsVisible = true;
                                break;
                        }
                    }
                }
                else
                {
                    foreach (var i in items)
                    {
                        switch (i.Model)
                        {
                            case GroupDefinition:
                                i.IsExpanded = false;
                                break;

                            default:
                                i.IsVisible = false;
                                break;
                        }
                    }
                }
            }
        }

        private void setExpandedSet(GroupedBeatmapSet set)
        {
            if (ExpandedBeatmapSet != null)
                setExpansionStateOfSetItems(ExpandedBeatmapSet, false);
            ExpandedBeatmapSet = set;
            setExpansionStateOfSetItems(ExpandedBeatmapSet, true);
        }

        private void setExpansionStateOfSetItems(GroupedBeatmapSet set, bool expanded)
        {
            if (grouping.SetItems.TryGetValue(set, out var items))
            {
                foreach (var i in items)
                {
                    if (i.Model is GroupedBeatmapSet)
                        i.IsExpanded = expanded;
                    else
                        i.IsVisible = expanded;
                }
            }
        }

        #endregion

        #region Audio

        private Sample? sampleChangeDifficulty;
        private Sample? sampleChangeSet;
        private Sample? sampleToggleGroup;

        private double audioFeedbackLastPlaybackTime;

        private void loadSamples(AudioManager audio)
        {
            sampleChangeDifficulty = audio.Samples.Get(@"SongSelect/select-difficulty");
            sampleChangeSet = audio.Samples.Get(@"SongSelect/select-expand");
            sampleToggleGroup = audio.Samples.Get(@"SongSelect/select-group");

            spinSample = audio.Samples.Get("SongSelect/random-spin");
            randomSelectSample = audio.Samples.Get(@"SongSelect/select-random");
        }

        private void playActivationSound(CarouselItem item)
        {
            if (Time.Current - audioFeedbackLastPlaybackTime >= OsuGameBase.SAMPLE_DEBOUNCE_TIME)
            {
                switch (item.Model)
                {
                    case GroupDefinition:
                        sampleToggleGroup?.Play();
                        return;

                    case GroupedBeatmapSet:
                        sampleChangeSet?.Play();
                        return;

                    case GroupedBeatmap:
                        sampleChangeDifficulty?.Play();
                        return;
                }

                audioFeedbackLastPlaybackTime = Time.Current;
            }
        }

        #endregion

        #region Animation

        /// <summary>
        /// Moves non-selected beatmaps to the right, hiding off-screen.
        /// </summary>
        public bool VisuallyFocusSelected { get; set; }

        private float selectionFocusOffset;

        protected override void Update()
        {
            base.Update();

            selectionFocusOffset = (float)Interpolation.DampContinuously(selectionFocusOffset, VisuallyFocusSelected ? 300 : 0, 100, Time.Elapsed);
        }

        protected override float GetPanelXOffset(Drawable panel)
        {
            return base.GetPanelXOffset(panel) + (((ICarouselPanel)panel).Selected.Value ? 0 : selectionFocusOffset);
        }

        #endregion

        #region Filtering

        public FilterCriteria? Criteria { get; private set; }

        private ScheduledDelegate? loadingDebounce;

        public void Filter(FilterCriteria criteria, bool showLoadingImmediately = false)
        {
            bool resetDisplay = grouping.BeatmapSetsGroupedTogether != BeatmapCarouselFilterGrouping.ShouldGroupBeatmapsTogether(criteria);

            Criteria = criteria;

            loadingDebounce ??= Scheduler.AddDelayed(() =>
            {
                if (loading.State.Value == Visibility.Visible)
                    return;

                Scroll.FadeColour(OsuColour.Gray(0.5f), 1000, Easing.OutQuint);
                loading.Show();
            }, showLoadingImmediately ? 0 : 250);

            FilterAsync(resetDisplay).ContinueWith(_ => Schedule(() =>
            {
                loadingDebounce?.Cancel();
                loadingDebounce = null;

                Scroll.FadeColour(OsuColour.Gray(1f), 500, Easing.OutQuint);
                loading.Hide();
            }));
        }

        protected override Task<IEnumerable<CarouselItem>> FilterAsync(bool clearExistingPanels = false)
        {
            if (Criteria == null)
                return Task.FromResult(Enumerable.Empty<CarouselItem>());

            return base.FilterAsync(clearExistingPanels);
        }

        #endregion

        #region Database fetches for grouping support

        [Resolved]
        private RealmAccess realm { get; set; } = null!;

        protected virtual List<BeatmapCollection> GetAllCollections() => realm.Run(r => r.All<BeatmapCollection>().AsEnumerable().Detach());

        protected virtual Dictionary<Guid, ScoreRank> GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => realm.Run(r =>
        {
            var topRankMapping = new Dictionary<Guid, ScoreRank>();

            var allLocalScores = r.GetAllLocalScoresForUser(criteria.LocalUserId)
                                  .Filter($@"{nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $0", criteria.Ruleset?.ShortName)
                                  .OrderByDescending(s => s.TotalScore)
                                  .ThenBy(s => s.Date);

            foreach (var score in allLocalScores)
            {
                Debug.Assert(score.BeatmapInfo != null);

                if (topRankMapping.ContainsKey(score.BeatmapInfo.ID))
                    continue;

                topRankMapping[score.BeatmapInfo.ID] = score.Rank;
            }

            return topRankMapping;
        });

        #endregion

        #region Drawable pooling

        private readonly DrawablePool<PanelBeatmap> beatmapPanelPool = new DrawablePool<PanelBeatmap>(100);
        private readonly DrawablePool<PanelBeatmapStandalone> standalonePanelPool = new DrawablePool<PanelBeatmapStandalone>(100);
        private readonly DrawablePool<PanelBeatmapSet> setPanelPool = new DrawablePool<PanelBeatmapSet>(100);
        private readonly DrawablePool<PanelGroup> groupPanelPool = new DrawablePool<PanelGroup>(100);
        private readonly DrawablePool<PanelGroupStarDifficulty> starsGroupPanelPool = new DrawablePool<PanelGroupStarDifficulty>(11);

        private void setupPools()
        {
            AddInternal(starsGroupPanelPool);
            AddInternal(groupPanelPool);
            AddInternal(beatmapPanelPool);
            AddInternal(standalonePanelPool);
            AddInternal(setPanelPool);
        }

        protected override bool CheckModelEquality(object? x, object? y)
        {
            // In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale
            // BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs.
            //
            // If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring
            // it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged
            // before changing matching requirements here.

            if (x is GroupedBeatmapSet groupedSetX && y is GroupedBeatmapSet groupedSetY)
                return groupedSetX.Equals(groupedSetY);

            if (x is GroupedBeatmap groupedBeatmapX && y is GroupedBeatmap groupedBeatmapY)
                return groupedBeatmapX.Equals(groupedBeatmapY);

            if (x is GroupDefinition groupX && y is GroupDefinition groupY)
                return groupX.Equals(groupY);

            if (x is StarDifficultyGroupDefinition starX && y is StarDifficultyGroupDefinition starY)
                return starX.Equals(starY);

            return base.CheckModelEquality(x, y);
        }

        protected override Drawable GetDrawableForDisplay(CarouselItem item)
        {
            switch (item.Model)
            {
                case StarDifficultyGroupDefinition:
                    return starsGroupPanelPool.Get();

                case GroupDefinition:
                    return groupPanelPool.Get();

                case GroupedBeatmap:
                    if (!grouping.BeatmapSetsGroupedTogether)
                        return standalonePanelPool.Get();

                    return beatmapPanelPool.Get();

                case GroupedBeatmapSet:
                    return setPanelPool.Get();
            }

            throw new InvalidOperationException();
        }

        #endregion

        #region Random selection handling

        private readonly Bindable<RandomSelectAlgorithm> randomAlgorithm = new Bindable<RandomSelectAlgorithm>();
        private readonly HashSet<BeatmapInfo> previouslyVisitedRandomBeatmaps = new HashSet<BeatmapInfo>();
        private readonly List<GroupedBeatmap> randomHistory = new List<GroupedBeatmap>();

        private Sample? spinSample;
        private Sample? randomSelectSample;

        public bool NextRandom()
        {
            var carouselItems = GetCarouselItems();

            if (carouselItems?.Any() != true)
                return false;

            var selectionBefore = CurrentSelectionItem;
            var beatmapBefore = selectionBefore?.Model as GroupedBeatmap;

            bool success;

            if (beatmapBefore != null)
            {
                // keep track of visited beatmaps and sets for rewind
                randomHistory.Add(beatmapBefore);
                // keep track of visited beatmaps for "RandomPermutation" random tracking.
                // note that this is reset when we run out of beatmaps, while `randomHistory` is not.
                previouslyVisitedRandomBeatmaps.Add(beatmapBefore.Beatmap);
            }

            if (grouping.BeatmapSetsGroupedTogether)
                success = nextRandomSet();
            else
                success = nextRandomBeatmap();

            if (!success)
            {
                if (beatmapBefore != null)
                    randomHistory.RemoveAt(randomHistory.Count - 1);
                return false;
            }

            // CurrentSelectionItem won't be valid until UpdateAfterChildren.
            // We probably want to fix this at some point since a few places are working-around this quirk.
            ScheduleAfterChildren(() =>
            {
                if (selectionBefore != null && CurrentSelectionItem != null)
                    playSpinSample(visiblePanelCountBetweenItems(selectionBefore, CurrentSelectionItem));
            });

            return true;
        }

        private bool nextRandomBeatmap()
        {
            ICollection<GroupedBeatmap> visibleBeatmaps = ExpandedGroup != null
                // In the case of grouping, users expect random to only operate on the expanded group.
                // This is going to incur some overhead as we don't have a group-beatmapset mapping currently.
                //
                // If this becomes an issue, we could either store a mapping, or run the random algorithm many times
                // using the `SetItems` method until we get a group HIT.
                ? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType<GroupedBeatmap>().ToArray()
                : GetCarouselItems()!.Select(i => i.Model).OfType<GroupedBeatmap>().ToArray();

            GroupedBeatmap beatmap;

            switch (randomAlgorithm.Value)
            {
                case RandomSelectAlgorithm.RandomPermutation:
                {
                    ICollection<GroupedBeatmap> notYetVisitedBeatmaps = visibleBeatmaps.ExceptBy(previouslyVisitedRandomBeatmaps, gb => gb.Beatmap).ToList();

                    if (!notYetVisitedBeatmaps.Any())
                    {
                        previouslyVisitedRandomBeatmaps.ExceptWith(visibleBeatmaps.Select(b => b.Beatmap));
                        notYetVisitedBeatmaps = visibleBeatmaps;
                        if (CurrentSelection is GroupedBeatmap groupedBeatmap)
                            notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([groupedBeatmap]).ToList();
                    }

                    if (notYetVisitedBeatmaps.Count == 0)
                        return false;

                    beatmap = notYetVisitedBeatmaps.ElementAt(RNG.Next(notYetVisitedBeatmaps.Count));
                    break;
                }

                case RandomSelectAlgorithm.Random:
                    beatmap = visibleBeatmaps.ElementAt(RNG.Next(visibleBeatmaps.Count));
                    break;

                default:
                    throw new ArgumentOutOfRangeException();
            }

            RequestSelection(beatmap);
            return true;
        }

        private bool nextRandomSet()
        {
            ICollection<GroupedBeatmapSet> visibleGroupedSets = ExpandedGroup != null
                // In the case of grouping, users expect random to only operate on the expanded group.
                // This is going to incur some overhead as we don't have a group-beatmapset mapping currently.
                //
                // If this becomes an issue, we could either store a mapping, or run the random algorithm many times
                // using the `SetItems` method until we get a group HIT.
                ? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType<GroupedBeatmapSet>().ToArray()
                // This is the fastest way to retrieve sets for randomisation.
                : grouping.SetItems.Keys;

            GroupedBeatmapSet set;

            switch (randomAlgorithm.Value)
            {
                case RandomSelectAlgorithm.RandomPermutation:
                {
                    ICollection<GroupedBeatmapSet> notYetVisitedSets =
                        visibleGroupedSets.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList();

                    if (!notYetVisitedSets.Any())
                    {
                        previouslyVisitedRandomBeatmaps.ExceptWith(visibleGroupedSets.SelectMany(setUnderGrouping => setUnderGrouping.BeatmapSet.Beatmaps));
                        notYetVisitedSets = visibleGroupedSets;
                        if (CurrentSelection is GroupedBeatmap groupedBeatmap)
                            notYetVisitedSets = notYetVisitedSets.ExceptBy([groupedBeatmap.Beatmap.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList();
                    }

                    if (notYetVisitedSets.Count == 0)
                        return false;

                    set = notYetVisitedSets.ElementAt(RNG.Next(notYetVisitedSets.Count));
                    break;
                }

                case RandomSelectAlgorithm.Random:
                    set = visibleGroupedSets.ElementAt(RNG.Next(visibleGroupedSets.Count));
                    break;

                default:
                    throw new ArgumentOutOfRangeException();
            }

            selectRecommendedDifficultyForBeatmapSet(set);
            return true;
        }

        public bool PreviousRandom()
        {
            var carouselItems = GetCarouselItems();

            if (carouselItems?.Any() != true)
                return false;

            while (randomHistory.Any())
            {
                var previousBeatmap = randomHistory[^1];
                randomHistory.RemoveAt(randomHistory.Count - 1);

                // when going back through rewind history, we may no longer be in the same grouping mode.
                // the user wants to go back to the beatmap first and foremost, so the most important thing is to find a panel that corresponds to the beatmap.
                // going back to the same group is a nice-to-have, but a secondary concern.
                var previousBeatmapItem = carouselItems.Where(i => i.Model is GroupedBeatmap gb && gb.Beatmap.Equals(previousBeatmap.Beatmap))
                                                       .MaxBy(i => ((GroupedBeatmap)i.Model).Group == previousBeatmap.Group);

                if (previousBeatmapItem == null)
                    return false;

                if (CurrentSelection is GroupedBeatmap groupedBeatmap)
                {
                    if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
                        previouslyVisitedRandomBeatmaps.Remove(groupedBeatmap.Beatmap);

                    if (CurrentSelectionItem == null)
                        playSpinSample(0);
                    else
                        playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem));
                }

                RequestSelection((GroupedBeatmap)previousBeatmapItem.Model);
                return true;
            }

            return false;
        }

        private double visiblePanelCountBetweenItems(CarouselItem item1, CarouselItem item2) => Math.Ceiling(Math.Abs(item1.CarouselYPosition - item2.CarouselYPosition) / PanelBeatmapSet.HEIGHT);

        private void playSpinSample(double distance)
        {
            var chan = spinSample?.GetChannel();

            if (chan != null)
            {
                chan.Frequency.Value = 1f + Math.Clamp(distance / 200, 0, 1);
                chan.Play();
            }

            randomSelectSample?.Play();
        }

        #endregion
    }

    /// <summary>
    /// Defines a grouping header for a set of carousel items.
    /// </summary>
    public record GroupDefinition
    {
        /// <summary>
        /// The order of this group in the carousel, sorted using ascending order.
        /// </summary>
        public int Order { get; }

        /// <summary>
        /// The title of this group.
        /// </summary>
        public string Title { get; }

        private readonly string uncasedTitle;

        public GroupDefinition(int order, string title)
        {
            Order = order;
            Title = title;
            uncasedTitle = title.ToLowerInvariant();
        }

        public virtual bool Equals(GroupDefinition? other) => uncasedTitle == other?.uncasedTitle;

        public override int GetHashCode() => HashCode.Combine(uncasedTitle);
    }

    /// <summary>
    /// Defines a grouping header for a set of carousel items grouped by star difficulty.
    /// </summary>
    public record StarDifficultyGroupDefinition(int Order, string Title, StarDifficulty Difficulty) : GroupDefinition(Order, Title);

    /// <summary>
    /// Used to represent a portion of a <see cref="BeatmapSetInfo"/> under a <see cref="GroupDefinition"/>.
    /// The purpose of this model is to support splitting beatmap sets apart when the active grouping mode demands it.
    /// </summary>
    public record GroupedBeatmapSet([UsedImplicitly] GroupDefinition? Group, BeatmapSetInfo BeatmapSet);

    /// <summary>
    /// Used to represent a <see cref="Beatmap"/> under a <see cref="GroupDefinition"/>.
    /// The purpose of this model is to support showing multiple copies of a beatmap, which can occur if a beatmap appears in multiple groups
    /// (most prominently, collections group mode).
    /// </summary>
    public record GroupedBeatmap(GroupDefinition? Group, BeatmapInfo Beatmap);
}