1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-19 17:40:08 +08:00

Merge branch 'master' into room-subscreen-split

This commit is contained in:
Dan Balasescu
2025-02-14 22:27:05 +09:00
Unverified
21 changed files with 984 additions and 32 deletions
@@ -376,6 +376,6 @@ namespace osu.Game.Tests.Visual.Collections
private void assertCollectionName(int index, string name)
=> AddUntilStep($"item {index + 1} has correct name",
() => dialog.ChildrenOfType<DrawableCollectionList>().Single().OrderedItems.ElementAt(index).ChildrenOfType<TextBox>().First().Text == name);
() => dialog.ChildrenOfType<DrawableCollectionList>().Single().OrderedItems.ElementAtOrDefault(index)?.ChildrenOfType<TextBox>().First().Text == name);
}
}
@@ -24,7 +24,11 @@ namespace osu.Game.Tests.Visual.Editing
Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies = new[] { (typeof(ScreenFooter), (object)footer) },
CachedDependencies = new[]
{
(typeof(ScreenFooter), (object)footer),
(typeof(BeatmapSubmissionSettings), new BeatmapSubmissionSettings()),
},
Children = new Drawable[]
{
receptor,
@@ -0,0 +1,113 @@
// 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.Diagnostics;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Graphics;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Playlists;
using osuTK;
using osuTK.Input;
using SharpCompress;
namespace osu.Game.Tests.Visual.Playlists
{
public partial class TestSceneAddPlaylistToCollectionButton : OsuManualInputManagerTestScene
{
private BeatmapManager manager = null!;
private BeatmapSetInfo importedBeatmap = null!;
private Room room = null!;
private AddPlaylistToCollectionButton button = null!;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
{
Dependencies.Cache(new RealmRulesetStore(Realm));
Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, API, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm);
Add(notificationOverlay);
}
[Cached(typeof(INotificationOverlay))]
private NotificationOverlay notificationOverlay = new NotificationOverlay
{
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
};
[SetUpSteps]
public void SetUpSteps()
{
AddStep("clear realm", () => Realm.Realm.Write(() => Realm.Realm.RemoveAll<BeatmapCollection>()));
AddStep("clear notifications", () => notificationOverlay.AllNotifications.Empty());
importBeatmap();
setupRoom();
AddStep("create button", () =>
{
Add(button = new AddPlaylistToCollectionButton(room)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(300, 40),
});
});
}
[Test]
public void TestButtonFlow()
{
AddStep("move mouse to button", () => InputManager.MoveMouseTo(button));
AddStep("click button", () => InputManager.Click(MouseButton.Left));
AddUntilStep("notification shown", () => notificationOverlay.AllNotifications.Any(n => n.Text.ToString().StartsWith("Created new collection", StringComparison.Ordinal)));
AddUntilStep("realm is updated", () => Realm.Realm.All<BeatmapCollection>().FirstOrDefault(c => c.Name == room.Name) != null);
}
private void importBeatmap() => AddStep("import beatmap", () =>
{
var beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo);
Debug.Assert(beatmap.BeatmapInfo.BeatmapSet != null);
importedBeatmap = manager.Import(beatmap.BeatmapInfo.BeatmapSet)!.Value.Detach();
});
private void setupRoom() => AddStep("setup room", () =>
{
room = new Room
{
Name = "my awesome room",
MaxAttempts = 5,
Host = API.LocalUser.Value
};
room.RecentParticipants = [room.Host];
room.EndDate = DateTimeOffset.Now.AddMinutes(5);
room.Playlist =
[
new PlaylistItem(importedBeatmap.Beatmaps.First())
{
RulesetID = new OsuRuleset().RulesetInfo.OnlineID
}
];
});
}
}
+14 -9
View File
@@ -61,6 +61,20 @@ namespace osu.Game.Database
Configuration = new LegacySkinDecoder().Decode(skinStreamReader)
};
MutateBeatmap(model, playableBeatmap);
// Encode to legacy format
var stream = new MemoryStream();
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw);
stream.Seek(0, SeekOrigin.Begin);
return stream;
}
protected virtual void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap)
{
// Convert beatmap elements to be compatible with legacy format
// So we truncate time and position values to integers, and convert paths with multiple segments to Bézier curves
@@ -145,15 +159,6 @@ namespace osu.Game.Database
hasPath.Path.ControlPoints.Add(new PathControlPoint(position));
}
}
// Encode to legacy format
var stream = new MemoryStream();
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
new LegacyBeatmapEncoder(playableBeatmap, beatmapSkin).Encode(sw);
stream.Seek(0, SeekOrigin.Begin);
return stream;
}
protected override string FileExtension => @".osz";
@@ -18,6 +18,8 @@ namespace osu.Game.Graphics.UserInterface
public abstract partial class OsuSliderBar<T> : SliderBar<T>, IHasTooltip
where T : struct, INumber<T>, IMinMaxValue<T>
{
public override bool AcceptsFocus => !Current.Disabled;
public bool PlaySamplesOnAdjust { get; set; } = true;
/// <summary>
@@ -162,6 +162,8 @@ namespace osu.Game.Graphics.UserInterface
protected partial class BoundSlider : RoundedSliderBar<double>
{
public override bool AcceptsFocus => false;
public new Nub Nub => base.Nub;
public string? DefaultString;
@@ -39,6 +39,31 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString SubmissionSettings => new TranslatableString(getKey(@"submission_settings"), @"Submission settings");
/// <summary>
/// "Submit beatmap!"
/// </summary>
public static LocalisableString ConfirmSubmission => new TranslatableString(getKey(@"confirm_submission"), @"Submit beatmap!");
/// <summary>
/// "Exporting beatmap for compatibility..."
/// </summary>
public static LocalisableString Exporting => new TranslatableString(getKey(@"exporting"), @"Exporting beatmap for compatibility...");
/// <summary>
/// "Preparing for upload..."
/// </summary>
public static LocalisableString Preparing => new TranslatableString(getKey(@"preparing"), @"Preparing for upload...");
/// <summary>
/// "Uploading beatmap contents..."
/// </summary>
public static LocalisableString Uploading => new TranslatableString(getKey(@"uploading"), @"Uploading beatmap contents...");
/// <summary>
/// "Finishing up..."
/// </summary>
public static LocalisableString Finishing => new TranslatableString(getKey(@"finishing"), @"Finishing up...");
/// <summary>
/// "Before you continue, we ask you to check whether the content you are uploading has been cleared for upload. Please understand that you are responsible for the content you upload to the platform and if in doubt, should ask permission from the creators before uploading!"
/// </summary>
@@ -115,9 +140,24 @@ namespace osu.Game.Localisation
public static LocalisableString LoadInBrowserAfterSubmission => new TranslatableString(getKey(@"load_in_browser_after_submission"), @"Load in browser after submission");
/// <summary>
/// "Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost."
/// "Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost."
/// </summary>
public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost.");
public static LocalisableString LegacyExportDisclaimer => new TranslatableString(getKey(@"legacy_export_disclaimer"), @"Note: In order to make it possible for users of all osu! versions to enjoy your beatmap, it will be exported in a backwards-compatible format. While we have made efforts to ensure that process keeps the beatmap playable in its intended form, some data related to features that previous versions of osu! do not support may be lost.");
/// <summary>
/// "Empty beatmaps cannot be submitted."
/// </summary>
public static LocalisableString EmptyBeatmapsCannotBeSubmitted => new TranslatableString(getKey(@"empty_beatmaps_cannot_be_submitted"), @"Empty beatmaps cannot be submitted.");
/// <summary>
/// "Update beatmap!"
/// </summary>
public static LocalisableString UpdateBeatmap => new TranslatableString(getKey(@"update_beatmap"), @"Update beatmap!");
/// <summary>
/// "Upload NEW beatmap!"
/// </summary>
public static LocalisableString UploadNewBeatmap => new TranslatableString(getKey(@"upload_new_beatmap"), @"Upload NEW beatmap!");
private static string getKey(string key) => $@"{prefix}:{key}";
}
+10
View File
@@ -69,6 +69,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString DeleteDifficulty => new TranslatableString(getKey(@"delete_difficulty"), @"Delete difficulty");
/// <summary>
/// "Edit externally"
/// </summary>
public static LocalisableString EditExternally => new TranslatableString(getKey(@"edit_externally"), @"Edit externally");
/// <summary>
/// "Submit beatmap"
/// </summary>
public static LocalisableString SubmitBeatmap => new TranslatableString(getKey(@"submit_beatmap"), @"Submit beatmap");
/// <summary>
/// "setup"
/// </summary>
@@ -26,10 +26,8 @@ namespace osu.Game.Online.API.Requests
public uint BeatmapSetID { get; }
// ReSharper disable once CollectionNeverUpdated.Global
public Dictionary<string, byte[]> FilesChanged { get; } = new Dictionary<string, byte[]>();
// ReSharper disable once CollectionNeverUpdated.Global
public HashSet<string> FilesDeleted { get; } = new HashSet<string>();
public PatchBeatmapPackageRequest(uint beatmapSetId)
@@ -48,7 +46,7 @@ namespace osu.Game.Online.API.Requests
foreach (string filename in FilesDeleted)
request.AddParameter(@"filesDeleted", filename, RequestParameterType.Form);
request.Timeout = 60_000;
request.Timeout = 600_000;
return request;
}
}
@@ -38,7 +38,7 @@ namespace osu.Game.Online.API.Requests
var request = base.CreateWebRequest();
request.AddFile(@"beatmapArchive", oszPackage);
request.Method = HttpMethod.Put;
request.Timeout = 60_000;
request.Timeout = 600_000;
return request;
}
}
@@ -13,6 +13,7 @@ namespace osu.Game.Online
SpectatorUrl = $@"{APIUrl}/signalr/spectator";
MultiplayerUrl = $@"{APIUrl}/signalr/multiplayer";
MetadataUrl = $@"{APIUrl}/signalr/metadata";
BeatmapSubmissionServiceUrl = $@"{APIUrl}/beatmap-submission";
}
}
}
+21 -1
View File
@@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Framework.Layout;
using osu.Game.Graphics;
@@ -54,6 +55,10 @@ namespace osu.Game.Overlays
private IconButton expandButton = null!;
private InputManager inputManager = null!;
private Drawable? draggedChild;
/// <summary>
/// Create a new instance.
/// </summary>
@@ -125,6 +130,8 @@ namespace osu.Game.Overlays
{
base.LoadComplete();
inputManager = GetContainingInputManager()!;
Expanded.BindValueChanged(_ => updateExpandedState(true));
updateExpandedState(false);
@@ -156,6 +163,13 @@ namespace osu.Game.Overlays
headerText.FadeTo(headerText.DrawWidth < DrawWidth ? 1 : 0, 150, Easing.OutQuint);
headerTextVisibilityCache.Validate();
}
// Dragged child finished its drag operation.
if (draggedChild != null && inputManager.DraggedDrawable != draggedChild)
{
draggedChild = null;
updateExpandedState(true);
}
}
protected override bool OnInvalidate(Invalidation invalidation, InvalidationSource source)
@@ -168,11 +182,17 @@ namespace osu.Game.Overlays
private void updateExpandedState(bool animate)
{
// before we collapse down, let's double check the user is not dragging a UI control contained within us.
if (inputManager.DraggedDrawable.IsRootedAt(this))
{
draggedChild = inputManager.DraggedDrawable;
}
// clearing transforms is necessary to avoid a previous height transform
// potentially continuing to get processed while content has changed to autosize.
content.ClearTransforms();
if (Expanded.Value || IsHovered)
if (Expanded.Value || IsHovered || draggedChild != null)
{
content.AutoSizeAxes = Axes.Y;
content.AutoSizeDuration = animate ? transition_duration : 0;
+3
View File
@@ -45,6 +45,8 @@ namespace osu.Game.Overlays
private LoadingSpinner loading = null!;
private ScheduledDelegate? loadingShowDelegate;
public bool Completed { get; private set; }
protected WizardOverlay(OverlayColourScheme scheme)
: base(scheme)
{
@@ -221,6 +223,7 @@ namespace osu.Game.Overlays
else
{
CurrentStepIndex = null;
Completed = true;
Hide();
}
+54 -1
View File
@@ -32,6 +32,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
@@ -52,6 +53,7 @@ using osu.Game.Screens.Edit.Compose.Components.Timeline;
using osu.Game.Screens.Edit.Design;
using osu.Game.Screens.Edit.GameplayTest;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Submission;
using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Edit.Verify;
using osu.Game.Screens.OnlinePlay;
@@ -111,6 +113,10 @@ namespace osu.Game.Screens.Edit
[Resolved(canBeNull: true)]
private INotificationOverlay notifications { get; set; }
[Resolved(canBeNull: true)]
[CanBeNull]
private LoginOverlay loginOverlay { get; set; }
[Resolved]
private RealmAccess realm { get; set; }
@@ -1251,11 +1257,22 @@ namespace osu.Game.Screens.Edit
if (RuntimeInfo.IsDesktop)
{
var externalEdit = new EditorMenuItem("Edit externally", MenuItemType.Standard, editExternally);
var externalEdit = new EditorMenuItem(EditorStrings.EditExternally, MenuItemType.Standard, editExternally);
saveRelatedMenuItems.Add(externalEdit);
yield return externalEdit;
}
bool isSetMadeOfLegacyRulesetBeatmaps = (isNewBeatmap && Ruleset.Value.IsLegacyRuleset())
|| (!isNewBeatmap && Beatmap.Value.BeatmapSetInfo.Beatmaps.All(b => b.Ruleset.IsLegacyRuleset()));
bool submissionAvailable = api.Endpoints.BeatmapSubmissionServiceUrl != null;
if (isSetMadeOfLegacyRulesetBeatmaps && submissionAvailable)
{
var upload = new EditorMenuItem(EditorStrings.SubmitBeatmap, MenuItemType.Standard, submitBeatmap);
saveRelatedMenuItems.Add(upload);
yield return upload;
}
if (editorBeatmap.BeatmapInfo.OnlineID > 0)
{
yield return new OsuMenuItemSpacer();
@@ -1304,6 +1321,42 @@ namespace osu.Game.Screens.Edit
}
}
private void submitBeatmap()
{
if (api.State.Value != APIState.Online)
{
loginOverlay?.Show();
return;
}
if (!editorBeatmap.HitObjects.Any())
{
notifications?.Post(new SimpleNotification
{
Text = BeatmapSubmissionStrings.EmptyBeatmapsCannotBeSubmitted,
});
return;
}
if (HasUnsavedChanges)
{
dialogOverlay.Push(new SaveRequiredPopupDialog(() => attemptMutationOperation(() =>
{
if (!Save())
return false;
startSubmission();
return true;
})));
}
else
{
startSubmission();
}
void startSubmission() => this.Push(new BeatmapSubmissionScreen());
}
private void exportBeatmap(bool legacy)
{
if (HasUnsavedChanges)
@@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Overlays;
using osu.Game.Localisation;
@@ -15,10 +17,14 @@ namespace osu.Game.Screens.Edit.Submission
}
[BackgroundDependencyLoader]
private void load()
private void load(IBindable<WorkingBeatmap> beatmap)
{
AddStep<ScreenContentPermissions>();
AddStep<ScreenFrequentlyAskedQuestions>();
if (beatmap.Value.BeatmapSetInfo.OnlineID <= 0)
{
AddStep<ScreenContentPermissions>();
AddStep<ScreenFrequentlyAskedQuestions>();
}
AddStep<ScreenSubmissionSettings>();
Header.Title = BeatmapSubmissionStrings.BeatmapSubmissionTitle;
@@ -0,0 +1,446 @@
// 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.IO;
using System.Linq;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Development;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Drawables.Cards;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO.Archives;
using osu.Game.Localisation;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Screens.Select;
using osuTK;
namespace osu.Game.Screens.Edit.Submission
{
public partial class BeatmapSubmissionScreen : OsuScreen
{
private BeatmapSubmissionOverlay overlay = null!;
public override bool DisallowExternalBeatmapRulesetChanges => true;
protected override bool InitialBackButtonVisibility => false;
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Resolved]
private RealmAccess realmAccess { get; set; } = null!;
[Resolved]
private Storage storage { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private OsuConfigManager configManager { get; set; } = null!;
[Resolved]
private OsuGame? game { get; set; }
[Resolved]
private BeatmapManager beatmaps { get; set; } = null!;
[Cached]
private BeatmapSubmissionSettings settings { get; } = new BeatmapSubmissionSettings();
private Container submissionProgress = null!;
private SubmissionStageProgress exportStep = null!;
private SubmissionStageProgress createSetStep = null!;
private SubmissionStageProgress uploadStep = null!;
private SubmissionStageProgress updateStep = null!;
private Container successContainer = null!;
private Container flashLayer = null!;
private uint? beatmapSetId;
private MemoryStream? beatmapPackageStream;
private ProgressNotification? exportProgressNotification;
private ProgressNotification? updateProgressNotification;
private Live<BeatmapSetInfo>? importedSet;
[BackgroundDependencyLoader]
private void load()
{
AddRangeInternal(new Drawable[]
{
overlay = new BeatmapSubmissionOverlay(),
submissionProgress = new Container
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
AutoSizeDuration = 400,
AutoSizeEasing = Easing.OutQuint,
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Width = 0.6f,
Masking = true,
CornerRadius = 10,
Children = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background5,
},
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Padding = new MarginPadding(20),
Spacing = new Vector2(5),
Children = new Drawable[]
{
createSetStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Preparing,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
exportStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Exporting,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
uploadStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Uploading,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
updateStep = new SubmissionStageProgress
{
StageDescription = BeatmapSubmissionStrings.Finishing,
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
},
successContainer = new Container
{
Padding = new MarginPadding(20),
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
AutoSizeAxes = Axes.Both,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Child = flashLayer = new Container
{
RelativeSizeAxes = Axes.Both,
Masking = true,
CornerRadius = BeatmapCard.CORNER_RADIUS,
Depth = float.MinValue,
Alpha = 0,
Child = new Box
{
RelativeSizeAxes = Axes.Both,
}
}
},
}
}
}
}
});
overlay.State.BindValueChanged(_ =>
{
if (overlay.State.Value == Visibility.Hidden)
{
if (!overlay.Completed)
{
allowExit();
this.Exit();
}
else
{
submissionProgress.FadeIn(200, Easing.OutQuint);
createBeatmapSet();
}
}
});
}
private void createBeatmapSet()
{
bool beatmapHasOnlineId = Beatmap.Value.BeatmapSetInfo.OnlineID > 0;
var createRequest = beatmapHasOnlineId
? PutBeatmapSetRequest.UpdateExisting(
(uint)Beatmap.Value.BeatmapSetInfo.OnlineID,
Beatmap.Value.BeatmapSetInfo.Beatmaps.Where(b => b.OnlineID > 0).Select(b => (uint)b.OnlineID).ToArray(),
(uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count(b => b.OnlineID <= 0),
settings.Target.Value)
: PutBeatmapSetRequest.CreateNew((uint)Beatmap.Value.BeatmapSetInfo.Beatmaps.Count, settings.Target.Value);
createRequest.Success += async response =>
{
createSetStep.SetCompleted();
beatmapSetId = response.BeatmapSetId;
// at this point the set has an assigned online ID.
// it's important to proactively store it to the realm database,
// so that in the event in further failures in the process, the online ID is not lost.
// losing it can incur creation of redundant new sets server-side, or even cause online ID confusion.
if (!beatmapHasOnlineId)
{
await realmAccess.WriteAsync(r =>
{
var refetchedSet = r.Find<BeatmapSetInfo>(Beatmap.Value.BeatmapSetInfo.ID);
refetchedSet!.OnlineID = (int)beatmapSetId.Value;
}).ConfigureAwait(true);
}
await createBeatmapPackage(response).ConfigureAwait(true);
};
createRequest.Failure += ex =>
{
createSetStep.SetFailed(ex.Message);
Logger.Log($"Beatmap set submission failed on creation: {ex}");
allowExit();
};
createSetStep.SetInProgress();
api.Queue(createRequest);
}
private async Task createBeatmapPackage(PutBeatmapSetResponse response)
{
Debug.Assert(ThreadSafety.IsUpdateThread);
exportStep.SetInProgress();
try
{
beatmapPackageStream = new MemoryStream();
exportProgressNotification = new ProgressNotification();
var legacyBeatmapExporter = new SubmissionBeatmapExporter(storage, response);
await legacyBeatmapExporter
.ExportToStreamAsync(Beatmap.Value.BeatmapSetInfo.ToLive(realmAccess), beatmapPackageStream, exportProgressNotification)
.ConfigureAwait(true);
}
catch (Exception ex)
{
exportStep.SetFailed(ex.Message);
exportProgressNotification = null;
Logger.Log($"Beatmap set submission failed on export: {ex}");
allowExit();
}
exportStep.SetCompleted();
exportProgressNotification = null;
await Task.Delay(200).ConfigureAwait(true);
if (response.Files.Count > 0)
await patchBeatmapSet(response.Files).ConfigureAwait(true);
else
replaceBeatmapSet();
}
private async Task patchBeatmapSet(ICollection<BeatmapSetFile> onlineFiles)
{
Debug.Assert(beatmapSetId != null);
Debug.Assert(beatmapPackageStream != null);
var onlineFilesByFilename = onlineFiles.ToDictionary(f => f.Filename, f => f.SHA2Hash);
// disposing the `ArchiveReader` makes the underlying stream no longer readable which we don't want.
// make a local copy to defend against it.
using var archiveReader = new ZipArchiveReader(new MemoryStream(beatmapPackageStream.ToArray()));
var filesToUpdate = new HashSet<string>();
foreach (string filename in archiveReader.Filenames)
{
string localHash = archiveReader.GetStream(filename).ComputeSHA2Hash();
if (!onlineFilesByFilename.Remove(filename, out string? onlineHash))
{
filesToUpdate.Add(filename);
continue;
}
if (localHash != onlineHash)
filesToUpdate.Add(filename);
}
var changedFiles = new Dictionary<string, byte[]>();
foreach (string file in filesToUpdate)
changedFiles.Add(file, await archiveReader.GetStream(file).ReadAllBytesToArrayAsync().ConfigureAwait(true));
var patchRequest = new PatchBeatmapPackageRequest(beatmapSetId.Value);
patchRequest.FilesChanged.AddRange(changedFiles);
patchRequest.FilesDeleted.AddRange(onlineFilesByFilename.Keys);
patchRequest.Success += uploadCompleted;
patchRequest.Failure += ex =>
{
uploadStep.SetFailed(ex.Message);
Logger.Log($"Beatmap submission failed on upload: {ex}");
allowExit();
};
patchRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / total);
api.Queue(patchRequest);
uploadStep.SetInProgress();
}
private void replaceBeatmapSet()
{
Debug.Assert(beatmapSetId != null);
Debug.Assert(beatmapPackageStream != null);
var uploadRequest = new ReplaceBeatmapPackageRequest(beatmapSetId.Value, beatmapPackageStream.ToArray());
uploadRequest.Success += uploadCompleted;
uploadRequest.Failure += ex =>
{
uploadStep.SetFailed(ex.Message);
Logger.Log($"Beatmap submission failed on upload: {ex}");
allowExit();
};
uploadRequest.Progressed += (current, total) => uploadStep.SetInProgress((float)current / Math.Max(total, 1));
api.Queue(uploadRequest);
uploadStep.SetInProgress();
}
private void uploadCompleted()
{
uploadStep.SetCompleted();
updateLocalBeatmap().ConfigureAwait(true);
}
private async Task updateLocalBeatmap()
{
Debug.Assert(beatmapSetId != null);
Debug.Assert(beatmapPackageStream != null);
updateStep.SetInProgress();
await Task.Delay(200).ConfigureAwait(true);
try
{
importedSet = await beatmaps.ImportAsUpdate(
updateProgressNotification = new ProgressNotification(),
new ImportTask(beatmapPackageStream, $"{beatmapSetId}.osz"),
Beatmap.Value.BeatmapSetInfo).ConfigureAwait(true);
}
catch (Exception ex)
{
updateStep.SetFailed(ex.Message);
Logger.Log($"Beatmap submission failed on local update: {ex}");
allowExit();
return;
}
updateStep.SetCompleted();
showBeatmapCard();
allowExit();
if (configManager.Get<bool>(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission))
{
await Task.Delay(1000).ConfigureAwait(true);
game?.OpenUrlExternally($"{api.Endpoints.WebsiteUrl}/beatmapsets/{beatmapSetId}");
}
}
private void showBeatmapCard()
{
Debug.Assert(beatmapSetId != null);
var getBeatmapSetRequest = new GetBeatmapSetRequest((int)beatmapSetId.Value);
getBeatmapSetRequest.Success += beatmapSet =>
{
LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded =>
{
successContainer.Add(loaded);
flashLayer.FadeOutFromOne(2000, Easing.OutQuint);
});
};
api.Queue(getBeatmapSetRequest);
}
private void allowExit()
{
BackButtonVisibility.Value = true;
}
protected override void Update()
{
base.Update();
if (exportProgressNotification != null && exportProgressNotification.Ongoing)
exportStep.SetInProgress(exportProgressNotification.Progress);
if (updateProgressNotification != null && updateProgressNotification.Ongoing)
updateStep.SetInProgress(updateProgressNotification.Progress);
}
public override bool OnExiting(ScreenExitEvent e)
{
// We probably want a method of cancelling in the future…
if (!BackButtonVisibility.Value)
return true;
if (importedSet != null)
{
game?.PerformFromScreen(s =>
{
if (s is OsuScreen osuScreen)
{
Debug.Assert(importedSet != null);
var targetBeatmap = importedSet.Value.Beatmaps.FirstOrDefault(b => b.DifficultyName == Beatmap.Value.BeatmapInfo.DifficultyName)
?? importedSet.Value.Beatmaps.First();
osuScreen.Beatmap.Value = beatmaps.GetWorkingBeatmap(targetBeatmap);
}
s.Push(new EditorLoader());
}, [typeof(SongSelect)]);
return false;
}
return base.OnExiting(e);
}
public override void OnEntering(ScreenTransitionEvent e)
{
base.OnEntering(e);
overlay.Show();
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
beatmapPackageStream?.Dispose();
}
}
}
@@ -0,0 +1,13 @@
// 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.Bindables;
using osu.Game.Online.API.Requests;
namespace osu.Game.Screens.Edit.Submission
{
public class BeatmapSubmissionSettings
{
public Bindable<BeatmapSubmissionTarget> Target { get; } = new Bindable<BeatmapSubmissionTarget>();
}
}
@@ -11,6 +11,7 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Overlays;
using osuTK;
@@ -22,8 +23,10 @@ namespace osu.Game.Screens.Edit.Submission
private readonly BindableBool notifyOnDiscussionReplies = new BindableBool();
private readonly BindableBool loadInBrowserAfterSubmission = new BindableBool();
public override LocalisableString? NextStepText => BeatmapSubmissionStrings.ConfirmSubmission;
[BackgroundDependencyLoader]
private void load(OsuConfigManager configManager, OsuColour colours)
private void load(OsuConfigManager configManager, OsuColour colours, BeatmapSubmissionSettings settings)
{
configManager.BindWith(OsuSetting.EditorSubmissionNotifyOnDiscussionReplies, notifyOnDiscussionReplies);
configManager.BindWith(OsuSetting.EditorSubmissionLoadInBrowserAfterSubmission, loadInBrowserAfterSubmission);
@@ -39,6 +42,7 @@ namespace osu.Game.Screens.Edit.Submission
{
RelativeSizeAxes = Axes.X,
Caption = BeatmapSubmissionStrings.BeatmapSubmissionTargetCaption,
Current = settings.Target,
},
new FormCheckBox
{
@@ -60,14 +64,5 @@ namespace osu.Game.Screens.Edit.Submission
}
});
}
private enum BeatmapSubmissionTarget
{
[LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetWIP))]
WIP,
[LocalisableDescription(typeof(BeatmapSubmissionStrings), nameof(BeatmapSubmissionStrings.BeatmapSubmissionTargetPending))]
Pending,
}
}
}
@@ -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;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Screens.Edit.Submission
{
public class SubmissionBeatmapExporter : LegacyBeatmapExporter
{
private readonly uint? beatmapSetId;
private readonly HashSet<int>? allocatedBeatmapIds;
public SubmissionBeatmapExporter(Storage storage, PutBeatmapSetResponse putBeatmapSetResponse)
: base(storage)
{
beatmapSetId = putBeatmapSetResponse.BeatmapSetId;
allocatedBeatmapIds = putBeatmapSetResponse.BeatmapIds.Select(id => (int)id).ToHashSet();
}
protected override void MutateBeatmap(BeatmapSetInfo beatmapSet, IBeatmap playableBeatmap)
{
base.MutateBeatmap(beatmapSet, playableBeatmap);
if (beatmapSetId != null && allocatedBeatmapIds != null)
{
playableBeatmap.BeatmapInfo.BeatmapSet = beatmapSet;
playableBeatmap.BeatmapInfo.BeatmapSet!.OnlineID = (int)beatmapSetId;
if (allocatedBeatmapIds.Contains(playableBeatmap.BeatmapInfo.OnlineID))
{
allocatedBeatmapIds.Remove(playableBeatmap.BeatmapInfo.OnlineID);
return;
}
if (playableBeatmap.BeatmapInfo.OnlineID > 0)
throw new InvalidOperationException(@"Encountered beatmap with ID that has not been assigned to it by the server!");
if (allocatedBeatmapIds.Count == 0)
throw new InvalidOperationException(@"Ran out of new beatmap IDs to assign to unsubmitted beatmaps!");
int newId = allocatedBeatmapIds.First();
allocatedBeatmapIds.Remove(newId);
playableBeatmap.BeatmapInfo.OnlineID = newId;
}
}
}
}
@@ -0,0 +1,178 @@
// 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 osu.Framework.Allocation;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using Realms;
namespace osu.Game.Screens.OnlinePlay.Playlists
{
public partial class AddPlaylistToCollectionButton : RoundedButton, IHasTooltip
{
private readonly Room room;
private IDisposable? beatmapSubscription;
private IDisposable? collectionSubscription;
private Live<BeatmapCollection>? collection;
private HashSet<string> localBeatmapHashes = new HashSet<string>();
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved(canBeNull: true)]
private INotificationOverlay? notifications { get; set; }
public AddPlaylistToCollectionButton(Room room)
{
this.room = room;
}
[BackgroundDependencyLoader]
private void load()
{
Action = () =>
{
if (room.Playlist.Count == 0)
return;
int countBefore = 0;
int countAfter = 0;
Text = "Updating collection...";
Enabled.Value = false;
realm.WriteAsync(r =>
{
var beatmaps = getBeatmapsForPlaylist(r).ToArray();
var c = getCollectionsForPlaylist(r).FirstOrDefault()
?? r.Add(new BeatmapCollection(room.Name));
countBefore = c.BeatmapMD5Hashes.Count;
foreach (var item in beatmaps)
{
if (!c.BeatmapMD5Hashes.Contains(item.MD5Hash))
c.BeatmapMD5Hashes.Add(item.MD5Hash);
}
countAfter = c.BeatmapMD5Hashes.Count;
}).ContinueWith(_ => Schedule(() =>
{
if (countBefore == 0)
notifications?.Post(new SimpleNotification { Text = $"Created new collection \"{room.Name}\" with {countAfter} beatmaps." });
else
notifications?.Post(new SimpleNotification { Text = $"Added {countAfter - countBefore} beatmaps to collection \"{room.Name}\"." });
}));
};
}
protected override void LoadComplete()
{
base.LoadComplete();
// will be updated via updateButtonState() when ready.
Enabled.Value = false;
if (room.Playlist.Count == 0)
return;
beatmapSubscription = realm.RegisterForNotifications(getBeatmapsForPlaylist, (sender, _) =>
{
localBeatmapHashes = sender.Select(b => b.MD5Hash).ToHashSet();
Schedule(updateButtonState);
});
collectionSubscription = realm.RegisterForNotifications(getCollectionsForPlaylist, (sender, _) =>
{
collection = sender.FirstOrDefault()?.ToLive(realm);
Schedule(updateButtonState);
});
}
private void updateButtonState()
{
int countToAdd = getCountToBeAdded();
if (collection == null)
Text = $"Create new collection with {countToAdd} beatmaps";
else if (hasAllItemsInCollection)
Text = "Collection complete!";
else
Text = $"Add {countToAdd} beatmaps to collection";
Enabled.Value = countToAdd > 0;
}
private int getCountToBeAdded()
{
if (collection == null)
return localBeatmapHashes.Count;
return collection.PerformRead(c =>
{
int count = localBeatmapHashes.Count;
foreach (string hash in localBeatmapHashes)
{
if (c.BeatmapMD5Hashes.Contains(hash))
count--;
}
return count;
});
}
private IQueryable<BeatmapCollection> getCollectionsForPlaylist(Realm r) => r.All<BeatmapCollection>().Where(c => c.Name == room.Name);
private IQueryable<BeatmapInfo> getBeatmapsForPlaylist(Realm r)
{
return r.All<BeatmapInfo>().Filter(string.Join(" OR ", room.Playlist.Select(item => $"(OnlineID == {item.Beatmap.OnlineID})").Distinct()));
}
private bool hasAllItemsInCollection
{
get
{
if (collection == null)
return false;
return room.Playlist.DistinctBy(i => i.Beatmap.OnlineID).Count() ==
collection.PerformRead(c => c.BeatmapMD5Hashes.Count);
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
beatmapSubscription?.Dispose();
collectionSubscription?.Dispose();
}
public LocalisableString TooltipText
{
get
{
if (Enabled.Value)
return string.Empty;
if (hasAllItemsInCollection)
return "All beatmaps have been added!";
return "Download some beatmaps first.";
}
}
}
}
@@ -230,6 +230,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
{
new Dimension(GridSizeMode.AutoSize),
new Dimension(),
new Dimension(GridSizeMode.AutoSize),
},
Content = new[]
{
@@ -252,6 +253,15 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
api.LocalUser.Value.Id));
}
}
},
new Drawable[]
{
new AddPlaylistToCollectionButton(room)
{
Margin = new MarginPadding { Top = 5 },
RelativeSizeAxes = Axes.X,
Size = new Vector2(1, 40)
}
}
}
},