Merge branch 'master' into JudgementCounter

This commit is contained in:
MK56
2023-01-11 11:04:37 +01:00
committed by GitHub
540 changed files with 6757 additions and 3112 deletions

View File

@ -109,6 +109,8 @@ namespace osu.Game.Audio
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
Stop();
Track?.Dispose();
}
}

View File

@ -35,7 +35,7 @@ namespace osu.Game.Audio
public bool Equals(SampleInfo? other)
=> other != null && sampleNames.SequenceEqual(other.sampleNames);
public override bool Equals(object obj)
public override bool Equals(object? obj)
=> obj is SampleInfo other && Equals(other);
}
}

View File

@ -81,9 +81,14 @@ namespace osu.Game.Beatmaps
public double GetMostCommonBeatLength()
{
double lastTime;
// The last playable time in the beatmap - the last timing point extends to this time.
// Note: This is more accurate and may present different results because osu-stable didn't have the ability to calculate slider durations in this context.
double lastTime = HitObjects.LastOrDefault()?.GetEndTime() ?? ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0;
if (!HitObjects.Any())
lastTime = ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0;
else
lastTime = this.GetLastObjectTime();
var mostCommon =
// Construct a set of (beatLength, duration) tuples for each individual timing point.

View File

@ -44,7 +44,7 @@ namespace osu.Game.Beatmaps
public override async Task<Live<BeatmapSetInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original)
{
var imported = await Import(notification, importTask);
var imported = await Import(notification, new[] { importTask }).ConfigureAwait(true);
if (!imported.Any())
return null;
@ -203,10 +203,10 @@ namespace osu.Game.Beatmaps
}
}
protected override void PostImport(BeatmapSetInfo model, Realm realm, bool batchImport)
protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters)
{
base.PostImport(model, realm, batchImport);
ProcessBeatmap?.Invoke((model, batchImport));
base.PostImport(model, realm, parameters);
ProcessBeatmap?.Invoke((model, parameters.Batch));
}
private void validateOnlineIds(BeatmapSetInfo beatmapSet, Realm realm)

View File

@ -44,6 +44,16 @@ namespace osu.Game.Beatmaps
public Action<(BeatmapSetInfo beatmapSet, bool isBatch)>? ProcessBeatmap { private get; set; }
public override bool PauseImports
{
get => base.PauseImports;
set
{
base.PauseImports = value;
beatmapImporter.PauseImports = value;
}
}
public BeatmapManager(Storage storage, RealmAccess realm, IAPIProvider? api, AudioManager audioManager, IResourceStore<byte[]> gameResources, GameHost? host = null,
WorkingBeatmap? defaultBeatmap = null, BeatmapDifficultyCache? difficultyCache = null, bool performOnlineLookups = false)
: base(storage, realm)
@ -456,15 +466,16 @@ namespace osu.Game.Beatmaps
public Task Import(params string[] paths) => beatmapImporter.Import(paths);
public Task Import(params ImportTask[] tasks) => beatmapImporter.Import(tasks);
public Task Import(ImportTask[] tasks, ImportParameters parameters = default) => beatmapImporter.Import(tasks, parameters);
public Task<IEnumerable<Live<BeatmapSetInfo>>> Import(ProgressNotification notification, params ImportTask[] tasks) => beatmapImporter.Import(notification, tasks);
public Task<IEnumerable<Live<BeatmapSetInfo>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) =>
beatmapImporter.Import(notification, tasks, parameters);
public Task<Live<BeatmapSetInfo>?> Import(ImportTask task, bool batchImport = false, CancellationToken cancellationToken = default) =>
beatmapImporter.Import(task, batchImport, cancellationToken);
public Task<Live<BeatmapSetInfo>?> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>
beatmapImporter.Import(task, parameters, cancellationToken);
public Live<BeatmapSetInfo>? Import(BeatmapSetInfo item, ArchiveReader? archive = null, CancellationToken cancellationToken = default) =>
beatmapImporter.ImportModel(item, archive, false, cancellationToken);
beatmapImporter.ImportModel(item, archive, default, cancellationToken);
public IEnumerable<string> HandledExtensions => beatmapImporter.HandledExtensions;

View File

@ -0,0 +1,22 @@
// 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 Newtonsoft.Json;
namespace osu.Game.Beatmaps
{
public struct BeatmapSetOnlineNomination
{
[JsonProperty(@"beatmapset_id")]
public int BeatmapsetId { get; set; }
[JsonProperty(@"reset")]
public bool Reset { get; set; }
[JsonProperty(@"rulesets")]
public string[]? Rulesets { get; set; }
[JsonProperty(@"user_id")]
public int UserId { get; set; }
}
}

View File

@ -178,7 +178,7 @@ namespace osu.Game.Beatmaps
{
try
{
await cacheDownloadRequest.PerformAsync();
await cacheDownloadRequest.PerformAsync().ConfigureAwait(false);
}
catch
{

View File

@ -16,7 +16,7 @@ namespace osu.Game.Beatmaps.ControlPoints
public void AttachGroup(ControlPointGroup pointGroup) => Time = pointGroup.Time;
public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time);
public int CompareTo(ControlPoint? other) => Time.CompareTo(other?.Time);
public virtual Color4 GetRepresentingColour(OsuColour colours) => colours.Yellow;
@ -32,7 +32,7 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
public ControlPoint DeepClone()
{
var copy = (ControlPoint)Activator.CreateInstance(GetType());
var copy = (ControlPoint)Activator.CreateInstance(GetType())!;
copy.CopyFrom(this);

View File

@ -26,7 +26,7 @@ namespace osu.Game.Beatmaps.ControlPoints
Time = time;
}
public int CompareTo(ControlPointGroup other) => Time.CompareTo(other.Time);
public int CompareTo(ControlPointGroup? other) => Time.CompareTo(other?.Time);
public void Add(ControlPoint point)
{

View File

@ -70,14 +70,14 @@ namespace osu.Game.Beatmaps.ControlPoints
/// </summary>
[JsonIgnore]
public double BPMMaximum =>
60000 / (TimingPoints.OrderBy(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength;
60000 / (TimingPoints.MinBy(c => c.BeatLength) ?? TimingControlPoint.DEFAULT).BeatLength;
/// <summary>
/// Finds the minimum BPM represented by any timing control point.
/// </summary>
[JsonIgnore]
public double BPMMinimum =>
60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength;
60000 / (TimingPoints.MaxBy(c => c.BeatLength) ?? TimingControlPoint.DEFAULT).BeatLength;
/// <summary>
/// Remove all <see cref="ControlPointGroup"/>s and return to a pristine state.
@ -211,8 +211,7 @@ namespace osu.Game.Beatmaps.ControlPoints
public static T BinarySearch<T>(IReadOnlyList<T> list, double time)
where T : class, IControlPoint
{
if (list == null)
throw new ArgumentNullException(nameof(list));
ArgumentNullException.ThrowIfNull(list);
if (list.Count == 0)
return null;
@ -300,7 +299,7 @@ namespace osu.Game.Beatmaps.ControlPoints
public ControlPointInfo DeepClone()
{
var controlPointInfo = (ControlPointInfo)Activator.CreateInstance(GetType());
var controlPointInfo = (ControlPointInfo)Activator.CreateInstance(GetType())!;
foreach (var point in AllControlPoints)
controlPointInfo.Add(point.Time, point.DeepClone());

View File

@ -51,11 +51,11 @@ namespace osu.Game.Beatmaps
if (!recommendedDifficultyMapping.TryGetValue(r, out double recommendation))
continue;
BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r)).OrderBy(b =>
BeatmapInfo beatmapInfo = beatmaps.Where(b => b.Ruleset.ShortName.Equals(r, StringComparison.Ordinal)).MinBy(b =>
{
double difference = b.StarRating - recommendation;
return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder
}).FirstOrDefault();
});
if (beatmapInfo != null)
return beatmapInfo;
@ -90,7 +90,7 @@ namespace osu.Game.Beatmaps
return recommendedDifficultyMapping
.OrderByDescending(pair => pair.Value)
.Select(pair => pair.Key)
.Where(r => !r.Equals(ruleset.Value.ShortName))
.Where(r => !r.Equals(ruleset.Value.ShortName, StringComparison.Ordinal))
.Prepend(ruleset.Value.ShortName);
}
}

View File

@ -15,8 +15,7 @@ namespace osu.Game.Beatmaps.Drawables
public BeatmapBackgroundSprite(IWorkingBeatmap working)
{
if (working == null)
throw new ArgumentNullException(nameof(working));
ArgumentNullException.ThrowIfNull(working);
this.working = working;
}

View File

@ -5,16 +5,19 @@ using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Online;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Localisation;
namespace osu.Game.Beatmaps.Drawables.Cards
{
public abstract partial class BeatmapCard : OsuClickableContainer
public abstract partial class BeatmapCard : OsuClickableContainer, IHasContextMenu
{
public const float TRANSITION_DURATION = 400;
public const float CORNER_RADIUS = 10;
@ -96,5 +99,10 @@ namespace osu.Game.Beatmaps.Drawables.Cards
throw new ArgumentOutOfRangeException(nameof(size), size, @"Unsupported card size");
}
}
public MenuItem[] ContextMenuItems => new MenuItem[]
{
new OsuMenuItem(ContextMenuStrings.ViewBeatmap, MenuItemType.Highlighted, Action),
};
}
}

View File

