1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 18:47:27 +08:00

Merge branch 'master' into new-dialog-sfx

This commit is contained in:
Dean Herbert 2022-06-15 18:49:24 +09:00
commit 7571ab6c63
23 changed files with 428 additions and 168 deletions

View File

@ -52,7 +52,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.615.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.611.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2022.615.0" />
</ItemGroup>
<ItemGroup Label="Transitive Dependencies">
<!-- Realm needs to be directly referenced in all Xamarin projects, as it will not pull in its transitive dependencies otherwise. -->

View File

@ -1,6 +1,8 @@
// 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.
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
@ -12,7 +14,6 @@ using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
@ -28,8 +29,6 @@ using SharpCompress.Archives.Zip;
using SharpCompress.Common;
using SharpCompress.Writers.Zip;
#nullable enable
namespace osu.Game.Tests.Database
{
[TestFixture]
@ -394,7 +393,6 @@ namespace osu.Game.Tests.Database
}
[Test]
[Ignore("intentionally broken by import optimisations")]
public void TestImportThenImportWithChangedFile()
{
RunTestWithRealmAsync(async (realm, storage) =>
@ -491,7 +489,6 @@ namespace osu.Game.Tests.Database
}
[Test]
[Ignore("intentionally broken by import optimisations")]
public void TestImportCorruptThenImport()
{
RunTestWithRealmAsync(async (realm, storage) =>
@ -503,16 +500,18 @@ namespace osu.Game.Tests.Database
var firstFile = imported.Files.First();
var fileStorage = storage.GetStorageForDirectory("files");
long originalLength;
using (var stream = storage.GetStream(firstFile.File.GetStoragePath()))
using (var stream = fileStorage.GetStream(firstFile.File.GetStoragePath()))
originalLength = stream.Length;
using (var stream = storage.CreateFileSafely(firstFile.File.GetStoragePath()))
using (var stream = fileStorage.CreateFileSafely(firstFile.File.GetStoragePath()))
stream.WriteByte(0);
var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm);
using (var stream = storage.GetStream(firstFile.File.GetStoragePath()))
using (var stream = fileStorage.GetStream(firstFile.File.GetStoragePath()))
Assert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import");
// check the newly "imported" beatmap is actually just the restored previous import. since it matches hash.
@ -622,7 +621,7 @@ namespace osu.Game.Tests.Database
using var importer = new BeatmapModelManager(realm, storage);
using var store = new RealmRulesetStore(realm, storage);
var imported = await LoadOszIntoStore(importer, realm.Realm);
var imported = await LoadOszIntoStore(importer, realm.Realm, batchImport: true);
deleteBeatmapSet(imported, realm.Realm);
@ -678,7 +677,7 @@ namespace osu.Game.Tests.Database
{
RunTestWithRealmAsync(async (realm, storage) =>
{
using var importer = new NonOptimisedBeatmapImporter(realm, storage);
using var importer = new BeatmapModelManager(realm, storage);
using var store = new RealmRulesetStore(realm, storage);
var imported = await LoadOszIntoStore(importer, realm.Realm);
@ -960,11 +959,11 @@ namespace osu.Game.Tests.Database
return realm.All<BeatmapSetInfo>().FirstOrDefault(beatmapSet => beatmapSet.ID == importedSet!.ID);
}
public static async Task<BeatmapSetInfo> LoadOszIntoStore(BeatmapImporter importer, Realm realm, string? path = null, bool virtualTrack = false)
public static async Task<BeatmapSetInfo> LoadOszIntoStore(BeatmapImporter importer, Realm realm, string? path = null, bool virtualTrack = false, bool batchImport = false)
{
string? temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack);
var importedSet = await importer.Import(new ImportTask(temp));
var importedSet = await importer.Import(new ImportTask(temp), batchImport);
Assert.NotNull(importedSet);
Debug.Assert(importedSet != null);
@ -1081,15 +1080,5 @@ namespace osu.Game.Tests.Database
Assert.Fail(failureMessage);
}
public class NonOptimisedBeatmapImporter : BeatmapImporter
{
public NonOptimisedBeatmapImporter(RealmAccess realm, Storage storage)
: base(realm, storage)
{
}
protected override bool HasCustomHashFunction => true;
}
}
}

View File

@ -0,0 +1,73 @@
// 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.IO;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.IO;
namespace osu.Game.Tests.Database
{
[TestFixture]
public class LegacyBeatmapImporterTest
{
private readonly TestLegacyBeatmapImporter importer = new TestLegacyBeatmapImporter();
[Test]
public void TestSongsSubdirectories()
{
using (var storage = new TemporaryNativeStorage("stable-songs-folder"))
{
var songsStorage = storage.GetStorageForDirectory(StableStorage.STABLE_DEFAULT_SONGS_PATH);
// normal beatmap folder
var beatmap1 = songsStorage.GetStorageForDirectory("beatmap1");
createFile(beatmap1, "beatmap.osu");
// songs subdirectory
var subdirectory = songsStorage.GetStorageForDirectory("subdirectory");
createFile(subdirectory, Path.Combine("beatmap2", "beatmap.osu"));
createFile(subdirectory, Path.Combine("beatmap3", "beatmap.osu"));
createFile(subdirectory, Path.Combine("sub-subdirectory", "beatmap4", "beatmap.osu"));
// songs subdirectory with system file
var subdirectory2 = songsStorage.GetStorageForDirectory("subdirectory2");
createFile(subdirectory2, ".DS_Store");
createFile(subdirectory2, Path.Combine("beatmap5", "beatmap.osu"));
createFile(subdirectory2, Path.Combine("beatmap6", "beatmap.osu"));
// empty songs subdirectory
songsStorage.GetStorageForDirectory("subdirectory3");
string[] paths = importer.GetStableImportPaths(songsStorage).ToArray();
Assert.That(paths.Length, Is.EqualTo(6));
Assert.That(paths.Contains(songsStorage.GetFullPath("beatmap1")));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap2"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap3"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "sub-subdirectory", "beatmap4"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap5"))));
Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap6"))));
}
static void createFile(Storage storage, string path)
{
using (var stream = storage.CreateFileSafely(path))
stream.WriteByte(0);
}
}
private class TestLegacyBeatmapImporter : LegacyBeatmapImporter
{
public TestLegacyBeatmapImporter()
: base(null)
{
}
public new IEnumerable<string> GetStableImportPaths(Storage storage) => base.GetStableImportPaths(storage);
}
}
}

View File

@ -225,10 +225,10 @@ namespace osu.Game.Tests.Online
this.testBeatmapManager = testBeatmapManager;
}
public override Live<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, CancellationToken cancellationToken = default)
public override Live<BeatmapSetInfo> Import(BeatmapSetInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default)
{
testBeatmapManager.AllowImport.Task.WaitSafely();
return (testBeatmapManager.CurrentImport = base.Import(item, archive, cancellationToken));
return (testBeatmapManager.CurrentImport = base.Import(item, archive, batchImport, cancellationToken));
}
}
}

