diff --git a/src/Ryujinx.Ava/AppHost.cs b/src/Ryujinx.Ava/AppHost.cs index cd066efba..4d751e2a9 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 168e9216d..cc062a256 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 09011005b..9004f7518 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 000000000..737896986 --- /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 e91937612..000000000 --- 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 8a4346556..8340d39df 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 000000000..d53ff566f --- /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 f15befbb3..7b476932b 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 b14905204..80df5d398 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 1be883ee1..65ab01eeb 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 2f688126a..46f29851c 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 01b857a62..9e2ca6870 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 000000000..951cd089e --- /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 08aa452eb..5f1ab5416 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 a968ad17b..3508ae3a4 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 55ec0127c..6a4fe6803 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 3b36d6e2e..9bb0fbf74 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 50151d733..afb6a9925 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 0c0eefd2c..c7a72ab9b 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 a9d4be109..8b0b35e6c 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); });