@ -138,9 +138,18 @@ namespace osu.Game.Beatmaps.Drawables.Cards
// This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left.
this.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint);
background.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
dropdownContent.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
borderContainer.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
if (Expanded.Value)
{
background.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
dropdownContent.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
borderContainer.FadeIn(BeatmapCard.TRANSITION_DURATION, Easing.OutQuint);
}
else
{
background.FadeOut(BeatmapCard.TRANSITION_DURATION / 3f, Easing.OutQuint);
dropdownContent.FadeOut(BeatmapCard.TRANSITION_DURATION / 3f, Easing.OutQuint);
borderContainer.FadeOut(BeatmapCard.TRANSITION_DURATION / 3f, Easing.OutQuint);
}
content.TweenEdgeEffectTo(new EdgeEffectParameters
{

View File

@ -11,7 +11,7 @@ using osu.Game.Beatmaps.Drawables.Cards.Buttons;
using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays;
using osu.Game.Screens.Ranking.Expanded.Accuracy;
using osu.Framework.Graphics.UserInterface;
using osuTK;
using osuTK.Graphics;
@ -30,7 +30,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
private readonly UpdateableOnlineBeatmapSetCover cover;
private readonly Container foreground;
private readonly PlayButton playButton;
private readonly SmoothCircularProgress progress;
private readonly CircularProgress progress;
private readonly Container content;
protected override Container<Drawable> Content => content;
@ -53,7 +53,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards
{
RelativeSizeAxes = Axes.Both
},
progress = new SmoothCircularProgress
progress = new CircularProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,

View File

@ -18,8 +18,7 @@ namespace osu.Game.Beatmaps.Drawables
public OnlineBeatmapSetCover(IBeatmapSetOnlineInfo set, BeatmapSetCoverType type = BeatmapSetCoverType.Cover)
{
if (set == null)
throw new ArgumentNullException(nameof(set));
ArgumentNullException.ThrowIfNull(set);
this.set = set;
this.type = type;

View File

@ -57,8 +57,7 @@ namespace osu.Game.Beatmaps.Formats
public static Decoder<T> GetDecoder<T>(LineBufferedReader stream)
where T : new()
{
if (stream == null)
throw new ArgumentNullException(nameof(stream));
ArgumentNullException.ThrowIfNull(stream);
if (!decoders.TryGetValue(typeof(T), out var typedDecoders))
throw new IOException(@"Unknown decoder type");

View File

@ -160,7 +160,7 @@ namespace osu.Game.Beatmaps.Formats
break;
case @"SampleSet":
defaultSampleBank = (LegacySampleBank)Enum.Parse(typeof(LegacySampleBank), pair.Value);
defaultSampleBank = Enum.Parse<LegacySampleBank>(pair.Value);
break;
case @"SampleVolume":
@ -218,7 +218,7 @@ namespace osu.Game.Beatmaps.Formats
break;
case @"Countdown":
beatmap.BeatmapInfo.Countdown = (CountdownType)Enum.Parse(typeof(CountdownType), pair.Value);
beatmap.BeatmapInfo.Countdown = Enum.Parse<CountdownType>(pair.Value);
break;
case @"CountdownOffset":

View File

@ -300,7 +300,7 @@ namespace osu.Game.Beatmaps.Formats
{
var comboColour = colours[i];
writer.Write(FormattableString.Invariant($"Combo{i}: "));
writer.Write(FormattableString.Invariant($"Combo{1 + i}: "));
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.R * byte.MaxValue)},"));
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.G * byte.MaxValue)},"));
writer.Write(FormattableString.Invariant($"{(byte)(comboColour.B * byte.MaxValue)},"));

View File

@ -132,13 +132,7 @@ namespace osu.Game.Beatmaps.Formats
protected KeyValuePair<string, string> SplitKeyVal(string line, char separator = ':', bool shouldTrim = true)
{
string[] split = line.Split(separator, 2);
if (shouldTrim)
{
for (int i = 0; i < split.Length; i++)
split[i] = split[i].Trim();
}
string[] split = line.Split(separator, 2, shouldTrim ? StringSplitOptions.TrimEntries : StringSplitOptions.None);
return new KeyValuePair<string, string>
(

View File

@ -301,11 +301,11 @@ namespace osu.Game.Beatmaps.Formats
}
}
private string parseLayer(string value) => Enum.Parse(typeof(LegacyStoryLayer), value).ToString();
private string parseLayer(string value) => Enum.Parse<LegacyStoryLayer>(value).ToString();
private Anchor parseOrigin(string value)
{
var origin = (LegacyOrigins)Enum.Parse(typeof(LegacyOrigins), value);
var origin = Enum.Parse<LegacyOrigins>(value);
switch (origin)
{
@ -343,8 +343,8 @@ namespace osu.Game.Beatmaps.Formats
private AnimationLoopType parseAnimationLoopType(string value)
{
var parsed = (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), value);
return Enum.IsDefined(typeof(AnimationLoopType), parsed) ? parsed : AnimationLoopType.LoopForever;
var parsed = Enum.Parse<AnimationLoopType>(value);
return Enum.IsDefined(parsed) ? parsed : AnimationLoopType.LoopForever;
}
private void handleVariables(string line)

View File

@ -4,6 +4,7 @@
#nullable disable
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Objects;
@ -102,5 +103,16 @@ namespace osu.Game.Beatmaps
addCombo(nested, ref combo);
}
}
/// <summary>
/// Find the absolute end time of the latest <see cref="HitObject"/> in a beatmap. Will throw if beatmap contains no objects.
/// </summary>
/// <remarks>
/// This correctly accounts for rulesets which have concurrent hitobjects which may have durations, causing the .Last() object
/// to not necessarily have the latest end time.
///
/// It's not super efficient so calls should be kept to a minimum.
/// </remarks>
public static double GetLastObjectTime(this IBeatmap beatmap) => beatmap.HitObjects.Max(h => h.GetEndTime());
}
}

View File

@ -34,8 +34,6 @@ namespace osu.Game.Beatmaps
// TODO: remove once the fallback lookup is not required (and access via `working.BeatmapInfo.Metadata` directly).
public BeatmapMetadata Metadata => BeatmapInfo.Metadata;
public Waveform Waveform => waveform.Value;
public Storyboard Storyboard => storyboard.Value;
public Texture Background => GetBackground(); // Texture uses ref counting, so we want to return a new instance every usage.
@ -48,10 +46,11 @@ namespace osu.Game.Beatmaps
private readonly object beatmapFetchLock = new object();
private readonly Lazy<Waveform> waveform;
private readonly Lazy<Storyboard> storyboard;
private readonly Lazy<ISkin> skin;
private Track track; // track is not Lazy as we allow transferring and loading multiple times.
private Waveform waveform; // waveform is also not Lazy as the track may change.
protected WorkingBeatmap(BeatmapInfo beatmapInfo, AudioManager audioManager)
{
@ -60,7 +59,6 @@ namespace osu.Game.Beatmaps
BeatmapInfo = beatmapInfo;
BeatmapSetInfo = beatmapInfo.BeatmapSet ?? new BeatmapSetInfo();
waveform = new Lazy<Waveform>(GetWaveform);
storyboard = new Lazy<Storyboard>(GetStoryboard);
skin = new Lazy<ISkin>(GetSkin);
}
@ -108,7 +106,16 @@ namespace osu.Game.Beatmaps
public virtual bool TrackLoaded => track != null;
public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000);
public Track LoadTrack()
{
track = GetBeatmapTrack() ?? GetVirtualTrack(1000);
// the track may have changed, recycle the current waveform.
waveform?.Dispose();
waveform = null;
return track;
}
public void PrepareTrackForPreview(bool looping, double offsetFromPreviewPoint = 0)
{
@ -171,6 +178,12 @@ namespace osu.Game.Beatmaps
#endregion
#region Waveform
public Waveform Waveform => waveform ??= GetWaveform();
#endregion
#region Beatmap
public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false;

View File

@ -141,6 +141,9 @@ namespace osu.Game.Beatmaps
try
{
string fileStorePath = BeatmapSetInfo.GetPathForFile(BeatmapInfo.Path);
// TODO: check validity of file
var stream = GetStream(fileStorePath);
if (stream == null)

View File

@ -19,6 +19,7 @@ namespace osu.Game.Configuration
SetDefault(Static.LoginOverlayDisplayed, false);
SetDefault(Static.MutedAudioNotificationShownOnce, false);
SetDefault(Static.LowBatteryNotificationShownOnce, false);
SetDefault(Static.FeaturedArtistDisclaimerShownOnce, false);
SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null);
SetDefault<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null);
}
@ -42,6 +43,7 @@ namespace osu.Game.Configuration
LoginOverlayDisplayed,
MutedAudioNotificationShownOnce,
LowBatteryNotificationShownOnce,
FeaturedArtistDisclaimerShownOnce,
/// <summary>
/// Info about seasonal backgrounds available fetched from API - see <see cref="APISeasonalBackgrounds"/>.
@ -53,6 +55,6 @@ namespace osu.Game.Configuration
/// The last playback time in milliseconds of a hover sample (from <see cref="HoverSounds"/>).
/// Used to debounce hover sounds game-wide to avoid volume saturation, especially in scrolling views with many UI controls like <see cref="SettingsOverlay"/>.
/// </summary>
LastHoverSoundPlaybackTime
LastHoverSoundPlaybackTime,
}
}

View File

@ -91,15 +91,15 @@ namespace osu.Game.Configuration
OrderPosition = orderPosition;
}
public int CompareTo(SettingSourceAttribute other)
public int CompareTo(SettingSourceAttribute? other)
{
if (OrderPosition == other.OrderPosition)
if (OrderPosition == other?.OrderPosition)
return 0;
// unordered items come last (are greater than any ordered items).
if (OrderPosition == null)
return 1;
if (other.OrderPosition == null)
if (other?.OrderPosition == null)
return -1;
// ordered items are sorted by the order value.
@ -113,7 +113,7 @@ namespace osu.Game.Configuration
{
foreach (var (attr, property) in obj.GetOrderedSettingsSourceProperties())
{
object value = property.GetValue(obj);
object value = property.GetValue(obj)!;
if (attr.SettingControlType != null)
{
@ -121,7 +121,7 @@ namespace osu.Game.Configuration
if (controlType.EnumerateBaseTypes().All(t => !t.IsGenericType || t.GetGenericTypeDefinition() != typeof(SettingsItem<>)))
throw new InvalidOperationException($"{nameof(SettingSourceAttribute)} had an unsupported custom control type ({controlType.ReadableName()})");
var control = (Drawable)Activator.CreateInstance(controlType);
var control = (Drawable)Activator.CreateInstance(controlType)!;
controlType.GetProperty(nameof(SettingsItem<object>.SettingSourceObject))?.SetValue(control, obj);
controlType.GetProperty(nameof(SettingsItem<object>.LabelText))?.SetValue(control, attr.Label);
controlType.GetProperty(nameof(SettingsItem<object>.TooltipText))?.SetValue(control, attr.Description);
@ -188,7 +188,7 @@ namespace osu.Game.Configuration
case IBindable bindable:
var dropdownType = typeof(ModSettingsEnumDropdown<>).MakeGenericType(bindable.GetType().GetGenericArguments()[0]);
var dropdown = (Drawable)Activator.CreateInstance(dropdownType);
var dropdown = (Drawable)Activator.CreateInstance(dropdownType)!;
dropdownType.GetProperty(nameof(SettingsDropdown<object>.LabelText))?.SetValue(dropdown, attr.Label);
dropdownType.GetProperty(nameof(SettingsDropdown<object>.TooltipText))?.SetValue(dropdown, attr.Description);
@ -231,7 +231,7 @@ namespace osu.Game.Configuration
// An unknown (e.g. enum) generic type.
var valueMethod = u.GetType().GetProperty(nameof(IBindable<int>.Value));
Debug.Assert(valueMethod != null);
return valueMethod.GetValue(u);
return valueMethod.GetValue(u)!;
default:
// fall back for non-bindable cases.

View File

@ -31,7 +31,8 @@ namespace osu.Game.Database
/// This will post notifications tracking progress.
/// </remarks>
/// <param name="tasks">The import tasks from which the files should be imported.</param>
Task Import(params ImportTask[] tasks);
/// <param name="parameters">Parameters to further configure the import process.</param>
Task Import(ImportTask[] tasks, ImportParameters parameters = default);
/// <summary>
/// An array of accepted file extensions (in the standard format of ".abc").

View File

@ -20,8 +20,9 @@ namespace osu.Game.Database
/// </summary>
/// <param name="notification">The notification to update.</param>
/// <param name="tasks">The import tasks.</param>
/// <param name="parameters">Parameters to further configure the import process.</param>
/// <returns>The imported models.</returns>
Task<IEnumerable<Live<TModel>>> Import(ProgressNotification notification, params ImportTask[] tasks);
Task<IEnumerable<Live<TModel>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default);
/// <summary>
/// Process a single import as an update for an existing model.

View File

@ -0,0 +1,25 @@
// 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.
namespace osu.Game.Database
{
public struct ImportParameters
{
/// <summary>
/// Whether this import is part of a larger batch.
/// </summary>
/// <remarks>
/// May skip intensive pre-import checks in favour of faster processing.
///
/// More specifically, 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.
///
/// Will also change scheduling behaviour to run at a lower priority.
/// </remarks>
public bool Batch { get; set; }
/// <summary>
/// Whether this import should use hard links rather than file copy operations if available.
/// </summary>
public bool PreferHardLinks { get; set; }
}
}

View File

@ -3,9 +3,11 @@
#nullable disable
using System.Collections.Generic;
using System.IO;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Utils;
using SharpCompress.Archives.Zip;
namespace osu.Game.Database
@ -31,14 +33,19 @@ namespace osu.Game.Database
UserFileStorage = storage.GetStorageForDirectory(@"files");
}
protected virtual string GetFilename(TModel item) => item.GetDisplayString();
/// <summary>
/// Exports an item to a legacy (.zip based) package.
/// </summary>
/// <param name="item">The item to export.</param>
public void Export(TModel item)
{
string filename = $"{item.GetDisplayString().GetValidFilename()}{FileExtension}";
string itemFilename = GetFilename(item).GetValidFilename();
IEnumerable<string> existingExports = exportStorage.GetFiles("", $"{itemFilename}*{FileExtension}");
string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}");
using (var stream = exportStorage.CreateFileSafely(filename))
ExportModelTo(item, stream);

View File

@ -42,8 +42,8 @@ namespace osu.Game.Database
[Resolved]
private RealmAccess realmAccess { get; set; } = null!;
[Resolved(canBeNull: true)] // canBeNull required while we remain on mono for mobile platforms.
private DesktopGameHost? desktopGameHost { get; set; }
[Resolved]
private GameHost gameHost { get; set; } = null!;
[Resolved]
private INotificationOverlay? notifications { get; set; }
@ -52,7 +52,20 @@ namespace osu.Game.Database
public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, desktopGameHost);
public void UpdateStorage(string stablePath) => cachedStorage = new StableStorage(stablePath, gameHost as DesktopGameHost);
public bool CheckSongsFolderHardLinkAvailability()
{
var stableStorage = GetCurrentStableStorage();
if (stableStorage == null || gameHost is not DesktopGameHost desktopGameHost)
return false;
string testExistingPath = stableStorage.GetSongStorage().GetFullPath(string.Empty);
string testDestinationPath = desktopGameHost.Storage.GetFullPath(string.Empty);
return HardLinkHelper.CheckAvailability(testDestinationPath, testExistingPath);
}
public virtual async Task<int> GetImportCount(StableContent content, CancellationToken cancellationToken)
{
@ -66,16 +79,16 @@ namespace osu.Game.Database
switch (content)
{
case StableContent.Beatmaps:
return await new LegacyBeatmapImporter(beatmaps).GetAvailableCount(stableStorage);
return await new LegacyBeatmapImporter(beatmaps).GetAvailableCount(stableStorage).ConfigureAwait(false);
case StableContent.Skins:
return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage);
return await new LegacySkinImporter(skins).GetAvailableCount(stableStorage).ConfigureAwait(false);
case StableContent.Collections:
return await new LegacyCollectionImporter(realmAccess).GetAvailableCount(stableStorage);
return await new LegacyCollectionImporter(realmAccess).GetAvailableCount(stableStorage).ConfigureAwait(false);
case StableContent.Scores:
return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage);
return await new LegacyScoreImporter(scores).GetAvailableCount(stableStorage).ConfigureAwait(false);
default:
throw new ArgumentException($"Only one {nameof(StableContent)} flag should be specified.");

View File

@ -57,7 +57,12 @@ namespace osu.Game.Database
return Task.CompletedTask;
}
return Task.Run(async () => await Importer.Import(GetStableImportPaths(storage).ToArray()).ConfigureAwait(false));
return Task.Run(async () =>
{
var tasks = GetStableImportPaths(storage).Select(p => new ImportTask(p)).ToArray();
await Importer.Import(tasks, new ImportParameters { Batch = true, PreferHardLinks = true }).ConfigureAwait(false);
});
}
/// <summary>

