diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index c4ba6e5143..6ec071be2f 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -9,7 +9,7 @@
       ]
     },
     "nvika": {
-      "version": "3.0.0",
+      "version": "4.0.0",
       "commands": [
         "nvika"
       ]
diff --git a/osu.Android.props b/osu.Android.props
index dbb0a6d610..7ae16b8b70 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -10,7 +10,7 @@
     <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Framework.Android" Version="2025.114.1" />
+    <PackageReference Include="ppy.osu.Framework.Android" Version="2025.115.0" />
   </ItemGroup>
   <PropertyGroup>
     <!-- Fody does not handle Android build well, and warns when unchanged.
diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
index 2018fd5ea9..deaa566864 100644
--- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
+++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
@@ -159,27 +159,26 @@ namespace osu.Game.Rulesets.Catch.Objects
         {
             // Note that this implementation is shared with the osu! ruleset's implementation.
             // If a change is made here, OsuHitObject.cs should also be updated.
-            ComboIndex = lastObj?.ComboIndex ?? 0;
-            ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
-            IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
+            int index = lastObj?.ComboIndex ?? 0;
+            int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
+            int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
 
-            if (this is BananaShower)
+            // - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
+            // - At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
+            //   but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
+            if (this is not BananaShower && (NewCombo || lastObj == null || lastObj is BananaShower))
             {
-                // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
-                return;
-            }
-
-            // At decode time, the first hitobject in the beatmap and the first hitobject after a banana shower are both enforced to be a new combo,
-            // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
-            if (NewCombo || lastObj == null || lastObj is BananaShower)
-            {
-                IndexInCurrentCombo = 0;
-                ComboIndex++;
-                ComboIndexWithOffsets += ComboOffset + 1;
+                inCurrentCombo = 0;
+                index++;
+                indexWithOffsets += ComboOffset + 1;
 
                 if (lastObj != null)
                     lastObj.LastInCombo = true;
             }
+
+            ComboIndex = index;
+            ComboIndexWithOffsets = indexWithOffsets;
+            IndexInCurrentCombo = inCurrentCombo;
         }
 
         protected override HitWindows CreateHitWindows() => HitWindows.Empty;
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs
index 17f365f820..a8a65f7edb 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneCursorTrail.cs
@@ -6,6 +6,7 @@
 using System;
 using System.Collections.Generic;
 using System.Linq;
+using JetBrains.Annotations;
 using NUnit.Framework;
 using osu.Framework.Allocation;
 using osu.Framework.Audio.Sample;
@@ -17,6 +18,7 @@ using osu.Framework.Graphics.Textures;
 using osu.Framework.Testing;
 using osu.Framework.Testing.Input;
 using osu.Game.Audio;
+using osu.Game.Rulesets.Osu.Skinning;
 using osu.Game.Rulesets.Osu.Skinning.Legacy;
 using osu.Game.Rulesets.Osu.UI.Cursor;
 using osu.Game.Skinning;
@@ -103,6 +105,23 @@ namespace osu.Game.Rulesets.Osu.Tests
             AddStep("contract", () => this.ChildrenOfType<CursorTrail>().Single().NewPartScale = Vector2.One);
         }
 
+        [Test]
+        public void TestRotation()
+        {
+            createTest(() =>
+            {
+                var skinContainer = new LegacySkinContainer(renderer, provideMiddle: true, enableRotation: true);
+                var legacyCursorTrail = new LegacyRotatingCursorTrail(skinContainer)
+                {
+                    NewPartScale = new Vector2(10)
+                };
+
+                skinContainer.Child = legacyCursorTrail;
+
+                return skinContainer;
+            });
+        }
+
         private void createTest(Func<Drawable> createContent) => AddStep("create trail", () =>
         {
             Clear();
@@ -121,12 +140,14 @@ namespace osu.Game.Rulesets.Osu.Tests
             private readonly IRenderer renderer;
             private readonly bool provideMiddle;
             private readonly bool provideCursor;
+            private readonly bool enableRotation;
 
-            public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true)
+            public LegacySkinContainer(IRenderer renderer, bool provideMiddle, bool provideCursor = true, bool enableRotation = false)
             {
                 this.renderer = renderer;
                 this.provideMiddle = provideMiddle;
                 this.provideCursor = provideCursor;
+                this.enableRotation = enableRotation;
 
                 RelativeSizeAxes = Axes.Both;
             }
@@ -152,7 +173,19 @@ namespace osu.Game.Rulesets.Osu.Tests
 
             public ISample GetSample(ISampleInfo sampleInfo) => null;
 
-            public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup) => null;
+            public IBindable<TValue> GetConfig<TLookup, TValue>(TLookup lookup)
+            {
+                switch (lookup)
+                {
+                    case OsuSkinConfiguration osuLookup:
+                        if (osuLookup == OsuSkinConfiguration.CursorTrailRotate)
+                            return SkinUtils.As<TValue>(new BindableBool(enableRotation));
+
+                        break;
+                }
+
+                return null;
+            }
 
             public ISkin FindProvider(Func<ISkin, bool> lookupFunction) => lookupFunction(this) ? this : null;
 
@@ -185,5 +218,19 @@ namespace osu.Game.Rulesets.Osu.Tests
                 MoveMouseTo(ToScreenSpace(DrawSize / 2 + DrawSize / 3 * rPos));
             }
         }
+
+        private partial class LegacyRotatingCursorTrail : LegacyCursorTrail
+        {
+            public LegacyRotatingCursorTrail([NotNull] ISkin skin)
+                : base(skin)
+            {
+            }
+
+            protected override void Update()
+            {
+                base.Update();
+                PartRotation += (float)(Time.Elapsed * 0.1);
+            }
+        }
     }
 }
diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
index 8c1bd6302e..9623d1999b 100644
--- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
+++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
@@ -184,27 +184,26 @@ namespace osu.Game.Rulesets.Osu.Objects
         {
             // Note that this implementation is shared with the osu!catch ruleset's implementation.
             // If a change is made here, CatchHitObject.cs should also be updated.
-            ComboIndex = lastObj?.ComboIndex ?? 0;
-            ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
-            IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
+            int index = lastObj?.ComboIndex ?? 0;
+            int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
+            int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
 
-            if (this is Spinner)
+            // - For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
+            // - At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo,
+            //   but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
+            if (this is not Spinner && (NewCombo || lastObj == null || lastObj is Spinner))
             {
-                // For the purpose of combo colours, spinners never start a new combo even if they are flagged as doing so.
-                return;
-            }
-
-            // At decode time, the first hitobject in the beatmap and the first hitobject after a spinner are both enforced to be a new combo,
-            // but this isn't directly enforced by the editor so the extra checks against the last hitobject are duplicated here.
-            if (NewCombo || lastObj == null || lastObj is Spinner)
-            {
-                IndexInCurrentCombo = 0;
-                ComboIndex++;
-                ComboIndexWithOffsets += ComboOffset + 1;
+                inCurrentCombo = 0;
+                index++;
+                indexWithOffsets += ComboOffset + 1;
 
                 if (lastObj != null)
                     lastObj.LastInCombo = true;
             }
+
+            ComboIndex = index;
+            ComboIndexWithOffsets = indexWithOffsets;
+            IndexInCurrentCombo = inCurrentCombo;
         }
 
         protected override HitWindows CreateHitWindows() => new OsuHitWindows();
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs
index 375d81049d..e526c4f14c 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursor.cs
@@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
 {
     public partial class LegacyCursor : SkinnableCursor
     {
+        public static readonly int REVOLUTION_DURATION = 10000;
+
         private const float pressed_scale = 1.3f;
         private const float released_scale = 1f;
 
@@ -52,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
         protected override void LoadComplete()
         {
             if (spin)
-                ExpandTarget.Spin(10000, RotationDirection.Clockwise);
+                ExpandTarget.Spin(REVOLUTION_DURATION, RotationDirection.Clockwise);
         }
 
         public override void Expand()
diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs
index ca0002d8c0..375bef721d 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyCursorTrail.cs
@@ -34,6 +34,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
         private void load(OsuConfigManager config, ISkinSource skinSource)
         {
             cursorSize = config.GetBindable<float>(OsuSetting.GameplayCursorSize).GetBoundCopy();
+            AllowPartRotation = skin.GetConfig<OsuSkinConfiguration, bool>(OsuSkinConfiguration.CursorTrailRotate)?.Value ?? true;
 
             Texture = skin.GetTexture("cursortrail");
 
diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs
index 9685ab685d..81488ca1a3 100644
--- a/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/OsuSkinConfiguration.cs
@@ -9,6 +9,7 @@ namespace osu.Game.Rulesets.Osu.Skinning
         CursorCentre,
         CursorExpand,
         CursorRotate,
+        CursorTrailRotate,
         HitCircleOverlayAboveNumber,
 
         // ReSharper disable once IdentifierTypo
diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
index 5132dc2859..1c2d69fa00 100644
--- a/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
+++ b/osu.Game.Rulesets.Osu/UI/Cursor/CursorTrail.cs
@@ -34,19 +34,24 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
         /// </summary>
         protected virtual float FadeExponent => 1.7f;
 
-        private readonly TrailPart[] parts = new TrailPart[max_sprites];
-        private int currentIndex;
-        private IShader shader;
-        private double timeOffset;
-        private float time;
-
         /// <summary>
         /// The scale used on creation of a new trail part.
         /// </summary>
-        public Vector2 NewPartScale = Vector2.One;
+        public Vector2 NewPartScale { get; set; } = Vector2.One;
 
-        private Anchor trailOrigin = Anchor.Centre;
+        /// <summary>
+        /// The rotation (in degrees) to apply to trail parts when <see cref="AllowPartRotation"/> is <c>true</c>.
+        /// </summary>
+        public float PartRotation { get; set; }
 
+        /// <summary>
+        /// Whether to rotate trail parts based on the value of <see cref="PartRotation"/>.
+        /// </summary>
+        protected bool AllowPartRotation { get; set; }
+
+        /// <summary>
+        /// The trail part texture origin.
+        /// </summary>
         protected Anchor TrailOrigin
         {
             get => trailOrigin;
@@ -57,6 +62,13 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
             }
         }
 
+        private readonly TrailPart[] parts = new TrailPart[max_sprites];
+        private Anchor trailOrigin = Anchor.Centre;
+        private int currentIndex;
+        private IShader shader;
+        private double timeOffset;
+        private float time;
+
         public CursorTrail()
         {
             // as we are currently very dependent on having a running clock, let's make our own clock for the time being.
@@ -220,6 +232,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
             private float time;
             private float fadeExponent;
+            private float angle;
 
             private readonly TrailPart[] parts = new TrailPart[max_sprites];
             private Vector2 originPosition;
@@ -239,6 +252,7 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
                 texture = Source.texture;
                 time = Source.time;
                 fadeExponent = Source.FadeExponent;
+                angle = Source.AllowPartRotation ? float.DegreesToRadians(Source.PartRotation) : 0;
 
                 originPosition = Vector2.Zero;
 
@@ -279,6 +293,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
                 renderer.PushLocalMatrix(DrawInfo.Matrix);
 
+                float sin = MathF.Sin(angle);
+                float cos = MathF.Cos(angle);
+
                 foreach (var part in parts)
                 {
                     if (part.InvalidationID == -1)
@@ -289,7 +306,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
                     vertexBatch.Add(new TexturedTrailVertex
                     {
-                        Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
+                        Position = rotateAround(
+                            new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
+                            part.Position, sin, cos),
                         TexturePosition = textureRect.BottomLeft,
                         TextureRect = new Vector4(0, 0, 1, 1),
                         Colour = DrawColourInfo.Colour.BottomLeft.Linear,
@@ -298,7 +317,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
                     vertexBatch.Add(new TexturedTrailVertex
                     {
-                        Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y),
+                        Position = rotateAround(
+                            new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X,
+                                part.Position.Y + texture.DisplayHeight * (1 - originPosition.Y) * part.Scale.Y), part.Position, sin, cos),
                         TexturePosition = textureRect.BottomRight,
                         TextureRect = new Vector4(0, 0, 1, 1),
                         Colour = DrawColourInfo.Colour.BottomRight.Linear,
@@ -307,7 +328,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
                     vertexBatch.Add(new TexturedTrailVertex
                     {
-                        Position = new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
+                        Position = rotateAround(
+                            new Vector2(part.Position.X + texture.DisplayWidth * (1 - originPosition.X) * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
+                            part.Position, sin, cos),
                         TexturePosition = textureRect.TopRight,
                         TextureRect = new Vector4(0, 0, 1, 1),
                         Colour = DrawColourInfo.Colour.TopRight.Linear,
@@ -316,7 +339,9 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
 
                     vertexBatch.Add(new TexturedTrailVertex
                     {
-                        Position = new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
+                        Position = rotateAround(
+                            new Vector2(part.Position.X - texture.DisplayWidth * originPosition.X * part.Scale.X, part.Position.Y - texture.DisplayHeight * originPosition.Y * part.Scale.Y),
+                            part.Position, sin, cos),
                         TexturePosition = textureRect.TopLeft,
                         TextureRect = new Vector4(0, 0, 1, 1),
                         Colour = DrawColourInfo.Colour.TopLeft.Linear,
@@ -330,6 +355,14 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
                 shader.Unbind();
             }
 
+            private static Vector2 rotateAround(Vector2 input, Vector2 origin, float sin, float cos)
+            {
+                float xTranslated = input.X - origin.X;
+                float yTranslated = input.Y - origin.Y;
+
+                return new Vector2(xTranslated * cos - yTranslated * sin, xTranslated * sin + yTranslated * cos) + origin;
+            }
+
             protected override void Dispose(bool isDisposing)
             {
                 base.Dispose(isDisposing);
diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs
index c2f7d84f5e..e84fb9e2d6 100644
--- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs
+++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursor.cs
@@ -36,6 +36,11 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
         /// </summary>
         public Vector2 CurrentExpandedScale => skinnableCursor.ExpandTarget?.Scale ?? Vector2.One;
 
+        /// <summary>
+        /// The current rotation of the cursor.
+        /// </summary>
+        public float CurrentRotation => skinnableCursor.ExpandTarget?.Rotation ?? 0;
+
         public IBindable<float> CursorScale => cursorScale;
 
         /// <summary>
diff --git a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs
index 8c0871d54f..974d99d7c8 100644
--- a/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs
+++ b/osu.Game.Rulesets.Osu/UI/Cursor/OsuCursorContainer.cs
@@ -83,7 +83,10 @@ namespace osu.Game.Rulesets.Osu.UI.Cursor
             base.Update();
 
             if (cursorTrail.Drawable is CursorTrail trail)
+            {
                 trail.NewPartScale = ActiveCursor.CurrentExpandedScale;
+                trail.PartRotation = ActiveCursor.CurrentRotation;
+            }
         }
 
         public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs
index 693e1e48d4..eeaa68e2ee 100644
--- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs
+++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs
@@ -3,6 +3,7 @@
 
 #nullable disable
 
+using System.Linq;
 using System.Threading;
 using NUnit.Framework;
 using osu.Framework.Allocation;
@@ -15,6 +16,7 @@ using osu.Framework.Input.Events;
 using osu.Framework.Input.States;
 using osu.Framework.Platform;
 using osu.Framework.Screens;
+using osu.Framework.Testing;
 using osu.Framework.Utils;
 using osu.Game.Beatmaps;
 using osu.Game.Configuration;
@@ -31,6 +33,7 @@ using osu.Game.Screens.Play;
 using osu.Game.Screens.Play.PlayerSettings;
 using osu.Game.Screens.Ranking;
 using osu.Game.Screens.Select;
+using osu.Game.Storyboards.Drawables;
 using osu.Game.Tests.Resources;
 using osuTK;
 using osuTK.Graphics;
@@ -45,6 +48,7 @@ namespace osu.Game.Tests.Visual.Background
         private LoadBlockingTestPlayer player;
         private BeatmapManager manager;
         private RulesetStore rulesets;
+        private UpdateCounter storyboardUpdateCounter;
 
         [BackgroundDependencyLoader]
         private void load(GameHost host, AudioManager audio)
@@ -194,6 +198,28 @@ namespace osu.Game.Tests.Visual.Background
             AddUntilStep("Storyboard is visible", () => player.IsStoryboardVisible);
         }
 
+        [Test]
+        public void TestStoryboardUpdatesWhenDimmed()
+        {
+            performFullSetup();
+            createFakeStoryboard();
+
+            AddStep("Enable fully dimmed storyboard", () =>
+            {
+                player.StoryboardReplacesBackground.Value = true;
+                player.StoryboardEnabled.Value = true;
+                player.DimmableStoryboard.IgnoreUserSettings.Value = false;
+                songSelect.DimLevel.Value = 1f;
+            });
+
+            AddUntilStep("Storyboard is invisible", () => !player.IsStoryboardVisible);
+
+            AddWaitStep("wait some", 20);
+
+            AddUntilStep("Storyboard is always present", () => player.ChildrenOfType<DrawableStoryboard>().Single().AlwaysPresent, () => Is.True);
+            AddUntilStep("Dimmable storyboard content is being updated", () => storyboardUpdateCounter.StoryboardContentLastUpdated, () => Is.EqualTo(Time.Current).Within(100));
+        }
+
         [Test]
         public void TestStoryboardIgnoreUserSettings()
         {
@@ -269,15 +295,19 @@ namespace osu.Game.Tests.Visual.Background
         {
             player.StoryboardEnabled.Value = false;
             player.StoryboardReplacesBackground.Value = false;
-            player.DimmableStoryboard.Add(new OsuSpriteText
+            player.DimmableStoryboard.AddRange(new Drawable[]
             {
-                Size = new Vector2(500, 50),
-                Alpha = 1,
-                Colour = Color4.White,
-                Anchor = Anchor.Centre,
-                Origin = Anchor.Centre,
-                Text = "THIS IS A STORYBOARD",
-                Font = new FontUsage(size: 50)
+                storyboardUpdateCounter = new UpdateCounter(),
+                new OsuSpriteText
+                {
+                    Size = new Vector2(500, 50),
+                    Alpha = 1,
+                    Colour = Color4.White,
+                    Anchor = Anchor.Centre,
+                    Origin = Anchor.Centre,
+                    Text = "THIS IS A STORYBOARD",
+                    Font = new FontUsage(size: 50)
+                }
             });
         });
 
@@ -353,7 +383,7 @@ namespace osu.Game.Tests.Visual.Background
             /// <summary>
             /// Make sure every time a screen gets pushed, the background doesn't get replaced
             /// </summary>
-            /// <returns>Whether or not the original background (The one created in DummySongSelect) is still the current background</returns>
+            /// <returns>Whether the original background (The one created in DummySongSelect) is still the current background</returns>
             public bool IsBackgroundCurrent() => background?.IsCurrentScreen() == true;
         }
 
@@ -384,7 +414,7 @@ namespace osu.Game.Tests.Visual.Background
 
             public new DimmableStoryboard DimmableStoryboard => base.DimmableStoryboard;
 
-            // Whether or not the player should be allowed to load.
+            // Whether the player should be allowed to load.
             public bool BlockLoad;
 
             public Bindable<bool> StoryboardEnabled;
@@ -451,6 +481,17 @@ namespace osu.Game.Tests.Visual.Background
             }
         }
 
+        private partial class UpdateCounter : Drawable
+        {
+            public double StoryboardContentLastUpdated;
+
+            protected override void Update()
+            {
+                base.Update();
+                StoryboardContentLastUpdated = Time.Current;
+            }
+        }
+
         private partial class TestDimmableBackground : BackgroundScreenBeatmap.DimmableBackground
         {
             public Color4 CurrentColour => Content.Colour;
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs
new file mode 100644
index 0000000000..9a54de1459
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorList.cs
@@ -0,0 +1,53 @@
+// 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.Threading;
+using NUnit.Framework;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Utils;
+using osu.Game.Graphics;
+using osu.Game.Screens.Play;
+using osu.Game.Screens.Play.HUD;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+    [TestFixture]
+    public partial class TestSceneSpectatorList : OsuTestScene
+    {
+        private readonly BindableList<SpectatorList.Spectator> spectators = new BindableList<SpectatorList.Spectator>();
+        private readonly Bindable<LocalUserPlayingState> localUserPlayingState = new Bindable<LocalUserPlayingState>();
+
+        private int counter;
+
+        [Test]
+        public void TestBasics()
+        {
+            SpectatorList list = null!;
+            AddStep("create spectator list", () => Child = list = new SpectatorList
+            {
+                Anchor = Anchor.Centre,
+                Origin = Anchor.Centre,
+                Spectators = { BindTarget = spectators },
+                UserPlayingState = { BindTarget = localUserPlayingState }
+            });
+
+            AddStep("start playing", () => localUserPlayingState.Value = LocalUserPlayingState.Playing);
+
+            AddRepeatStep("add a user", () =>
+            {
+                int id = Interlocked.Increment(ref counter);
+                spectators.Add(new SpectatorList.Spectator(id, $"User {id}"));
+            }, 10);
+
+            AddRepeatStep("remove random user", () => spectators.RemoveAt(RNG.Next(0, spectators.Count)), 5);
+
+            AddStep("change font to venera", () => list.Font.Value = Typeface.Venera);
+            AddStep("change font to torus", () => list.Font.Value = Typeface.Torus);
+            AddStep("change header colour", () => list.HeaderColour.Value = new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1));
+
+            AddStep("enter break", () => localUserPlayingState.Value = LocalUserPlayingState.Break);
+            AddStep("stop playing", () => localUserPlayingState.Value = LocalUserPlayingState.NotPlaying);
+        }
+    }
+}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs
new file mode 100644
index 0000000000..f99e0a418a
--- /dev/null
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarouselV2.cs
@@ -0,0 +1,273 @@
+// 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.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.ObjectExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Primitives;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Testing;
+using osu.Framework.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Graphics.Containers;
+using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Filter;
+using osu.Game.Screens.SelectV2;
+using osu.Game.Tests.Beatmaps;
+using osu.Game.Tests.Resources;
+using osuTK.Graphics;
+using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
+
+namespace osu.Game.Tests.Visual.SongSelect
+{
+    [TestFixture]
+    public partial class TestSceneBeatmapCarouselV2 : OsuManualInputManagerTestScene
+    {
+        private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>();
+
+        [Cached(typeof(BeatmapStore))]
+        private BeatmapStore store;
+
+        private OsuTextFlowContainer stats = null!;
+        private BeatmapCarousel carousel = null!;
+
+        private OsuScrollContainer scroll => carousel.ChildrenOfType<OsuScrollContainer>().Single();
+
+        private int beatmapCount;
+
+        public TestSceneBeatmapCarouselV2()
+        {
+            store = new TestBeatmapStore
+            {
+                BeatmapSets = { BindTarget = beatmapSets }
+            };
+
+            beatmapSets.BindCollectionChanged((_, _) =>
+            {
+                beatmapCount = beatmapSets.Sum(s => s.Beatmaps.Count);
+            });
+
+            Scheduler.AddDelayed(updateStats, 100, true);
+        }
+
+        [SetUpSteps]
+        public void SetUpSteps()
+        {
+            AddStep("create components", () =>
+            {
+                beatmapSets.Clear();
+
+                Box topBox;
+                Children = new Drawable[]
+                {
+                    new GridContainer
+                    {
+                        RelativeSizeAxes = Axes.Both,
+                        ColumnDimensions = new[]
+                        {
+                            new Dimension(GridSizeMode.Relative, 1),
+                        },
+                        RowDimensions = new[]
+                        {
+                            new Dimension(GridSizeMode.Absolute, 200),
+                            new Dimension(),
+                            new Dimension(GridSizeMode.Absolute, 200),
+                        },
+                        Content = new[]
+                        {
+                            new Drawable[]
+                            {
+                                topBox = new Box
+                                {
+                                    Anchor = Anchor.Centre,
+                                    Origin = Anchor.Centre,
+                                    Colour = Color4.Cyan,
+                                    RelativeSizeAxes = Axes.Both,
+                                    Alpha = 0.4f,
+                                },
+                            },
+                            new Drawable[]
+                            {
+                                carousel = new BeatmapCarousel
+                                {
+                                    Anchor = Anchor.Centre,
+                                    Origin = Anchor.Centre,
+                                    Width = 500,
+                                    RelativeSizeAxes = Axes.Y,
+                                },
+                            },
+                            new[]
+                            {
+                                new Box
+                                {
+                                    Anchor = Anchor.Centre,
+                                    Origin = Anchor.Centre,
+                                    Colour = Color4.Cyan,
+                                    RelativeSizeAxes = Axes.Both,
+                                    Alpha = 0.4f,
+                                },
+                                topBox.CreateProxy(),
+                            }
+                        }
+                    },
+                    stats = new OsuTextFlowContainer(cp => cp.Font = FrameworkFont.Regular.With())
+                    {
+                        Padding = new MarginPadding(10),
+                        TextAnchor = Anchor.CentreLeft,
+                        Anchor = Anchor.CentreLeft,
+                        Origin = Anchor.CentreLeft,
+                    },
+                };
+            });
+
+            AddStep("sort by title", () =>
+            {
+                carousel.Filter(new FilterCriteria { Sort = SortMode.Title });
+            });
+        }
+
+        [Test]
+        public void TestBasic()
+        {
+            AddStep("add 10 beatmaps", () =>
+            {
+                for (int i = 0; i < 10; i++)
+                    beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
+            });
+
+            AddStep("add 1 beatmap", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))));
+
+            AddStep("remove all beatmaps", () => beatmapSets.Clear());
+        }
+
+        [Test]
+        public void TestSorting()
+        {
+            AddStep("add 10 beatmaps", () =>
+            {
+                for (int i = 0; i < 10; i++)
+                    beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
+            });
+
+            AddStep("sort by difficulty", () =>
+            {
+                carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty });
+            });
+
+            AddStep("sort by artist", () =>
+            {
+                carousel.Filter(new FilterCriteria { Sort = SortMode.Artist });
+            });
+        }
+
+        [Test]
+        public void TestScrollPositionMaintainedOnAddSecondSelected()
+        {
+            Quad positionBefore = default;
+
+            AddStep("add 10 beatmaps", () =>
+            {
+                for (int i = 0; i < 10; i++)
+                    beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
+            });
+
+            AddUntilStep("visual item added", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
+
+            AddStep("select middle beatmap", () => carousel.CurrentSelection = beatmapSets.ElementAt(beatmapSets.Count - 2));
+            AddStep("scroll to selected item", () => scroll.ScrollTo(scroll.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value)));
+
+            AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target));
+
+            AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad);
+
+            AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last()));
+            AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False);
+            AddAssert("select screen position unchanged", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad,
+                () => Is.EqualTo(positionBefore));
+        }
+
+        [Test]
+        public void TestScrollPositionMaintainedOnAddLastSelected()
+        {
+            Quad positionBefore = default;
+
+            AddStep("add 10 beatmaps", () =>
+            {
+                for (int i = 0; i < 10; i++)
+                    beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
+            });
+
+            AddUntilStep("visual item added", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
+
+            AddStep("scroll to last item", () => scroll.ScrollToEnd(false));
+
+            AddStep("select last beatmap", () => carousel.CurrentSelection = beatmapSets.First());
+
+            AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target));
+
+            AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad);
+
+            AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last()));
+            AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False);
+            AddAssert("select screen position unchanged", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad,
+                () => Is.EqualTo(positionBefore));
+        }
+
+        [Test]
+        public void TestAddRemoveOneByOne()
+        {
+            AddRepeatStep("add beatmaps", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
+
+            AddRepeatStep("remove beatmaps", () => beatmapSets.RemoveAt(RNG.Next(0, beatmapSets.Count)), 20);
+        }
+
+        [Test]
+        [Explicit]
+        public void TestInsane()
+        {
+            const int count = 200000;
+
+            List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
+
+            AddStep($"populate {count} test beatmaps", () =>
+            {
+                generated.Clear();
+                Task.Run(() =>
+                {
+                    for (int j = 0; j < count; j++)
+                        generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
+                }).ConfigureAwait(true);
+            });
+
+            AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
+            AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
+            AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
+
+            AddStep("add all beatmaps", () => beatmapSets.AddRange(generated));
+        }
+
+        private void updateStats()
+        {
+            if (carousel.IsNull())
+                return;
+
+            stats.Text = $"""
+                                        store
+                                          sets: {beatmapSets.Count}
+                                          beatmaps: {beatmapCount}
+                                        carousel:
+                                          sorting: {carousel.IsFiltering}
+                                          tracked: {carousel.ItemsTracked}
+                                          displayable: {carousel.DisplayableItems}
+                                          displayed: {carousel.VisibleItems}
+                          """;
+        }
+    }
+}
diff --git a/osu.Game/Graphics/Containers/OsuHoverContainer.cs b/osu.Game/Graphics/Containers/OsuHoverContainer.cs
index 3b5e48d23e..e396eb6ec9 100644
--- a/osu.Game/Graphics/Containers/OsuHoverContainer.cs
+++ b/osu.Game/Graphics/Containers/OsuHoverContainer.cs
@@ -15,9 +15,11 @@ namespace osu.Game.Graphics.Containers
     {
         protected const float FADE_DURATION = 500;
 
-        protected Color4 HoverColour;
+        public Color4? HoverColour { get; set; }
+        private Color4 fallbackHoverColour;
 
-        protected Color4 IdleColour = Color4.White;
+        public Color4? IdleColour { get; set; }
+        private Color4 fallbackIdleColour;
 
         protected virtual IEnumerable<Drawable> EffectTargets => new[] { Content };
 
@@ -67,18 +69,18 @@ namespace osu.Game.Graphics.Containers
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
-            if (HoverColour == default)
-                HoverColour = colours.Yellow;
+            fallbackHoverColour = colours.Yellow;
+            fallbackIdleColour = Color4.White;
         }
 
         protected override void LoadComplete()
         {
             base.LoadComplete();
-            EffectTargets.ForEach(d => d.FadeColour(IdleColour));
+            EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour));
         }
 
