// Copyright (c) ppy Pty Ltd . 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.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Development; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.Placeholders; using osuTK; using osuTK.Graphics; using osu.Game.Localisation; namespace osu.Game.Online.Leaderboards { /// /// A leaderboard which displays a scrolling list of top scores, along with a single "user best" /// for the local user. /// /// The scope of the leaderboard (ie. global or local). /// The score model class. public abstract partial class Leaderboard : CompositeDrawable { /// /// The currently displayed scores. /// public IBindableList Scores => scores; private readonly BindableList scores = new BindableList(); /// /// Whether the current scope should refetch in response to changes in API connectivity state. /// protected abstract bool IsOnlineScope { get; } private const double fade_duration = 300; private readonly OsuScrollContainer scrollContainer; private readonly Container placeholderContainer; private readonly UserTopScoreContainer userScoreContainer; private FillFlowContainer? scoreFlowContainer; private readonly LoadingSpinner loading; private CancellationTokenSource? currentFetchCancellationSource; private CancellationTokenSource? currentScoresAsyncLoadCancellationSource; private APIRequest? fetchScoresRequest; private LeaderboardState state; [Resolved(CanBeNull = true)] private IAPIProvider? api { get; set; } private readonly IBindable apiState = new Bindable(); private TScope scope = default!; public TScope Scope { get => scope; set { if (EqualityComparer.Default.Equals(value, scope)) return; scope = value; RefetchScores(); } } protected Leaderboard() { InternalChildren = new Drawable[] { new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, Masking = true, Child = new GridContainer { RelativeSizeAxes = Axes.Both, RowDimensions = new[] { new Dimension(), new Dimension(GridSizeMode.AutoSize), }, Content = new[] { new Drawable[] { scrollContainer = new OsuScrollContainer { RelativeSizeAxes = Axes.Both, ScrollbarVisible = false, } }, new Drawable[] { userScoreContainer = new UserTopScoreContainer(CreateDrawableTopScore) }, }, }, }, loading = new LoadingSpinner(), placeholderContainer = new Container { RelativeSizeAxes = Axes.Both }, }; } protected override void LoadComplete() { base.LoadComplete(); if (api != null) { apiState.BindTo(api.State); apiState.BindValueChanged(state => { switch (state.NewValue) { case APIState.Online: case APIState.Offline: if (IsOnlineScope) RefetchScores(); break; } }); } RefetchScores(); } /// /// Perform a full refetch of scores using current criteria. /// public void RefetchScores() => Scheduler.AddOnce(refetchScores); /// /// Clear all scores from the display. /// public void ClearScores() { cancelPendingWork(); SetScores(null); } /// /// Call when a retrieval or display failure happened to show a relevant message to the user. /// /// The state to display. protected void SetErrorState(LeaderboardState state) { switch (state) { case LeaderboardState.NoScores: case LeaderboardState.Retrieving: case LeaderboardState.Success: throw new InvalidOperationException($"State {state} cannot be set by a leaderboard implementation."); } Debug.Assert(!scores.Any()); setState(state); } /// /// Call when retrieved scores are ready to be displayed. /// /// The scores to display. /// The user top score, if any. protected void SetScores(IEnumerable? scores, TScoreInfo? userScore = default) { this.scores.Clear(); if (scores != null) this.scores.AddRange(scores); // Non-delayed schedule may potentially run inline (due to IsMainThread check passing) after leaderboard is disposed. // This is guarded against in BeatmapLeaderboard via web request cancellation, but let's be extra safe. if (!IsDisposed) { // Schedule needs to be non-delayed here for the weird logic in refetchScores to work. // If it is removed, the placeholder will be incorrectly updated to "no scores" rather than "retrieving". // This whole flow should be refactored in the future. Scheduler.Add(applyNewScores, false); } void applyNewScores() { userScoreContainer.Score.Value = userScore; if (userScore == null) userScoreContainer.Hide(); else userScoreContainer.Show(); updateScoresDrawables(); } } /// /// Performs a fetch/refresh of scores to be displayed. /// /// /// An responsible for the fetch operation. This will be queued and performed automatically. protected abstract APIRequest? FetchScores(CancellationToken cancellationToken); protected abstract LeaderboardScore CreateDrawableScore(TScoreInfo model, int index); protected abstract LeaderboardScore CreateDrawableTopScore(TScoreInfo model); private void refetchScores() { Debug.Assert(ThreadSafety.IsUpdateThread); ClearScores(); setState(LeaderboardState.Retrieving); currentFetchCancellationSource = new CancellationTokenSource(); fetchScoresRequest = FetchScores(currentFetchCancellationSource.Token); if (fetchScoresRequest == null) return; fetchScoresRequest.Failure += e => Schedule(() => { if (e is OperationCanceledException || currentFetchCancellationSource.IsCancellationRequested) return; SetErrorState(LeaderboardState.NetworkFailure); }); api?.Queue(fetchScoresRequest); } private void cancelPendingWork() { currentFetchCancellationSource?.Cancel(); currentScoresAsyncLoadCancellationSource?.Cancel(); fetchScoresRequest?.Cancel(); } private void updateScoresDrawables() { currentScoresAsyncLoadCancellationSource?.Cancel(); scoreFlowContainer? .FadeOut(fade_duration, Easing.OutQuint) .Expire(); scoreFlowContainer = null; if (!scores.Any()) { setState(LeaderboardState.NoScores); return; } LoadComponentAsync(new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Spacing = new Vector2(0f, 5f), Padding = new MarginPadding { Top = 10, Bottom = 5 }, ChildrenEnumerable = scores.Select((s, index) => CreateDrawableScore(s, index + 1)) }, newFlow => { setState(LeaderboardState.Success); scrollContainer.Add(scoreFlowContainer = newFlow); double delay = 0; foreach (var s in scoreFlowContainer.Children) { using (s.BeginDelayedSequence(delay)) s.Show(); delay += 50; } scrollContainer.ScrollToStart(false); }, (currentScoresAsyncLoadCancellationSource = new CancellationTokenSource()).Token); } #region Placeholder handling private Placeholder? placeholder; private void setState(LeaderboardState state) { if (state == this.state) return; if (state == LeaderboardState.Retrieving) loading.Show(); else loading.Hide(); this.state = state; placeholder?.FadeOut(150, Easing.OutQuint).Expire(); placeholder = getPlaceholderFor(state); if (placeholder == null) return; placeholderContainer.Child = placeholder; placeholder.ScaleTo(0.8f).Then().ScaleTo(1, fade_duration * 3, Easing.OutQuint); placeholder.FadeInFromZero(fade_duration, Easing.OutQuint); } private Placeholder? getPlaceholderFor(LeaderboardState state) { switch (state) { case LeaderboardState.NetworkFailure: return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync) { Action = RefetchScores }; case LeaderboardState.NoneSelected: return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap); case LeaderboardState.RulesetUnavailable: return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset); case LeaderboardState.BeatmapUnavailable: return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap); case LeaderboardState.NoScores: return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet); case LeaderboardState.NotLoggedIn: return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards); case LeaderboardState.NotSupporter: return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard); case LeaderboardState.Retrieving: return null; case LeaderboardState.Success: return null; default: throw new ArgumentOutOfRangeException(); } } #endregion #region Fade handling protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); float fadeBottom = scrollContainer.Current + scrollContainer.DrawHeight; float fadeTop = scrollContainer.Current + LeaderboardScore.HEIGHT; if (!scrollContainer.IsScrolledToEnd()) fadeBottom -= LeaderboardScore.HEIGHT; if (scoreFlowContainer == null) return; foreach (var c in scoreFlowContainer.Children) { float topY = c.ToSpaceOfOtherDrawable(Vector2.Zero, scoreFlowContainer).Y; float bottomY = topY + LeaderboardScore.HEIGHT; bool requireBottomFade = bottomY >= fadeBottom; if (!requireBottomFade) c.Colour = Color4.White; else if (topY > fadeBottom + LeaderboardScore.HEIGHT || bottomY < fadeTop - LeaderboardScore.HEIGHT) c.Colour = Color4.Transparent; else { if (bottomY - fadeBottom > 0) { c.Colour = ColourInfo.GradientVertical( Color4.White.Opacity(Math.Min(1 - (topY - fadeBottom) / LeaderboardScore.HEIGHT, 1)), Color4.White.Opacity(Math.Min(1 - (bottomY - fadeBottom) / LeaderboardScore.HEIGHT, 1))); } else { c.Colour = ColourInfo.GradientVertical( Color4.White.Opacity(Math.Min(1 - (fadeTop - topY) / LeaderboardScore.HEIGHT, 1)), Color4.White.Opacity(Math.Min(1 - (fadeTop - bottomY) / LeaderboardScore.HEIGHT, 1))); } } } } #endregion } }