View File

@ -20,6 +20,14 @@ namespace osu.Game.Database
{
}
protected override string GetFilename(ScoreInfo score)
{
string scoreString = score.GetDisplayString();
string filename = $"{scoreString} ({score.Date.LocalDateTime:yyyy-MM-dd_HH-mm})";
return filename;
}
public override void ExportModelTo(ScoreInfo model, Stream outputStream)
{
var file = model.Files.SingleOrDefault();

View File

@ -61,6 +61,6 @@ namespace osu.Game.Database
public override int GetHashCode() => HashCode.Combine(ID);
public override string ToString() => PerformRead(i => i.ToString());
public override string? ToString() => PerformRead(i => i.ToString());
}
}

View File

@ -71,9 +71,9 @@ namespace osu.Game.Database
bool importSuccessful;
if (originalModel != null)
importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel)) != null;
importSuccessful = (await importer.ImportAsUpdate(notification, new ImportTask(filename), originalModel).ConfigureAwait(false)) != null;
else
importSuccessful = (await importer.Import(notification, new ImportTask(filename))).Any();
importSuccessful = (await importer.Import(notification, new[] { new ImportTask(filename) }).ConfigureAwait(false)).Any();
// for now a failed import will be marked as a failed download for simplicity.
if (!importSuccessful)

View File

@ -18,6 +18,11 @@ namespace osu.Game.Database
public class ModelManager<TModel> : IModelManager<TModel>, IModelFileManager<TModel, RealmNamedFileUsage>
where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete
{
/// <summary>
/// Temporarily pause imports to avoid performance overheads affecting gameplay scenarios.
/// </summary>
public virtual bool PauseImports { get; set; }
protected RealmAccess Realm { get; }
private readonly RealmFileStore realmFileStore;

View File

@ -481,7 +481,7 @@ namespace osu.Game.Database
// server, which we don't use. May want to report upstream or revisit in the future.
using (var realm = getRealmInstance())
// ReSharper disable once AccessToDisposedClosure (WriteAsync should be marked as [InstantHandle]).
await realm.WriteAsync(() => action(realm));
await realm.WriteAsync(() => action(realm)).ConfigureAwait(false);
pendingAsyncWrites.Signal();
});
@ -558,7 +558,7 @@ namespace osu.Game.Database
return new InvokeOnDisposal(() => model.PropertyChanged -= onPropertyChanged);
void onPropertyChanged(object sender, PropertyChangedEventArgs args)
void onPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName == propertyName)
onChanged(propLookupCompiled(model));

View File

@ -56,6 +56,11 @@ namespace osu.Game.Database
/// </summary>
private static readonly ThreadedTaskScheduler import_scheduler_batch = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter<TModel>));
/// <summary>
/// Temporarily pause imports to avoid performance overheads affecting gameplay scenarios.
/// </summary>
public bool PauseImports { get; set; }
public abstract IEnumerable<string> HandledExtensions { get; }
protected readonly RealmFileStore Files;
@ -81,16 +86,16 @@ namespace osu.Game.Database
public Task Import(params string[] paths) => Import(paths.Select(p => new ImportTask(p)).ToArray());
public Task Import(params ImportTask[] tasks)
public Task Import(ImportTask[] tasks, ImportParameters parameters = default)
{
var notification = new ProgressNotification { State = ProgressNotificationState.Active };
PostNotification?.Invoke(notification);
return Import(notification, tasks);
return Import(notification, tasks, parameters);
}
public async Task<IEnumerable<Live<TModel>>> Import(ProgressNotification notification, params ImportTask[] tasks)
public async Task<IEnumerable<Live<TModel>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default)
{
if (tasks.Length == 0)
{
@ -106,7 +111,7 @@ namespace osu.Game.Database
var imported = new List<Live<TModel>>();
bool isBatchImport = tasks.Length >= minimum_items_considered_batch_import;
parameters.Batch |= tasks.Length >= minimum_items_considered_batch_import;
await Task.WhenAll(tasks.Select(async task =>
{
@ -115,7 +120,7 @@ namespace osu.Game.Database
try
{
var model = await Import(task, isBatchImport, notification.CancellationToken).ConfigureAwait(false);
var model = await Import(task, parameters, notification.CancellationToken).ConfigureAwait(false);
lock (imported)
{
@ -149,9 +154,12 @@ namespace osu.Game.Database
}
else
{
notification.CompletionText = imported.Count == 1
? $"Imported {imported.First().GetDisplayString()}!"
: $"Imported {imported.Count} {HumanisedModelName}s!";
if (tasks.Length > imported.Count)
notification.CompletionText = $"Imported {imported.Count} of {tasks.Length} {HumanisedModelName}s.";
else if (imported.Count > 1)
notification.CompletionText = $"Imported {imported.Count} {HumanisedModelName}s!";
else
notification.CompletionText = $"Imported {imported.First().GetDisplayString()}!";
if (imported.Count > 0 && PresentImport != null)
{
@ -176,16 +184,16 @@ namespace osu.Game.Database
/// 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="batchImport">Whether this import is part of a larger batch.</param>
/// <param name="parameters">Parameters to further configure the import process.</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 batchImport = false, CancellationToken cancellationToken = default)
public async Task<Live<TModel>?> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
Live<TModel>? import;
using (ArchiveReader reader = task.GetReader())
import = await importFromArchive(reader, batchImport, cancellationToken).ConfigureAwait(false);
import = await importFromArchive(reader, parameters, 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.
@ -211,9 +219,9 @@ namespace osu.Game.Database
/// This method also handled queueing the import task on a relevant import thread pool.
/// </remarks>
/// <param name="archive">The archive to be imported.</param>
/// <param name="batchImport">Whether this import is part of a larger batch.</param>
/// <param name="parameters">Parameters to further configure the import process.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
private async Task<Live<TModel>?> importFromArchive(ArchiveReader archive, bool batchImport = false, CancellationToken cancellationToken = default)
private async Task<Live<TModel>?> importFromArchive(ArchiveReader archive, ImportParameters parameters = default, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
@ -236,10 +244,10 @@ namespace osu.Game.Database
return null;
}
var scheduledImport = Task.Factory.StartNew(() => ImportModel(model, archive, batchImport, cancellationToken),
var scheduledImport = Task.Factory.StartNew(() => ImportModel(model, archive, parameters, cancellationToken),
cancellationToken,
TaskCreationOptions.HideScheduler,
batchImport ? import_scheduler_batch : import_scheduler);
parameters.Batch ? import_scheduler_batch : import_scheduler);
return await scheduledImport.ConfigureAwait(false);
}
@ -249,15 +257,15 @@ namespace osu.Game.Database
/// </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="parameters">Parameters to further configure the import process.</param>
/// <param name="cancellationToken">An optional cancellation token.</param>
public virtual Live<TModel>? ImportModel(TModel item, ArchiveReader? archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => Realm.Run(realm =>
public virtual Live<TModel>? ImportModel(TModel item, ArchiveReader? archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) => Realm.Run(realm =>
{
cancellationToken.ThrowIfCancellationRequested();
pauseIfNecessary(cancellationToken);
TModel? existing;
if (batchImport && archive != null)
if (parameters.Batch && archive != null)
{
// this is a fast bail condition to improve large import performance.
item.Hash = computeHashFast(archive);
@ -303,7 +311,7 @@ namespace osu.Game.Database
foreach (var filenames in getShortenedFilenames(archive))
{
using (Stream s = archive.GetStream(filenames.original))
files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false), filenames.shortened));
files.Add(new RealmNamedFileUsage(Files.Add(s, realm, false, parameters.PreferHardLinks), filenames.shortened));
}
}
@ -358,7 +366,7 @@ namespace osu.Game.Database
// import to store
realm.Add(item);
PostImport(item, realm, batchImport);
PostImport(item, realm, parameters);
transaction.Commit();
}
@ -493,8 +501,8 @@ namespace osu.Game.Database
/// </summary>
/// <param name="model">The model prepared for import.</param>
/// <param name="realm">The current realm context.</param>
/// <param name="batchImport">Whether the import was part of a batch.</param>
protected virtual void PostImport(TModel model, Realm realm, bool batchImport)
/// <param name="parameters">Parameters to further configure the import process.</param>
protected virtual void PostImport(TModel model, Realm realm, ImportParameters parameters)
{
}
@ -551,6 +559,23 @@ namespace osu.Game.Database
/// <returns>Whether to perform deletion.</returns>
protected virtual bool ShouldDeleteArchive(string path) => false;
private void pauseIfNecessary(CancellationToken cancellationToken)
{
if (!PauseImports)
return;
Logger.Log($@"{GetType().Name} is being paused.");
while (PauseImports)
{
cancellationToken.ThrowIfCancellationRequested();
Thread.Sleep(500);
}
cancellationToken.ThrowIfCancellationRequested();
Logger.Log($@"{GetType().Name} is being resumed.");
}
private IEnumerable<string> getIDs(IEnumerable<INamedFile> files)
{
foreach (var f in files.OrderBy(f => f.Filename))

View File

@ -10,6 +10,7 @@ using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Models;
using Realms;
@ -41,7 +42,8 @@ namespace osu.Game.Database
/// <param name="data">The file data stream.</param>
/// <param name="realm">The realm instance to add to. Should already be in a transaction.</param>
/// <param name="addToRealm">Whether the <see cref="RealmFile"/> should immediately be added to the underlying realm. If <c>false</c> is provided here, the instance must be manually added.</param>
public RealmFile Add(Stream data, Realm realm, bool addToRealm = true)
/// <param name="preferHardLinks">Whether this import should use hard links rather than file copy operations if available.</param>
public RealmFile Add(Stream data, Realm realm, bool addToRealm = true, bool preferHardLinks = false)
{
string hash = data.ComputeSHA2Hash();
@ -50,7 +52,7 @@ namespace osu.Game.Database
var file = existing ?? new RealmFile { Hash = hash };
if (!checkFileExistsAndMatchesHash(file))
copyToStore(file, data);
copyToStore(file, data, preferHardLinks);
if (addToRealm && !file.IsManaged)
realm.Add(file);
@ -58,8 +60,15 @@ namespace osu.Game.Database
return file;
}
private void copyToStore(RealmFile file, Stream data)
private void copyToStore(RealmFile file, Stream data, bool preferHardLinks)
{
if (data is FileStream fs && preferHardLinks)
{
// attempt to do a fast hard link rather than copy.
if (HardLinkHelper.TryCreateHardLink(Storage.GetFullPath(file.GetStoragePath(), true), fs.Name))
return;
}
data.Seek(0, SeekOrigin.Begin);
using (var output = Storage.CreateFileSafely(file.GetStoragePath()))

View File

@ -66,10 +66,10 @@ namespace osu.Game.Extensions
foreach (var (_, property) in component.GetSettingsSourceProperties())
{
if (!info.Settings.TryGetValue(property.Name.ToSnakeCase(), out object settingValue))
if (!info.Settings.TryGetValue(property.Name.ToSnakeCase(), out object? settingValue))
continue;
skinnable.CopyAdjustedSetting((IBindable)property.GetValue(component), settingValue);
skinnable.CopyAdjustedSetting(((IBindable)property.GetValue(component)!), settingValue);
}
}

View File

@ -37,7 +37,7 @@ namespace osu.Game.Extensions
if (cancellationToken.IsCancellationRequested)
{
tcs.SetCanceled();
tcs.SetCanceled(cancellationToken);
}
else
{

View File

@ -36,8 +36,7 @@ namespace osu.Game.Graphics.Containers
/// <param name="easing">The easing type of the initial transform.</param>
public void StartTracking(OsuLogo logo, double duration = 0, Easing easing = Easing.None)
{
if (logo == null)
throw new ArgumentNullException(nameof(logo));
ArgumentNullException.ThrowIfNull(logo);
if (logo.IsTracking && Logo == null)
throw new InvalidOperationException($"Cannot track an instance of {typeof(OsuLogo)} to multiple {typeof(LogoTrackingContainer)}s");

View File

@ -0,0 +1,34 @@
// 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 Markdig;
using Markdig.Extensions.GenericAttributes;
using Markdig.Renderers;
using Markdig.Syntax;
namespace osu.Game.Graphics.Containers.Markdown.Extensions
{
/// <summary>
/// A variant of <see cref="Markdig.Extensions.GenericAttributes.GenericAttributesExtension"/>
/// which only handles generic attributes in the current markdown <see cref="Block"/> and ignores inline generic attributes.
/// </summary>
/// <remarks>
/// For rationale, see implementation of <see cref="Setup(Markdig.MarkdownPipelineBuilder)"/>.
/// </remarks>
public class BlockAttributeExtension : IMarkdownExtension
{
private readonly GenericAttributesExtension genericAttributesExtension = new GenericAttributesExtension();
public void Setup(MarkdownPipelineBuilder pipeline)
{
genericAttributesExtension.Setup(pipeline);
// GenericAttributesExtension registers a GenericAttributesParser in pipeline.InlineParsers.
// this conflicts with the CustomContainerExtension, leading to some custom containers (e.g. flags) not displaying.
// as a workaround, remove the inline parser here before it can do damage.
pipeline.InlineParsers.RemoveAll(parser => parser is GenericAttributesParser);
}
public void Setup(MarkdownPipeline pipeline, IMarkdownRenderer renderer) => genericAttributesExtension.Setup(pipeline, renderer);
}
}

View File

@ -0,0 +1,21 @@
// 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 Markdig;
namespace osu.Game.Graphics.Containers.Markdown.Extensions
{
public static class OsuMarkdownExtensions
{
/// <summary>
/// Uses the block attributes extension.
/// </summary>
/// <param name="pipeline">The pipeline.</param>
/// <returns>The modified pipeline.</returns>
public static MarkdownPipelineBuilder UseBlockAttributes(this MarkdownPipelineBuilder pipeline)
{
pipeline.Extensions.AddIfNotAlready<BlockAttributeExtension>();
return pipeline;
}
}
}

View File

@ -0,0 +1,30 @@
// 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 Markdig.Extensions.Footnotes;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Containers.Markdown.Footnotes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Graphics.Containers.Markdown.Footnotes
{
public partial class OsuMarkdownFootnote : MarkdownFootnote
{
public OsuMarkdownFootnote(Footnote footnote)
: base(footnote)
{
}
public override SpriteText CreateOrderMarker(int order) => CreateSpriteText().With(marker =>
{
marker.Text = LocalisableString.Format("{0}.", order);
});
public override MarkdownTextFlowContainer CreateTextFlow() => base.CreateTextFlow().With(textFlow =>
{
textFlow.Margin = new MarginPadding { Left = 30 };
});
}
}

View File

@ -0,0 +1,62 @@
// 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 Markdig.Extensions.Footnotes;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Graphics.Containers.Markdown.Footnotes
{
public partial class OsuMarkdownFootnoteBacklink : OsuHoverContainer
{
private readonly FootnoteLink backlink;
private SpriteIcon spriteIcon = null!;
[Resolved]
private IMarkdownTextComponent parentTextComponent { get; set; } = null!;
protected override IEnumerable<Drawable> EffectTargets => spriteIcon.Yield();
public OsuMarkdownFootnoteBacklink(FootnoteLink backlink)
{
this.backlink = backlink;
}
[BackgroundDependencyLoader(true)]
private void load(OverlayColourProvider colourProvider, OsuMarkdownContainer markdownContainer, OverlayScrollContainer? scrollContainer)
{
float fontSize = parentTextComponent.CreateSpriteText().Font.Size;
Size = new Vector2(fontSize);
IdleColour = colourProvider.Light2;
HoverColour = colourProvider.Light1;
Add(spriteIcon = new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Margin = new MarginPadding { Left = 5 },
Size = new Vector2(fontSize / 2),
Icon = FontAwesome.Solid.ArrowUp,
});
if (scrollContainer != null)
{
Action = () =>
{
var footnoteLink = markdownContainer.ChildrenOfType<OsuMarkdownFootnoteLink>().Single(footnoteLink => footnoteLink.FootnoteLink.Index == backlink.Index);
scrollContainer.ScrollIntoView(footnoteLink);
};
}
}
}
}

View File

@ -0,0 +1,80 @@
// 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 Markdig.Extensions.Footnotes;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Testing;
using osu.Game.Overlays;
namespace osu.Game.Graphics.Containers.Markdown.Footnotes
{
public partial class OsuMarkdownFootnoteLink : OsuHoverContainer, IHasCustomTooltip
{
public readonly FootnoteLink FootnoteLink;
private SpriteText spriteText = null!;
[Resolved]
private IMarkdownTextComponent parentTextComponent { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
[Resolved]
private OsuMarkdownContainer markdownContainer { get; set; } = null!;
protected override IEnumerable<Drawable> EffectTargets => spriteText.Yield();
public OsuMarkdownFootnoteLink(FootnoteLink footnoteLink)
{
FootnoteLink = footnoteLink;
AutoSizeAxes = Axes.Both;
}
[BackgroundDependencyLoader(true)]
private void load(OsuMarkdownContainer markdownContainer, OverlayScrollContainer? scrollContainer)
{
IdleColour = colourProvider.Light2;
HoverColour = colourProvider.Light1;
spriteText = parentTextComponent.CreateSpriteText();
Add(spriteText.With(t =>
{
float baseSize = t.Font.Size;
t.Font = t.Font.With(size: baseSize * 0.58f);
t.Margin = new MarginPadding { Bottom = 0.33f * baseSize };
t.Text = LocalisableString.Format("[{0}]", FootnoteLink.Index);
}));
if (scrollContainer != null)
{
Action = () =>
{
var footnote = markdownContainer.ChildrenOfType<OsuMarkdownFootnote>().Single(footnote => footnote.Footnote.Label == FootnoteLink.Footnote.Label);
scrollContainer.ScrollIntoView(footnote);
};
}
}
public object TooltipContent
{
get
{
var span = FootnoteLink.Footnote.LastChild.Span;
return markdownContainer.Text.Substring(span.Start, span.Length);
}
}
public ITooltip GetCustomTooltip() => new OsuMarkdownFootnoteTooltip(colourProvider);
}
}

View File

@ -0,0 +1,76 @@
// 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 Markdig.Extensions.Footnotes;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Graphics.Containers.Markdown.Footnotes
{
public partial class OsuMarkdownFootnoteTooltip : CompositeDrawable, ITooltip
{
private readonly FootnoteMarkdownContainer markdownContainer;
[Cached]
private OverlayColourProvider colourProvider;
public OsuMarkdownFootnoteTooltip(OverlayColourProvider colourProvider)
{
this.colourProvider = colourProvider;
Masking = true;
Width = 200;
AutoSizeAxes = Axes.Y;
CornerRadius = 4;
InternalChildren = new Drawable[]
{
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = colourProvider.Background6
},
markdownContainer = new FootnoteMarkdownContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
DocumentMargin = new MarginPadding(),
DocumentPadding = new MarginPadding { Horizontal = 10, Vertical = 5 }
}
};
}
public void Move(Vector2 pos) => Position = pos;
public void SetContent(object content) => markdownContainer.SetContent((string)content);
private partial class FootnoteMarkdownContainer : OsuMarkdownContainer
{
private string? lastFootnote;
public void SetContent(string footnote)
{
if (footnote == lastFootnote)
return;
lastFootnote = Text = footnote;
}
public override MarkdownTextFlowContainer CreateTextFlow() => new FootnoteMarkdownTextFlowContainer();
}
private partial class FootnoteMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer
{
protected override void AddFootnoteBacklink(FootnoteLink footnoteBacklink)
{
// we don't want footnote backlinks to show up in tooltips.
}
}
}
}

View File

@ -4,41 +4,25 @@
#nullable disable
using Markdig;
using Markdig.Extensions.AutoLinks;
using Markdig.Extensions.CustomContainers;
using Markdig.Extensions.EmphasisExtras;
using Markdig.Extensions.Footnotes;
using Markdig.Extensions.Tables;
using Markdig.Extensions.Yaml;
using Markdig.Syntax;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Containers.Markdown.Footnotes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.Containers.Markdown.Footnotes;
using osu.Game.Graphics.Sprites;
using osuTK;
namespace osu.Game.Graphics.Containers.Markdown
{
[Cached]
public partial class OsuMarkdownContainer : MarkdownContainer
{
/// <summary>
/// Allows this markdown container to parse and link footnotes.
/// </summary>
/// <seealso cref="FootnoteExtension"/>
protected virtual bool Footnotes => false;
/// <summary>
/// Allows this markdown container to make URL text clickable.
/// </summary>
/// <seealso cref="AutoLinkExtension"/>
protected virtual bool Autolinks => false;
/// <summary>
/// Allows this markdown container to parse custom containers (used for flags and infoboxes).
/// </summary>
/// <seealso cref="CustomContainerExtension"/>
protected virtual bool CustomContainers => false;
public OsuMarkdownContainer()
{
LineSpacing = 21;
@ -99,25 +83,17 @@ namespace osu.Game.Graphics.Containers.Markdown
return new OsuMarkdownUnorderedListItem(level);
}
// reference: https://github.com/ppy/osu-web/blob/05488a96b25b5a09f2d97c54c06dd2bae59d1dc8/app/Libraries/Markdown/OsuMarkdown.php#L301
protected override MarkdownPipeline CreateBuilder()
{
var pipeline = new MarkdownPipelineBuilder()
.UseAutoIdentifiers()
.UsePipeTables()
.UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
.UseYamlFrontMatter();
protected override MarkdownFootnoteGroup CreateFootnoteGroup(FootnoteGroup footnoteGroup) => base.CreateFootnoteGroup(footnoteGroup).With(g => g.Spacing = new Vector2(5));
if (Footnotes)
pipeline = pipeline.UseFootnotes();
protected override MarkdownFootnote CreateFootnote(Footnote footnote) => new OsuMarkdownFootnote(footnote);
if (Autolinks)
pipeline = pipeline.UseAutoLinks();
protected sealed override MarkdownPipeline CreateBuilder()
=> Options.BuildPipeline();
if (CustomContainers)
pipeline.UseCustomContainers();
return pipeline.Build();
}
/// <summary>
/// Creates a <see cref="OsuMarkdownContainerOptions"/> instance which is used to determine
/// which CommonMark/Markdig extensions should be enabled for this <see cref="OsuMarkdownContainer"/>.
/// </summary>
protected virtual OsuMarkdownContainerOptions Options => new OsuMarkdownContainerOptions();
}
}

View File

@ -0,0 +1,71 @@
// 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 Markdig;
using Markdig.Extensions.AutoLinks;
using Markdig.Extensions.CustomContainers;
using Markdig.Extensions.EmphasisExtras;
using Markdig.Extensions.Footnotes;
using osu.Game.Graphics.Containers.Markdown.Extensions;
namespace osu.Game.Graphics.Containers.Markdown
{
/// <summary>
/// Groups options of customising the set of available extensions to <see cref="OsuMarkdownContainer"/> instances.
/// </summary>
public class OsuMarkdownContainerOptions
{
/// <summary>
/// Allows the <see cref="OsuMarkdownContainer"/> to parse and link footnotes.
/// </summary>
/// <seealso cref="FootnoteExtension"/>
public bool Footnotes { get; init; }
/// <summary>
/// Allows the <see cref="OsuMarkdownContainer"/> container to make URL text clickable.
/// </summary>
/// <seealso cref="AutoLinkExtension"/>
public bool Autolinks { get; init; }
/// <summary>
/// Allows the <see cref="OsuMarkdownContainer"/> to parse custom containers (used for flags and infoboxes).
/// </summary>
/// <seealso cref="CustomContainerExtension"/>
public bool CustomContainers { get; init; }
/// <summary>
/// Allows the <see cref="OsuMarkdownContainer"/> to parse custom attributes in block elements (used e.g. for custom anchor names in the wiki).
/// </summary>
/// <seealso cref="BlockAttributeExtension"/>
public bool BlockAttributes { get; init; }
/// <summary>
/// Returns a prepared <see cref="MarkdownPipeline"/> according to the options specified by the current <see cref="OsuMarkdownContainerOptions"/> instance.
/// </summary>
/// <remarks>
/// Compare: https://github.com/ppy/osu-web/blob/05488a96b25b5a09f2d97c54c06dd2bae59d1dc8/app/Libraries/Markdown/OsuMarkdown.php#L301
/// </remarks>
public MarkdownPipeline BuildPipeline()
{
var pipeline = new MarkdownPipelineBuilder()
.UseAutoIdentifiers()
.UsePipeTables()
.UseEmphasisExtras(EmphasisExtraOptions.Strikethrough)
.UseYamlFrontMatter();
if (Footnotes)
pipeline = pipeline.UseFootnotes();
if (Autolinks)
pipeline = pipeline.UseAutoLinks();
if (CustomContainers)
pipeline = pipeline.UseCustomContainers();
if (BlockAttributes)
pipeline = pipeline.UseBlockAttributes();
return pipeline.Build();
}
}
}

View File

@ -6,6 +6,7 @@
using System;
using System.Linq;
using Markdig.Extensions.CustomContainers;
using Markdig.Extensions.Footnotes;
using Markdig.Syntax.Inlines;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
@ -13,6 +14,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.Containers.Markdown.Footnotes;
using osu.Game.Overlays;
using osu.Game.Users;
using osu.Game.Users.Drawables;
@ -36,6 +38,10 @@ namespace osu.Game.Graphics.Containers.Markdown
Text = codeInline.Content
});
protected override void AddFootnoteLink(FootnoteLink footnoteLink) => AddDrawable(new OsuMarkdownFootnoteLink(footnoteLink));
protected override void AddFootnoteBacklink(FootnoteLink footnoteBacklink) => AddDrawable(new OsuMarkdownFootnoteBacklink(footnoteBacklink));
protected override SpriteText CreateEmphasisedSpriteText(bool bold, bool italic)
=> CreateSpriteText().With(t => t.Font = t.Font.With(weight: bold ? FontWeight.Bold : FontWeight.Regular, italics: italic));

View File

@ -1,14 +1,14 @@
// 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 disable
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics.UserInterface;
using osuTK;
namespace osu.Game.Graphics.Containers
{
@ -18,6 +18,12 @@ namespace osu.Game.Graphics.Containers
private readonly Container content = new Container { RelativeSizeAxes = Axes.Both };
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
// base call is checked for cases when `OsuClickableContainer` has masking applied to it directly (ie. externally in object initialisation).
base.ReceivePositionalInputAt(screenSpacePos)
// Implementations often apply masking / edge rounding at a content level, so it's imperative to check that as well.
&& Content.ReceivePositionalInputAt(screenSpacePos);
protected override Container<Drawable> Content => content;
protected virtual HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverClickSounds(sampleSet) { Enabled = { BindTarget = Enabled } };
@ -38,11 +44,11 @@ namespace osu.Game.Graphics.Containers
content.AutoSizeAxes = AutoSizeAxes;
}
InternalChildren = new Drawable[]
{
content,
CreateHoverSounds(sampleSet)
};
AddInternal(content);
Add(CreateHoverSounds(sampleSet));
}
protected override void ClearInternal(bool disposeChildren = true) =>
throw new InvalidOperationException($"Clearing {nameof(InternalChildren)} will cause critical failure. Use {nameof(Clear)} instead.");
}
}

View File

@ -25,8 +25,6 @@ namespace osu.Game.Graphics.Containers
protected virtual string PopInSampleName => "UI/overlay-pop-in";
protected virtual string PopOutSampleName => "UI/overlay-pop-out";
protected override bool BlockScrollInput => false;
protected override bool BlockNonPositionalInput => true;
/// <summary>
@ -90,6 +88,15 @@ namespace osu.Game.Graphics.Containers
base.OnMouseUp(e);
}
protected override bool OnScroll(ScrollEvent e)
{
// allow for controlling volume when alt is held.
// mostly for compatibility with osu-stable.
if (e.AltPressed) return false;
return true;
}
public virtual bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (e.Repeat)

View File

@ -240,7 +240,9 @@ namespace osu.Game.Graphics.Containers
headerBackgroundContainer.Height = expandableHeaderSize + fixedHeaderSize;
headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0;
float smallestSectionHeight = Children.Count > 0 ? Children.Min(d => d.Height) : 0;
var flowChildren = scrollContentContainer.FlowingChildren.OfType<T>();
float smallestSectionHeight = flowChildren.Any() ? flowChildren.Min(d => d.Height) : 0;
// scroll offset is our fixed header height if we have it plus 10% of content height
// plus 5% to fix floating point errors and to not have a section instantly unselect when scrolling upwards
@ -249,7 +251,7 @@ namespace osu.Game.Graphics.Containers
float scrollCentre = fixedHeaderSize + scrollContainer.DisplayableContent * scroll_y_centre + selectionLenienceAboveSection;
var presentChildren = Children.Where(c => c.IsPresent);
var presentChildren = flowChildren.Where(c => c.IsPresent);
if (lastClickedSection != null)
SelectedSection.Value = lastClickedSection;

View File

@ -234,7 +234,7 @@ namespace osu.Game.Graphics.Cursor
SampleChannel channel = tapSample.GetChannel();
// Scale to [-0.75, 0.75] so that the sample isn't fully panned left or right (sounds weird)
channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * 0.75;
channel.Balance.Value = ((activeCursor.X / DrawWidth) * 2 - 1) * OsuGameBase.SFX_STEREO_STRENGTH;
channel.Frequency.Value = baseFrequency - (random_range / 2f) + RNG.NextDouble(random_range);
channel.Volume.Value = baseFrequency;

View File

@ -9,6 +9,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites;
using osuTK;
@ -69,8 +70,8 @@ namespace osu.Game.Graphics
{
DateTimeOffset localDate = date.ToLocalTime();
dateText.Text = $"{localDate:d MMMM yyyy} ";
timeText.Text = $"{localDate:HH:mm:ss \"UTC\"z}";
dateText.Text = LocalisableString.Interpolate($"{localDate:d MMMM yyyy} ");
timeText.Text = LocalisableString.Interpolate($"{localDate:HH:mm:ss \"UTC\"z}");
}
public void Move(Vector2 pos) => Position = pos;

View File

@ -22,38 +22,8 @@ namespace osu.Game.Graphics
public static Color4 Gray(byte amt) => new Color4(amt, amt, amt, 255);
/// <summary>
/// Retrieves the colour for a <see cref="DifficultyRating"/>.
/// Retrieves the colour for a given point in the star range.
/// </summary>
/// <remarks>
/// Sourced from the @diff-{rating} variables in https://github.com/ppy/osu-web/blob/71fbab8936d79a7929d13854f5e854b4f383b236/resources/assets/less/variables.less.
/// </remarks>
public Color4 ForDifficultyRating(DifficultyRating difficulty, bool useLighterColour = false)
{
switch (difficulty)
{
case DifficultyRating.Easy:
return Color4Extensions.FromHex("4ebfff");
case DifficultyRating.Normal:
return Color4Extensions.FromHex("66ff91");
case DifficultyRating.Hard:
return Color4Extensions.FromHex("f7e85d");
case DifficultyRating.Insane:
return Color4Extensions.FromHex("ff7e68");
case DifficultyRating.Expert:
return Color4Extensions.FromHex("fe3c71");
case DifficultyRating.ExpertPlus:
return Color4Extensions.FromHex("6662dd");
default:
throw new ArgumentOutOfRangeException(nameof(difficulty));
}
}
public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(new[]
{
(0.1f, Color4Extensions.FromHex("aaaaaa")),

View File

@ -4,6 +4,7 @@
#nullable disable
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
@ -117,11 +118,11 @@ namespace osu.Game.Graphics
host.GetClipboard()?.SetImage(image);
string filename = getFilename();
(string filename, var stream) = getWritableStream();
if (filename == null) return;
using (var stream = storage.CreateFileSafely(filename))
using (stream)
{
switch (screenshotFormat.Value)
{
@ -142,7 +143,7 @@ namespace osu.Game.Graphics
notificationOverlay.Post(new SimpleNotification
{
Text = $"{filename} saved!",
Text = $"Screenshot {filename} saved!",
Activated = () =>
{
storage.PresentFileExternally(filename);
@ -152,23 +153,28 @@ namespace osu.Game.Graphics
}
});
private string getFilename()
private static readonly object filename_reservation_lock = new object();
private (string filename, Stream stream) getWritableStream()
{
var dt = DateTime.Now;
string fileExt = screenshotFormat.ToString().ToLowerInvariant();
string withoutIndex = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}.{fileExt}";
if (!storage.Exists(withoutIndex))
return withoutIndex;
for (ulong i = 1; i < ulong.MaxValue; i++)
lock (filename_reservation_lock)
{
string indexedName = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}-{i}.{fileExt}";
if (!storage.Exists(indexedName))
return indexedName;
}
var dt = DateTime.Now;
string fileExt = screenshotFormat.ToString().ToLowerInvariant();
return null;
string withoutIndex = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}.{fileExt}";
if (!storage.Exists(withoutIndex))
return (withoutIndex, storage.GetStream(withoutIndex, FileAccess.Write, FileMode.Create));
for (ulong i = 1; i < ulong.MaxValue; i++)
{
string indexedName = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}-{i}.{fileExt}";
if (!storage.Exists(indexedName))
return (indexedName, storage.GetStream(indexedName, FileAccess.Write, FileMode.Create));
}
return (null, null);
}
}
}
}