View File

@ -10,6 +10,7 @@ using osu.Framework.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
@ -61,6 +62,21 @@ namespace osu.Game.Tests.Visual.Editing
AddUntilStep("context menu is visible", () => contextMenuContainer.ChildrenOfType<OsuContextMenu>().Single().State == MenuState.Open);
}
[Test]
public void TestSelectAndShowContextMenuOutsideBounds()
{
var addedObject = new HitCircle { StartTime = 100, Position = OsuPlayfield.BASE_SIZE };
AddStep("add hitobject", () => EditorBeatmap.Add(addedObject));
AddStep("descale blueprint container", () => this.ChildrenOfType<HitObjectComposer>().Single().Scale = new Vector2(0.5f));
AddStep("move mouse to bottom-right", () => InputManager.MoveMouseTo(blueprintContainer.ToScreenSpace(blueprintContainer.LayoutRectangle.BottomRight + new Vector2(10))));
AddStep("right click", () => InputManager.Click(MouseButton.Right));
AddUntilStep("hitobject selected", () => EditorBeatmap.SelectedHitObjects.Single() == addedObject);
AddUntilStep("context menu is visible", () => contextMenuContainer.ChildrenOfType<OsuContextMenu>().Single().State == MenuState.Open);
}
[Test]
public void TestNudgeSelection()
{

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using JetBrains.Annotations;
using Moq;
using NUnit.Framework;
using osu.Framework.Allocation;
@ -13,6 +14,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Overlays.Toolbar;
using osu.Game.Rulesets;
using osuTK.Graphics;
@ -28,21 +30,44 @@ namespace osu.Game.Tests.Visual.Menus
[Resolved]
private IRulesetStore rulesets { get; set; }
private readonly Mock<INotificationOverlay> notifications = new Mock<INotificationOverlay>();
[Cached]
private readonly NowPlayingOverlay nowPlayingOverlay = new NowPlayingOverlay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
Y = Toolbar.HEIGHT,
};
[Cached]
private readonly VolumeOverlay volumeOverlay = new VolumeOverlay
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
};
private readonly Mock<TestNotificationOverlay> notifications = new Mock<TestNotificationOverlay>();
private readonly BindableInt unreadNotificationCount = new BindableInt();
[BackgroundDependencyLoader]
private void load()
{
Dependencies.CacheAs(notifications.Object);
Dependencies.CacheAs<INotificationOverlay>(notifications.Object);
notifications.SetupGet(n => n.UnreadCount).Returns(unreadNotificationCount);
}
[SetUp]
public void SetUp() => Schedule(() =>
{
Child = toolbar = new TestToolbar { State = { Value = Visibility.Visible } };
Remove(nowPlayingOverlay);
Remove(volumeOverlay);
Children = new Drawable[]
{
nowPlayingOverlay,
volumeOverlay,
toolbar = new TestToolbar { State = { Value = Visibility.Visible } },
};
});
[Test]
@ -122,9 +147,51 @@ namespace osu.Game.Tests.Visual.Menus
AddAssert("not scrolled", () => scroll.Current == 0);
}
[Test]
public void TestVolumeControlViaMusicButtonScroll()
{
AddStep("hover toolbar music button", () => InputManager.MoveMouseTo(this.ChildrenOfType<ToolbarMusicButton>().Single()));
AddStep("reset volume", () => Audio.Volume.Value = 1);
AddRepeatStep("scroll down", () => InputManager.ScrollVerticalBy(-10), 5);
AddAssert("volume lowered down", () => Audio.Volume.Value < 1);
AddRepeatStep("scroll up", () => InputManager.ScrollVerticalBy(10), 5);
AddAssert("volume raised up", () => Audio.Volume.Value == 1);
}
[Test]
public void TestVolumeControlViaMusicButtonArrowKeys()
{
AddStep("hover toolbar music button", () => InputManager.MoveMouseTo(this.ChildrenOfType<ToolbarMusicButton>().Single()));
AddStep("reset volume", () => Audio.Volume.Value = 1);
AddRepeatStep("arrow down", () => InputManager.Key(Key.Down), 5);
AddAssert("volume lowered down", () => Audio.Volume.Value < 1);
AddRepeatStep("arrow up", () => InputManager.Key(Key.Up), 5);
AddAssert("volume raised up", () => Audio.Volume.Value == 1);
}
public class TestToolbar : Toolbar
{
public new Bindable<OverlayActivation> OverlayActivationMode => base.OverlayActivationMode as Bindable<OverlayActivation>;
}
// interface mocks break hot reload, mocking this stub implementation instead works around it.
// see: https://github.com/moq/moq4/issues/1252
[UsedImplicitly]
public class TestNotificationOverlay : INotificationOverlay
{
public virtual void Post(Notification notification)
{
}
public virtual void Hide()
{
}
public virtual IBindable<int> UnreadCount => null;
}
}
}

