diff --git a/osu.Game/Online/API/Requests/ResponseWithCursor.cs b/osu.Game/Online/API/Requests/ResponseWithCursor.cs index e38e73dd01..51e88ca52b 100644 --- a/osu.Game/Online/API/Requests/ResponseWithCursor.cs +++ b/osu.Game/Online/API/Requests/ResponseWithCursor.cs @@ -13,4 +13,13 @@ namespace osu.Game.Online.API.Requests [JsonProperty("cursor")] public dynamic CursorJson; } + + public abstract class ResponseWithCursor : ResponseWithCursor where T : class + { + /// + /// Cursor deserialized into T class type (cannot implicitly convert type to object using raw Cursor) + /// + [JsonProperty("cursor")] + public T Cursor; + } } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index 047496b473..fb2cc66dd8 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -5,11 +5,21 @@ using osu.Framework.IO.Network; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osu.Game.Rulesets; +using Newtonsoft.Json; namespace osu.Game.Online.API.Requests { public class SearchBeatmapSetsRequest : APIRequest { + public class Cursor + { + [JsonProperty("approved_date")] + public string ApprovedDate; + + [JsonProperty("_id")] + public string Id; + } + public SearchCategory SearchCategory { get; set; } public SortCriteria SortCriteria { get; set; } @@ -22,17 +32,20 @@ namespace osu.Game.Online.API.Requests private readonly string query; private readonly RulesetInfo ruleset; + private readonly Cursor cursor; private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc"; - public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset) + public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset, Cursor cursor = null, + SearchCategory searchCategory = SearchCategory.Any, SortCriteria sortCriteria = SortCriteria.Ranked, SortDirection sortDirection = SortDirection.Descending) { this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query); this.ruleset = ruleset; + this.cursor = cursor; - SearchCategory = SearchCategory.Any; - SortCriteria = SortCriteria.Ranked; - SortDirection = SortDirection.Descending; + SearchCategory = searchCategory; + SortCriteria = sortCriteria; + SortDirection = sortDirection; Genre = SearchGenre.Any; Language = SearchLanguage.Any; } @@ -55,6 +68,12 @@ namespace osu.Game.Online.API.Requests req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}"); + if (cursor != null) + { + req.AddParameter("cursor[_id]", cursor.Id); + req.AddParameter("cursor[approved_date]", cursor.ApprovedDate); + } + return req; } diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs index 3c4fb11ed1..2adf7004e8 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsResponse.cs @@ -7,7 +7,7 @@ using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API.Requests { - public class SearchBeatmapSetsResponse : ResponseWithCursor + public class SearchBeatmapSetsResponse : ResponseWithCursor { [JsonProperty("beatmapsets")] public IEnumerable BeatmapSets; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 4dd60c7113..c3e8505ddc 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -13,7 +12,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -24,6 +22,8 @@ namespace osu.Game.Overlays.BeatmapListing { public Action> SearchFinished; public Action SearchStarted; + /// List of currently displayed beatmap entries + private List currentBeatmaps; [Resolved] private IAPIProvider api { get; set; } @@ -35,7 +35,7 @@ namespace osu.Game.Overlays.BeatmapListing private readonly BeatmapListingSortTabControl sortControl; private readonly Box sortControlBackground; - private SearchBeatmapSetsRequest getSetsRequest; + private BeatmapListingPager beatmapListingPager; public BeatmapListingFilterControl() { @@ -115,12 +115,13 @@ namespace osu.Game.Overlays.BeatmapListing } private ScheduledDelegate queryChangedDebounce; + private ScheduledDelegate queryPagingDebounce; private void queueUpdateSearch(bool queryTextChanged = false) { SearchStarted?.Invoke(); - getSetsRequest?.Cancel(); + beatmapListingPager?.Reset(); queryChangedDebounce?.Cancel(); queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100); @@ -128,37 +129,55 @@ namespace osu.Game.Overlays.BeatmapListing private void updateSearch() { - getSetsRequest = new SearchBeatmapSetsRequest(searchControl.Query.Value, searchControl.Ruleset.Value) - { - SearchCategory = searchControl.Category.Value, - SortCriteria = sortControl.Current.Value, - SortDirection = sortControl.SortDirection.Value, - Genre = searchControl.Genre.Value, - Language = searchControl.Language.Value - }; + beatmapListingPager = new BeatmapListingPager( + api, + rulesets, + searchControl.Query.Value, + searchControl.Ruleset.Value, + searchControl.Category.Value, + sortControl.Current.Value, + sortControl.SortDirection.Value + ); - getSetsRequest.Success += response => Schedule(() => onSearchFinished(response)); + queryPagingDebounce?.Cancel(); + queryPagingDebounce = null; + beatmapListingPager.PageFetched += onSearchFinished; - api.Queue(getSetsRequest); + AddPageToResult(); } - private void onSearchFinished(SearchBeatmapSetsResponse response) + private void onSearchFinished(List beatmaps) { - var beatmaps = response.BeatmapSets.Select(r => r.ToBeatmapSet(rulesets)).ToList(); - - searchControl.BeatmapSet = response.Total == 0 ? null : beatmaps.First(); + queryPagingDebounce = Scheduler.AddDelayed(() => queryPagingDebounce = null, 1000); + if (currentBeatmaps == null || !beatmapListingPager.IsPastFirstPage) + currentBeatmaps = beatmaps; + else + currentBeatmaps.AddRange(beatmaps); + SearchFinished?.Invoke(beatmaps); } protected override void Dispose(bool isDisposing) { - getSetsRequest?.Cancel(); + beatmapListingPager?.Reset(); queryChangedDebounce?.Cancel(); + queryPagingDebounce?.Cancel(); base.Dispose(isDisposing); } public void TakeFocus() => searchControl.TakeFocus(); + + /// Request next 50 matches if available + public void AddPageToResult() + { + if (beatmapListingPager == null || !beatmapListingPager.CanFetchNextPage) + return; + if (queryPagingDebounce != null) + return; + + beatmapListingPager.FetchNextPage(); + } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingPager.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingPager.cs new file mode 100644 index 0000000000..66faf8df7a --- /dev/null +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingPager.cs @@ -0,0 +1,92 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; + +namespace osu.Game.Overlays.BeatmapListing +{ + public class BeatmapListingPager + { + private readonly IAPIProvider api; + private readonly RulesetStore rulesets; + private readonly string query; + private readonly RulesetInfo ruleset; + private readonly SearchCategory searchCategory; + private readonly SortCriteria sortCriteria; + private readonly SortDirection sortDirection; + + public event PageFetchHandler PageFetched; + private SearchBeatmapSetsRequest getSetsRequest; + private SearchBeatmapSetsResponse lastResponse; + + /// Reports end of results + private bool isLastPageFetched = false; + /// Job in process lock flag + private bool isFetching => getSetsRequest != null; + /// Whether beatmaps should be appended or replaced + public bool IsPastFirstPage { get; private set; } = false; + /// call FetchNextPage() safe-check + public bool CanFetchNextPage => !isLastPageFetched && !isFetching; + + public BeatmapListingPager(IAPIProvider api, RulesetStore rulesets, string query, RulesetInfo ruleset, SearchCategory searchCategory = SearchCategory.Any, SortCriteria sortCriteria = SortCriteria.Ranked, SortDirection sortDirection = SortDirection.Descending) + { + this.api = api; + this.rulesets = rulesets; + this.query = query; + this.ruleset = ruleset; + this.searchCategory = searchCategory; + this.sortCriteria = sortCriteria; + this.sortDirection = sortDirection; + } + + public void FetchNextPage() + { + if (isFetching) + return; + + if (lastResponse != null) + IsPastFirstPage = true; + + getSetsRequest = new SearchBeatmapSetsRequest( + query, + ruleset, + lastResponse?.Cursor, + searchCategory, + sortCriteria, + sortDirection); + + getSetsRequest.Success += response => + { + var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList(); + + if (sets.Count == 0) + isLastPageFetched = true; + + lastResponse = response; + getSetsRequest = null; + + PageFetched?.Invoke(sets); + }; + + api.Queue(getSetsRequest); + } + + public void Reset() + { + isLastPageFetched = false; + IsPastFirstPage = false; + + lastResponse = null; + + getSetsRequest?.Cancel(); + getSetsRequest = null; + } + + public delegate void PageFetchHandler(List sets); + } +} diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index f680f7c67b..c495c8d21b 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -30,13 +30,21 @@ namespace osu.Game.Overlays private Drawable currentContent; private LoadingLayer loadingLayer; private Container panelTarget; + private FillFlowContainer foundContent; + private NotFoundDrawable notFoundContent; + + private OverlayScrollContainer resultScrollContainer; + /// Scroll distance threshold from results tail, higher means sooner + private const int pagination_scroll_distance = 500; + /// This is paging event flag + private bool shouldAddNextPage => resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance); public BeatmapListingOverlay() : base(OverlayColourScheme.Blue) { } - private BeatmapListingFilterControl filterControl; + private BeatmapListingFilterControl filterControl;//actual search settings [BackgroundDependencyLoader] private void load() @@ -48,7 +56,7 @@ namespace osu.Game.Overlays RelativeSizeAxes = Axes.Both, Colour = ColourProvider.Background6 }, - new OverlayScrollContainer + resultScrollContainer = new OverlayScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, @@ -80,9 +88,14 @@ namespace osu.Game.Overlays { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Padding = new MarginPadding { Horizontal = 20 } - }, - loadingLayer = new LoadingLayer(panelTarget) + Padding = new MarginPadding { Horizontal = 20 }, + Children = new Drawable[] + { + foundContent = new FillFlowContainer(), + notFoundContent = new NotFoundDrawable(), + loadingLayer = new LoadingLayer(panelTarget) + } + } } }, } @@ -112,27 +125,52 @@ namespace osu.Game.Overlays private void onSearchFinished(List beatmaps) { + //No matches case if (!beatmaps.Any()) { - LoadComponentAsync(new NotFoundDrawable(), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); return; } - var newPanels = new FillFlowContainer + //New query case + if (!shouldAddNextPage) { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Alpha = 0, - Margin = new MarginPadding { Vertical = 15 }, - ChildrenEnumerable = beatmaps.Select(b => new GridBeatmapPanel(b) + //Spawn new child + var newPanels = new FillFlowContainer { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }) - }; + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Alpha = 0, + Margin = new MarginPadding { Vertical = 15 }, + ChildrenEnumerable = beatmaps.Select(b => new GridBeatmapPanel(b) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }) + }; - LoadComponentAsync(newPanels, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + foundContent = newPanels; + LoadComponentAsync(foundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); + } + + //Pagination case + else + { + + beatmaps.ForEach(x => + { + LoadComponentAsync(new GridBeatmapPanel(x) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, loaded => + { + foundContent.Add(loaded); + loaded.FadeIn(200, Easing.OutQuint); + }); + }); + } } private void addContentToPlaceholder(Drawable content) @@ -149,13 +187,18 @@ namespace osu.Game.Overlays // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird. // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0. // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so. - lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y); + lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y) + .Then().Schedule(() => panelTarget.Remove(lastContent)); } - panelTarget.Add(currentContent = content); - currentContent.FadeIn(200, Easing.OutQuint); + if (!content.IsAlive) + panelTarget.Add(content); + content.FadeIn(200, Easing.OutQuint); + + currentContent = content; } + protected override void Dispose(bool isDisposing) { cancellationToken?.Cancel(); @@ -203,5 +246,14 @@ namespace osu.Game.Overlays }); } } + + protected override void Update() + { + base.Update(); + + if (shouldAddNextPage) + filterControl.AddPageToResult(); + + } } }