From 623604c39186901fd64c8e04e9aa959d5c825529 Mon Sep 17 00:00:00 2001 From: SamusAranX Date: Mon, 6 Nov 2023 22:47:44 +0100 Subject: [PATCH] Overhaul of string formatting/parsing/sorting logic for TimeSpans, DateTimes, and file sizes (#4956) * Fixed formatting/parsing issues with ApplicationData properties TimePlayed, LastPlayed, and FileSize Replaced double-based TimePlayed property with TimeSpan?-based one in ApplicationData and ApplicationMetadata Added a migration for TimePlayed, just like in #4861 Consolidated ApplicationData's FileSize* properties into one FileSize property Added a formatting/parsing helper class ValueFormatUtils for TimeSpans, DateTimes, and file sizes Added new value converters for TimeSpans and file sizes for the Avalonia UI Added TimePlayedSortComparer Fixed sort order in LastPlayedSortComparer Fixed sort order for ApplicationData fields TimePlayed, LastPlayed, and FileSize Fixed crashes caused by SortHelper Replaced SystemInfo.ToMiBString with ToGiBString backed by ValueFormatUtils Replaced SaveModel.GetSizeString() with ValueFormatUtils * Additional ApplicationLibrary changes that got lost in the last commit * Removed unneeded usings * Removed converters as they are no longer needed * Updated comment on FormatDateTime * Removed base10 parameter from ValueFormatUtils FormatFileSize now always returns base 2 values with base 10 units Made ParseFileSize capable of parsing both base 2 and base 10 units * Removed nullable attribute from TimePlayed property Centralized TimePlayed update code into ApplicationMetadata * Changed UpdateTimePlayed() to use TimeSpan logic * Removed JsonIgnore attributes from ApplicationData * Implemented requested format changes * Fixed mistakes in method documentation comments * Made it so the Last Played value "Never" is localized in the Avalonia UI * Implemented suggestions * Remove unused import * Did a comment refinement pass in ValueFormatUtils.cs * Reordered ValueFormatUtils methods and sorted them into #regions * Integrated functionality from #5056 Also removed Logger print from last_played migration code * Implemented suggestions * Moved ValueFormatUtils and SystemInfo to namespace Ryujinx.Ui.Common * common: Respect proper value format convention and use base10 by default This could be discuss again in another issue/PR, for now revert to the previous behavior. Signed-off-by: Mary Guillemard --------- Signed-off-by: Mary Guillemard Co-authored-by: TSR Berry <20988865+TSRBerry@users.noreply.github.com> Co-authored-by: Mary Guillemard --- src/Ryujinx.Ava/AppHost.cs | 2 +- src/Ryujinx.Ava/Program.cs | 2 +- .../UI/Controls/ApplicationListView.axaml | 6 +- .../UI/Helpers/LocalizedNeverConverter.cs | 43 ++++ .../UI/Helpers/NullableDateTimeConverter.cs | 38 --- .../Models/Generic/LastPlayedSortComparer.cs | 13 +- .../Models/Generic/TimePlayedSortComparer.cs | 31 +++ src/Ryujinx.Ava/UI/Models/SaveModel.cs | 23 +- .../UI/ViewModels/MainWindowViewModel.cs | 31 +-- src/Ryujinx.Ui.Common/App/ApplicationData.cs | 26 +-- .../App/ApplicationLibrary.cs | 63 ++--- .../App/ApplicationMetadata.cs | 34 ++- .../Helper/ValueFormatUtils.cs | 219 ++++++++++++++++++ .../SystemInfo/LinuxSystemInfo.cs | 2 +- .../SystemInfo/MacOSSystemInfo.cs | 2 +- .../SystemInfo/SystemInfo.cs | 7 +- .../SystemInfo/WindowsSystemInfo.cs | 2 +- src/Ryujinx/Program.cs | 2 +- src/Ryujinx/Ui/Helper/SortHelper.cs | 81 +------ src/Ryujinx/Ui/MainWindow.cs | 14 +- 20 files changed, 398 insertions(+), 243 deletions(-) create mode 100644 src/Ryujinx.Ava/UI/Helpers/LocalizedNeverConverter.cs delete mode 100644 src/Ryujinx.Ava/UI/Helpers/NullableDateTimeConverter.cs create mode 100644 src/Ryujinx.Ava/UI/Models/Generic/TimePlayedSortComparer.cs create mode 100644 src/Ryujinx.Ui.Common/Helper/ValueFormatUtils.cs rename src/{Ryujinx.Common => Ryujinx.Ui.Common}/SystemInfo/LinuxSystemInfo.cs (98%) rename src/{Ryujinx.Common => Ryujinx.Ui.Common}/SystemInfo/MacOSSystemInfo.cs (99%) rename src/{Ryujinx.Common => Ryujinx.Ui.Common}/SystemInfo/SystemInfo.cs (87%) rename src/{Ryujinx.Common => Ryujinx.Ui.Common}/SystemInfo/WindowsSystemInfo.cs (98%) diff --git a/src/Ryujinx.Ava/AppHost.cs b/src/Ryujinx.Ava/AppHost.cs index cd066efbac..4d751e2a97 100644 --- a/src/Ryujinx.Ava/AppHost.cs +++ b/src/Ryujinx.Ava/AppHost.cs @@ -716,7 +716,7 @@ namespace Ryujinx.Ava ApplicationLibrary.LoadAndSaveMetaData(Device.Processes.ActiveApplication.ProgramIdText, appMetadata => { - appMetadata.LastPlayed = DateTime.UtcNow; + appMetadata.UpdatePreGame(); }); return true; diff --git a/src/Ryujinx.Ava/Program.cs b/src/Ryujinx.Ava/Program.cs index 168e9216d5..cc062a256f 100644 --- a/src/Ryujinx.Ava/Program.cs +++ b/src/Ryujinx.Ava/Program.cs @@ -6,13 +6,13 @@ using Ryujinx.Common; using Ryujinx.Common.Configuration; using Ryujinx.Common.GraphicsDriver; using Ryujinx.Common.Logging; -using Ryujinx.Common.SystemInfo; using Ryujinx.Common.SystemInterop; using Ryujinx.Modules; using Ryujinx.SDL2.Common; using Ryujinx.Ui.Common; using Ryujinx.Ui.Common.Configuration; using Ryujinx.Ui.Common.Helper; +using Ryujinx.Ui.Common.SystemInfo; using System; using System.IO; using System.Runtime.InteropServices; diff --git a/src/Ryujinx.Ava/UI/Controls/ApplicationListView.axaml b/src/Ryujinx.Ava/UI/Controls/ApplicationListView.axaml index 09011005b3..9004f7518d 100644 --- a/src/Ryujinx.Ava/UI/Controls/ApplicationListView.axaml +++ b/src/Ryujinx.Ava/UI/Controls/ApplicationListView.axaml @@ -126,17 +126,17 @@ Spacing="5"> diff --git a/src/Ryujinx.Ava/UI/Helpers/LocalizedNeverConverter.cs b/src/Ryujinx.Ava/UI/Helpers/LocalizedNeverConverter.cs new file mode 100644 index 0000000000..7378969868 --- /dev/null +++ b/src/Ryujinx.Ava/UI/Helpers/LocalizedNeverConverter.cs @@ -0,0 +1,43 @@ +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ui.Common.Helper; +using System; +using System.Globalization; + +namespace Ryujinx.Ava.UI.Helpers +{ + /// + /// This makes sure that the string "Never" that's returned by is properly localized in the Avalonia UI. + /// After the Avalonia UI has been made the default and the GTK UI is removed, should be updated to directly return a localized string. + /// + internal class LocalizedNeverConverter : MarkupExtension, IValueConverter + { + private static readonly LocalizedNeverConverter _instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is not string valStr) + { + return ""; + } + + if (valStr == "Never") + { + return LocaleManager.Instance[LocaleKeys.Never]; + } + + return valStr; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + return _instance; + } + } +} diff --git a/src/Ryujinx.Ava/UI/Helpers/NullableDateTimeConverter.cs b/src/Ryujinx.Ava/UI/Helpers/NullableDateTimeConverter.cs deleted file mode 100644 index e919376120..0000000000 --- a/src/Ryujinx.Ava/UI/Helpers/NullableDateTimeConverter.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Avalonia.Data.Converters; -using Avalonia.Markup.Xaml; -using Ryujinx.Ava.Common.Locale; -using System; -using System.Globalization; - -namespace Ryujinx.Ava.UI.Helpers -{ - internal class NullableDateTimeConverter : MarkupExtension, IValueConverter - { - private static readonly NullableDateTimeConverter _instance = new(); - - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - if (value == null) - { - return LocaleManager.Instance[LocaleKeys.Never]; - } - - if (value is DateTime dateTime) - { - return dateTime.ToLocalTime().ToString(culture); - } - - throw new NotSupportedException(); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotSupportedException(); - } - - public override object ProvideValue(IServiceProvider serviceProvider) - { - return _instance; - } - } -} diff --git a/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs b/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs index 8a43465563..8340d39df3 100644 --- a/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs +++ b/src/Ryujinx.Ava/UI/Models/Generic/LastPlayedSortComparer.cs @@ -13,20 +13,19 @@ namespace Ryujinx.Ava.UI.Models.Generic public int Compare(ApplicationData x, ApplicationData y) { - var aValue = x.LastPlayed; - var bValue = y.LastPlayed; + DateTime aValue = DateTime.UnixEpoch, bValue = DateTime.UnixEpoch; - if (!aValue.HasValue) + if (x?.LastPlayed != null) { - aValue = DateTime.UnixEpoch; + aValue = x.LastPlayed.Value; } - if (!bValue.HasValue) + if (y?.LastPlayed != null) { - bValue = DateTime.UnixEpoch; + bValue = y.LastPlayed.Value; } - return (IsAscending ? 1 : -1) * DateTime.Compare(bValue.Value, aValue.Value); + return (IsAscending ? 1 : -1) * DateTime.Compare(aValue, bValue); } } } diff --git a/src/Ryujinx.Ava/UI/Models/Generic/TimePlayedSortComparer.cs b/src/Ryujinx.Ava/UI/Models/Generic/TimePlayedSortComparer.cs new file mode 100644 index 0000000000..d53ff566f3 --- /dev/null +++ b/src/Ryujinx.Ava/UI/Models/Generic/TimePlayedSortComparer.cs @@ -0,0 +1,31 @@ +using Ryujinx.Ui.App.Common; +using System; +using System.Collections.Generic; + +namespace Ryujinx.Ava.UI.Models.Generic +{ + internal class TimePlayedSortComparer : IComparer + { + public TimePlayedSortComparer() { } + public TimePlayedSortComparer(bool isAscending) { IsAscending = isAscending; } + + public bool IsAscending { get; } + + public int Compare(ApplicationData x, ApplicationData y) + { + TimeSpan aValue = TimeSpan.Zero, bValue = TimeSpan.Zero; + + if (x?.TimePlayed != null) + { + aValue = x.TimePlayed; + } + + if (y?.TimePlayed != null) + { + bValue = y.TimePlayed; + } + + return (IsAscending ? 1 : -1) * TimeSpan.Compare(aValue, bValue); + } + } +} diff --git a/src/Ryujinx.Ava/UI/Models/SaveModel.cs b/src/Ryujinx.Ava/UI/Models/SaveModel.cs index f15befbb3a..7b476932ba 100644 --- a/src/Ryujinx.Ava/UI/Models/SaveModel.cs +++ b/src/Ryujinx.Ava/UI/Models/SaveModel.cs @@ -4,7 +4,7 @@ using Ryujinx.Ava.UI.ViewModels; using Ryujinx.Ava.UI.Windows; using Ryujinx.HLE.FileSystem; using Ryujinx.Ui.App.Common; -using System; +using Ryujinx.Ui.Common.Helper; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -38,26 +38,7 @@ namespace Ryujinx.Ava.UI.Models public bool SizeAvailable { get; set; } - public string SizeString => GetSizeString(); - - private string GetSizeString() - { - const int Scale = 1024; - string[] orders = { "GiB", "MiB", "KiB" }; - long max = (long)Math.Pow(Scale, orders.Length); - - foreach (string order in orders) - { - if (Size > max) - { - return $"{decimal.Divide(Size, max):##.##} {order}"; - } - - max /= Scale; - } - - return "0 KiB"; - } + public string SizeString => ValueFormatUtils.FormatFileSize(Size); public SaveModel(SaveDataInfo info) { diff --git a/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs index b14905204a..80df5d3981 100644 --- a/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs @@ -930,21 +930,20 @@ namespace Ryujinx.Ava.UI.ViewModels return SortMode switch { #pragma warning disable IDE0055 // Disable formatting + ApplicationSort.Title => IsAscending ? SortExpressionComparer.Ascending(app => app.TitleName) + : SortExpressionComparer.Descending(app => app.TitleName), + ApplicationSort.Developer => IsAscending ? SortExpressionComparer.Ascending(app => app.Developer) + : SortExpressionComparer.Descending(app => app.Developer), ApplicationSort.LastPlayed => new LastPlayedSortComparer(IsAscending), - ApplicationSort.FileSize => IsAscending ? SortExpressionComparer.Ascending(app => app.FileSizeBytes) - : SortExpressionComparer.Descending(app => app.FileSizeBytes), - ApplicationSort.TotalTimePlayed => IsAscending ? SortExpressionComparer.Ascending(app => app.TimePlayedNum) - : SortExpressionComparer.Descending(app => app.TimePlayedNum), - ApplicationSort.Title => IsAscending ? SortExpressionComparer.Ascending(app => app.TitleName) - : SortExpressionComparer.Descending(app => app.TitleName), + ApplicationSort.TotalTimePlayed => new TimePlayedSortComparer(IsAscending), + ApplicationSort.FileType => IsAscending ? SortExpressionComparer.Ascending(app => app.FileExtension) + : SortExpressionComparer.Descending(app => app.FileExtension), + ApplicationSort.FileSize => IsAscending ? SortExpressionComparer.Ascending(app => app.FileSize) + : SortExpressionComparer.Descending(app => app.FileSize), + ApplicationSort.Path => IsAscending ? SortExpressionComparer.Ascending(app => app.Path) + : SortExpressionComparer.Descending(app => app.Path), ApplicationSort.Favorite => !IsAscending ? SortExpressionComparer.Ascending(app => app.Favorite) : SortExpressionComparer.Descending(app => app.Favorite), - ApplicationSort.Developer => IsAscending ? SortExpressionComparer.Ascending(app => app.Developer) - : SortExpressionComparer.Descending(app => app.Developer), - ApplicationSort.FileType => IsAscending ? SortExpressionComparer.Ascending(app => app.FileExtension) - : SortExpressionComparer.Descending(app => app.FileExtension), - ApplicationSort.Path => IsAscending ? SortExpressionComparer.Ascending(app => app.Path) - : SortExpressionComparer.Descending(app => app.Path), _ => null, #pragma warning restore IDE0055 }; @@ -1549,13 +1548,7 @@ namespace Ryujinx.Ava.UI.ViewModels { ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => { - if (appMetadata.LastPlayed.HasValue) - { - double sessionTimePlayed = DateTime.UtcNow.Subtract(appMetadata.LastPlayed.Value).TotalSeconds; - appMetadata.TimePlayed += Math.Round(sessionTimePlayed, MidpointRounding.AwayFromZero); - } - - appMetadata.LastPlayed = DateTime.UtcNow; + appMetadata.UpdatePostGame(); }); } diff --git a/src/Ryujinx.Ui.Common/App/ApplicationData.cs b/src/Ryujinx.Ui.Common/App/ApplicationData.cs index 1be883ee1a..65ab01eeb7 100644 --- a/src/Ryujinx.Ui.Common/App/ApplicationData.cs +++ b/src/Ryujinx.Ui.Common/App/ApplicationData.cs @@ -9,10 +9,9 @@ using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Common.Logging; using Ryujinx.HLE.FileSystem; +using Ryujinx.Ui.Common.Helper; using System; -using System.Globalization; using System.IO; -using System.Text.Json.Serialization; namespace Ryujinx.Ui.App.Common { @@ -24,29 +23,18 @@ namespace Ryujinx.Ui.App.Common public string TitleId { get; set; } public string Developer { get; set; } public string Version { get; set; } - public string TimePlayed { get; set; } - public double TimePlayedNum { get; set; } + public TimeSpan TimePlayed { get; set; } public DateTime? LastPlayed { get; set; } public string FileExtension { get; set; } - public string FileSize { get; set; } - public double FileSizeBytes { get; set; } + public long FileSize { get; set; } public string Path { get; set; } public BlitStruct ControlHolder { get; set; } - [JsonIgnore] - public string LastPlayedString - { - get - { - if (!LastPlayed.HasValue) - { - // TODO: maybe put localized string here instead of just "Never" - return "Never"; - } + public string TimePlayedString => ValueFormatUtils.FormatTimeSpan(TimePlayed); - return LastPlayed.Value.ToLocalTime().ToString(CultureInfo.CurrentCulture); - } - } + public string LastPlayedString => ValueFormatUtils.FormatDateTime(LastPlayed); + + public string FileSizeString => ValueFormatUtils.FormatFileSize(FileSize); public static string GetApplicationBuildId(VirtualFileSystem virtualFileSystem, string titleFilePath) { diff --git a/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs b/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs index 2f688126a4..46f29851cc 100644 --- a/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.Ui.Common/App/ApplicationLibrary.cs @@ -155,7 +155,7 @@ namespace Ryujinx.Ui.App.Common return; } - double fileSize = new FileInfo(applicationPath).Length * 0.000000000931; + long fileSize = new FileInfo(applicationPath).Length; string titleName = "Unknown"; string titleId = "0000000000000000"; string developer = "Unknown"; @@ -425,25 +425,25 @@ namespace Ryujinx.Ui.App.Common { appMetadata.Title = titleName; - if (appMetadata.LastPlayedOld == default || appMetadata.LastPlayed.HasValue) + // Only do the migration if time_played has a value and timespan_played hasn't been updated yet. + if (appMetadata.TimePlayedOld != default && appMetadata.TimePlayed == TimeSpan.Zero) { - // Don't do the migration if last_played doesn't exist or last_played_utc already has a value. - return; + appMetadata.TimePlayed = TimeSpan.FromSeconds(appMetadata.TimePlayedOld); + appMetadata.TimePlayedOld = default; } - // Migrate from string-based last_played to DateTime-based last_played_utc. - if (DateTime.TryParse(appMetadata.LastPlayedOld, out DateTime lastPlayedOldParsed)) + // Only do the migration if last_played has a value and last_played_utc doesn't exist yet. + if (appMetadata.LastPlayedOld != default && !appMetadata.LastPlayed.HasValue) { - Logger.Info?.Print(LogClass.Application, $"last_played found: \"{appMetadata.LastPlayedOld}\", migrating to last_played_utc"); - appMetadata.LastPlayed = lastPlayedOldParsed; + // Migrate from string-based last_played to DateTime-based last_played_utc. + if (DateTime.TryParse(appMetadata.LastPlayedOld, out DateTime lastPlayedOldParsed)) + { + appMetadata.LastPlayed = lastPlayedOldParsed; + + // Migration successful: deleting last_played from the metadata file. + appMetadata.LastPlayedOld = default; + } - // Migration successful: deleting last_played from the metadata file. - appMetadata.LastPlayedOld = default; - } - else - { - // Migration failed: emitting warning but leaving the unparsable value in the metadata file so the user can fix it. - Logger.Warning?.Print(LogClass.Application, $"Last played string \"{appMetadata.LastPlayedOld}\" is invalid for current system culture, skipping (did current culture change?)"); } }); @@ -455,12 +455,10 @@ namespace Ryujinx.Ui.App.Common TitleId = titleId, Developer = developer, Version = version, - TimePlayed = ConvertSecondsToFormattedString(appMetadata.TimePlayed), - TimePlayedNum = appMetadata.TimePlayed, + TimePlayed = appMetadata.TimePlayed, LastPlayed = appMetadata.LastPlayed, - FileExtension = Path.GetExtension(applicationPath).ToUpper().Remove(0, 1), - FileSize = (fileSize < 1) ? (fileSize * 1024).ToString("0.##") + " MiB" : fileSize.ToString("0.##") + " GiB", - FileSizeBytes = fileSize, + FileExtension = Path.GetExtension(applicationPath).TrimStart('.').ToUpper(), + FileSize = fileSize, Path = applicationPath, ControlHolder = controlHolder, }; @@ -716,31 +714,6 @@ namespace Ryujinx.Ui.App.Common return applicationIcon ?? _ncaIcon; } - private static string ConvertSecondsToFormattedString(double seconds) - { - TimeSpan time = TimeSpan.FromSeconds(seconds); - - string timeString; - if (time.Days != 0) - { - timeString = $"{time.Days}d {time.Hours:D2}h {time.Minutes:D2}m"; - } - else if (time.Hours != 0) - { - timeString = $"{time.Hours:D2}h {time.Minutes:D2}m"; - } - else if (time.Minutes != 0) - { - timeString = $"{time.Minutes:D2}m"; - } - else - { - timeString = "Never"; - } - - return timeString; - } - private void GetGameInformation(ref ApplicationControlProperty controlData, out string titleName, out string titleId, out string publisher, out string version) { _ = Enum.TryParse(_desiredTitleLanguage.ToString(), out TitleLanguage desiredTitleLanguage); diff --git a/src/Ryujinx.Ui.Common/App/ApplicationMetadata.cs b/src/Ryujinx.Ui.Common/App/ApplicationMetadata.cs index 01b857a620..9e2ca68708 100644 --- a/src/Ryujinx.Ui.Common/App/ApplicationMetadata.cs +++ b/src/Ryujinx.Ui.Common/App/ApplicationMetadata.cs @@ -7,13 +7,45 @@ namespace Ryujinx.Ui.App.Common { public string Title { get; set; } public bool Favorite { get; set; } - public double TimePlayed { get; set; } + + [JsonPropertyName("timespan_played")] + public TimeSpan TimePlayed { get; set; } = TimeSpan.Zero; [JsonPropertyName("last_played_utc")] public DateTime? LastPlayed { get; set; } = null; + [JsonPropertyName("time_played")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] + public double TimePlayedOld { get; set; } + [JsonPropertyName("last_played")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public string LastPlayedOld { get; set; } + + /// + /// Updates . Call this before launching a game. + /// + public void UpdatePreGame() + { + LastPlayed = DateTime.UtcNow; + } + + /// + /// Updates and . Call this after a game ends. + /// + public void UpdatePostGame() + { + DateTime? prevLastPlayed = LastPlayed; + UpdatePreGame(); + + if (!prevLastPlayed.HasValue) + { + return; + } + + TimeSpan diff = DateTime.UtcNow - prevLastPlayed.Value; + double newTotalSeconds = TimePlayed.Add(diff).TotalSeconds; + TimePlayed = TimeSpan.FromSeconds(Math.Round(newTotalSeconds, MidpointRounding.AwayFromZero)); + } } } diff --git a/src/Ryujinx.Ui.Common/Helper/ValueFormatUtils.cs b/src/Ryujinx.Ui.Common/Helper/ValueFormatUtils.cs new file mode 100644 index 0000000000..951cd089e8 --- /dev/null +++ b/src/Ryujinx.Ui.Common/Helper/ValueFormatUtils.cs @@ -0,0 +1,219 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace Ryujinx.Ui.Common.Helper +{ + public static class ValueFormatUtils + { + private static readonly string[] _fileSizeUnitStrings = + { + "B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", // Base 10 units, used for formatting and parsing + "KB", "MB", "GB", "TB", "PB", "EB", // Base 2 units, used for parsing legacy values + }; + + /// + /// Used by . + /// + public enum FileSizeUnits + { + Auto = -1, + Bytes = 0, + Kibibytes = 1, + Mebibytes = 2, + Gibibytes = 3, + Tebibytes = 4, + Pebibytes = 5, + Exbibytes = 6, + Kilobytes = 7, + Megabytes = 8, + Gigabytes = 9, + Terabytes = 10, + Petabytes = 11, + Exabytes = 12, + } + + private const double SizeBase10 = 1000; + private const double SizeBase2 = 1024; + private const int UnitEBIndex = 6; + + #region Value formatters + + /// + /// Creates a human-readable string from a . + /// + /// The to be formatted. + /// A formatted string that can be displayed in the UI. + public static string FormatTimeSpan(TimeSpan? timeSpan) + { + if (!timeSpan.HasValue || timeSpan.Value.TotalSeconds < 1) + { + // Game was never played + return TimeSpan.Zero.ToString("c", CultureInfo.InvariantCulture); + } + + if (timeSpan.Value.TotalDays < 1) + { + // Game was played for less than a day + return timeSpan.Value.ToString("c", CultureInfo.InvariantCulture); + } + + // Game was played for more than a day + TimeSpan onlyTime = timeSpan.Value.Subtract(TimeSpan.FromDays(timeSpan.Value.Days)); + string onlyTimeString = onlyTime.ToString("c", CultureInfo.InvariantCulture); + + return $"{timeSpan.Value.Days}d, {onlyTimeString}"; + } + + /// + /// Creates a human-readable string from a . + /// + /// The to be formatted. This is expected to be UTC-based. + /// The that's used in formatting. Defaults to . + /// A formatted string that can be displayed in the UI. + public static string FormatDateTime(DateTime? utcDateTime, CultureInfo culture = null) + { + culture ??= CultureInfo.CurrentCulture; + + if (!utcDateTime.HasValue) + { + // In the Avalonia UI, this is turned into a localized version of "Never" by LocalizedNeverConverter. + return "Never"; + } + + return utcDateTime.Value.ToLocalTime().ToString(culture); + } + + /// + /// Creates a human-readable file size string. + /// + /// The file size in bytes. + /// Formats the passed size value as this unit, bypassing the automatic unit choice. + /// A human-readable file size string. + public static string FormatFileSize(long size, FileSizeUnits forceUnit = FileSizeUnits.Auto) + { + if (size <= 0) + { + return $"0 {_fileSizeUnitStrings[0]}"; + } + + int unitIndex = (int)forceUnit; + if (forceUnit == FileSizeUnits.Auto) + { + unitIndex = Convert.ToInt32(Math.Floor(Math.Log(size, SizeBase10))); + + // Apply an upper bound so that exabytes are the biggest unit used when formatting. + if (unitIndex > UnitEBIndex) + { + unitIndex = UnitEBIndex; + } + } + + double sizeRounded; + + if (unitIndex > UnitEBIndex) + { + sizeRounded = Math.Round(size / Math.Pow(SizeBase10, unitIndex - UnitEBIndex), 1); + } + else + { + sizeRounded = Math.Round(size / Math.Pow(SizeBase2, unitIndex), 1); + } + + string sizeFormatted = sizeRounded.ToString(CultureInfo.InvariantCulture); + + return $"{sizeFormatted} {_fileSizeUnitStrings[unitIndex]}"; + } + + #endregion + + #region Value parsers + + /// + /// Parses a string generated by and returns the original . + /// + /// A string representing a . + /// A object. If the input string couldn't been parsed, is returned. + public static TimeSpan ParseTimeSpan(string timeSpanString) + { + TimeSpan returnTimeSpan = TimeSpan.Zero; + + // An input string can either look like "01:23:45" or "1d, 01:23:45" if the timespan represents a duration of more than a day. + // Here, we split the input string to check if it's the former or the latter. + var valueSplit = timeSpanString.Split(", "); + if (valueSplit.Length > 1) + { + var dayPart = valueSplit[0].Split("d")[0]; + if (int.TryParse(dayPart, out int days)) + { + returnTimeSpan = returnTimeSpan.Add(TimeSpan.FromDays(days)); + } + } + + if (TimeSpan.TryParse(valueSplit.Last(), out TimeSpan parsedTimeSpan)) + { + returnTimeSpan = returnTimeSpan.Add(parsedTimeSpan); + } + + return returnTimeSpan; + } + + /// + /// Parses a string generated by and returns the original . + /// + /// The string representing a . + /// A object. If the input string couldn't be parsed, is returned. + public static DateTime ParseDateTime(string dateTimeString) + { + if (!DateTime.TryParse(dateTimeString, CultureInfo.CurrentCulture, out DateTime parsedDateTime)) + { + // Games that were never played are supposed to appear before the oldest played games in the list, + // so returning DateTime.UnixEpoch here makes sense. + return DateTime.UnixEpoch; + } + + return parsedDateTime; + } + + /// + /// Parses a string generated by and returns a representing a number of bytes. + /// + /// A string representing a file size formatted with . + /// A representing a number of bytes. + public static long ParseFileSize(string sizeString) + { + // Enumerating over the units backwards because otherwise, sizeString.EndsWith("B") would exit the loop in the first iteration. + for (int i = _fileSizeUnitStrings.Length - 1; i >= 0; i--) + { + string unit = _fileSizeUnitStrings[i]; + if (!sizeString.EndsWith(unit)) + { + continue; + } + + string numberString = sizeString.Split(" ")[0]; + if (!double.TryParse(numberString, CultureInfo.InvariantCulture, out double number)) + { + break; + } + + double sizeBase = SizeBase2; + + // If the unit index is one that points to a base 10 unit in the FileSizeUnitStrings array, subtract 6 to arrive at a usable power value. + if (i > UnitEBIndex) + { + i -= UnitEBIndex; + sizeBase = SizeBase10; + } + + number *= Math.Pow(sizeBase, i); + + return Convert.ToInt64(number); + } + + return 0; + } + + #endregion + } +} diff --git a/src/Ryujinx.Common/SystemInfo/LinuxSystemInfo.cs b/src/Ryujinx.Ui.Common/SystemInfo/LinuxSystemInfo.cs similarity index 98% rename from src/Ryujinx.Common/SystemInfo/LinuxSystemInfo.cs rename to src/Ryujinx.Ui.Common/SystemInfo/LinuxSystemInfo.cs index 08aa452eb1..5f1ab54167 100644 --- a/src/Ryujinx.Common/SystemInfo/LinuxSystemInfo.cs +++ b/src/Ryujinx.Ui.Common/SystemInfo/LinuxSystemInfo.cs @@ -5,7 +5,7 @@ using System.Globalization; using System.IO; using System.Runtime.Versioning; -namespace Ryujinx.Common.SystemInfo +namespace Ryujinx.Ui.Common.SystemInfo { [SupportedOSPlatform("linux")] class LinuxSystemInfo : SystemInfo diff --git a/src/Ryujinx.Common/SystemInfo/MacOSSystemInfo.cs b/src/Ryujinx.Ui.Common/SystemInfo/MacOSSystemInfo.cs similarity index 99% rename from src/Ryujinx.Common/SystemInfo/MacOSSystemInfo.cs rename to src/Ryujinx.Ui.Common/SystemInfo/MacOSSystemInfo.cs index a968ad17b0..3508ae3a4a 100644 --- a/src/Ryujinx.Common/SystemInfo/MacOSSystemInfo.cs +++ b/src/Ryujinx.Ui.Common/SystemInfo/MacOSSystemInfo.cs @@ -5,7 +5,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Text; -namespace Ryujinx.Common.SystemInfo +namespace Ryujinx.Ui.Common.SystemInfo { [SupportedOSPlatform("macos")] partial class MacOSSystemInfo : SystemInfo diff --git a/src/Ryujinx.Common/SystemInfo/SystemInfo.cs b/src/Ryujinx.Ui.Common/SystemInfo/SystemInfo.cs similarity index 87% rename from src/Ryujinx.Common/SystemInfo/SystemInfo.cs rename to src/Ryujinx.Ui.Common/SystemInfo/SystemInfo.cs index 55ec0127c6..6a4fe68038 100644 --- a/src/Ryujinx.Common/SystemInfo/SystemInfo.cs +++ b/src/Ryujinx.Ui.Common/SystemInfo/SystemInfo.cs @@ -1,10 +1,11 @@ using Ryujinx.Common.Logging; +using Ryujinx.Ui.Common.Helper; using System; using System.Runtime.InteropServices; using System.Runtime.Intrinsics.X86; using System.Text; -namespace Ryujinx.Common.SystemInfo +namespace Ryujinx.Ui.Common.SystemInfo { public class SystemInfo { @@ -20,13 +21,13 @@ namespace Ryujinx.Common.SystemInfo CpuName = "Unknown"; } - private static string ToMiBString(ulong bytesValue) => (bytesValue == 0) ? "Unknown" : $"{bytesValue / 1024 / 1024} MiB"; + private static string ToGBString(ulong bytesValue) => (bytesValue == 0) ? "Unknown" : ValueFormatUtils.FormatFileSize((long)bytesValue, ValueFormatUtils.FileSizeUnits.Gibibytes); public void Print() { Logger.Notice.Print(LogClass.Application, $"Operating System: {OsDescription}"); Logger.Notice.Print(LogClass.Application, $"CPU: {CpuName}"); - Logger.Notice.Print(LogClass.Application, $"RAM: Total {ToMiBString(RamTotal)} ; Available {ToMiBString(RamAvailable)}"); + Logger.Notice.Print(LogClass.Application, $"RAM: Total {ToGBString(RamTotal)} ; Available {ToGBString(RamAvailable)}"); } public static SystemInfo Gather() diff --git a/src/Ryujinx.Common/SystemInfo/WindowsSystemInfo.cs b/src/Ryujinx.Ui.Common/SystemInfo/WindowsSystemInfo.cs similarity index 98% rename from src/Ryujinx.Common/SystemInfo/WindowsSystemInfo.cs rename to src/Ryujinx.Ui.Common/SystemInfo/WindowsSystemInfo.cs index 3b36d6e2e4..9bb0fbf742 100644 --- a/src/Ryujinx.Common/SystemInfo/WindowsSystemInfo.cs +++ b/src/Ryujinx.Ui.Common/SystemInfo/WindowsSystemInfo.cs @@ -4,7 +4,7 @@ using System.Management; using System.Runtime.InteropServices; using System.Runtime.Versioning; -namespace Ryujinx.Common.SystemInfo +namespace Ryujinx.Ui.Common.SystemInfo { [SupportedOSPlatform("windows")] partial class WindowsSystemInfo : SystemInfo diff --git a/src/Ryujinx/Program.cs b/src/Ryujinx/Program.cs index 50151d7335..afb6a99253 100644 --- a/src/Ryujinx/Program.cs +++ b/src/Ryujinx/Program.cs @@ -3,7 +3,6 @@ using Ryujinx.Common; using Ryujinx.Common.Configuration; using Ryujinx.Common.GraphicsDriver; using Ryujinx.Common.Logging; -using Ryujinx.Common.SystemInfo; using Ryujinx.Common.SystemInterop; using Ryujinx.Modules; using Ryujinx.SDL2.Common; @@ -11,6 +10,7 @@ using Ryujinx.Ui; using Ryujinx.Ui.Common; using Ryujinx.Ui.Common.Configuration; using Ryujinx.Ui.Common.Helper; +using Ryujinx.Ui.Common.SystemInfo; using Ryujinx.Ui.Widgets; using SixLabors.ImageSharp.Formats.Jpeg; using System; diff --git a/src/Ryujinx/Ui/Helper/SortHelper.cs b/src/Ryujinx/Ui/Helper/SortHelper.cs index 0c0eefd2c6..c7a72ab9b0 100644 --- a/src/Ryujinx/Ui/Helper/SortHelper.cs +++ b/src/Ryujinx/Ui/Helper/SortHelper.cs @@ -1,4 +1,5 @@ using Gtk; +using Ryujinx.Ui.Common.Helper; using System; namespace Ryujinx.Ui.Helper @@ -7,88 +8,26 @@ namespace Ryujinx.Ui.Helper { public static int TimePlayedSort(ITreeModel model, TreeIter a, TreeIter b) { - static string ReverseFormat(string time) - { - if (time == "Never") - { - return "00"; - } + TimeSpan aTimeSpan = ValueFormatUtils.ParseTimeSpan(model.GetValue(a, 5).ToString()); + TimeSpan bTimeSpan = ValueFormatUtils.ParseTimeSpan(model.GetValue(b, 5).ToString()); - var numbers = time.Split(new char[] { 'd', 'h', 'm' }); - - time = time.Replace(" ", "").Replace("d", ".").Replace("h", ":").Replace("m", ""); - - if (numbers.Length == 2) - { - return $"00.00:{time}"; - } - else if (numbers.Length == 3) - { - return $"00.{time}"; - } - - return time; - } - - string aValue = ReverseFormat(model.GetValue(a, 5).ToString()); - string bValue = ReverseFormat(model.GetValue(b, 5).ToString()); - - return TimeSpan.Compare(TimeSpan.Parse(aValue), TimeSpan.Parse(bValue)); + return TimeSpan.Compare(aTimeSpan, bTimeSpan); } public static int LastPlayedSort(ITreeModel model, TreeIter a, TreeIter b) { - string aValue = model.GetValue(a, 6).ToString(); - string bValue = model.GetValue(b, 6).ToString(); + DateTime aDateTime = ValueFormatUtils.ParseDateTime(model.GetValue(a, 6).ToString()); + DateTime bDateTime = ValueFormatUtils.ParseDateTime(model.GetValue(b, 6).ToString()); - if (aValue == "Never") - { - aValue = DateTime.UnixEpoch.ToString(); - } - - if (bValue == "Never") - { - bValue = DateTime.UnixEpoch.ToString(); - } - - return DateTime.Compare(DateTime.Parse(bValue), DateTime.Parse(aValue)); + return DateTime.Compare(aDateTime, bDateTime); } public static int FileSizeSort(ITreeModel model, TreeIter a, TreeIter b) { - string aValue = model.GetValue(a, 8).ToString(); - string bValue = model.GetValue(b, 8).ToString(); + long aSize = ValueFormatUtils.ParseFileSize(model.GetValue(a, 8).ToString()); + long bSize = ValueFormatUtils.ParseFileSize(model.GetValue(b, 8).ToString()); - if (aValue[^3..] == "GiB") - { - aValue = (float.Parse(aValue[0..^3]) * 1024).ToString(); - } - else - { - aValue = aValue[0..^3]; - } - - if (bValue[^3..] == "GiB") - { - bValue = (float.Parse(bValue[0..^3]) * 1024).ToString(); - } - else - { - bValue = bValue[0..^3]; - } - - if (float.Parse(aValue) > float.Parse(bValue)) - { - return -1; - } - else if (float.Parse(bValue) > float.Parse(aValue)) - { - return 1; - } - else - { - return 0; - } + return aSize.CompareTo(bSize); } } } diff --git a/src/Ryujinx/Ui/MainWindow.cs b/src/Ryujinx/Ui/MainWindow.cs index a9d4be1097..8b0b35e6c9 100644 --- a/src/Ryujinx/Ui/MainWindow.cs +++ b/src/Ryujinx/Ui/MainWindow.cs @@ -954,7 +954,7 @@ namespace Ryujinx.Ui ApplicationLibrary.LoadAndSaveMetaData(_emulationContext.Processes.ActiveApplication.ProgramIdText, appMetadata => { - appMetadata.LastPlayed = DateTime.UtcNow; + appMetadata.UpdatePreGame(); }); } } @@ -1097,13 +1097,7 @@ namespace Ryujinx.Ui { ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => { - if (appMetadata.LastPlayed.HasValue) - { - double sessionTimePlayed = DateTime.UtcNow.Subtract(appMetadata.LastPlayed.Value).TotalSeconds; - appMetadata.TimePlayed += Math.Round(sessionTimePlayed, MidpointRounding.AwayFromZero); - } - - appMetadata.LastPlayed = DateTime.UtcNow; + appMetadata.UpdatePostGame(); }); } } @@ -1177,10 +1171,10 @@ namespace Ryujinx.Ui $"{args.AppData.TitleName}\n{args.AppData.TitleId.ToUpper()}", args.AppData.Developer, args.AppData.Version, - args.AppData.TimePlayed, + args.AppData.TimePlayedString, args.AppData.LastPlayedString, args.AppData.FileExtension, - args.AppData.FileSize, + args.AppData.FileSizeString, args.AppData.Path, args.AppData.ControlHolder); });