View File

@ -353,11 +353,11 @@ namespace osu.Game.Beatmaps
public Task<IEnumerable<Live<BeatmapSetInfo>>> Import(ProgressNotification notification, params ImportTask[] tasks) => beatmapModelManager.Import(notification, tasks);
public Task<Live<BeatmapSetInfo>?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) => beatmapModelManager.Import(task, lowPriority, cancellationToken);
public Task<Live<BeatmapSetInfo>?> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) => beatmapModelManager.Import(task, batchImport, cancellationToken);
public Task<Live<BeatmapSetInfo>?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) => beatmapModelManager.Import(archive, lowPriority, cancellationToken);
public Task<Live<BeatmapSetInfo>?> Import(ArchiveReader archive, bool batchImport = false, CancellationToken cancellationToken = default) => beatmapModelManager.Import(archive, batchImport, cancellationToken);
public Live<BeatmapSetInfo>? Import(BeatmapSetInfo item, ArchiveReader? archive = null, CancellationToken cancellationToken = default) => beatmapModelManager.Import(item, archive, cancellationToken);
public Live<BeatmapSetInfo>? Import(BeatmapSetInfo item, ArchiveReader? archive = null, CancellationToken cancellationToken = default) => beatmapModelManager.Import(item, archive, false, cancellationToken);
public IEnumerable<string> HandledExtensions => beatmapModelManager.HandledExtensions;

