-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Expand file tree
/
Copy pathBeatmapCarousel.cs
More file actions
1236 lines (994 loc) · 52.7 KB
/
BeatmapCarousel.cs
File metadata and controls
1236 lines (994 loc) · 52.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.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.Extensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Localisation;
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.Online.API;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using Realms;
namespace osu.Game.Screens.Select
{
[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;
/// <summary>
/// Retrieves all beatmap sets which are currently contained within the specified <see cref="GroupDefinition"/>.
/// </summary>
/// <param name="group">The group to retrieve beatmap sets for.</param>
public IEnumerable<BeatmapSetInfo> GetBeatmapSetsForGroup(GroupDefinition group)
=> grouping.SetItems.Keys
.Where(set => EqualityComparer<GroupDefinition?>.Default.Equals(set.Group, group))
.Select(set => set.BeatmapSet);
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
{
if (CurrentSelection != null && (top == CurrentSelectionItem || bottom == CurrentSelectionItem))
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
{
GetCriteria = () => Criteria!,
GetCollections = GetAllCollections,
GetLocalUserTopRanks = GetBeatmapInfoGuidToTopRankMapping,
GetFavouriteBeatmapSets = GetFavouriteBeatmapSets,
}
};
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 keep the previous one for now.
// we are about to modify `Items`, which - if required - will trigger a re-filter,
// which will pick a correct group - if one is present - via `HandleFilterCompleted()`.
RequestSelection(new GroupedBeatmap(CurrentGroupedBeatmap?.Group, 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);
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 import-as-update flows, such as updating the beatmap with the latest online versions, or external editing inside editor
oldBeatmap.ID == newBeatmap.ID &&
// 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 FindCarouselItemsForSelection(ref Selection keyboardSelection, ref Selection selection, IList<CarouselItem> items)
{
if (keyboardSelection.Model != null && grouping.ItemMap.TryGetValue(keyboardSelection.Model, out var keyboardSelectionItem))
keyboardSelection = keyboardSelection with { CarouselItem = keyboardSelectionItem.item, Index = keyboardSelectionItem.index };
if (selection.Model != null && grouping.ItemMap.TryGetValue(selection.Model, out var selectionItem))
selection = selection with { CarouselItem = selectionItem.item, Index = selectionItem.index };
}
protected override void HandleFilterCompleted()
{
base.HandleFilterCompleted();
attemptSelectSingleFilteredResult();
if (CurrentSelection is GroupedBeatmap selection)
{
// Check whether the selection-group mapping is still valid post-filter.
if (!grouping.ItemMap.ContainsKey(selection))
{
// If the group no longer exists (or the item no longer exists in the previous group), grab an arbitrary other instance of the beatmap under the first group encountered.
var newSelection = GetCarouselItems()?
.Select(i => i.Model)
.OfType<GroupedBeatmap>()
.FirstOrDefault(gb => CheckModelEquality(gb.Beatmap, selection.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;
}
}
// Transfer the previous flag states across to the new models.
if (ExpandedBeatmapSet != null) setExpandedSet(ExpandedBeatmapSet);
if (ExpandedGroup != null) setExpandedGroup(ExpandedGroup);
foreach (var item in Scroll.Panels.OfType<PanelBeatmapSet>().Where(p => p.Item != null))
updateVisibleBeatmaps((GroupedBeatmapSet)item.Item!.Model, item);
}
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>();
// do not request recommended selection if the user already had selected a difficulty within the single filtered beatmap set,
// as it could change the difficulty that will be selected
var preexistingSelection = beatmaps.FirstOrDefault(b => b.Equals(CurrentSelection as GroupedBeatmap));
if (preexistingSelection != null)
{
// the selection might not have an item associated with it, if it was fully filtered away previously
// in this case, request to reselect it
if (CurrentSelectionItem == null)
RequestSelection(preexistingSelection);
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)
{
GroupedBeatmapSet? lastExpandedSet = ExpandedBeatmapSet;
// It's important that we update the stored ExpandedBeatmapSet even when
// sets are not grouped together.
//
// This is stored when selection is changed and used later to ensure correct
// visual states are achieved (see call of this method in `HandleFilterCompleted`
// for an important case).
ExpandedBeatmapSet = set;
if (!grouping.BeatmapSetsGroupedTogether)
return;
setExpansionStateOfSetItems(lastExpandedSet, false);
setExpansionStateOfSetItems(ExpandedBeatmapSet, true);
}
private void setExpansionStateOfSetItems(GroupedBeatmapSet? set, bool expanded)
{
if (set == null)
return;
bool canMakeVisible = !grouping.GroupItems.Any() || ExpandedGroup == set.Group;
if (grouping.SetItems.TryGetValue(set, out var items))
{
foreach (var i in items)
{
if (i.Model is GroupedBeatmapSet)
i.IsExpanded = expanded;
else
i.IsVisible = canMakeVisible && expanded;
}
}
}
protected override double? GetScrollTarget()
{
double? target = base.GetScrollTarget();
// if the base implementation returned null, it means that the keyboard selection has been filtered out and is no longer visible
// attempt a fallback to other possibly expanded panels (set first, then group)
if (target == null)
{
CarouselItem? targetItem = null;
if (ExpandedBeatmapSet != null && grouping.ItemMap.TryGetValue(ExpandedBeatmapSet, out var setItem))
targetItem = setItem.item;
if (targetItem == null && ExpandedGroup != null && grouping.ItemMap.TryGetValue(ExpandedGroup, out var groupItem))
targetItem = groupItem.item;
target = targetItem?.CarouselYPosition;
}
return target;
}
#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 Fetches for grouping support
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
/// <remarks>
/// FOOTGUN WARNING: this being sorted on the realm side before detaching is IMPORTANT.
/// realm supports sorting as an internal operation, and realm's implementation of string sorting does NOT match dotnet's
/// with respect to treatment of punctuation characters like <c>-</c> or <c>_</c>, among others.
/// All other places that show lists of collections also use the realm-side sorting implementation,
/// because they use the sorting operation inside subscription queries for efficient drawable management,
/// so this usage kind of has to follow suit.
/// </remarks>
protected virtual List<BeatmapCollection> GetAllCollections() => realm.Run(r => r.All<BeatmapCollection>().OrderBy(c => c.Name).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;
});
/// <remarks>
/// Note that calling <c>.ToHashSet()</c> below has two purposes:
/// one being performance of contain checks in filtering code,
/// another being slightly better thread safety (as <see cref="ILocalUserState.FavouriteBeatmapSets"/> could be mutated during async filtering).
/// </remarks>
protected HashSet<int> GetFavouriteBeatmapSets() => api.LocalUserState.FavouriteBeatmapSets.ToHashSet();
#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 readonly DrawablePool<PanelGroupRankDisplay> ranksGroupPanelPool = new DrawablePool<PanelGroupRankDisplay>(9);
private readonly DrawablePool<PanelGroupRankedStatus> statusGroupPanelPool = new DrawablePool<PanelGroupRankedStatus>(8);
private void setupPools()
{
AddInternal(statusGroupPanelPool);
AddInternal(ranksGroupPanelPool);
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);
// `BeatmapInfo` is no longer used directly in carousel items, but in rare circumstances still is used for model equality comparisons
// (see `beatmapSetsChanged()` deletion handling logic, which aims to find a beatmap close to the just-deleted one, disregarding grouping concerns)
if (x is BeatmapInfo beatmapInfoX && y is BeatmapInfo beatmapInfoY)
return beatmapInfoX.Equals(beatmapInfoY);
if (x is StarDifficultyGroupDefinition starX && y is StarDifficultyGroupDefinition starY)
return starX.Equals(starY);
if (x is RankDisplayGroupDefinition rankX && y is RankDisplayGroupDefinition rankY)
return rankX.Equals(rankY);
if (x is RankedStatusGroupDefinition statusX && y is RankedStatusGroupDefinition statusY)
return statusX.Equals(statusY);
// NOTE: this branch must be AFTER all branches that compare `GroupDefinition` subtypes!
// this is an optimisation measure. any subclass of `GroupDefinition` will pass the `is GroupDefinition` check,
// and testing a subclass of `GroupDefinition` against any other `GroupDefinition` (or subclass thereof)
// will result in a casting cascade of `Equals(GroupDefinition) -> Equals(object) -> Equals(GroupDefinitionSubClass)`
// (that last one only if the type check passes)
if (x is GroupDefinition groupX && y is GroupDefinition groupY)
return groupX.Equals(groupY);
return base.CheckModelEquality(x, y);
}
protected override Drawable GetDrawableForDisplay(CarouselItem item)
{
switch (item.Model)
{
case RankedStatusGroupDefinition:
return statusGroupPanelPool.Get();
case StarDifficultyGroupDefinition:
return starsGroupPanelPool.Get();
case RankDisplayGroupDefinition:
return ranksGroupPanelPool.Get();
case GroupDefinition:
return groupPanelPool.Get();
case GroupedBeatmap:
if (!grouping.BeatmapSetsGroupedTogether)
return standalonePanelPool.Get();
return beatmapPanelPool.Get();
case GroupedBeatmapSet groupedBeatmapSet:
var setPanel = setPanelPool.Get();
updateVisibleBeatmaps(groupedBeatmapSet, setPanel);
return setPanel;
}
throw new InvalidOperationException();
}
private void updateVisibleBeatmaps(GroupedBeatmapSet groupedBeatmapSet, PanelBeatmapSet setPanel)
{
HashSet<BeatmapInfo> visibleBeatmaps = [];
if (grouping.SetItems.TryGetValue(groupedBeatmapSet, out var visibleItems))
visibleBeatmaps = visibleItems.Where(i => i.Model is GroupedBeatmap).Select(i => ((GroupedBeatmap)i.Model).Beatmap).ToHashSet();
setPanel.VisibleBeatmaps.Value = visibleBeatmaps;
}
#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);