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 <mary@mary.zone>

---------

Signed-off-by: Mary Guillemard <mary@mary.zone>
Co-authored-by: TSR Berry <20988865+TSRBerry@users.noreply.github.com>
Co-authored-by: Mary Guillemard <mary@mary.zone>
This commit is contained in:
SamusAranX 2023-11-06 22:47:44 +01:00 committed by GitHub
parent 617c5700ca
commit 623604c391
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 398 additions and 243 deletions

View File

@ -716,7 +716,7 @@ namespace Ryujinx.Ava
ApplicationLibrary.LoadAndSaveMetaData(Device.Processes.ActiveApplication.ProgramIdText, appMetadata => ApplicationLibrary.LoadAndSaveMetaData(Device.Processes.ActiveApplication.ProgramIdText, appMetadata =>
{ {
appMetadata.LastPlayed = DateTime.UtcNow; appMetadata.UpdatePreGame();
}); });
return true; return true;

View File

@ -6,13 +6,13 @@ using Ryujinx.Common;
using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration;
using Ryujinx.Common.GraphicsDriver; using Ryujinx.Common.GraphicsDriver;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Common.SystemInfo;
using Ryujinx.Common.SystemInterop; using Ryujinx.Common.SystemInterop;
using Ryujinx.Modules; using Ryujinx.Modules;
using Ryujinx.SDL2.Common; using Ryujinx.SDL2.Common;
using Ryujinx.Ui.Common; using Ryujinx.Ui.Common;
using Ryujinx.Ui.Common.Configuration; using Ryujinx.Ui.Common.Configuration;
using Ryujinx.Ui.Common.Helper; using Ryujinx.Ui.Common.Helper;
using Ryujinx.Ui.Common.SystemInfo;
using System; using System;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;

View File

@ -126,17 +126,17 @@
Spacing="5"> Spacing="5">
<TextBlock <TextBlock
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Text="{Binding TimePlayed}" Text="{Binding TimePlayedString}"
TextAlignment="Right" TextAlignment="Right"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBlock <TextBlock
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Text="{Binding LastPlayed, Converter={helpers:NullableDateTimeConverter}}" Text="{Binding LastPlayedString, Converter={helpers:LocalizedNeverConverter}}"
TextAlignment="Right" TextAlignment="Right"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBlock <TextBlock
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
Text="{Binding FileSize}" Text="{Binding FileSizeString}"
TextAlignment="Right" TextAlignment="Right"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</StackPanel> </StackPanel>

View File

@ -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
{
/// <summary>
/// This <see cref="IValueConverter"/> makes sure that the string "Never" that's returned by <see cref="ValueFormatUtils.FormatDateTime"/> is properly localized in the Avalonia UI.
/// After the Avalonia UI has been made the default and the GTK UI is removed, <see cref="ValueFormatUtils"/> should be updated to directly return a localized string.
/// </summary>
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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -13,20 +13,19 @@ namespace Ryujinx.Ava.UI.Models.Generic
public int Compare(ApplicationData x, ApplicationData y) public int Compare(ApplicationData x, ApplicationData y)
{ {
var aValue = x.LastPlayed; DateTime aValue = DateTime.UnixEpoch, bValue = DateTime.UnixEpoch;
var bValue = y.LastPlayed;
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);
} }
} }
} }

View File

@ -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<ApplicationData>
{
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);
}
}
}

View File

@ -4,7 +4,7 @@ using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Ava.UI.Windows; using Ryujinx.Ava.UI.Windows;
using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.FileSystem;
using Ryujinx.Ui.App.Common; using Ryujinx.Ui.App.Common;
using System; using Ryujinx.Ui.Common.Helper;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -38,26 +38,7 @@ namespace Ryujinx.Ava.UI.Models
public bool SizeAvailable { get; set; } public bool SizeAvailable { get; set; }
public string SizeString => GetSizeString(); public string SizeString => ValueFormatUtils.FormatFileSize(Size);
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 SaveModel(SaveDataInfo info) public SaveModel(SaveDataInfo info)
{ {

View File

@ -930,21 +930,20 @@ namespace Ryujinx.Ava.UI.ViewModels
return SortMode switch return SortMode switch
{ {
#pragma warning disable IDE0055 // Disable formatting #pragma warning disable IDE0055 // Disable formatting
ApplicationSort.LastPlayed => new LastPlayedSortComparer(IsAscending),
ApplicationSort.FileSize => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileSizeBytes)
: SortExpressionComparer<ApplicationData>.Descending(app => app.FileSizeBytes),
ApplicationSort.TotalTimePlayed => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.TimePlayedNum)
: SortExpressionComparer<ApplicationData>.Descending(app => app.TimePlayedNum),
ApplicationSort.Title => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.TitleName) ApplicationSort.Title => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.TitleName)
: SortExpressionComparer<ApplicationData>.Descending(app => app.TitleName), : SortExpressionComparer<ApplicationData>.Descending(app => app.TitleName),
ApplicationSort.Favorite => !IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Favorite)
: SortExpressionComparer<ApplicationData>.Descending(app => app.Favorite),
ApplicationSort.Developer => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Developer) ApplicationSort.Developer => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Developer)
: SortExpressionComparer<ApplicationData>.Descending(app => app.Developer), : SortExpressionComparer<ApplicationData>.Descending(app => app.Developer),
ApplicationSort.LastPlayed => new LastPlayedSortComparer(IsAscending),
ApplicationSort.TotalTimePlayed => new TimePlayedSortComparer(IsAscending),
ApplicationSort.FileType => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileExtension) ApplicationSort.FileType => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileExtension)
: SortExpressionComparer<ApplicationData>.Descending(app => app.FileExtension), : SortExpressionComparer<ApplicationData>.Descending(app => app.FileExtension),
ApplicationSort.FileSize => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileSize)
: SortExpressionComparer<ApplicationData>.Descending(app => app.FileSize),
ApplicationSort.Path => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Path) ApplicationSort.Path => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Path)
: SortExpressionComparer<ApplicationData>.Descending(app => app.Path), : SortExpressionComparer<ApplicationData>.Descending(app => app.Path),
ApplicationSort.Favorite => !IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Favorite)
: SortExpressionComparer<ApplicationData>.Descending(app => app.Favorite),
_ => null, _ => null,
#pragma warning restore IDE0055 #pragma warning restore IDE0055
}; };
@ -1549,13 +1548,7 @@ namespace Ryujinx.Ava.UI.ViewModels
{ {
ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata =>
{ {
if (appMetadata.LastPlayed.HasValue) appMetadata.UpdatePostGame();
{
double sessionTimePlayed = DateTime.UtcNow.Subtract(appMetadata.LastPlayed.Value).TotalSeconds;
appMetadata.TimePlayed += Math.Round(sessionTimePlayed, MidpointRounding.AwayFromZero);
}
appMetadata.LastPlayed = DateTime.UtcNow;
}); });
} }

View File

@ -9,10 +9,9 @@ using LibHac.Tools.FsSystem;
using LibHac.Tools.FsSystem.NcaUtils; using LibHac.Tools.FsSystem.NcaUtils;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.FileSystem;
using Ryujinx.Ui.Common.Helper;
using System; using System;
using System.Globalization;
using System.IO; using System.IO;
using System.Text.Json.Serialization;
namespace Ryujinx.Ui.App.Common namespace Ryujinx.Ui.App.Common
{ {
@ -24,29 +23,18 @@ namespace Ryujinx.Ui.App.Common
public string TitleId { get; set; } public string TitleId { get; set; }
public string Developer { get; set; } public string Developer { get; set; }
public string Version { get; set; } public string Version { get; set; }
public string TimePlayed { get; set; } public TimeSpan TimePlayed { get; set; }
public double TimePlayedNum { get; set; }
public DateTime? LastPlayed { get; set; } public DateTime? LastPlayed { get; set; }
public string FileExtension { get; set; } public string FileExtension { get; set; }
public string FileSize { get; set; } public long FileSize { get; set; }
public double FileSizeBytes { get; set; }
public string Path { get; set; } public string Path { get; set; }
public BlitStruct<ApplicationControlProperty> ControlHolder { get; set; } public BlitStruct<ApplicationControlProperty> ControlHolder { get; set; }
[JsonIgnore] public string TimePlayedString => ValueFormatUtils.FormatTimeSpan(TimePlayed);
public string LastPlayedString
{
get
{
if (!LastPlayed.HasValue)
{
// TODO: maybe put localized string here instead of just "Never"
return "Never";
}
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) public static string GetApplicationBuildId(VirtualFileSystem virtualFileSystem, string titleFilePath)
{ {

View File

@ -155,7 +155,7 @@ namespace Ryujinx.Ui.App.Common
return; return;
} }
double fileSize = new FileInfo(applicationPath).Length * 0.000000000931; long fileSize = new FileInfo(applicationPath).Length;
string titleName = "Unknown"; string titleName = "Unknown";
string titleId = "0000000000000000"; string titleId = "0000000000000000";
string developer = "Unknown"; string developer = "Unknown";
@ -425,25 +425,25 @@ namespace Ryujinx.Ui.App.Common
{ {
appMetadata.Title = titleName; 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. appMetadata.TimePlayed = TimeSpan.FromSeconds(appMetadata.TimePlayedOld);
return; appMetadata.TimePlayedOld = default;
} }
// 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)
{
// Migrate from string-based last_played to DateTime-based last_played_utc. // Migrate from string-based last_played to DateTime-based last_played_utc.
if (DateTime.TryParse(appMetadata.LastPlayedOld, out DateTime lastPlayedOldParsed)) if (DateTime.TryParse(appMetadata.LastPlayedOld, out DateTime lastPlayedOldParsed))
{ {
Logger.Info?.Print(LogClass.Application, $"last_played found: \"{appMetadata.LastPlayedOld}\", migrating to last_played_utc");
appMetadata.LastPlayed = lastPlayedOldParsed; appMetadata.LastPlayed = lastPlayedOldParsed;
// Migration successful: deleting last_played from the metadata file. // Migration successful: deleting last_played from the metadata file.
appMetadata.LastPlayedOld = default; 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, TitleId = titleId,
Developer = developer, Developer = developer,
Version = version, Version = version,
TimePlayed = ConvertSecondsToFormattedString(appMetadata.TimePlayed), TimePlayed = appMetadata.TimePlayed,
TimePlayedNum = appMetadata.TimePlayed,
LastPlayed = appMetadata.LastPlayed, LastPlayed = appMetadata.LastPlayed,
FileExtension = Path.GetExtension(applicationPath).ToUpper().Remove(0, 1), FileExtension = Path.GetExtension(applicationPath).TrimStart('.').ToUpper(),
FileSize = (fileSize < 1) ? (fileSize * 1024).ToString("0.##") + " MiB" : fileSize.ToString("0.##") + " GiB", FileSize = fileSize,
FileSizeBytes = fileSize,
Path = applicationPath, Path = applicationPath,
ControlHolder = controlHolder, ControlHolder = controlHolder,
}; };
@ -716,31 +714,6 @@ namespace Ryujinx.Ui.App.Common
return applicationIcon ?? _ncaIcon; 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) 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); _ = Enum.TryParse(_desiredTitleLanguage.ToString(), out TitleLanguage desiredTitleLanguage);

View File

@ -7,13 +7,45 @@ namespace Ryujinx.Ui.App.Common
{ {
public string Title { get; set; } public string Title { get; set; }
public bool Favorite { 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")] [JsonPropertyName("last_played_utc")]
public DateTime? LastPlayed { get; set; } = null; public DateTime? LastPlayed { get; set; } = null;
[JsonPropertyName("time_played")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public double TimePlayedOld { get; set; }
[JsonPropertyName("last_played")] [JsonPropertyName("last_played")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public string LastPlayedOld { get; set; } public string LastPlayedOld { get; set; }
/// <summary>
/// Updates <see cref="LastPlayed"/>. Call this before launching a game.
/// </summary>
public void UpdatePreGame()
{
LastPlayed = DateTime.UtcNow;
}
/// <summary>
/// Updates <see cref="LastPlayed"/> and <see cref="TimePlayed"/>. Call this after a game ends.
/// </summary>
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));
}
} }
} }

View File

@ -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
};
/// <summary>
/// Used by <see cref="FormatFileSize"/>.
/// </summary>
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
/// <summary>
/// Creates a human-readable string from a <see cref="TimeSpan"/>.
/// </summary>
/// <param name="timeSpan">The <see cref="TimeSpan"/> to be formatted.</param>
/// <returns>A formatted string that can be displayed in the UI.</returns>
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}";
}
/// <summary>
/// Creates a human-readable string from a <see cref="DateTime"/>.
/// </summary>
/// <param name="utcDateTime">The <see cref="DateTime"/> to be formatted. This is expected to be UTC-based.</param>
/// <param name="culture">The <see cref="CultureInfo"/> that's used in formatting. Defaults to <see cref="CultureInfo.CurrentCulture"/>.</param>
/// <returns>A formatted string that can be displayed in the UI.</returns>
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);
}
/// <summary>
/// Creates a human-readable file size string.
/// </summary>
/// <param name="size">The file size in bytes.</param>
/// <param name="forceUnit">Formats the passed size value as this unit, bypassing the automatic unit choice.</param>
/// <returns>A human-readable file size string.</returns>
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
/// <summary>
/// Parses a string generated by <see cref="FormatTimeSpan"/> and returns the original <see cref="TimeSpan"/>.
/// </summary>
/// <param name="timeSpanString">A string representing a <see cref="TimeSpan"/>.</param>
/// <returns>A <see cref="TimeSpan"/> object. If the input string couldn't been parsed, <see cref="TimeSpan.Zero"/> is returned.</returns>
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;
}
/// <summary>
/// Parses a string generated by <see cref="FormatDateTime"/> and returns the original <see cref="DateTime"/>.
/// </summary>
/// <param name="dateTimeString">The string representing a <see cref="DateTime"/>.</param>
/// <returns>A <see cref="DateTime"/> object. If the input string couldn't be parsed, <see cref="DateTime.UnixEpoch"/> is returned.</returns>
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;
}
/// <summary>
/// Parses a string generated by <see cref="FormatFileSize"/> and returns a <see cref="long"/> representing a number of bytes.
/// </summary>
/// <param name="sizeString">A string representing a file size formatted with <see cref="FormatFileSize"/>.</param>
/// <returns>A <see cref="long"/> representing a number of bytes.</returns>
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
}
}

View File

@ -5,7 +5,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Runtime.Versioning; using System.Runtime.Versioning;
namespace Ryujinx.Common.SystemInfo namespace Ryujinx.Ui.Common.SystemInfo
{ {
[SupportedOSPlatform("linux")] [SupportedOSPlatform("linux")]
class LinuxSystemInfo : SystemInfo class LinuxSystemInfo : SystemInfo

View File

@ -5,7 +5,7 @@ using System.Runtime.InteropServices;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Text; using System.Text;
namespace Ryujinx.Common.SystemInfo namespace Ryujinx.Ui.Common.SystemInfo
{ {
[SupportedOSPlatform("macos")] [SupportedOSPlatform("macos")]
partial class MacOSSystemInfo : SystemInfo partial class MacOSSystemInfo : SystemInfo

View File

@ -1,10 +1,11 @@
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Ui.Common.Helper;
using System; using System;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Intrinsics.X86; using System.Runtime.Intrinsics.X86;
using System.Text; using System.Text;
namespace Ryujinx.Common.SystemInfo namespace Ryujinx.Ui.Common.SystemInfo
{ {
public class SystemInfo public class SystemInfo
{ {
@ -20,13 +21,13 @@ namespace Ryujinx.Common.SystemInfo
CpuName = "Unknown"; 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() public void Print()
{ {
Logger.Notice.Print(LogClass.Application, $"Operating System: {OsDescription}"); Logger.Notice.Print(LogClass.Application, $"Operating System: {OsDescription}");
Logger.Notice.Print(LogClass.Application, $"CPU: {CpuName}"); 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() public static SystemInfo Gather()

View File

@ -4,7 +4,7 @@ using System.Management;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.Versioning; using System.Runtime.Versioning;
namespace Ryujinx.Common.SystemInfo namespace Ryujinx.Ui.Common.SystemInfo
{ {
[SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")]
partial class WindowsSystemInfo : SystemInfo partial class WindowsSystemInfo : SystemInfo

View File

@ -3,7 +3,6 @@ using Ryujinx.Common;
using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration;
using Ryujinx.Common.GraphicsDriver; using Ryujinx.Common.GraphicsDriver;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Common.SystemInfo;
using Ryujinx.Common.SystemInterop; using Ryujinx.Common.SystemInterop;
using Ryujinx.Modules; using Ryujinx.Modules;
using Ryujinx.SDL2.Common; using Ryujinx.SDL2.Common;
@ -11,6 +10,7 @@ using Ryujinx.Ui;
using Ryujinx.Ui.Common; using Ryujinx.Ui.Common;
using Ryujinx.Ui.Common.Configuration; using Ryujinx.Ui.Common.Configuration;
using Ryujinx.Ui.Common.Helper; using Ryujinx.Ui.Common.Helper;
using Ryujinx.Ui.Common.SystemInfo;
using Ryujinx.Ui.Widgets; using Ryujinx.Ui.Widgets;
using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Formats.Jpeg;
using System; using System;

View File

@ -1,4 +1,5 @@
using Gtk; using Gtk;
using Ryujinx.Ui.Common.Helper;
using System; using System;
namespace Ryujinx.Ui.Helper namespace Ryujinx.Ui.Helper
@ -7,88 +8,26 @@ namespace Ryujinx.Ui.Helper
{ {
public static int TimePlayedSort(ITreeModel model, TreeIter a, TreeIter b) public static int TimePlayedSort(ITreeModel model, TreeIter a, TreeIter b)
{ {
static string ReverseFormat(string time) TimeSpan aTimeSpan = ValueFormatUtils.ParseTimeSpan(model.GetValue(a, 5).ToString());
{ TimeSpan bTimeSpan = ValueFormatUtils.ParseTimeSpan(model.GetValue(b, 5).ToString());
if (time == "Never")
{
return "00";
}
var numbers = time.Split(new char[] { 'd', 'h', 'm' }); return TimeSpan.Compare(aTimeSpan, bTimeSpan);
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));
} }
public static int LastPlayedSort(ITreeModel model, TreeIter a, TreeIter b) public static int LastPlayedSort(ITreeModel model, TreeIter a, TreeIter b)
{ {
string aValue = model.GetValue(a, 6).ToString(); DateTime aDateTime = ValueFormatUtils.ParseDateTime(model.GetValue(a, 6).ToString());
string bValue = model.GetValue(b, 6).ToString(); DateTime bDateTime = ValueFormatUtils.ParseDateTime(model.GetValue(b, 6).ToString());
if (aValue == "Never") return DateTime.Compare(aDateTime, bDateTime);
{
aValue = DateTime.UnixEpoch.ToString();
}
if (bValue == "Never")
{
bValue = DateTime.UnixEpoch.ToString();
}
return DateTime.Compare(DateTime.Parse(bValue), DateTime.Parse(aValue));
} }
public static int FileSizeSort(ITreeModel model, TreeIter a, TreeIter b) public static int FileSizeSort(ITreeModel model, TreeIter a, TreeIter b)
{ {
string aValue = model.GetValue(a, 8).ToString(); long aSize = ValueFormatUtils.ParseFileSize(model.GetValue(a, 8).ToString());
string bValue = model.GetValue(b, 8).ToString(); long bSize = ValueFormatUtils.ParseFileSize(model.GetValue(b, 8).ToString());
if (aValue[^3..] == "GiB") return aSize.CompareTo(bSize);
{
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;
}
} }
} }
} }

View File

@ -954,7 +954,7 @@ namespace Ryujinx.Ui
ApplicationLibrary.LoadAndSaveMetaData(_emulationContext.Processes.ActiveApplication.ProgramIdText, appMetadata => ApplicationLibrary.LoadAndSaveMetaData(_emulationContext.Processes.ActiveApplication.ProgramIdText, appMetadata =>
{ {
appMetadata.LastPlayed = DateTime.UtcNow; appMetadata.UpdatePreGame();
}); });
} }
} }
@ -1097,13 +1097,7 @@ namespace Ryujinx.Ui
{ {
ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata =>
{ {
if (appMetadata.LastPlayed.HasValue) appMetadata.UpdatePostGame();
{
double sessionTimePlayed = DateTime.UtcNow.Subtract(appMetadata.LastPlayed.Value).TotalSeconds;
appMetadata.TimePlayed += Math.Round(sessionTimePlayed, MidpointRounding.AwayFromZero);
}
appMetadata.LastPlayed = DateTime.UtcNow;
}); });
} }
} }
@ -1177,10 +1171,10 @@ namespace Ryujinx.Ui
$"{args.AppData.TitleName}\n{args.AppData.TitleId.ToUpper()}", $"{args.AppData.TitleName}\n{args.AppData.TitleId.ToUpper()}",
args.AppData.Developer, args.AppData.Developer,
args.AppData.Version, args.AppData.Version,
args.AppData.TimePlayed, args.AppData.TimePlayedString,
args.AppData.LastPlayedString, args.AppData.LastPlayedString,
args.AppData.FileExtension, args.AppData.FileExtension,
args.AppData.FileSize, args.AppData.FileSizeString,
args.AppData.Path, args.AppData.Path,
args.AppData.ControlHolder); args.AppData.ControlHolder);
}); });