View File

@ -1,6 +1,9 @@
// 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 osu.Framework.IO.Stores;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.IO;
@ -13,6 +16,24 @@ namespace osu.Game.Database
protected override Storage PrepareStableStorage(StableStorage stableStorage) => stableStorage.GetSongStorage();
protected override IEnumerable<string> GetStableImportPaths(Storage storage)
{
foreach (string directory in storage.GetDirectories(string.Empty))
{
var directoryStorage = storage.GetStorageForDirectory(directory);
if (!directoryStorage.GetFiles(string.Empty).ExcludeSystemFileNames().Any())
{
// if a directory doesn't contain files, attempt looking for beatmaps inside of that directory.
// this is a special behaviour in stable for beatmaps only, see https://github.com/ppy/osu/issues/18615.
foreach (string subDirectory in GetStableImportPaths(directoryStorage))
yield return subDirectory;
}
else
yield return storage.GetFullPath(directory);
}
}
public LegacyBeatmapImporter(IModelImporter<BeatmapSetInfo> importer)
: base(importer)
{

View File

@ -14,7 +14,7 @@ namespace osu.Game.IO
/// </summary>
public class StableStorage : DesktopStorage
{
private const string stable_default_songs_path = "Songs";
public const string STABLE_DEFAULT_SONGS_PATH = "Songs";
private readonly DesktopGameHost host;
private readonly Lazy<string> songsPath;
@ -62,7 +62,7 @@ namespace osu.Game.IO
}
}
return GetFullPath(stable_default_songs_path);
return GetFullPath(STABLE_DEFAULT_SONGS_PATH);
}
}
}

View File