View File

@ -52,8 +52,8 @@ namespace osu.Game.Graphics.UserInterface
public readonly SpriteIcon Chevron;
//don't allow clicking between transitions and don't make the chevron clickable
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Alpha == 1f && Text.ReceivePositionalInputAt(screenSpacePos);
//don't allow clicking between transitions
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Alpha == 1f && base.ReceivePositionalInputAt(screenSpacePos);
public override bool HandleNonPositionalInput => State == Visibility.Visible;
public override bool HandlePositionalInput => State == Visibility.Visible;
@ -95,7 +95,7 @@ namespace osu.Game.Graphics.UserInterface
{
Text.Font = Text.Font.With(size: 18);
Text.Margin = new MarginPadding { Vertical = 8 };
Padding = new MarginPadding { Right = padding + ChevronSize };
Margin = new MarginPadding { Right = padding + ChevronSize };
Add(Chevron = new SpriteIcon
{
Anchor = Anchor.CentreRight,

View File

@ -82,7 +82,7 @@ namespace osu.Game.Graphics.UserInterface
if (Link != null)
{
items.Add(new OsuMenuItem("Open", MenuItemType.Standard, () => host.OpenUrlExternally(Link)));
items.Add(new OsuMenuItem("Open", MenuItemType.Highlighted, () => host.OpenUrlExternally(Link)));
items.Add(new OsuMenuItem("Copy URL", MenuItemType.Standard, copyUrl));
}

View File

@ -45,6 +45,9 @@ namespace osu.Game.Graphics.UserInterface
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.ShiftPressed)
return false;
switch (e.Key)
{
case Key.Up:

View File

@ -114,8 +114,7 @@ namespace osu.Game.Graphics.UserInterface
get => current;
set
{
if (value == null)
throw new ArgumentNullException(nameof(value));
ArgumentNullException.ThrowIfNull(value);
current.UnbindBindings();
current.BindTo(value);

View File

@ -1,8 +1,6 @@
// 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 disable
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
@ -13,6 +11,7 @@ using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Graphics.UserInterface
@ -20,16 +19,12 @@ namespace osu.Game.Graphics.UserInterface
/// <summary>
/// A button with added default sound effects.
/// </summary>
public partial class OsuButton : Button
public abstract partial class OsuButton : Button
{
public LocalisableString Text
{
get => SpriteText?.Text ?? default;
set
{
if (SpriteText != null)
SpriteText.Text = value;
}
get => SpriteText.Text;
set => SpriteText.Text = value;
}
private Color4? backgroundColour;
@ -66,13 +61,19 @@ namespace osu.Game.Graphics.UserInterface
protected override Container<Drawable> Content { get; }
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) =>
// base call is checked for cases when `OsuClickableContainer` has masking applied to it directly (ie. externally in object initialisation).
base.ReceivePositionalInputAt(screenSpacePos)
// Implementations often apply masking / edge rounding at a content level, so it's imperative to check that as well.
&& Content.ReceivePositionalInputAt(screenSpacePos);
protected Box Hover;
protected Box Background;
protected SpriteText SpriteText;
private readonly Box flashLayer;
public OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Button)
protected OsuButton(HoverSampleSet? hoverSounds = HoverSampleSet.Button)
{
Height = 40;
@ -115,7 +116,7 @@ namespace osu.Game.Graphics.UserInterface
});
if (hoverSounds.HasValue)
AddInternal(new HoverClickSounds(hoverSounds.Value) { Enabled = { BindTarget = Enabled } });
Add(new HoverClickSounds(hoverSounds.Value) { Enabled = { BindTarget = Enabled } });
}
[BackgroundDependencyLoader]

View File

@ -12,7 +12,7 @@ namespace osu.Game.Graphics.UserInterface
{
public OsuEnumDropdown()
{
Items = (T[])Enum.GetValues(typeof(T));
Items = Enum.GetValues<T>();
}
}
}

View File

@ -277,7 +277,7 @@ namespace osu.Game.Graphics.UserInterface
{
var samples = sampleMap[feedbackSampleType];
if (samples == null || samples.Length == 0)
if (samples.Length == 0)
return null;
return samples[RNG.Next(0, samples.Length)]?.GetChannel();

View File

@ -197,7 +197,7 @@ namespace osu.Game.Graphics.UserInterface
}, true);
}
[BackgroundDependencyLoader]
[BackgroundDependencyLoader(true)]
private void load(OverlayColourProvider? colourProvider)
{
if (colourProvider == null) return;

View File

@ -23,8 +23,7 @@ namespace osu.Game.IO.FileAbstraction
public void CloseStream(Stream stream)
{
if (stream == null)
throw new ArgumentNullException(nameof(stream));
ArgumentNullException.ThrowIfNull(stream);
stream.Close();
}

View File

@ -0,0 +1,190 @@
// 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.IO;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;
using Microsoft.Win32.SafeHandles;
using osu.Framework;
namespace osu.Game.IO
{
internal static class HardLinkHelper
{
public static bool CheckAvailability(string testDestinationPath, string testSourcePath)
{
// For simplicity, only support desktop operating systems for now.
if (!RuntimeInfo.IsDesktop)
return false;
const string test_filename = "_hard_link_test";
testDestinationPath = Path.Combine(testDestinationPath, test_filename);
testSourcePath = Path.Combine(testSourcePath, test_filename);
cleanupFiles();
try
{
File.WriteAllText(testSourcePath, string.Empty);
// Test availability by creating an arbitrary hard link between the source and destination paths.
return TryCreateHardLink(testDestinationPath, testSourcePath);
}
catch
{
return false;
}
finally
{
cleanupFiles();
}
void cleanupFiles()
{
try
{
File.Delete(testDestinationPath);
File.Delete(testSourcePath);
}
catch
{
}
}
}
/// <summary>
/// Attempts to create a hard link from <paramref name="sourcePath"/> to <paramref name="destinationPath"/>,
/// using platform-specific native methods.
/// </summary>
/// <remarks>
/// Hard links are only available on desktop platforms.
/// </remarks>
/// <returns>Whether the hard link was successfully created.</returns>
public static bool TryCreateHardLink(string destinationPath, string sourcePath)
{
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
return CreateHardLink(destinationPath, sourcePath, IntPtr.Zero);
case RuntimeInfo.Platform.Linux:
case RuntimeInfo.Platform.macOS:
return link(sourcePath, destinationPath) == 0;
default:
return false;
}
}
// For future use (to detect if a file is a hard link with other references existing on disk).
public static int GetFileLinkCount(string filePath)
{
int result = 0;
switch (RuntimeInfo.OS)
{
case RuntimeInfo.Platform.Windows:
SafeFileHandle handle = CreateFile(filePath, FileAccess.Read, FileShare.Read, IntPtr.Zero, FileMode.Open, FileAttributes.Archive, IntPtr.Zero);
ByHandleFileInformation fileInfo;
if (GetFileInformationByHandle(handle, out fileInfo))
result = (int)fileInfo.NumberOfLinks;
CloseHandle(handle);
break;
case RuntimeInfo.Platform.Linux:
case RuntimeInfo.Platform.macOS:
if (stat(filePath, out var statbuf) == 0)
result = (int)statbuf.st_nlink;
break;
}
return result;
}
#region Windows native methods
[DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateHardLink(string lpFileName, string lpExistingFileName, IntPtr lpSecurityAttributes);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
private static extern SafeFileHandle CreateFile(
string lpFileName,
[MarshalAs(UnmanagedType.U4)] FileAccess dwDesiredAccess,
[MarshalAs(UnmanagedType.U4)] FileShare dwShareMode,
IntPtr lpSecurityAttributes,
[MarshalAs(UnmanagedType.U4)] FileMode dwCreationDisposition,
[MarshalAs(UnmanagedType.U4)] FileAttributes dwFlagsAndAttributes,
IntPtr hTemplateFile);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out ByHandleFileInformation lpFileInformation);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(SafeHandle hObject);
[StructLayout(LayoutKind.Sequential)]
private struct ByHandleFileInformation
{
public readonly uint FileAttributes;
public readonly FILETIME CreationTime;
public readonly FILETIME LastAccessTime;
public readonly FILETIME LastWriteTime;
public readonly uint VolumeSerialNumber;
public readonly uint FileSizeHigh;
public readonly uint FileSizeLow;
public readonly uint NumberOfLinks;
public readonly uint FileIndexHigh;
public readonly uint FileIndexLow;
}
#endregion
#region Linux native methods
#pragma warning disable IDE1006 // Naming rule violation
[DllImport("libc", SetLastError = true)]
public static extern int link(string oldpath, string newpath);
[DllImport("libc", SetLastError = true)]
private static extern int stat(string pathname, out struct_stat statbuf);
// ReSharper disable once InconsistentNaming
// Struct layout is likely non-portable across unices. Tread with caution.
[StructLayout(LayoutKind.Sequential)]
private struct struct_stat
{
public readonly long st_dev;
public readonly long st_ino;
public readonly long st_nlink;
public readonly int st_mode;
public readonly int st_uid;
public readonly int st_gid;
public readonly long st_rdev;
public readonly long st_size;
public readonly long st_blksize;
public readonly long st_blocks;
public readonly timespec st_atim;
public readonly timespec st_mtim;
public readonly timespec st_ctim;
}
// ReSharper disable once InconsistentNaming
[StructLayout(LayoutKind.Sequential)]
private struct timespec
{
public readonly long tv_sec;
public readonly long tv_nsec;
}
#pragma warning restore IDE1006
#endregion
}
}

View File

@ -63,7 +63,7 @@ namespace osu.Game.IO.Serialization.Converters
throw new JsonException("Expected $type token.");
string typeName = lookupTable[(int)tok["$type"]];
var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull());
var instance = (T)Activator.CreateInstance(Type.GetType(typeName).AsNonNull())!;
serializer.Populate(itemReader, instance);
list.Add(instance);

View File

@ -0,0 +1,33 @@
// 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
{
public static class BeatmapOverlayStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.BeatmapOverlayStrings";
/// <summary>
/// "User content disclaimer"
/// </summary>
public static LocalisableString UserContentDisclaimerHeader => new TranslatableString(getKey(@"user_content_disclaimer"), @"User content disclaimer");
/// <summary>
/// "By turning off the &quot;Featured Artist&quot; filter, all user-uploaded content will be displayed.
///
/// This includes content that may not be correctly licensed for osu! usage. Browse at your own risk."
/// </summary>
public static LocalisableString UserContentDisclaimerDescription => new TranslatableString(getKey(@"by_turning_off_the_featured"), @"By turning off the ""Featured Artist"" filter, all user-uploaded content will be displayed.
This includes content that may not be correctly licensed for osu! usage. Browse at your own risk.");
/// <summary>
/// "I understand"
/// </summary>
public static LocalisableString UserContentConfirmButtonText => new TranslatableString(getKey(@"understood"), @"I understand");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,24 @@
// 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
{
public static class ContextMenuStrings
{
private const string prefix = @"osu.Game.Resources.Localisation.ContextMenu";
/// <summary>
/// "View profile"
/// </summary>
public static LocalisableString ViewProfile => new TranslatableString(getKey(@"view_profile"), @"View profile");
/// <summary>
/// "View beatmap"
/// </summary>
public static LocalisableString ViewBeatmap => new TranslatableString(getKey(@"view_beatmap"), @"View beatmap");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -15,10 +15,10 @@ namespace osu.Game.Localisation
public static LocalisableString Header => new TranslatableString(getKey(@"header"), @"Import");
/// <summary>
/// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation."
/// "If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way."
/// </summary>
public static LocalisableString Description => new TranslatableString(getKey(@"description"),
@"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will create a copy, and not affect your existing installation.");
@"If you have an installation of a previous osu! version, you can choose to migrate your existing content. Note that this will not affect your existing installation's files in any way.");
/// <summary>
/// "previous osu! install"

View File

@ -64,6 +64,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString RunSetupWizard => new TranslatableString(getKey(@"run_setup_wizard"), @"Run setup wizard");
/// <summary>
/// "Learn more about lazer"
/// </summary>
public static LocalisableString LearnMoreAboutLazer => new TranslatableString(getKey(@"learn_more_about_lazer"), @"Learn more about lazer");
/// <summary>
/// "Check out the feature comparison and FAQ"
/// </summary>
public static LocalisableString LearnMoreAboutLazerTooltip => new TranslatableString(getKey(@"check_out_the_feature_comparison"), @"Check out the feature comparison and FAQ");
/// <summary>
/// "You are running the latest release ({0})"
/// </summary>

View File

@ -54,11 +54,6 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString RestartAndReOpenRequiredForCompletion => new TranslatableString(getKey(@"restart_and_re_open_required_for_completion"), @"To complete this operation, osu! will close. Please open it again to use the new data location.");
/// <summary>
/// "Import beatmaps from stable"
/// </summary>
public static LocalisableString ImportBeatmapsFromStable => new TranslatableString(getKey(@"import_beatmaps_from_stable"), @"Import beatmaps from stable");
/// <summary>
/// "Delete ALL beatmaps"
/// </summary>
@ -69,31 +64,16 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString DeleteAllBeatmapVideos => new TranslatableString(getKey(@"delete_all_beatmap_videos"), @"Delete ALL beatmap videos");
/// <summary>
/// "Import scores from stable"
/// </summary>
public static LocalisableString ImportScoresFromStable => new TranslatableString(getKey(@"import_scores_from_stable"), @"Import scores from stable");
/// <summary>
/// "Delete ALL scores"
/// </summary>
public static LocalisableString DeleteAllScores => new TranslatableString(getKey(@"delete_all_scores"), @"Delete ALL scores");
/// <summary>
/// "Import skins from stable"
/// </summary>
public static LocalisableString ImportSkinsFromStable => new TranslatableString(getKey(@"import_skins_from_stable"), @"Import skins from stable");
/// <summary>
/// "Delete ALL skins"
/// </summary>
public static LocalisableString DeleteAllSkins => new TranslatableString(getKey(@"delete_all_skins"), @"Delete ALL skins");
/// <summary>
/// "Import collections from stable"
/// </summary>
public static LocalisableString ImportCollectionsFromStable => new TranslatableString(getKey(@"import_collections_from_stable"), @"Import collections from stable");
/// <summary>
/// "Delete ALL collections"
/// </summary>

View File

@ -27,7 +27,7 @@ namespace osu.Game.Models
public bool IsBot => false;
public bool Equals(RealmUser other)
public bool Equals(RealmUser? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;

View File

@ -259,7 +259,11 @@ namespace osu.Game.Online.API
var friendsReq = new GetFriendsRequest();
friendsReq.Failure += _ => state.Value = APIState.Failing;
friendsReq.Success += res => friends.AddRange(res);
friendsReq.Success += res =>
{
friends.Clear();
friends.AddRange(res);
};
if (!handleRequest(friendsReq))
{

View File

@ -39,7 +39,7 @@ namespace osu.Game.Online.API
foreach (var (_, property) in mod.GetSettingsSourceProperties())
{
var bindable = (IBindable)property.GetValue(mod);
var bindable = (IBindable)property.GetValue(mod)!;
if (!bindable.IsDefault)
Settings.Add(property.Name.ToSnakeCase(), bindable.GetUnderlyingSettingValue());
@ -60,16 +60,16 @@ namespace osu.Game.Online.API
{
foreach (var (_, property) in resultMod.GetSettingsSourceProperties())
{
if (!Settings.TryGetValue(property.Name.ToSnakeCase(), out object settingValue))
if (!Settings.TryGetValue(property.Name.ToSnakeCase(), out object? settingValue))
continue;
try
{
resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod), settingValue);
resultMod.CopyAdjustedSetting((IBindable)property.GetValue(resultMod)!, settingValue);
}
catch (Exception ex)
{
Logger.Log($"Failed to copy mod setting value '{settingValue ?? "null"}' to \"{property.Name}\": {ex.Message}");
Logger.Log($"Failed to copy mod setting value '{settingValue}' to \"{property.Name}\": {ex.Message}");
}
}
}
@ -79,7 +79,7 @@ namespace osu.Game.Online.API
public bool ShouldSerializeSettings() => Settings.Count > 0;
public bool Equals(APIMod other)
public bool Equals(APIMod? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;

View File

@ -1,8 +1,6 @@
// 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 disable
using System;
using osu.Game.Beatmaps;
using osu.Game.Rulesets;
@ -11,10 +9,11 @@ using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Mods;
using System.Text;
using System.Collections.Generic;
using System.Linq;
namespace osu.Game.Online.API.Requests
{
public class GetScoresRequest : APIRequest<APIScoresCollection>
public class GetScoresRequest : APIRequest<APIScoresCollection>, IEquatable<GetScoresRequest>
{
public const int MAX_SCORES_PER_REQUEST = 50;
@ -23,7 +22,7 @@ namespace osu.Game.Online.API.Requests
private readonly IRulesetInfo ruleset;
private readonly IEnumerable<IMod> mods;
public GetScoresRequest(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset, BeatmapLeaderboardScope scope = BeatmapLeaderboardScope.Global, IEnumerable<IMod> mods = null)
public GetScoresRequest(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset, BeatmapLeaderboardScope scope = BeatmapLeaderboardScope.Global, IEnumerable<IMod>? mods = null)
{
if (beatmapInfo.OnlineID <= 0)
throw new InvalidOperationException($"Cannot lookup a beatmap's scores without having a populated {nameof(IBeatmapInfo.OnlineID)}.");
@ -51,5 +50,16 @@ namespace osu.Game.Online.API.Requests
return query.ToString();
}
public bool Equals(GetScoresRequest? other)
{
if (ReferenceEquals(null, other)) return false;
if (ReferenceEquals(this, other)) return true;
return beatmapInfo.Equals(other.beatmapInfo)
&& scope == other.scope
&& ruleset.Equals(other.ruleset)
&& mods.SequenceEqual(other.mods);
}
}
}

View File

@ -32,6 +32,7 @@ namespace osu.Game.Online.API.Requests
Loved,
Pending,
Guest,
Graveyard
Graveyard,
Nominated,
}
}

View File

@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests
{
public class GetUsersRequest : APIRequest<GetUsersResponse>
{
private readonly int[] userIds;
public readonly int[] UserIds;
private const int max_ids_per_request = 50;
@ -18,9 +18,9 @@ namespace osu.Game.Online.API.Requests
if (userIds.Length > max_ids_per_request)
throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {max_ids_per_request} IDs at once");
this.userIds = userIds;
UserIds = userIds;
}
protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", userIds);
protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", UserIds);
}
}

View File

@ -143,7 +143,7 @@ namespace osu.Game.Online.API.Requests.Responses
public bool Equals(IRulesetInfo? other) => other is APIRuleset r && this.MatchesOnlineID(r);
public int CompareTo(IRulesetInfo other)
public int CompareTo(IRulesetInfo? other)
{
if (!(other is APIRuleset ruleset))
throw new ArgumentException($@"Object is not of type {nameof(APIRuleset)}.", nameof(other));

View File

@ -111,6 +111,12 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"language")]
public BeatmapSetOnlineLanguage Language { get; set; }
[JsonProperty(@"current_nominations")]
public BeatmapSetOnlineNomination[]? CurrentNominations { get; set; }
[JsonProperty(@"related_users")]
public APIUser[]? RelatedUsers { get; set; }
public string Source { get; set; } = string.Empty;
[JsonProperty(@"tags")]

View File

@ -21,7 +21,7 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty]
private string type
{
set => Type = (RecentActivityType)Enum.Parse(typeof(RecentActivityType), value.ToPascalCase());
set => Type = Enum.Parse<RecentActivityType>(value.ToPascalCase());
}
public RecentActivityType Type;
@ -29,7 +29,7 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty]
private string scoreRank
{
set => ScoreRank = (ScoreRank)Enum.Parse(typeof(ScoreRank), value);
set => ScoreRank = Enum.Parse<ScoreRank>(value);
}
public ScoreRank ScoreRank;

View File

@ -164,6 +164,9 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"guest_beatmapset_count")]
public int GuestBeatmapsetCount;
[JsonProperty(@"nominated_beatmapset_count")]
public int NominatedBeatmapsetCount;
[JsonProperty(@"scores_best_count")]
public int ScoresBestCount;
@ -182,7 +185,7 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"playstyle")]
private string[] playStyle
{
set => PlayStyles = value?.Select(str => Enum.Parse(typeof(APIPlayStyle), str, true)).Cast<APIPlayStyle>().ToArray();
set => PlayStyles = value?.Select(str => Enum.Parse<APIPlayStyle>(str, true)).ToArray();
}
public APIPlayStyle[] PlayStyles;
@ -252,6 +255,9 @@ namespace osu.Game.Online.API.Requests.Responses
[CanBeNull]
public Dictionary<string, UserStatistics> RulesetsStatistics { get; set; }
[JsonProperty("groups")]
public APIUserGroup[] Groups;
public override string ToString() => Username;
/// <summary>

View File

@ -0,0 +1,37 @@
// 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 Newtonsoft.Json;
namespace osu.Game.Online.API.Requests.Responses
{
public class APIUserGroup
{
[JsonProperty(@"colour")]
public string Colour { get; set; } = null!;
[JsonProperty(@"has_listing")]
public bool HasListings { get; set; }
[JsonProperty(@"has_playmodes")]
public bool HasPlaymodes { get; set; }
[JsonProperty(@"id")]
public int Id { get; set; }
[JsonProperty(@"identifier")]
public string Identifier { get; set; } = null!;
[JsonProperty(@"is_probationary")]
public bool IsProbationary { get; set; }
[JsonProperty(@"name")]
public string Name { get; set; } = null!;
[JsonProperty(@"short_name")]
public string ShortName { get; set; } = null!;
[JsonProperty(@"playmodes")]
public string[]? Playmodes { get; set; }
}
}

View File