-        private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour, FADE_DURATION, Easing.OutQuint));
+        private void fadeIn() => EffectTargets.ForEach(d => d.FadeColour(HoverColour ?? fallbackHoverColour, FADE_DURATION, Easing.OutQuint));
 
-        private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour, FADE_DURATION, Easing.OutQuint));
+        private void fadeOut() => EffectTargets.ForEach(d => d.FadeColour(IdleColour ?? fallbackIdleColour, FADE_DURATION, Easing.OutQuint));
     }
 }
diff --git a/osu.Game/Localisation/HUD/SpectatorListStrings.cs b/osu.Game/Localisation/HUD/SpectatorListStrings.cs
new file mode 100644
index 0000000000..8d82250526
--- /dev/null
+++ b/osu.Game/Localisation/HUD/SpectatorListStrings.cs
@@ -0,0 +1,19 @@
+// 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 osu.Framework.Localisation;
+
+namespace osu.Game.Localisation.HUD
+{
+    public static class SpectatorListStrings
+    {
+        private const string prefix = @"osu.Game.Resources.Localisation.SpectatorList";
+
+        /// <summary>
+        /// "Spectators ({0})"
+        /// </summary>
+        public static LocalisableString SpectatorCount(int arg0) => new TranslatableString(getKey(@"spectator_count"), @"Spectators ({0})", arg0);
+
+        private static string getKey(string key) => $@"{prefix}:{key}";
+    }
+}
diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs
index fa107a0e43..e4baeb4838 100644
--- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs
+++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs
@@ -56,7 +56,7 @@ namespace osu.Game.Online.Chat
         [BackgroundDependencyLoader]
         private void load(OsuColour colours)
         {
-            IdleColour = overlayColourProvider?.Light2 ?? colours.Blue;
+            IdleColour ??= overlayColourProvider?.Light2 ?? colours.Blue;
         }
 
         protected override IEnumerable<Drawable> EffectTargets => Parts;
diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
index af78d62789..c4425643fd 100644
--- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
@@ -1,6 +1,7 @@
 // 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.Linq;
 using osu.Framework.Allocation;
 using osu.Framework.Bindables;
@@ -200,16 +201,19 @@ namespace osu.Game.Overlays.Profile.Header.Components
 
                 case FriendStatus.NotMutual:
                     IdleColour = colour.Green.Opacity(0.7f);
-                    HoverColour = IdleColour.Lighten(0.1f);
+                    HoverColour = IdleColour.Value.Lighten(0.1f);
                     break;
 
                 case FriendStatus.Mutual:
                     IdleColour = colour.Pink.Opacity(0.7f);
-                    HoverColour = IdleColour.Lighten(0.1f);
+                    HoverColour = IdleColour.Value.Lighten(0.1f);
                     break;
+
+                default:
+                    throw new ArgumentOutOfRangeException();
             }
 
-            EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour : IdleColour, FADE_DURATION, Easing.OutQuint));
+            EffectTargets.ForEach(d => d.FadeColour(IsHovered ? HoverColour.Value : IdleColour.Value, FADE_DURATION, Easing.OutQuint));
         }
 
         private enum FriendStatus
diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs
index cc521aeab7..a9fa918ea0 100644
--- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs
+++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs
@@ -87,19 +87,23 @@ namespace osu.Game.Rulesets.Objects.Types
         /// <param name="lastObj">The previous hitobject, or null if this is the first object in the beatmap.</param>
         void UpdateComboInformation(IHasComboInformation? lastObj)
         {
-            ComboIndex = lastObj?.ComboIndex ?? 0;
-            ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
-            IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
+            int index = lastObj?.ComboIndex ?? 0;
+            int indexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0;
+            int inCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0;
 
             if (NewCombo || lastObj == null)
             {
-                IndexInCurrentCombo = 0;
-                ComboIndex++;
-                ComboIndexWithOffsets += ComboOffset + 1;
+                inCurrentCombo = 0;
+                index++;
+                indexWithOffsets += ComboOffset + 1;
 
                 if (lastObj != null)
                     lastObj.LastInCombo = true;
             }
+
+            ComboIndex = index;
+            ComboIndexWithOffsets = indexWithOffsets;
+            IndexInCurrentCombo = inCurrentCombo;
         }
     }
 }
diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs
index b6cfca58db..46c0371d9f 100644
--- a/osu.Game/Rulesets/Scoring/HitResult.cs
+++ b/osu.Game/Rulesets/Scoring/HitResult.cs
@@ -28,7 +28,7 @@ namespace osu.Game.Rulesets.Scoring
         /// </summary>
         /// <remarks>
         /// This miss window should determine how early a hit can be before it is considered for judgement (as opposed to being ignored as
-        /// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time).
+        /// "too far in the future"). It should also define when a forced miss should be triggered (as a result of no user input in time).
         /// </remarks>
         [Description(@"Miss")]
         [EnumMember(Value = "miss")]
diff --git a/osu.Game/Screens/Play/DimmableStoryboard.cs b/osu.Game/Screens/Play/DimmableStoryboard.cs
index 84d99ea863..a096400fe0 100644
--- a/osu.Game/Screens/Play/DimmableStoryboard.cs
+++ b/osu.Game/Screens/Play/DimmableStoryboard.cs
@@ -69,7 +69,22 @@ namespace osu.Game.Screens.Play
 
         protected override void LoadComplete()
         {
-            ShowStoryboard.BindValueChanged(_ => initializeStoryboard(true), true);
+            ShowStoryboard.BindValueChanged(show =>
+            {
+                initializeStoryboard(true);
+
+                if (drawableStoryboard != null)
+                {
+                    // Regardless of user dim setting, for the time being we need to ensure storyboards are still updated in the background (even if not displayed).
+                    // If we don't do this, an intensive storyboard will have a lot of catch-up work to do at the start of a break, causing a huge stutter.
+                    //
+                    // This can be reconsidered when https://github.com/ppy/osu-framework/issues/6491 is resolved.
+                    bool alwaysPresent = show.NewValue;
+
+                    Content.AlwaysPresent = alwaysPresent;
+                    drawableStoryboard.AlwaysPresent = alwaysPresent;
+                }
+            }, true);
             base.LoadComplete();
         }
 
diff --git a/osu.Game/Screens/Play/HUD/SpectatorList.cs b/osu.Game/Screens/Play/HUD/SpectatorList.cs
new file mode 100644
index 0000000000..438aa61d9d
--- /dev/null
+++ b/osu.Game/Screens/Play/HUD/SpectatorList.cs
@@ -0,0 +1,242 @@
+// 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.Specialized;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Extensions.LocalisationExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Pooling;
+using osu.Game.Configuration;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Online.Chat;
+using osu.Game.Users;
+using osu.Game.Localisation.HUD;
+using osu.Game.Localisation.SkinComponents;
+using osuTK.Graphics;
+
+namespace osu.Game.Screens.Play.HUD
+{
+    public partial class SpectatorList : CompositeDrawable
+    {
+        private const int max_spectators_displayed = 10;
+
+        public BindableList<Spectator> Spectators { get; } = new BindableList<Spectator>();
+        public Bindable<LocalUserPlayingState> UserPlayingState { get; } = new Bindable<LocalUserPlayingState>();
+
+        [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.Font), nameof(SkinnableComponentStrings.FontDescription))]
+        public Bindable<Typeface> Font { get; } = new Bindable<Typeface>(Typeface.Torus);
+
+        [SettingSource(typeof(SkinnableComponentStrings), nameof(SkinnableComponentStrings.TextColour), nameof(SkinnableComponentStrings.TextColourDescription))]
+        public BindableColour4 HeaderColour { get; } = new BindableColour4(Colour4.White);
+
+        protected OsuSpriteText Header { get; private set; } = null!;
+
+        private FillFlowContainer mainFlow = null!;
+        private FillFlowContainer<SpectatorListEntry> spectatorsFlow = null!;
+        private DrawablePool<SpectatorListEntry> pool = null!;
+
+        [BackgroundDependencyLoader]
+        private void load(OsuColour colours)
+        {
+            AutoSizeAxes = Axes.Y;
+
+            InternalChildren = new Drawable[]
+            {
+                mainFlow = new FillFlowContainer
+                {
+                    AutoSizeAxes = Axes.Both,
+                    Direction = FillDirection.Vertical,
+                    Children = new Drawable[]
+                    {
+                        Header = new OsuSpriteText
+                        {
+                            Colour = colours.Blue0,
+                            Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold),
+                        },
+                        spectatorsFlow = new FillFlowContainer<SpectatorListEntry>
+                        {
+                            AutoSizeAxes = Axes.Both,
+                            Direction = FillDirection.Vertical,
+                        }
+                    }
+                },
+                pool = new DrawablePool<SpectatorListEntry>(max_spectators_displayed),
+            };
+
+            HeaderColour.Value = Header.Colour;
+        }
+
+        protected override void LoadComplete()
+        {
+            base.LoadComplete();
+
+            Spectators.BindCollectionChanged(onSpectatorsChanged, true);
+            UserPlayingState.BindValueChanged(_ => updateVisibility());
+
+            Font.BindValueChanged(_ => updateAppearance());
+            HeaderColour.BindValueChanged(_ => updateAppearance(), true);
+            FinishTransforms(true);
+
+            this.FadeInFromZero(200, Easing.OutQuint);
+        }
+
+        private void onSpectatorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
+        {
+            switch (e.Action)
+            {
+                case NotifyCollectionChangedAction.Add:
+                {
+                    for (int i = 0; i < e.NewItems!.Count; i++)
+                    {
+                        var spectator = (Spectator)e.NewItems![i]!;
+                        int index = Math.Max(e.NewStartingIndex, 0) + i;
+
+                        if (index >= max_spectators_displayed)
+                            break;
+
+                        addNewSpectatorToList(index, spectator);
+                    }
+
+                    break;
+                }
+
+                case NotifyCollectionChangedAction.Remove:
+                {
+                    spectatorsFlow.RemoveAll(entry => e.OldItems!.Contains(entry.Current.Value), false);
+
+                    for (int i = 0; i < spectatorsFlow.Count; i++)
+                        spectatorsFlow.SetLayoutPosition(spectatorsFlow[i], i);
+
+                    if (Spectators.Count >= max_spectators_displayed && spectatorsFlow.Count < max_spectators_displayed)
+                    {
+                        for (int i = spectatorsFlow.Count; i < max_spectators_displayed; i++)
+                            addNewSpectatorToList(i, Spectators[i]);
+                    }
+
+                    break;
+                }
+
+                case NotifyCollectionChangedAction.Reset:
+                {
+                    spectatorsFlow.Clear(false);
+                    break;
+                }
+
+                default:
+                    throw new NotSupportedException();
+            }
+
+            Header.Text = SpectatorListStrings.SpectatorCount(Spectators.Count).ToUpper();
+            updateVisibility();
+
+            for (int i = 0; i < spectatorsFlow.Count; i++)
+            {
+                spectatorsFlow[i].Colour = i < max_spectators_displayed - 1
+                    ? Color4.White
+                    : ColourInfo.GradientVertical(Color4.White, Color4.White.Opacity(0));
+            }
+        }
+
+        private void addNewSpectatorToList(int i, Spectator spectator)
+        {
+            var entry = pool.Get(entry =>
+            {
+                entry.Current.Value = spectator;
+                entry.UserPlayingState = UserPlayingState;
+            });
+
+            spectatorsFlow.Insert(i, entry);
+        }
+
+        private void updateVisibility()
+        {
+            mainFlow.FadeTo(Spectators.Count > 0 && UserPlayingState.Value != LocalUserPlayingState.NotPlaying ? 1 : 0, 250, Easing.OutQuint);
+        }
+
+        private void updateAppearance()
+        {
+            Header.Font = OsuFont.GetFont(Font.Value, 12, FontWeight.Bold);
+            Header.Colour = HeaderColour.Value;
+
+            Width = Header.DrawWidth;
+        }
+
+        private partial class SpectatorListEntry : PoolableDrawable
+        {
+            public Bindable<Spectator> Current { get; } = new Bindable<Spectator>();
+
+            private readonly BindableWithCurrent<LocalUserPlayingState> current = new BindableWithCurrent<LocalUserPlayingState>();
+
+            public Bindable<LocalUserPlayingState> UserPlayingState
+            {
+                get => current.Current;
+                set => current.Current = value;
+            }
+
+            private OsuSpriteText username = null!;
+            private DrawableLinkCompiler? linkCompiler;
+
+            [Resolved]
+            private OsuGame? game { get; set; }
+
+            [BackgroundDependencyLoader]
+            private void load()
+            {
+                AutoSizeAxes = Axes.Both;
+
+                InternalChildren = new Drawable[]
+                {
+                    username = new OsuSpriteText(),
+                };
+            }
+
+            protected override void LoadComplete()
+            {
+                base.LoadComplete();
+                UserPlayingState.BindValueChanged(_ => updateEnabledState());
+                Current.BindValueChanged(_ => updateState(), true);
+            }
+
+            protected override void PrepareForUse()
+            {
+                base.PrepareForUse();
+
+                username.MoveToX(10)
+                        .Then()
+                        .MoveToX(0, 400, Easing.OutQuint);
+
+                this.FadeInFromZero(400, Easing.OutQuint);
+            }
+
+            private void updateState()
+            {
+                username.Text = Current.Value.Username;
+                linkCompiler?.Expire();
+                AddInternal(linkCompiler = new DrawableLinkCompiler([username])
+                {
+                    IdleColour = Colour4.White,
+                    Action = () => game?.HandleLink(new LinkDetails(LinkAction.OpenUserProfile, Current.Value)),
+                });
+                updateEnabledState();
+            }
+
+            private void updateEnabledState()
+            {
+                if (linkCompiler != null)
+                    linkCompiler.Enabled.Value = UserPlayingState.Value != LocalUserPlayingState.Playing;
+            }
+        }
+
+        public record Spectator(int OnlineID, string Username) : IUser
+        {
+            public CountryCode CountryCode => CountryCode.Unknown;
+            public bool IsBot => false;
+        }
+    }
+}
diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
index f93fa1b3c5..ac224794ea 100644
--- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
+++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs
@@ -165,13 +165,14 @@ namespace osu.Game.Screens.Play.PlayerSettings
                     if (setInfo == null) // only the case for tests.
                         return;
 