@ -2,24 +2,131 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Transforms;
using osu.Framework.Input.Events;
using osu.Framework.Threading;
using osu.Game.Input.Bindings;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Overlays.Toolbar
{
public class ToolbarMusicButton : ToolbarOverlayToggleButton
{
private Circle volumeBar;
protected override Anchor TooltipAnchor => Anchor.TopRight;
public ToolbarMusicButton()
{
Hotkey = GlobalAction.ToggleNowPlaying;
AutoSizeAxes = Axes.X;
}
[BackgroundDependencyLoader(true)]
private void load(NowPlayingOverlay music)
{
StateContainer = music;
Flow.Padding = new MarginPadding { Horizontal = Toolbar.HEIGHT / 4 };
Flow.Add(volumeDisplay = new Container
{
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Width = 3f,
Height = IconContainer.Height,
Margin = new MarginPadding { Horizontal = 2.5f },
Masking = true,
Children = new[]
{
new Circle
{
RelativeSizeAxes = Axes.Both,
Colour = Color4.White.Opacity(0.25f),
},
volumeBar = new Circle
{
RelativeSizeAxes = Axes.Both,
Height = 0f,
Anchor = Anchor.BottomLeft,
Origin = Anchor.BottomLeft,
Colour = Color4.White,
}
}
});
}
[Resolved]
private AudioManager audio { get; set; }
[Resolved(canBeNull: true)]
private VolumeOverlay volume { get; set; }
private IBindable<double> globalVolume;
private Container volumeDisplay;
protected override void LoadComplete()
{
base.LoadComplete();
globalVolume = audio.Volume.GetBoundCopy();
globalVolume.BindValueChanged(v => volumeBar.ResizeHeightTo((float)v.NewValue, 200, Easing.OutQuint), true);
}
protected override bool OnKeyDown(KeyDownEvent e)
{
switch (e.Key)
{
case Key.Up:
focusForAdjustment();
volume?.Adjust(GlobalAction.IncreaseVolume);
return true;
case Key.Down:
focusForAdjustment();
volume?.Adjust(GlobalAction.DecreaseVolume);
return true;
}
return base.OnKeyDown(e);
}
protected override bool OnScroll(ScrollEvent e)
{
focusForAdjustment();
volume?.Adjust(GlobalAction.IncreaseVolume, e.ScrollDelta.Y, e.IsPrecise);
return true;
}
private void focusForAdjustment()
{
volume?.FocusMasterVolume();
expandVolumeBarTemporarily();
}
private TransformSequence<Container> expandTransform;
private ScheduledDelegate contractTransform;
private void expandVolumeBarTemporarily()
{
// avoid starting a new transform if one is already active.
if (expandTransform == null)
{
expandTransform = volumeDisplay.ResizeWidthTo(6, 500, Easing.OutQuint);
expandTransform.Finally(_ => expandTransform = null);
}
contractTransform?.Cancel();
contractTransform = Scheduler.AddDelayed(() =>
{
volumeDisplay.ResizeWidthTo(3f, 500, Easing.OutQuint);
}, 1000);
}
}
}

View File

@ -140,11 +140,16 @@ namespace osu.Game.Overlays
private ScheduledDelegate popOutDelegate;
public void FocusMasterVolume()
{
volumeMeters.Select(volumeMeterMaster);
}
public override void Show()
{
// Focus on the master meter as a default if previously hidden
if (State.Value == Visibility.Hidden)
volumeMeters.Select(volumeMeterMaster);
FocusMasterVolume();
if (State.Value == Visibility.Visible)
schedulePopOut();

View File

@ -280,7 +280,7 @@ namespace osu.Game.Scoring
public Task<IEnumerable<Live<ScoreInfo>>> Import(ProgressNotification notification, params ImportTask[] tasks) => scoreModelManager.Import(notification, tasks);
public Live<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) => scoreModelManager.Import(item, archive, cancellationToken);
public Live<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => scoreModelManager.Import(item, archive, batchImport, cancellationToken);
public bool IsAvailableLocally(ScoreInfo model) => scoreModelManager.IsAvailableLocally(model);

View File

@ -106,6 +106,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// </summary>
protected virtual bool AllowDeselectionDuringDrag => true;
/// <remarks>
/// Positional input must be received outside the container's bounds,
/// in order to handle blueprints which are partially offscreen.
/// </remarks>
/// <seealso cref="SelectionHandler{T}.ReceivePositionalInputAt"/>
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
protected override bool OnMouseDown(MouseDownEvent e)
{
bool selectionPerformed = performMouseDownActions(e);

View File

@ -30,8 +30,6 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// </summary>
public class ComposeBlueprintContainer : EditorBlueprintContainer
{
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
private readonly Container<PlacementBlueprint> placementBlueprintContainer;
protected new EditorSelectionHandler SelectionHandler => (EditorSelectionHandler)base.SelectionHandler;

View File

@ -97,6 +97,13 @@ namespace osu.Game.Screens.Edit.Compose.Components
#region User Input Handling
/// <remarks>
/// Positional input must be received outside the container's bounds,
/// in order to handle blueprints which are partially offscreen.
/// </remarks>
/// <seealso cref="BlueprintContainer{T}.ReceivePositionalInputAt"/>
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
/// <summary>
/// Handles the selected items being moved.
/// </summary>

View File

@ -33,9 +33,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
private Bindable<HitObject> placement;
private SelectionBlueprint<HitObject> placementBlueprint;
// We want children within the timeline to be interactable
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos);
public TimelineBlueprintContainer(HitObjectComposer composer)
: base(composer)
{

View File

@ -6,23 +6,16 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Input.Events;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osuTK;
using osuTK.Input;
namespace osu.Game.Screens.Edit.Compose.Components.Timeline
{
internal class TimelineSelectionHandler : EditorSelectionHandler
{
[Resolved]
private Timeline timeline { get; set; }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos);
// for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation
public override bool HandleMovement(MoveSelectionEvent<HitObject> moveEvent) => true;

View File

@ -276,13 +276,10 @@ namespace osu.Game.Skinning
public Task<IEnumerable<Live<SkinInfo>>> Import(ProgressNotification notification, params ImportTask[] tasks) => skinModelManager.Import(notification, tasks);
public Task<Live<SkinInfo>> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) => skinModelManager.Import(task, lowPriority, cancellationToken);
public Task<Live<SkinInfo>> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) => skinModelManager.Import(task, batchImport, cancellationToken);
public Task<Live<SkinInfo>> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) =>
skinModelManager.Import(archive, lowPriority, cancellationToken);
public Live<SkinInfo> Import(SkinInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) =>
skinModelManager.Import(item, archive, cancellationToken);
public Task<Live<SkinInfo>> Import(ArchiveReader archive, bool batchImport = false, CancellationToken cancellationToken = default) =>
skinModelManager.Import(archive, batchImport, cancellationToken);
#endregion