@ -98,6 +98,11 @@ namespace osu.Game.Online.Chat
/// </summary>
public Bindable<Message> HighlightedMessage = new Bindable<Message>();
/// <summary>
/// The current text box message while in this <see cref="Channel"/>.
/// </summary>
public Bindable<string> TextBoxMessage = new Bindable<string>(string.Empty);
[JsonConstructor]
public Channel()
{

View File

@ -71,7 +71,6 @@ namespace osu.Game.Online.Chat
private UserLookupCache users { get; set; }
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
private bool channelsInitialised;
private ScheduledDelegate scheduledAck;
private long? lastSilenceMessageId;
@ -95,15 +94,7 @@ namespace osu.Game.Online.Chat
connector.NewMessages += msgs => Schedule(() => addMessages(msgs));
connector.PresenceReceived += () => Schedule(() =>
{
if (!channelsInitialised)
{
channelsInitialised = true;
// we want this to run after the first presence so we can see if the user is in any channels already.
initializeChannels();
}
});
connector.PresenceReceived += () => Schedule(initializeChannels);
connector.Start();
@ -118,8 +109,7 @@ namespace osu.Game.Online.Chat
/// <param name="name"></param>
public void OpenChannel(string name)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
ArgumentNullException.ThrowIfNull(name);
CurrentChannel.Value = AvailableChannels.FirstOrDefault(c => c.Name == name) ?? throw new ChannelNotFoundException(name);
}
@ -130,8 +120,7 @@ namespace osu.Game.Online.Chat
/// <param name="user">The user the private channel is opened with.</param>
public void OpenPrivateChannel(APIUser user)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
ArgumentNullException.ThrowIfNull(user);
if (user.Id == api.LocalUser.Value.Id)
return;
@ -337,6 +326,11 @@ namespace osu.Game.Online.Chat
private void initializeChannels()
{
// This request is self-retrying until it succeeds.
// To avoid requests piling up when not logged in (ie. API is unavailable) exit early.
if (!api.IsLoggedIn)
return;
var req = new ListChannelsRequest();
bool joinDefaults = JoinedChannels.Count == 0;
@ -352,10 +346,11 @@ namespace osu.Game.Online.Chat
joinChannel(ch);
}
};
req.Failure += error =>
{
Logger.Error(error, "Fetching channel list failed");
initializeChannels();
Scheduler.AddDelayed(initializeChannels, 60000);
};
api.Queue(req);
@ -529,6 +524,10 @@ namespace osu.Game.Online.Chat
{
Logger.Log($"Joined public channel {channel}");
joinChannel(channel, fetchInitialMessages);
// Required after joining public channels to mark the user as online in them.
// Todo: Temporary workaround for https://github.com/ppy/osu-web/issues/9602
SendAck();
};
req.Failure += e =>
{

View File

@ -1,9 +1,6 @@
// 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 disable
using System;
using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Online.Chat
@ -13,7 +10,6 @@ namespace osu.Game.Online.Chat
public InfoMessage(string message)
: base(null)
{
Timestamp = DateTimeOffset.Now;
Content = message;
Sender = APIUser.SYSTEM_USER;

View File

@ -1,8 +1,6 @@
// 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 disable
namespace osu.Game.Online.Chat
{
public class LocalEchoMessage : LocalMessage

View File

@ -1,7 +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.
#nullable disable
using System;
namespace osu.Game.Online.Chat
{
@ -13,6 +13,7 @@ namespace osu.Game.Online.Chat
protected LocalMessage(long? id)
: base(id)
{
Timestamp = DateTimeOffset.Now;
}
}
}

View File

@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using Newtonsoft.Json;
using osu.Game.Online.API.Requests.Responses;
@ -59,19 +60,28 @@ namespace osu.Game.Online.Chat
/// <remarks>The <see cref="Link"/>s' <see cref="Link.Index"/> and <see cref="Link.Length"/>s are according to <see cref="DisplayContent"/></remarks>
public List<Link> Links;
private static long constructionOrderStatic;
private readonly long constructionOrder;
public Message(long? id)
{
Id = id;
constructionOrder = Interlocked.Increment(ref constructionOrderStatic);
}
public int CompareTo(Message other)
{
if (!Id.HasValue)
return other.Id.HasValue ? 1 : Timestamp.CompareTo(other.Timestamp);
if (!other.Id.HasValue)
return -1;
if (Id.HasValue && other.Id.HasValue)
return Id.Value.CompareTo(other.Id.Value);
return Id.Value.CompareTo(other.Id.Value);
int timestampComparison = Timestamp.CompareTo(other.Timestamp);
if (timestampComparison != 0)
return timestampComparison;
// Timestamp might not be accurate enough to make a stable sorting decision.
return constructionOrder.CompareTo(other.constructionOrder);
}
public virtual bool Equals(Message other)
@ -85,6 +95,6 @@ namespace osu.Game.Online.Chat
// ReSharper disable once ImpureMethodCallOnReadonlyValueField
public override int GetHashCode() => Id.GetHashCode();
public override string ToString() => $"[{ChannelId}] ({Id}) {Sender}: {Content}";
public override string ToString() => $"({(Id?.ToString() ?? "null")}) {Timestamp} {Sender}: {Content}";
}
}

View File

@ -71,7 +71,7 @@ namespace osu.Game.Online.Chat
{
int index = m.Index - captureOffset;
string? displayText = string.Format(display,
string displayText = string.Format(display,
m.Groups[0],
m.Groups["text"].Value,
m.Groups["url"].Value).Trim();
@ -109,7 +109,7 @@ namespace osu.Game.Online.Chat
foreach (Match m in regex.Matches(result.Text, startIndex))
{
int index = m.Index;
string? linkText = m.Groups["link"].Value;
string linkText = m.Groups["link"].Value;
int indexLength = linkText.Length;
var details = GetLinkDetails(linkText);
@ -125,7 +125,7 @@ namespace osu.Game.Online.Chat
public static LinkDetails GetLinkDetails(string url)
{
string[]? args = url.Split('/', StringSplitOptions.RemoveEmptyEntries);
string[] args = url.Split('/', StringSplitOptions.RemoveEmptyEntries);
args[0] = args[0].TrimEnd(':');
switch (args[0])
@ -341,6 +341,8 @@ namespace osu.Game.Online.Chat
OpenWiki,
Custom,
OpenChangelog,
FilterBeatmapSetGenre,
FilterBeatmapSetLanguage,
}
public class Link : IComparable<Link>
@ -362,6 +364,6 @@ namespace osu.Game.Online.Chat
public bool Overlaps(Link otherLink) => Index < otherLink.Index + otherLink.Length && otherLink.Index < Index + Length;
public int CompareTo(Link otherLink) => Index > otherLink.Index ? 1 : -1;
public int CompareTo(Link? otherLink) => Index > otherLink?.Index ? 1 : -1;
}
}

View File

@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using osu.Framework.Allocation;
@ -61,12 +62,16 @@ namespace osu.Game.Online.Chat
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
Debug.Assert(e.NewItems != null);
foreach (var channel in e.NewItems.Cast<Channel>())
channel.NewMessagesArrived += checkNewMessages;
break;
case NotifyCollectionChangedAction.Remove:
Debug.Assert(e.OldItems != null);
foreach (var channel in e.OldItems.Cast<Channel>())
channel.NewMessagesArrived -= checkNewMessages;

View File

@ -111,8 +111,13 @@ namespace osu.Game.Online.Chat
{
drawableChannel?.Expire();
if (e.OldValue != null)
TextBox?.Current.UnbindFrom(e.OldValue.TextBoxMessage);
if (e.NewValue == null) return;
TextBox?.Current.BindTo(e.NewValue.TextBoxMessage);
drawableChannel = CreateDrawableChannel(e.NewValue);
drawableChannel.CreateChatLineAction = CreateMessage;
drawableChannel.Padding = new MarginPadding { Bottom = postingTextBox ? text_box_height : 0 };

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
@ -59,17 +60,21 @@ namespace osu.Game.Online
var builder = new HubConnectionBuilder()
.WithUrl(endpoint, options =>
{
// Use HttpClient.DefaultProxy once on net6 everywhere.
// The credential setter can also be removed at this point.
options.Proxy = WebRequest.DefaultWebProxy;
if (options.Proxy != null)
options.Proxy.Credentials = CredentialCache.DefaultCredentials;
// Configuring proxies is not supported on iOS, see https://github.com/xamarin/xamarin-macios/issues/14632.
if (RuntimeInfo.OS != RuntimeInfo.Platform.iOS)
{
// Use HttpClient.DefaultProxy once on net6 everywhere.
// The credential setter can also be removed at this point.
options.Proxy = WebRequest.DefaultWebProxy;
if (options.Proxy != null)
options.Proxy.Credentials = CredentialCache.DefaultCredentials;
}
options.Headers.Add("Authorization", $"Bearer {api.AccessToken}");
options.Headers.Add("OsuVersionHash", versionHash);
});
if (RuntimeInfo.SupportsJIT && preferMessagePack)
if (RuntimeFeature.IsDynamicCodeCompiled && preferMessagePack)
{
builder.AddMessagePackProtocol(options =>
{

View File

@ -136,9 +136,8 @@ namespace osu.Game.Online.Leaderboards
{
if (displayedScore != null)
{
timestampLabel.Text = prefer24HourTime.Value
? $"Played on {displayedScore.Date.ToLocalTime():d MMMM yyyy HH:mm}"
: $"Played on {displayedScore.Date.ToLocalTime():d MMMM yyyy h:mm tt}";
timestampLabel.Text = LocalisableString.Format("Played on {0}",
displayedScore.Date.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt"));
}
}

View File

@ -68,7 +68,7 @@ namespace osu.Game.Online.Metadata
while (true)
{
Logger.Log($"Requesting catch-up from {lastQueueId.Value}");
var catchUpChanges = await GetChangesSince(lastQueueId.Value);
var catchUpChanges = await GetChangesSince(lastQueueId.Value).ConfigureAwait(true);
lastQueueId.Value = catchUpChanges.LastProcessedQueueID;
@ -78,7 +78,7 @@ namespace osu.Game.Online.Metadata
break;
}
await ProcessChanges(catchUpChanges.BeatmapSetIDs);
await ProcessChanges(catchUpChanges.BeatmapSetIDs).ConfigureAwait(true);
}
}
catch (Exception e)
@ -101,7 +101,7 @@ namespace osu.Game.Online.Metadata
if (!catchingUp)
lastQueueId.Value = updates.LastProcessedQueueID;
await ProcessChanges(updates.BeatmapSetIDs);
await ProcessChanges(updates.BeatmapSetIDs).ConfigureAwait(false);
}
public override Task<BeatmapUpdates> GetChangesSince(int queueId)

View File

@ -822,7 +822,7 @@ namespace osu.Game.Online.Multiplayer
{
if (cancellationToken.IsCancellationRequested)
{
tcs.SetCanceled();
tcs.SetCanceled(cancellationToken);
return;
}

View File

@ -54,10 +54,10 @@ namespace osu.Game.Online.Multiplayer
return UserID == other.UserID;
}
public override bool Equals(object obj)
public override bool Equals(object? obj)
{
if (ReferenceEquals(this, obj)) return true;
if (obj.GetType() != GetType()) return false;
if (obj?.GetType() != GetType()) return false;
return Equals((MultiplayerRoomUser)obj);
}

View File

@ -80,7 +80,7 @@ namespace osu.Game.Online.Multiplayer
try
{
return await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty);
return await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty).ConfigureAwait(false);
}
catch (HubException exception)
{
@ -88,8 +88,8 @@ namespace osu.Game.Online.Multiplayer
{
Debug.Assert(connector != null);
await connector.Reconnect();
return await JoinRoom(roomId, password);
await connector.Reconnect().ConfigureAwait(false);
return await JoinRoom(roomId, password).ConfigureAwait(false);
}
throw;

Some files were not shown because too many files have changed in this diff Show More