-                    // Apply to all difficulties in a beatmap set for now (they generally always share timing).
+                    // Apply to all difficulties in a beatmap set if they have the same audio
+                    // (they generally always share timing).
                     foreach (var b in setInfo.Beatmaps)
                     {
                         BeatmapUserSettings userSettings = b.UserSettings;
                         double val = Current.Value;
 
-                        if (userSettings.Offset != val)
+                        if (userSettings.Offset != val && b.AudioEquals(beatmap.Value.BeatmapInfo))
                             userSettings.Offset = val;
                     }
                 });
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
new file mode 100644
index 0000000000..93d4c90be0
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs
@@ -0,0 +1,106 @@
+// 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.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Pooling;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Screens.Select;
+
+namespace osu.Game.Screens.SelectV2
+{
+    [Cached]
+    public partial class BeatmapCarousel : Carousel<BeatmapInfo>
+    {
+        private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
+
+        private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
+
+        private readonly LoadingLayer loading;
+
+        public BeatmapCarousel()
+        {
+            DebounceDelay = 100;
+            DistanceOffscreenToPreload = 100;
+
+            Filters = new ICarouselFilter[]
+            {
+                new BeatmapCarouselFilterSorting(() => Criteria),
+                new BeatmapCarouselFilterGrouping(() => Criteria),
+            };
+
+            AddInternal(carouselPanelPool);
+
+            AddInternal(loading = new LoadingLayer(dimBackground: true));
+        }
+
+        [BackgroundDependencyLoader]
+        private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
+        {
+            detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
+            detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
+        }
+
+        protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get();
+
+        protected override CarouselItem CreateCarouselItemForModel(BeatmapInfo model) => new BeatmapCarouselItem(model);
+
+        private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
+        {
+            // 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>? newBeatmapSets = changed.NewItems?.Cast<BeatmapSetInfo>();
+            IEnumerable<BeatmapSetInfo>? beatmapSetInfos = changed.OldItems?.Cast<BeatmapSetInfo>();
+
+            switch (changed.Action)
+            {
+                case NotifyCollectionChangedAction.Add:
+                    Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps));
+                    break;
+
+                case NotifyCollectionChangedAction.Remove:
+
+                    foreach (var set in beatmapSetInfos!)
+                    {
+                        foreach (var beatmap in set.Beatmaps)
+                            Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
+                    }
+
+                    break;
+
+                case NotifyCollectionChangedAction.Move:
+                case NotifyCollectionChangedAction.Replace:
+                    throw new NotImplementedException();
+
+                case NotifyCollectionChangedAction.Reset:
+                    Items.Clear();
+                    break;
+            }
+        }
+
+        public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
+
+        public void Filter(FilterCriteria criteria)
+        {
+            Criteria = criteria;
+            FilterAsync().FireAndForget();
+        }
+
+        protected override async Task FilterAsync()
+        {
+            loading.Show();
+            await base.FilterAsync().ConfigureAwait(true);
+            loading.Hide();
+        }
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
new file mode 100644
index 0000000000..6cdd15d301
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs
@@ -0,0 +1,60 @@
+// 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.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using osu.Game.Beatmaps;
+using osu.Game.Screens.Select;
+
+namespace osu.Game.Screens.SelectV2
+{
+    public class BeatmapCarouselFilterGrouping : ICarouselFilter
+    {
+        private readonly Func<FilterCriteria> getCriteria;
+
+        public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
+        {
+            this.getCriteria = getCriteria;
+        }
+
+        public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
+        {
+            var criteria = getCriteria();
+
+            if (criteria.SplitOutDifficulties)
+            {
+                foreach (var item in items)
+                    ((BeatmapCarouselItem)item).HasGroupHeader = false;
+
+                return items;
+            }
+
+            CarouselItem? lastItem = null;
+
+            var newItems = new List<CarouselItem>(items.Count());
+
+            foreach (var item in items)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+
+                if (item.Model is BeatmapInfo b)
+                {
+                    // Add set header
+                    if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID))
+                        newItems.Add(new BeatmapCarouselItem(b.BeatmapSet!) { IsGroupHeader = true });
+                }
+
+                newItems.Add(item);
+                lastItem = item;
+
+                var beatmapCarouselItem = (BeatmapCarouselItem)item;
+                beatmapCarouselItem.HasGroupHeader = true;
+            }
+
+            return newItems;
+        }, cancellationToken).ConfigureAwait(false);
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs
new file mode 100644
index 0000000000..df41aa3e86
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterSorting.cs
@@ -0,0 +1,65 @@
+// 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.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using osu.Game.Beatmaps;
+using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Filter;
+using osu.Game.Utils;
+
+namespace osu.Game.Screens.SelectV2
+{
+    public class BeatmapCarouselFilterSorting : ICarouselFilter
+    {
+        private readonly Func<FilterCriteria> getCriteria;
+
+        public BeatmapCarouselFilterSorting(Func<FilterCriteria> getCriteria)
+        {
+            this.getCriteria = getCriteria;
+        }
+
+        public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
+        {
+            var criteria = getCriteria();
+
+            return items.OrderDescending(Comparer<CarouselItem>.Create((a, b) =>
+            {
+                int comparison = 0;
+
+                if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb)
+                {
+                    switch (criteria.Sort)
+                    {
+                        case SortMode.Artist:
+                            comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
+                            if (comparison == 0)
+                                goto case SortMode.Title;
+                            break;
+
+                        case SortMode.Difficulty:
+                            comparison = ab.StarRating.CompareTo(bb.StarRating);
+                            break;
+
+                        case SortMode.Title:
+                            comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
+                            break;
+
+                        default:
+                            throw new ArgumentOutOfRangeException();
+                    }
+                }
+
+                if (comparison != 0) return comparison;
+
+                if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem)
+                    return aItem.ID.CompareTo(bItem.ID);
+
+                return 0;
+            }));
+        }, cancellationToken).ConfigureAwait(false);
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs
new file mode 100644
index 0000000000..dd7aae3db9
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/BeatmapCarouselItem.cs
@@ -0,0 +1,48 @@
+// 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 osu.Game.Beatmaps;
+using osu.Game.Database;
+
+namespace osu.Game.Screens.SelectV2
+{
+    public class BeatmapCarouselItem : CarouselItem
+    {
+        public readonly Guid ID;
+
+        /// <summary>
+        /// Whether this item has a header providing extra information for it.
+        /// When displaying items which don't have header, we should make sure enough information is included inline.
+        /// </summary>
+        public bool HasGroupHeader { get; set; }
+
+        /// <summary>
+        /// Whether this item is a group header.
+        /// Group headers are generally larger in display. Setting this will account for the size difference.
+        /// </summary>
+        public bool IsGroupHeader { get; set; }
+
+        public override float DrawHeight => IsGroupHeader ? 80 : 40;
+
+        public BeatmapCarouselItem(object model)
+            : base(model)
+        {
+            ID = (Model as IHasGuidPrimaryKey)?.ID ?? Guid.NewGuid();
+        }
+
+        public override string? ToString()
+        {
+            switch (Model)
+            {
+                case BeatmapInfo bi:
+                    return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)";
+
+                case BeatmapSetInfo si:
+                    return $"{si.Metadata}";
+            }
+
+            return Model.ToString();
+        }
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs
new file mode 100644
index 0000000000..27023b50be
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/BeatmapCarouselPanel.cs
@@ -0,0 +1,102 @@
+// 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.Diagnostics;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Pooling;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Input.Events;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics.Sprites;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Screens.SelectV2
+{
+    public partial class BeatmapCarouselPanel : PoolableDrawable, ICarouselPanel
+    {
+        [Resolved]
+        private BeatmapCarousel carousel { get; set; } = null!;
+
+        public CarouselItem? Item
+        {
+            get => item;
+            set
+            {
+                item = value;
+
+                selected.UnbindBindings();
+
+                if (item != null)
+                    selected.BindTo(item.Selected);
+            }
+        }
+
+        private readonly BindableBool selected = new BindableBool();
+        private CarouselItem? item;
+
+        [BackgroundDependencyLoader]
+        private void load()
+        {
+            selected.BindValueChanged(value =>
+            {
+                if (value.NewValue)
+                {
+                    BorderThickness = 5;
+                    BorderColour = Color4.Pink;
+                }
+                else
+                {
+                    BorderThickness = 0;
+                }
+            });
+        }
+
+        protected override void FreeAfterUse()
+        {
+            base.FreeAfterUse();
+            Item = null;
+        }
+
+        protected override void PrepareForUse()
+        {
+            base.PrepareForUse();
+
+            Debug.Assert(Item != null);
+
+            DrawYPosition = Item.CarouselYPosition;
+
+            Size = new Vector2(500, Item.DrawHeight);
+            Masking = true;
+
+            InternalChildren = new Drawable[]
+            {
+                new Box
+                {
+                    Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5),
+                    RelativeSizeAxes = Axes.Both,
+                },
+                new OsuSpriteText
+                {
+                    Text = Item.ToString() ?? string.Empty,
+                    Padding = new MarginPadding(5),
+                    Anchor = Anchor.CentreLeft,
+                    Origin = Anchor.CentreLeft,
+                }
+            };
+
+            this.FadeInFromZero(500, Easing.OutQuint);
+        }
+
+        protected override bool OnClick(ClickEvent e)
+        {
+            carousel.CurrentSelection = Item!.Model;
+            return true;
+        }
+
+        public double DrawYPosition { get; set; }
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/Carousel.cs b/osu.Game/Screens/SelectV2/Carousel.cs
new file mode 100644
index 0000000000..12a86be7b9
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/Carousel.cs
@@ -0,0 +1,482 @@
+// 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.Diagnostics;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.TypeExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Pooling;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Logging;
+using osu.Framework.Utils;
+using osu.Game.Graphics.Containers;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Screens.SelectV2
+{
+    /// <summary>
+    /// A highly efficient vertical list display that is used primarily for the song select screen,
+    /// but flexible enough to be used for other use cases.
+    /// </summary>
+    public abstract partial class Carousel<T> : CompositeDrawable
+    {
+        /// <summary>
+        /// A collection of filters which should be run each time a <see cref="FilterAsync"/> is executed.
+        /// </summary>
+        protected IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
+
+        /// <summary>
+        /// Height of the area above the carousel that should be treated as visible due to transparency of elements in front of it.
+        /// </summary>
+        public float BleedTop { get; set; } = 0;
+
+        /// <summary>
+        /// Height of the area below the carousel that should be treated as visible due to transparency of elements in front of it.
+        /// </summary>
+        public float BleedBottom { get; set; } = 0;
+
+        /// <summary>
+        /// The number of pixels outside the carousel's vertical bounds to manifest drawables.
+        /// This allows preloading content before it scrolls into view.
+        /// </summary>
+        public float DistanceOffscreenToPreload { get; set; }
+
+        /// <summary>
+        /// When a new request arrives to change filtering, the number of milliseconds to wait before performing the filter.
+        /// Regardless of any external debouncing, this is a safety measure to avoid triggering too many threaded operations.
+        /// </summary>
+        public int DebounceDelay { get; set; }
+
+        /// <summary>
+        /// Whether an asynchronous filter / group operation is currently underway.
+        /// </summary>
+        public bool IsFiltering => !filterTask.IsCompleted;
+
+        /// <summary>
+        /// The number of displayable items currently being tracked (before filtering).
+        /// </summary>
+        public int ItemsTracked => Items.Count;
+
+        /// <summary>
+        /// The number of carousel items currently in rotation for display.
+        /// </summary>
+        public int DisplayableItems => displayedCarouselItems?.Count ?? 0;
+
+        /// <summary>
+        /// The number of items currently actualised into drawables.
+        /// </summary>
+        public int VisibleItems => scroll.Panels.Count;
+
+        /// <summary>
+        /// All items which are to be considered for display in this carousel.
+        /// Mutating this list will automatically queue a <see cref="FilterAsync"/>.
+        /// </summary>
+        /// <remarks>
+        /// Note that an <see cref="ICarouselFilter"/> may add new items which are displayed but not tracked in this list.
+        /// </remarks>
+        protected readonly BindableList<T> Items = new BindableList<T>();
+
+        /// <summary>
+        /// The currently selected model.
+        /// </summary>
+        /// <remarks>
+        /// Setting this will ensure <see cref="CarouselItem.Selected"/> is set to <c>true</c> only on the matching <see cref="CarouselItem"/>.
+        /// Of note, if no matching item exists all items will be deselected while waiting for potential new item which matches.
+        /// </remarks>
+        public virtual object? CurrentSelection
+        {
+            get => currentSelection;
+            set
+            {
+                if (currentSelectionCarouselItem != null)
+                    currentSelectionCarouselItem.Selected.Value = false;
+
+                currentSelection = value;
+
+                currentSelectionCarouselItem = null;
+                currentSelectionYPosition = null;
+                updateSelection();
+            }
+        }
+
+        private List<CarouselItem>? displayedCarouselItems;
+
+        private readonly CarouselScrollContainer scroll;
+
+        protected Carousel()
+        {
+            InternalChildren = new Drawable[]
+            {
+                new Box
+                {
+                    Colour = Color4.Black,
+                    RelativeSizeAxes = Axes.Both,
+                },
+                scroll = new CarouselScrollContainer
+                {
+                    RelativeSizeAxes = Axes.Both,
+                    Masking = false,
+                }
+            };
+
+            Items.BindCollectionChanged((_, _) => FilterAsync());
+        }
+
+        /// <summary>
+        /// Queue an asynchronous filter operation.
+        /// </summary>
+        protected virtual Task FilterAsync() => filterTask = performFilter();
+
+        /// <summary>
+        /// Create a drawable for the given carousel item so it can be displayed.
+        /// </summary>
+        /// <remarks>
+        /// For efficiency, it is recommended the drawables are retrieved from a <see cref="DrawablePool{T}"/>.
+        /// </remarks>
+        /// <param name="item">The item which should be represented by the returned drawable.</param>
+        /// <returns>The manifested drawable.</returns>
+        protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
+
+        /// <summary>
+        /// Create an internal carousel representation for the provided model object.
+        /// </summary>
+        /// <param name="model">The model.</param>
+        /// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
+        protected abstract CarouselItem CreateCarouselItemForModel(T model);
+
+        #region Filtering and display preparation
+
+        private Task filterTask = Task.CompletedTask;
+        private CancellationTokenSource cancellationSource = new CancellationTokenSource();
+
+        private async Task performFilter()
+        {
+            Debug.Assert(SynchronizationContext.Current != null);
+
+            Stopwatch stopwatch = Stopwatch.StartNew();
+            var cts = new CancellationTokenSource();
+
+            lock (this)
+            {
+                cancellationSource.Cancel();
+                cancellationSource = cts;
+            }
+
+            if (DebounceDelay > 0)
+            {
+                log($"Filter operation queued, waiting for {DebounceDelay} ms debounce");
+                await Task.Delay(DebounceDelay, cts.Token).ConfigureAwait(true);
+            }
+
+            // Copy must be performed on update thread for now (see ConfigureAwait above).
+            // Could potentially be optimised in the future if it becomes an issue.
+            IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(CreateCarouselItemForModel));
+
+            await Task.Run(async () =>
+            {
+                try
+                {
+                    foreach (var filter in Filters)
+                    {
+                        log($"Performing {filter.GetType().ReadableName()}");
+                        items = await filter.Run(items, cts.Token).ConfigureAwait(false);
+                    }
+
+                    log("Updating Y positions");
+                    await updateYPositions(items, cts.Token).ConfigureAwait(false);
+                }
+                catch (OperationCanceledException)
+                {
+                    log("Cancelled due to newer request arriving");
+                }
+            }, cts.Token).ConfigureAwait(true);
+
+            if (cts.Token.IsCancellationRequested)
+                return;
+
+            log("Items ready for display");
+            displayedCarouselItems = items.ToList();
+            displayedRange = null;
+
+            updateSelection();
+
+            void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
+        }
+
+        private async Task updateYPositions(IEnumerable<CarouselItem> carouselItems, CancellationToken cancellationToken) => await Task.Run(() =>
+        {
+            const float spacing = 10;
+            float yPos = 0;
+
+            foreach (var item in carouselItems)
+            {
+                item.CarouselYPosition = yPos;
+                yPos += item.DrawHeight + spacing;
+            }
+        }, cancellationToken).ConfigureAwait(false);
+
+        #endregion
+
+        #region Selection handling
+
+        private object? currentSelection;
+        private CarouselItem? currentSelectionCarouselItem;
+        private double? currentSelectionYPosition;
+
+        private void updateSelection()
+        {
+            currentSelectionCarouselItem = null;
+
+            if (displayedCarouselItems == null) return;
+
+            foreach (var item in displayedCarouselItems)
+            {
+                bool isSelected = item.Model == currentSelection;
+
+                if (isSelected)
+                {
+                    currentSelectionCarouselItem = item;
+
+                    if (currentSelectionYPosition != item.CarouselYPosition)
+                    {
+                        if (currentSelectionYPosition != null)
+                        {
+                            float adjustment = (float)(item.CarouselYPosition - currentSelectionYPosition.Value);
+                            scroll.OffsetScrollPosition(adjustment);
+                        }
+
+                        currentSelectionYPosition = item.CarouselYPosition;
+                    }
+                }
+
+                item.Selected.Value = isSelected;
+            }
+        }
+
+        #endregion
+
+        #region Display handling
+
+        private DisplayRange? displayedRange;
+
+        private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
+
+        /// <summary>
+        /// The position of the lower visible bound with respect to the current scroll position.
+        /// </summary>
+        private float visibleBottomBound => (float)(scroll.Current + DrawHeight + BleedBottom);
+
+        /// <summary>
+        /// The position of the upper visible bound with respect to the current scroll position.
+        /// </summary>
+        private float visibleUpperBound => (float)(scroll.Current - BleedTop);
+
+        protected override void Update()
+        {
+            base.Update();
+
+            if (displayedCarouselItems == null)
+                return;
+
+            var range = getDisplayRange();
+
+            if (range != displayedRange)
+            {
+                Logger.Log($"Updating displayed range of carousel from {displayedRange} to {range}");
+                displayedRange = range;
+
+                updateDisplayedRange(range);
+            }
+
+            foreach (var panel in scroll.Panels)
+            {
+                var carouselPanel = (ICarouselPanel)panel;
+
+                if (panel.Depth != carouselPanel.DrawYPosition)
+                    scroll.Panels.ChangeChildDepth(panel, (float)carouselPanel.DrawYPosition);
+            }
+        }
+
+        private DisplayRange getDisplayRange()
+        {
+            Debug.Assert(displayedCarouselItems != null);
+
+            // Find index range of all items that should be on-screen
+            carouselBoundsItem.CarouselYPosition = visibleUpperBound - DistanceOffscreenToPreload;
+            int firstIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
+            if (firstIndex < 0) firstIndex = ~firstIndex;
+
+            carouselBoundsItem.CarouselYPosition = visibleBottomBound + DistanceOffscreenToPreload;
+            int lastIndex = displayedCarouselItems.BinarySearch(carouselBoundsItem);
+            if (lastIndex < 0) lastIndex = ~lastIndex;
+
+            firstIndex = Math.Max(0, firstIndex - 1);
+            lastIndex = Math.Max(0, lastIndex - 1);
+
+            return new DisplayRange(firstIndex, lastIndex);
+        }
+
+        private void updateDisplayedRange(DisplayRange range)
+        {
+            Debug.Assert(displayedCarouselItems != null);
+
+            List<CarouselItem> toDisplay = range.Last - range.First == 0
+                ? new List<CarouselItem>()
+                : displayedCarouselItems.GetRange(range.First, range.Last - range.First + 1);
+
+            // Iterate over all panels which are already displayed and figure which need to be displayed / removed.
+            foreach (var panel in scroll.Panels)
+            {
+                var carouselPanel = (ICarouselPanel)panel;
+
+                // The case where we're intending to display this panel, but it's already displayed.
+                // Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation.
+                var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model);
+
+                if (existing != null)
+                {
+                    carouselPanel.Item = existing;
+                    toDisplay.Remove(existing);
+                    continue;
+                }
+
+                // If the new display range doesn't contain the panel, it's no longer required for display.
+                expirePanelImmediately(panel);
+            }
+
+            // Add any new items which need to be displayed and haven't yet.
+            foreach (var item in toDisplay)
+            {
+                var drawable = GetDrawableForDisplay(item);
+
+                if (drawable is not ICarouselPanel carouselPanel)
+                    throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
+
+                carouselPanel.Item = item;
+                scroll.Add(drawable);
+            }
+
+            // Update the total height of all items (to make the scroll container scrollable through the full height even though
+            // most items are not displayed / loaded).
+            if (displayedCarouselItems.Count > 0)
+            {
+                var lastItem = displayedCarouselItems[^1];
+                scroll.SetLayoutHeight((float)(lastItem.CarouselYPosition + lastItem.DrawHeight));
+            }
+            else
+                scroll.SetLayoutHeight(0);
+        }
+
+        private static void expirePanelImmediately(Drawable panel)
+        {
+            panel.FinishTransforms();
+            panel.Expire();
+        }
+
+        #endregion
+
+        #region Internal helper classes
+
+        private record DisplayRange(int First, int Last);
+
+        /// <summary>
+        /// Implementation of scroll container which handles very large vertical lists by internally using <c>double</c> precision
+        /// for pre-display Y values.
+        /// </summary>
+        private partial class CarouselScrollContainer : OsuScrollContainer
+        {
+            public readonly Container Panels;
+
+            public void SetLayoutHeight(float height) => Panels.Height = height;
+
+            public CarouselScrollContainer()
+            {
+                // Managing our own custom layout within ScrollContent causes feedback with public ScrollContainer calculations,
+                // so we must maintain one level of separation from ScrollContent.
+                base.Add(Panels = new Container
+                {
+                    Name = "Layout content",
+                    RelativeSizeAxes = Axes.X,
+                });
+            }
+
+            public override void OffsetScrollPosition(double offset)
+            {
+                base.OffsetScrollPosition(offset);
+
+                foreach (var panel in Panels)
+                {
+                    var c = (ICarouselPanel)panel;
+                    Debug.Assert(c.Item != null);
+
+                    c.DrawYPosition += offset;
+                }
+            }
+
+            protected override void Update()
+            {
+                base.Update();
+
+                foreach (var panel in Panels)
+                {
+                    var c = (ICarouselPanel)panel;
+                    Debug.Assert(c.Item != null);
+
+                    if (c.DrawYPosition != c.Item.CarouselYPosition)
+                        c.DrawYPosition = Interpolation.DampContinuously(c.DrawYPosition, c.Item.CarouselYPosition, 50, Time.Elapsed);
+                }
+            }
+
+            public override void Clear(bool disposeChildren)
+            {
+                Panels.Height = 0;
+                Panels.Clear(disposeChildren);
+            }
+
+            public override void Add(Drawable drawable)
+            {
+                if (drawable is not ICarouselPanel)
+                    throw new InvalidOperationException($"Carousel panel drawables must implement {typeof(ICarouselPanel)}");
+
+                Panels.Add(drawable);
+            }
+
+            public override double GetChildPosInContent(Drawable d, Vector2 offset)
+            {
+                if (d is not ICarouselPanel panel)
+                    return base.GetChildPosInContent(d, offset);
+
+                return panel.DrawYPosition + offset.X;
+            }
+
+            protected override void ApplyCurrentToContent()
+            {
+                Debug.Assert(ScrollDirection == Direction.Vertical);
+
+                double scrollableExtent = -Current + ScrollableExtent * ScrollContent.RelativeAnchorPosition.Y;
+
+                foreach (var d in Panels)
+                    d.Y = (float)(((ICarouselPanel)d).DrawYPosition + scrollableExtent);
+            }
+        }
+
+        private class BoundsCarouselItem : CarouselItem
+        {
+            public override float DrawHeight => 0;
+
+            public BoundsCarouselItem()
+                : base(new object())
+            {
+            }
+        }
+
+        #endregion
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/CarouselItem.cs b/osu.Game/Screens/SelectV2/CarouselItem.cs
new file mode 100644
index 0000000000..4636e8a32f
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/CarouselItem.cs
@@ -0,0 +1,44 @@
+// 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 osu.Framework.Bindables;
+
+namespace osu.Game.Screens.SelectV2
+{
+    /// <summary>
+    /// Represents a single display item for display in a <see cref="Carousel{T}"/>.
+    /// This is used to house information related to the attached model that helps with display and tracking.
+    /// </summary>
+    public abstract class CarouselItem : IComparable<CarouselItem>
+    {
+        public readonly BindableBool Selected = new BindableBool();
+
+        /// <summary>
+        /// The model this item is representing.
+        /// </summary>
+        public readonly object Model;
+
+        /// <summary>
+        /// The current Y position in the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
+        /// </summary>
+        public double CarouselYPosition { get; set; }
+
+        /// <summary>
+        /// The height this item will take when displayed.
+        /// </summary>
+        public abstract float DrawHeight { get; }
+
+        protected CarouselItem(object model)
+        {
+            Model = model;
+        }
+
+        public int CompareTo(CarouselItem? other)
+        {
+            if (other == null) return 1;
+
+            return CarouselYPosition.CompareTo(other.CarouselYPosition);
+        }
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/ICarouselFilter.cs b/osu.Game/Screens/SelectV2/ICarouselFilter.cs
new file mode 100644
index 0000000000..f510a7cd4b
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/ICarouselFilter.cs
@@ -0,0 +1,23 @@
+// 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.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace osu.Game.Screens.SelectV2
+{
+    /// <summary>
+    /// An interface representing a filter operation which can be run on a <see cref="Carousel{T}"/>.
+    /// </summary>
+    public interface ICarouselFilter
+    {
+        /// <summary>
+        /// Execute the filter operation.
+        /// </summary>
+        /// <param name="items">The items to be filtered.</param>
+        /// <param name="cancellationToken">A cancellation token.</param>
+        /// <returns>The post-filtered items.</returns>
+        Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken);
+    }
+}
diff --git a/osu.Game/Screens/SelectV2/ICarouselPanel.cs b/osu.Game/Screens/SelectV2/ICarouselPanel.cs
new file mode 100644
index 0000000000..117feab621
--- /dev/null
+++ b/osu.Game/Screens/SelectV2/ICarouselPanel.cs
@@ -0,0 +1,23 @@
+// 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 osu.Framework.Graphics;
+
+namespace osu.Game.Screens.SelectV2
+{
+    /// <summary>
+    /// An interface to be attached to any <see cref="Drawable"/>s which are used for display inside a <see cref="Carousel{T}"/>.
+    /// </summary>
+    public interface ICarouselPanel
+    {
+        /// <summary>
+        /// The Y position which should be used for displaying this item within the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
+        /// </summary>
+        double DrawYPosition { get; set; }
+
+        /// <summary>
+        /// The carousel item this drawable is representing. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
+        /// </summary>
+        CarouselItem? Item { get; set; }
+    }
+}
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
index 095bd95314..5ef6b30a82 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
@@ -35,7 +35,7 @@ namespace osu.Game.Storyboards.Drawables
 
         protected override Container<DrawableStoryboardLayer> Content { get; }
 
-        protected override Vector2 DrawScale => new Vector2(Parent!.DrawHeight / 480);
+        protected override Vector2 DrawScale => new Vector2((Parent?.DrawHeight ?? 0) / 480);
 
         public override bool RemoveCompletedTransforms => false;
 
diff --git a/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs
index 1734f1397f..eaef2af7c8 100644
--- a/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs
+++ b/osu.Game/Tests/Beatmaps/TestBeatmapStore.cs
@@ -11,6 +11,6 @@ namespace osu.Game.Tests.Beatmaps
     internal partial class TestBeatmapStore : BeatmapStore
     {
         public readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
-        public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets;
+        public override IBindableList<BeatmapSetInfo> GetBeatmapSets(CancellationToken? cancellationToken) => BeatmapSets.GetBoundCopy();
     }
 }
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 65636afc7b..e1bc971034 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -35,7 +35,7 @@
       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
     </PackageReference>
     <PackageReference Include="Realm" Version="20.1.0" />
-    <PackageReference Include="ppy.osu.Framework" Version="2025.114.1" />
+    <PackageReference Include="ppy.osu.Framework" Version="2025.115.0" />
     <PackageReference Include="ppy.osu.Game.Resources" Version="2024.1224.0" />
     <PackageReference Include="Sentry" Version="5.0.0" />
     <!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
diff --git a/osu.iOS.props b/osu.iOS.props
index afbcf49d32..ece42e87b4 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -17,6 +17,6 @@
     <MtouchInterpreter>-all</MtouchInterpreter>
   </PropertyGroup>
   <ItemGroup>
-    <PackageReference Include="ppy.osu.Framework.iOS" Version="2025.114.1" />
+    <PackageReference Include="ppy.osu.Framework.iOS" Version="2025.115.0" />
   </ItemGroup>
 </Project>