View File

@ -46,8 +46,6 @@ namespace osu.Game.Skinning
private const string unknown_creator_string = @"Unknown";
protected override bool HasCustomHashFunction => true;
protected override void Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file);

View File

@ -32,12 +32,16 @@ namespace osu.Game.Stores
public abstract class RealmArchiveModelImporter<TModel> : IModelImporter<TModel>
where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete
{
/// <summary>
/// The maximum number of concurrent imports to run per import scheduler.
/// </summary>
private const int import_queue_request_concurrency = 1;
/// <summary>
/// The size of a batch import operation before considering it a lower priority operation.
/// The minimum number of items in a single import call in order for the import to be processed as a batch.
/// Batch imports will apply optimisations preferring speed over consistency when detecting changes in already-imported items.
/// </summary>
private const int low_priority_import_batch_size = 1;
private const int minimum_items_considered_batch_import = 10;
/// <summary>
/// A singleton scheduler shared by all <see cref="RealmArchiveModelImporter{TModel}"/>.
@ -49,11 +53,11 @@ namespace osu.Game.Stores
private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter<TModel>));
/// <summary>
/// A second scheduler for lower priority imports.
/// A second scheduler for batch imports.
/// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue.
/// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this.
/// </summary>
private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter<TModel>));
private static readonly ThreadedTaskScheduler import_scheduler_batch = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter<TModel>));
public virtual IEnumerable<string> HandledExtensions => new[] { @".zip" };
@ -105,7 +109,7 @@ namespace osu.Game.Stores
var imported = new List<Live<TModel>>();
bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size;
bool isBatchImport = tasks.Length >= minimum_items_considered_batch_import;
try
{
@ -115,7 +119,7 @@ namespace osu.Game.Stores
try
{
var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false);
var model = await Import(task, isBatchImport, notification.CancellationToken).ConfigureAwait(false);
lock (imported)
{
@ -178,16 +182,16 @@ namespace osu.Game.Stores
/// Note that this bypasses the UI flow and should only be used for special cases or testing.
/// </summary>
/// <param name="task">The <see cref="ImportTask"/> containing data about the <typeparamref name="TModel"/> to import.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="batchImport">Whether this import is part of a larger batch.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
/// <returns>The imported model, if successful.</returns>
public async Task<Live<TModel>?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default)
public async Task<Live<TModel>?> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Live<TModel>? import;
using (ArchiveReader reader = task.GetReader())
import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false);
import = await Import(reader, batchImport, cancellationToken).ConfigureAwait(false);
// We may or may not want to delete the file depending on where it is stored.
// e.g. reconstructing/repairing database with items from default storage.
@ -210,9 +214,9 @@ namespace osu.Game.Stores
/// Silently import an item from an <see cref="ArchiveReader"/>.
/// </summary>
/// <param name="archive">The archive to be imported.</param>
/// <param name="lowPriority">Whether this is a low priority import.</param>
/// <param name="batchImport">Whether this import is part of a larger batch.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
public async Task<Live<TModel>?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default)
public async Task<Live<TModel>?> Import(ArchiveReader archive, bool batchImport = false, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@ -235,10 +239,10 @@ namespace osu.Game.Stores
return null;
}
var scheduledImport = Task.Factory.StartNew(() => Import(model, archive, cancellationToken),
var scheduledImport = Task.Factory.StartNew(() => Import(model, archive, batchImport, cancellationToken),
cancellationToken,
TaskCreationOptions.HideScheduler,
lowPriority ? import_scheduler_low_priority : import_scheduler);
batchImport ? import_scheduler_batch : import_scheduler);
return await scheduledImport.ConfigureAwait(false);
}
@ -248,106 +252,104 @@ namespace osu.Game.Stores
/// </summary>
/// <param name="item">The model to be imported.</param>
/// <param name="archive">An optional archive to use for model population.</param>
/// <param name="batchImport">If <c>true</c>, imports will be skipped before they begin, given an existing model matches on hash and filenames. Should generally only be used for large batch imports, as it may defy user expectations when updating an existing model.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
public virtual Live<TModel>? Import(TModel item, ArchiveReader? archive = null, CancellationToken cancellationToken = default)
public virtual Live<TModel>? Import(TModel item, ArchiveReader? archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => Realm.Run(realm =>
{
return Realm.Run(realm =>
cancellationToken.ThrowIfCancellationRequested();
bool checkedExisting = false;
TModel? existing = null;
if (batchImport && archive != null)
{
cancellationToken.ThrowIfCancellationRequested();
// this is a fast bail condition to improve large import performance.
item.Hash = computeHashFast(archive);
bool checkedExisting = false;
TModel? existing = null;
checkedExisting = true;
existing = CheckForExisting(item, realm);
if (archive != null && !HasCustomHashFunction)
if (existing != null)
{
// this is a fast bail condition to improve large import performance.
item.Hash = computeHashFast(archive);
// bare minimum comparisons
//
// note that this should really be checking filesizes on disk (of existing files) for some degree of sanity.
// or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files.
if (CanSkipImport(existing, item) &&
getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f)) &&
checkAllFilesExist(existing))
{
LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
checkedExisting = true;
existing = CheckForExisting(item, realm);
using (var transaction = realm.BeginWrite())
{
UndeleteForReuse(existing);
transaction.Commit();
}
return existing.ToLive(Realm);
}
LogForModel(item, @"Found existing (optimised) but failed pre-check.");
}
}
try
{
LogForModel(item, @"Beginning import...");
// TODO: do we want to make the transaction this local? not 100% sure, will need further investigation.
using (var transaction = realm.BeginWrite())
{
if (archive != null)
// TODO: look into rollback of file additions (or delayed commit).
item.Files.AddRange(createFileInfos(archive, Files, realm));
item.Hash = ComputeHash(item);
// TODO: we may want to run this outside of the transaction.
Populate(item, archive, realm, cancellationToken);
if (!checkedExisting)
existing = CheckForExisting(item, realm);
if (existing != null)
{
// bare minimum comparisons
//
// note that this should really be checking filesizes on disk (of existing files) for some degree of sanity.
// or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files.
if (CanSkipImport(existing, item) &&
getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f)) &&
checkAllFilesExist(existing))
if (CanReuseExisting(existing, item))
{
LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
using (var transaction = realm.BeginWrite())
{
UndeleteForReuse(existing);
transaction.Commit();
}
UndeleteForReuse(existing);
transaction.Commit();
return existing.ToLive(Realm);
}
LogForModel(item, @"Found existing (optimised) but failed pre-check.");
}
}
LogForModel(item, @"Found existing but failed re-use check.");
try
{
LogForModel(item, @"Beginning import...");
// TODO: do we want to make the transaction this local? not 100% sure, will need further investigation.
using (var transaction = realm.BeginWrite())
{
if (archive != null)
// TODO: look into rollback of file additions (or delayed commit).
item.Files.AddRange(createFileInfos(archive, Files, realm));
item.Hash = ComputeHash(item);
// TODO: we may want to run this outside of the transaction.
Populate(item, archive, realm, cancellationToken);
if (!checkedExisting)
existing = CheckForExisting(item, realm);
if (existing != null)
{
if (CanReuseExisting(existing, item))
{
LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) skipping import.");
UndeleteForReuse(existing);
transaction.Commit();
return existing.ToLive(Realm);
}
LogForModel(item, @"Found existing but failed re-use check.");
existing.DeletePending = true;
}
PreImport(item, realm);
// import to store
realm.Add(item);
transaction.Commit();
existing.DeletePending = true;
}
LogForModel(item, @"Import successfully completed!");
}
catch (Exception e)
{
if (!(e is TaskCanceledException))
LogForModel(item, @"Database import or population failed and has been rolled back.", e);
PreImport(item, realm);
throw;
// import to store
realm.Add(item);
transaction.Commit();
}
return (Live<TModel>?)item.ToLive(Realm);
});
}
LogForModel(item, @"Import successfully completed!");
}
catch (Exception e)
{
if (!(e is TaskCanceledException))
LogForModel(item, @"Database import or population failed and has been rolled back.", e);
throw;
}
return (Live<TModel>?)item.ToLive(Realm);
});
/// <summary>
/// Any file extensions which should be included in hash creation.
@ -375,19 +377,13 @@ namespace osu.Game.Stores
Logger.Log($"{prefix} {message}", LoggingTarget.Database);
}
/// <summary>
/// Whether the implementation overrides <see cref="ComputeHash"/> with a custom implementation.
/// Custom hash implementations must bypass the early exit in the import flow (see <see cref="computeHashFast"/> usage).
/// </summary>
protected virtual bool HasCustomHashFunction => false;
/// <summary>
/// Create a SHA-2 hash from the provided archive based on file content of all files matching <see cref="HashableFileTypes"/>.
/// </summary>
/// <remarks>
/// In the case of no matching files, a hash will be generated from the passed archive's <see cref="ArchiveReader.Name"/>.
/// </remarks>
protected virtual string ComputeHash(TModel item)
protected string ComputeHash(TModel item)
{
// for now, concatenate all hashable files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream();

View File

@ -147,7 +147,7 @@ namespace osu.Game.Tests.Visual
protected override BeatmapModelManager CreateBeatmapModelManager(Storage storage, RealmAccess realm, RulesetStore rulesets, BeatmapOnlineLookupQueue onlineLookupQueue)
{
return new TestBeatmapModelManager(storage, realm, onlineLookupQueue);
return new BeatmapModelManager(realm, storage, onlineLookupQueue);
}
protected override WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap defaultBeatmap, GameHost host)
@ -181,17 +181,6 @@ namespace osu.Game.Tests.Visual
=> testBeatmapManager.TestBeatmap;
}
internal class TestBeatmapModelManager : BeatmapModelManager
{
public TestBeatmapModelManager(Storage storage, RealmAccess databaseAccess, BeatmapOnlineLookupQueue beatmapOnlineLookupQueue)
: base(databaseAccess, storage, beatmapOnlineLookupQueue)
{
}
protected override string ComputeHash(BeatmapSetInfo item)
=> string.Empty;
}
public override void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
{
// don't actually care about saving for this context.

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.14.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.611.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.615.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.615.0" />
<PackageReference Include="Sentry" Version="3.17.1" />
<PackageReference Include="SharpCompress" Version="0.31.0" />

View File

@ -61,7 +61,7 @@
<Reference Include="System.Net.Http" />
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.611.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2022.615.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2022.615.0" />
</ItemGroup>
<!-- See https://github.com/dotnet/runtime/issues/35988 (can be removed after Xamarin uses net6.0) -->
@ -84,7 +84,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="5.0.14" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2022.611.0" />
<PackageReference Include="ppy.osu.Framework" Version="2022.615.0" />
<PackageReference Include="SharpCompress" Version="0.31.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />