aboutsummaryrefslogtreecommitdiff
path: root/src/Ryujinx.Ava/UI/ViewModels
diff options
context:
space:
mode:
authorTSR Berry <20988865+TSRBerry@users.noreply.github.com>2023-04-08 01:22:00 +0200
committerMary <thog@protonmail.com>2023-04-27 23:51:14 +0200
commitcee712105850ac3385cd0091a923438167433f9f (patch)
tree4a5274b21d8b7f938c0d0ce18736d3f2993b11b1 /src/Ryujinx.Ava/UI/ViewModels
parentcd124bda587ef09668a971fa1cac1c3f0cfc9f21 (diff)
Move solution and projects to src
Diffstat (limited to 'src/Ryujinx.Ava/UI/ViewModels')
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs133
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs467
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs363
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs15
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs899
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs338
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs1907
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs585
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs252
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs230
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs18
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs25
-rw-r--r--src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs120
13 files changed, 5352 insertions, 0 deletions
diff --git a/src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs
new file mode 100644
index 00000000..479411cb
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/AboutWindowViewModel.cs
@@ -0,0 +1,133 @@
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Ui.Common.Configuration;
+using System;
+using System.Net.Http;
+using System.Net.NetworkInformation;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class AboutWindowViewModel : BaseModel
+ {
+ private Bitmap _githubLogo;
+ private Bitmap _discordLogo;
+ private Bitmap _patreonLogo;
+ private Bitmap _twitterLogo;
+
+ private string _version;
+ private string _supporters;
+
+ public Bitmap GithubLogo
+ {
+ get => _githubLogo;
+ set
+ {
+ _githubLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap DiscordLogo
+ {
+ get => _discordLogo;
+ set
+ {
+ _discordLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap PatreonLogo
+ {
+ get => _patreonLogo;
+ set
+ {
+ _patreonLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap TwitterLogo
+ {
+ get => _twitterLogo;
+ set
+ {
+ _twitterLogo = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Supporters
+ {
+ get => _supporters;
+ set
+ {
+ _supporters = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Version
+ {
+ get => _version;
+ set
+ {
+ _version = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Developers => LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.AboutPageDeveloperListMore, "gdkchan, Ac_K, marysaka, rip in peri peri, LDj3SNuD, emmaus, Thealexbarney, GoffyDude, TSRBerry, IsaacMarovitz");
+
+ public AboutWindowViewModel()
+ {
+ Version = Program.Version;
+
+ var assets = AvaloniaLocator.Current.GetService<Avalonia.Platform.IAssetLoader>();
+
+ if (ConfigurationState.Instance.Ui.BaseStyle.Value == "Light")
+ {
+ GithubLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_GitHub_Light.png?assembly=Ryujinx.Ui.Common")));
+ DiscordLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Discord_Light.png?assembly=Ryujinx.Ui.Common")));
+ PatreonLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Patreon_Light.png?assembly=Ryujinx.Ui.Common")));
+ TwitterLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Twitter_Light.png?assembly=Ryujinx.Ui.Common")));
+ }
+ else
+ {
+ GithubLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_GitHub_Dark.png?assembly=Ryujinx.Ui.Common")));
+ DiscordLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Discord_Dark.png?assembly=Ryujinx.Ui.Common")));
+ PatreonLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Patreon_Dark.png?assembly=Ryujinx.Ui.Common")));
+ TwitterLogo = new Bitmap(assets.Open(new Uri("resm:Ryujinx.Ui.Common.Resources.Logo_Twitter_Dark.png?assembly=Ryujinx.Ui.Common")));
+ }
+
+ Dispatcher.UIThread.InvokeAsync(DownloadPatronsJson);
+ }
+
+ private async Task DownloadPatronsJson()
+ {
+ if (!NetworkInterface.GetIsNetworkAvailable())
+ {
+ Supporters = LocaleManager.Instance[LocaleKeys.ConnectionError];
+
+ return;
+ }
+
+ HttpClient httpClient = new();
+
+ try
+ {
+ string patreonJsonString = await httpClient.GetStringAsync("https://patreon.ryujinx.org/");
+
+ Supporters = string.Join(", ", JsonHelper.Deserialize(patreonJsonString, CommonJsonContext.Default.StringArray)) + "\n\n";
+ }
+ catch
+ {
+ Supporters = LocaleManager.Instance[LocaleKeys.ApiError];
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs
new file mode 100644
index 00000000..bb92798f
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs
@@ -0,0 +1,467 @@
+using Avalonia;
+using Avalonia.Collections;
+using Avalonia.Media.Imaging;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Ui.Common.Models.Amiibo;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using AmiiboJsonSerializerContext = Ryujinx.Ui.Common.Models.Amiibo.AmiiboJsonSerializerContext;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class AmiiboWindowViewModel : BaseModel, IDisposable
+ {
+ private const string DefaultJson = "{ \"amiibo\": [] }";
+ private const float AmiiboImageSize = 350f;
+
+ private readonly string _amiiboJsonPath;
+ private readonly byte[] _amiiboLogoBytes;
+ private readonly HttpClient _httpClient;
+ private readonly StyleableWindow _owner;
+
+ private Bitmap _amiiboImage;
+ private List<AmiiboApi> _amiiboList;
+ private AvaloniaList<AmiiboApi> _amiibos;
+ private ObservableCollection<string> _amiiboSeries;
+
+ private int _amiiboSelectedIndex;
+ private int _seriesSelectedIndex;
+ private bool _enableScanning;
+ private bool _showAllAmiibo;
+ private bool _useRandomUuid;
+ private string _usage;
+
+ private static readonly AmiiboJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public AmiiboWindowViewModel(StyleableWindow owner, string lastScannedAmiiboId, string titleId)
+ {
+ _owner = owner;
+
+ _httpClient = new HttpClient
+ {
+ Timeout = TimeSpan.FromSeconds(30)
+ };
+
+ LastScannedAmiiboId = lastScannedAmiiboId;
+ TitleId = titleId;
+
+ Directory.CreateDirectory(Path.Join(AppDataManager.BaseDirPath, "system", "amiibo"));
+
+ _amiiboJsonPath = Path.Join(AppDataManager.BaseDirPath, "system", "amiibo", "Amiibo.json");
+ _amiiboList = new List<AmiiboApi>();
+ _amiiboSeries = new ObservableCollection<string>();
+ _amiibos = new AvaloniaList<AmiiboApi>();
+
+ _amiiboLogoBytes = EmbeddedResources.Read("Ryujinx.Ui.Common/Resources/Logo_Amiibo.png");
+
+ _ = LoadContentAsync();
+ }
+
+ public AmiiboWindowViewModel() { }
+
+ public string TitleId { get; set; }
+ public string LastScannedAmiiboId { get; set; }
+
+ public UserResult Response { get; private set; }
+
+ public bool UseRandomUuid
+ {
+ get => _useRandomUuid;
+ set
+ {
+ _useRandomUuid = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowAllAmiibo
+ {
+ get => _showAllAmiibo;
+ set
+ {
+ _showAllAmiibo = value;
+
+ ParseAmiiboData();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public AvaloniaList<AmiiboApi> AmiiboList
+ {
+ get => _amiibos;
+ set
+ {
+ _amiibos = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public ObservableCollection<string> AmiiboSeries
+ {
+ get => _amiiboSeries;
+ set
+ {
+ _amiiboSeries = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public int SeriesSelectedIndex
+ {
+ get => _seriesSelectedIndex;
+ set
+ {
+ _seriesSelectedIndex = value;
+
+ FilterAmiibo();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int AmiiboSelectedIndex
+ {
+ get => _amiiboSelectedIndex;
+ set
+ {
+ _amiiboSelectedIndex = value;
+
+ EnableScanning = _amiiboSelectedIndex >= 0 && _amiiboSelectedIndex < _amiibos.Count;
+
+ SetAmiiboDetails();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Bitmap AmiiboImage
+ {
+ get => _amiiboImage;
+ set
+ {
+ _amiiboImage = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string Usage
+ {
+ get => _usage;
+ set
+ {
+ _usage = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool EnableScanning
+ {
+ get => _enableScanning;
+ set
+ {
+ _enableScanning = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+
+ private async Task LoadContentAsync()
+ {
+ string amiiboJsonString = DefaultJson;
+
+ if (File.Exists(_amiiboJsonPath))
+ {
+ amiiboJsonString = await File.ReadAllTextAsync(_amiiboJsonPath);
+
+ if (await NeedsUpdate(JsonHelper.Deserialize(amiiboJsonString, SerializerContext.AmiiboJson).LastUpdated))
+ {
+ amiiboJsonString = await DownloadAmiiboJson();
+ }
+ }
+ else
+ {
+ try
+ {
+ amiiboJsonString = await DownloadAmiiboJson();
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to download amiibo data: {ex}");
+
+ ShowInfoDialog();
+ }
+ }
+
+ _amiiboList = JsonHelper.Deserialize(amiiboJsonString, SerializerContext.AmiiboJson).Amiibo;
+ _amiiboList = _amiiboList.OrderBy(amiibo => amiibo.AmiiboSeries).ToList();
+
+ ParseAmiiboData();
+ }
+
+ private void ParseAmiiboData()
+ {
+ _amiiboSeries.Clear();
+ _amiibos.Clear();
+
+ for (int i = 0; i < _amiiboList.Count; i++)
+ {
+ if (!_amiiboSeries.Contains(_amiiboList[i].AmiiboSeries))
+ {
+ if (!ShowAllAmiibo)
+ {
+ foreach (AmiiboApiGamesSwitch game in _amiiboList[i].GamesSwitch)
+ {
+ if (game != null)
+ {
+ if (game.GameId.Contains(TitleId))
+ {
+ AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
+
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
+ }
+ }
+ }
+
+ if (LastScannedAmiiboId != "")
+ {
+ SelectLastScannedAmiibo();
+ }
+ else
+ {
+ SeriesSelectedIndex = 0;
+ }
+ }
+
+ private void SelectLastScannedAmiibo()
+ {
+ AmiiboApi scanned = _amiiboList.FirstOrDefault(amiibo => amiibo.GetId() == LastScannedAmiiboId);
+
+ SeriesSelectedIndex = AmiiboSeries.IndexOf(scanned.AmiiboSeries);
+ AmiiboSelectedIndex = AmiiboList.IndexOf(scanned);
+ }
+
+ private void FilterAmiibo()
+ {
+ _amiibos.Clear();
+
+ if (_seriesSelectedIndex < 0)
+ {
+ return;
+ }
+
+ List<AmiiboApi> amiiboSortedList = _amiiboList
+ .Where(amiibo => amiibo.AmiiboSeries == _amiiboSeries[SeriesSelectedIndex])
+ .OrderBy(amiibo => amiibo.Name).ToList();
+
+ for (int i = 0; i < amiiboSortedList.Count; i++)
+ {
+ if (!_amiibos.Contains(amiiboSortedList[i]))
+ {
+ if (!_showAllAmiibo)
+ {
+ foreach (AmiiboApiGamesSwitch game in amiiboSortedList[i].GamesSwitch)
+ {
+ if (game != null)
+ {
+ if (game.GameId.Contains(TitleId))
+ {
+ _amiibos.Add(amiiboSortedList[i]);
+
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ _amiibos.Add(amiiboSortedList[i]);
+ }
+ }
+ }
+
+ AmiiboSelectedIndex = 0;
+ }
+
+ private void SetAmiiboDetails()
+ {
+ ResetAmiiboPreview();
+
+ Usage = string.Empty;
+
+ if (_amiiboSelectedIndex < 0)
+ {
+ return;
+ }
+
+ AmiiboApi selected = _amiibos[_amiiboSelectedIndex];
+
+ string imageUrl = _amiiboList.FirstOrDefault(amiibo => amiibo.Equals(selected)).Image;
+
+ string usageString = "";
+
+ for (int i = 0; i < _amiiboList.Count; i++)
+ {
+ if (_amiiboList[i].Equals(selected))
+ {
+ bool writable = false;
+
+ foreach (AmiiboApiGamesSwitch item in _amiiboList[i].GamesSwitch)
+ {
+ if (item.GameId.Contains(TitleId))
+ {
+ foreach (AmiiboApiUsage usageItem in item.AmiiboUsage)
+ {
+ usageString += Environment.NewLine +
+ $"- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}";
+
+ writable = usageItem.Write;
+ }
+ }
+ }
+
+ if (usageString.Length == 0)
+ {
+ usageString = LocaleManager.Instance[LocaleKeys.Unknown] + ".";
+ }
+
+ Usage = $"{LocaleManager.Instance[LocaleKeys.Usage]} {(writable ? $" ({LocaleManager.Instance[LocaleKeys.Writable]})" : "")} : {usageString}";
+ }
+ }
+
+ _ = UpdateAmiiboPreview(imageUrl);
+ }
+
+ private async Task<bool> NeedsUpdate(DateTime oldLastModified)
+ {
+ try
+ {
+ HttpResponseMessage response =
+ await _httpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, "https://amiibo.ryujinx.org/"));
+
+ if (response.IsSuccessStatusCode)
+ {
+ return response.Content.Headers.LastModified != oldLastModified;
+ }
+
+ return false;
+ }
+ catch (Exception ex)
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to check for amiibo updates: {ex}");
+
+ ShowInfoDialog();
+
+ return false;
+ }
+ }
+
+ private async Task<string> DownloadAmiiboJson()
+ {
+ HttpResponseMessage response = await _httpClient.GetAsync("https://amiibo.ryujinx.org/");
+
+ if (response.IsSuccessStatusCode)
+ {
+ string amiiboJsonString = await response.Content.ReadAsStringAsync();
+
+ using (FileStream amiiboJsonStream = File.Create(_amiiboJsonPath, 4096, FileOptions.WriteThrough))
+ {
+ amiiboJsonStream.Write(Encoding.UTF8.GetBytes(amiiboJsonString));
+ }
+
+ return amiiboJsonString;
+ }
+
+ Logger.Error?.Print(LogClass.Application, $"Failed to download amiibo data. Response status code: {response.StatusCode}");
+
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
+ LocaleManager.Instance[LocaleKeys.DialogAmiiboApiFailFetchMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ "",
+ LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+
+ Close();
+
+ return DefaultJson;
+ }
+
+ private void Close()
+ {
+ Dispatcher.UIThread.Post(_owner.Close);
+ }
+
+ private async Task UpdateAmiiboPreview(string imageUrl)
+ {
+ HttpResponseMessage response = await _httpClient.GetAsync(imageUrl);
+
+ if (response.IsSuccessStatusCode)
+ {
+ byte[] amiiboPreviewBytes = await response.Content.ReadAsByteArrayAsync();
+ using (MemoryStream memoryStream = new(amiiboPreviewBytes))
+ {
+ Bitmap bitmap = new(memoryStream);
+
+ double ratio = Math.Min(AmiiboImageSize / bitmap.Size.Width,
+ AmiiboImageSize / bitmap.Size.Height);
+
+ int resizeHeight = (int)(bitmap.Size.Height * ratio);
+ int resizeWidth = (int)(bitmap.Size.Width * ratio);
+
+ AmiiboImage = bitmap.CreateScaledBitmap(new PixelSize(resizeWidth, resizeHeight));
+ }
+ }
+ else
+ {
+ Logger.Error?.Print(LogClass.Application, $"Failed to get amiibo preview. Response status code: {response.StatusCode}");
+ }
+ }
+
+ private void ResetAmiiboPreview()
+ {
+ using (MemoryStream memoryStream = new(_amiiboLogoBytes))
+ {
+ Bitmap bitmap = new(memoryStream);
+
+ AmiiboImage = bitmap;
+ }
+ }
+
+ private async void ShowInfoDialog()
+ {
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
+ LocaleManager.Instance[LocaleKeys.DialogAmiiboApiConnectErrorMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ "",
+ LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs
new file mode 100644
index 00000000..b2b31014
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs
@@ -0,0 +1,363 @@
+using Avalonia.Media;
+using DynamicData;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ncm;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.FileSystem;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Formats.Png;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Color = Avalonia.Media.Color;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ internal class AvatarProfileViewModel : BaseModel, IDisposable
+ {
+ private const int MaxImageTasks = 4;
+
+ private static readonly Dictionary<string, byte[]> _avatarStore = new();
+ private static bool _isPreloading;
+ private static Action _loadCompleteAction;
+
+ private ObservableCollection<ProfileImageModel> _images;
+ private Color _backgroundColor = Colors.White;
+
+ private int _selectedIndex;
+ private int _imagesLoaded;
+ private bool _isActive;
+ private byte[] _selectedImage;
+ private bool _isIndeterminate = true;
+
+ public bool IsActive
+ {
+ get => _isActive;
+ set => _isActive = value;
+ }
+
+ public AvatarProfileViewModel()
+ {
+ _images = new ObservableCollection<ProfileImageModel>();
+ }
+
+ public AvatarProfileViewModel(Action loadCompleteAction)
+ {
+ _images = new ObservableCollection<ProfileImageModel>();
+
+ if (_isPreloading)
+ {
+ _loadCompleteAction = loadCompleteAction;
+ }
+ else
+ {
+ ReloadImages();
+ }
+ }
+
+ public Color BackgroundColor
+ {
+ get => _backgroundColor;
+ set
+ {
+ _backgroundColor = value;
+
+ IsActive = false;
+
+ ReloadImages();
+ }
+ }
+
+ public ObservableCollection<ProfileImageModel> Images
+ {
+ get => _images;
+ set
+ {
+ _images = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsIndeterminate
+ {
+ get => _isIndeterminate;
+ set
+ {
+ _isIndeterminate = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int ImageCount => _avatarStore.Count;
+
+ public int ImagesLoaded
+ {
+ get => _imagesLoaded;
+ set
+ {
+ _imagesLoaded = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public int SelectedIndex
+ {
+ get => _selectedIndex;
+ set
+ {
+ _selectedIndex = value;
+
+ if (_selectedIndex == -1)
+ {
+ SelectedImage = null;
+ }
+ else
+ {
+ SelectedImage = _images[_selectedIndex].Data;
+ }
+
+ OnPropertyChanged();
+ }
+ }
+
+ public byte[] SelectedImage
+ {
+ get => _selectedImage;
+ private set => _selectedImage = value;
+ }
+
+ public void ReloadImages()
+ {
+ if (_isPreloading)
+ {
+ IsIndeterminate = false;
+ return;
+ }
+ Task.Run(() =>
+ {
+ IsActive = true;
+
+ Images.Clear();
+ int selectedIndex = _selectedIndex;
+ int index = 0;
+
+ ImagesLoaded = 0;
+ IsIndeterminate = false;
+
+ var keys = _avatarStore.Keys.ToList();
+
+ var newImages = new List<ProfileImageModel>();
+ var tasks = new List<Task>();
+
+ for (int i = 0; i < MaxImageTasks; i++)
+ {
+ var start = i;
+ tasks.Add(Task.Run(() => ImageTask(start)));
+ }
+
+ Task.WaitAll(tasks.ToArray());
+
+ Images.AddRange(newImages);
+
+ void ImageTask(int start)
+ {
+ for (int i = start; i < keys.Count; i += MaxImageTasks)
+ {
+ if (!IsActive)
+ {
+ return;
+ }
+
+ var key = keys[i];
+ var image = _avatarStore[keys[i]];
+
+ var data = ProcessImage(image);
+ newImages.Add(new ProfileImageModel(key, data));
+ if (index++ == selectedIndex)
+ {
+ SelectedImage = data;
+ }
+
+ Interlocked.Increment(ref _imagesLoaded);
+ OnPropertyChanged(nameof(ImagesLoaded));
+ }
+ }
+ });
+ }
+
+ private byte[] ProcessImage(byte[] data)
+ {
+ using (MemoryStream streamJpg = new())
+ {
+ Image avatarImage = Image.Load(data, new PngDecoder());
+
+ avatarImage.Mutate(x => x.BackgroundColor(new Rgba32(BackgroundColor.R,
+ BackgroundColor.G,
+ BackgroundColor.B,
+ BackgroundColor.A)));
+ avatarImage.SaveAsJpeg(streamJpg);
+
+ return streamJpg.ToArray();
+ }
+ }
+
+ public static void PreloadAvatars(ContentManager contentManager, VirtualFileSystem virtualFileSystem)
+ {
+ try
+ {
+ if (_avatarStore.Count > 0)
+ {
+ return;
+ }
+
+ _isPreloading = true;
+
+ string contentPath =
+ contentManager.GetInstalledContentPath(0x010000000000080A, StorageId.BuiltInSystem,
+ NcaContentType.Data);
+ string avatarPath = virtualFileSystem.SwitchPathToSystemPath(contentPath);
+
+ if (!string.IsNullOrWhiteSpace(avatarPath))
+ {
+ using (IStorage ncaFileStream = new LocalStorage(avatarPath, FileAccess.Read, FileMode.Open))
+ {
+ Nca nca = new(virtualFileSystem.KeySet, ncaFileStream);
+ IFileSystem romfs = nca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid);
+
+ foreach (DirectoryEntryEx item in romfs.EnumerateEntries())
+ {
+ // TODO: Parse DatabaseInfo.bin and table.bin files for more accuracy.
+ if (item.Type == DirectoryEntryType.File && item.FullPath.Contains("chara") &&
+ item.FullPath.Contains("szs"))
+ {
+ using var file = new UniqueRef<IFile>();
+
+ romfs.OpenFile(ref file.Ref, ("/" + item.FullPath).ToU8Span(), OpenMode.Read)
+ .ThrowIfFailure();
+
+ using (MemoryStream stream = new())
+ using (MemoryStream streamPng = new())
+ {
+ file.Get.AsStream().CopyTo(stream);
+
+ stream.Position = 0;
+
+ Image avatarImage = Image.LoadPixelData<Rgba32>(DecompressYaz0(stream), 256, 256);
+
+ avatarImage.SaveAsPng(streamPng);
+
+ _avatarStore.Add(item.FullPath, streamPng.ToArray());
+ }
+ }
+ }
+ }
+ }
+ }
+ finally
+ {
+ _isPreloading = false;
+ _loadCompleteAction?.Invoke();
+ }
+ }
+
+ private static byte[] DecompressYaz0(Stream stream)
+ {
+ using (BinaryReader reader = new(stream))
+ {
+ reader.ReadInt32(); // Magic
+
+ uint decodedLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt32());
+
+ reader.ReadInt64(); // Padding
+
+ byte[] input = new byte[stream.Length - stream.Position];
+ stream.Read(input, 0, input.Length);
+
+ uint inputOffset = 0;
+
+ byte[] output = new byte[decodedLength];
+ uint outputOffset = 0;
+
+ ushort mask = 0;
+ byte header = 0;
+
+ while (outputOffset < decodedLength)
+ {
+ if ((mask >>= 1) == 0)
+ {
+ header = input[inputOffset++];
+ mask = 0x80;
+ }
+
+ if ((header & mask) != 0)
+ {
+ if (outputOffset == output.Length)
+ {
+ break;
+ }
+
+ output[outputOffset++] = input[inputOffset++];
+ }
+ else
+ {
+ byte byte1 = input[inputOffset++];
+ byte byte2 = input[inputOffset++];
+
+ uint dist = (uint)((byte1 & 0xF) << 8) | byte2;
+ uint position = outputOffset - (dist + 1);
+
+ uint length = (uint)byte1 >> 4;
+ if (length == 0)
+ {
+ length = (uint)input[inputOffset++] + 0x12;
+ }
+ else
+ {
+ length += 2;
+ }
+
+ uint gap = outputOffset - position;
+ uint nonOverlappingLength = length;
+
+ if (nonOverlappingLength > gap)
+ {
+ nonOverlappingLength = gap;
+ }
+
+ Buffer.BlockCopy(output, (int)position, output, (int)outputOffset, (int)nonOverlappingLength);
+ outputOffset += nonOverlappingLength;
+ position += nonOverlappingLength;
+ length -= nonOverlappingLength;
+
+ while (length-- > 0)
+ {
+ output[outputOffset++] = output[position++];
+ }
+ }
+ }
+
+ return output;
+ }
+ }
+
+ public void Dispose()
+ {
+ _loadCompleteAction = null;
+ IsActive = false;
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs b/src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs
new file mode 100644
index 00000000..5a3717fd
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/BaseModel.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class BaseModel : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler PropertyChanged;
+
+ protected void OnPropertyChanged([CallerMemberName] string propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs
new file mode 100644
index 00000000..dd261b10
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs
@@ -0,0 +1,899 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Svg.Skia;
+using Avalonia.Threading;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.Configuration.Hid.Controller;
+using Ryujinx.Common.Configuration.Hid.Controller.Motion;
+using Ryujinx.Common.Configuration.Hid.Keyboard;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.Input;
+using Ryujinx.Ui.Common.Configuration;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using ConfigGamepadInputId = Ryujinx.Common.Configuration.Hid.Controller.GamepadInputId;
+using ConfigStickInputId = Ryujinx.Common.Configuration.Hid.Controller.StickInputId;
+using Key = Ryujinx.Common.Configuration.Hid.Key;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class ControllerSettingsViewModel : BaseModel, IDisposable
+ {
+ private const string Disabled = "disabled";
+ private const string ProControllerResource = "Ryujinx.Ui.Common/Resources/Controller_ProCon.svg";
+ private const string JoyConPairResource = "Ryujinx.Ui.Common/Resources/Controller_JoyConPair.svg";
+ private const string JoyConLeftResource = "Ryujinx.Ui.Common/Resources/Controller_JoyConLeft.svg";
+ private const string JoyConRightResource = "Ryujinx.Ui.Common/Resources/Controller_JoyConRight.svg";
+ private const string KeyboardString = "keyboard";
+ private const string ControllerString = "controller";
+ private readonly MainWindow _mainWindow;
+
+ private PlayerIndex _playerId;
+ private int _controller;
+ private int _controllerNumber = 0;
+ private string _controllerImage;
+ private int _device;
+ private object _configuration;
+ private string _profileName;
+ private bool _isLoaded;
+ private readonly UserControl _owner;
+
+ private static readonly InputConfigJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public IGamepadDriver AvaloniaKeyboardDriver { get; }
+ public IGamepad SelectedGamepad { get; private set; }
+
+ public ObservableCollection<PlayerModel> PlayerIndexes { get; set; }
+ public ObservableCollection<(DeviceType Type, string Id, string Name)> Devices { get; set; }
+ internal ObservableCollection<ControllerModel> Controllers { get; set; }
+ public AvaloniaList<string> ProfilesList { get; set; }
+ public AvaloniaList<string> DeviceList { get; set; }
+
+ // XAML Flags
+ public bool ShowSettings => _device > 0;
+ public bool IsController => _device > 1;
+ public bool IsKeyboard => !IsController;
+ public bool IsRight { get; set; }
+ public bool IsLeft { get; set; }
+
+ public bool IsModified { get; set; }
+
+ public object Configuration
+ {
+ get => _configuration;
+ set
+ {
+ _configuration = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public PlayerIndex PlayerId
+ {
+ get => _playerId;
+ set
+ {
+ if (IsModified)
+ {
+ return;
+ }
+
+ IsModified = false;
+ _playerId = value;
+
+ if (!Enum.IsDefined(typeof(PlayerIndex), _playerId))
+ {
+ _playerId = PlayerIndex.Player1;
+ }
+
+ LoadConfiguration();
+ LoadDevice();
+ LoadProfiles();
+
+ _isLoaded = true;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int Controller
+ {
+ get => _controller;
+ set
+ {
+ _controller = value;
+
+ if (_controller == -1)
+ {
+ _controller = 0;
+ }
+
+ if (Controllers.Count > 0 && value < Controllers.Count && _controller > -1)
+ {
+ ControllerType controller = Controllers[_controller].Type;
+
+ IsLeft = true;
+ IsRight = true;
+
+ switch (controller)
+ {
+ case ControllerType.Handheld:
+ ControllerImage = JoyConPairResource;
+ break;
+ case ControllerType.ProController:
+ ControllerImage = ProControllerResource;
+ break;
+ case ControllerType.JoyconPair:
+ ControllerImage = JoyConPairResource;
+ break;
+ case ControllerType.JoyconLeft:
+ ControllerImage = JoyConLeftResource;
+ IsRight = false;
+ break;
+ case ControllerType.JoyconRight:
+ ControllerImage = JoyConRightResource;
+ IsLeft = false;
+ break;
+ }
+
+ LoadInputDriver();
+ LoadProfiles();
+ }
+
+ OnPropertyChanged();
+ NotifyChanges();
+ }
+ }
+
+ public string ControllerImage
+ {
+ get => _controllerImage;
+ set
+ {
+ _controllerImage = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(Image));
+ }
+ }
+
+ public SvgImage Image
+ {
+ get
+ {
+ SvgImage image = new SvgImage();
+
+ if (!string.IsNullOrWhiteSpace(_controllerImage))
+ {
+ SvgSource source = new SvgSource();
+
+ source.Load(EmbeddedResources.GetStream(_controllerImage));
+
+ image.Source = source;
+ }
+
+ return image;
+ }
+ }
+
+ public string ProfileName
+ {
+ get => _profileName; set
+ {
+ _profileName = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int Device
+ {
+ get => _device;
+ set
+ {
+ _device = value < 0 ? 0 : value;
+
+ if (_device >= Devices.Count)
+ {
+ return;
+ }
+
+ var selected = Devices[_device].Type;
+
+ if (selected != DeviceType.None)
+ {
+ LoadControllers();
+
+ if (_isLoaded)
+ {
+ LoadConfiguration(LoadDefaultConfiguration());
+ }
+ }
+
+ OnPropertyChanged();
+ NotifyChanges();
+ }
+ }
+
+ public InputConfig Config { get; set; }
+
+ public ControllerSettingsViewModel(UserControl owner) : this()
+ {
+ _owner = owner;
+
+ if (Program.PreviewerDetached)
+ {
+ _mainWindow =
+ (MainWindow)((IClassicDesktopStyleApplicationLifetime)Avalonia.Application.Current
+ .ApplicationLifetime).MainWindow;
+
+ AvaloniaKeyboardDriver = new AvaloniaKeyboardDriver(owner);
+
+ _mainWindow.InputManager.GamepadDriver.OnGamepadConnected += HandleOnGamepadConnected;
+ _mainWindow.InputManager.GamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected;
+ if (_mainWindow.ViewModel.AppHost != null)
+ {
+ _mainWindow.ViewModel.AppHost.NpadManager.BlockInputUpdates();
+ }
+
+ _isLoaded = false;
+
+ LoadDevices();
+
+ PlayerId = PlayerIndex.Player1;
+ }
+ }
+
+ public ControllerSettingsViewModel()
+ {
+ PlayerIndexes = new ObservableCollection<PlayerModel>();
+ Controllers = new ObservableCollection<ControllerModel>();
+ Devices = new ObservableCollection<(DeviceType Type, string Id, string Name)>();
+ ProfilesList = new AvaloniaList<string>();
+ DeviceList = new AvaloniaList<string>();
+
+ ControllerImage = ProControllerResource;
+
+ PlayerIndexes.Add(new(PlayerIndex.Player1, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer1]));
+ PlayerIndexes.Add(new(PlayerIndex.Player2, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer2]));
+ PlayerIndexes.Add(new(PlayerIndex.Player3, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer3]));
+ PlayerIndexes.Add(new(PlayerIndex.Player4, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer4]));
+ PlayerIndexes.Add(new(PlayerIndex.Player5, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer5]));
+ PlayerIndexes.Add(new(PlayerIndex.Player6, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer6]));
+ PlayerIndexes.Add(new(PlayerIndex.Player7, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer7]));
+ PlayerIndexes.Add(new(PlayerIndex.Player8, LocaleManager.Instance[LocaleKeys.ControllerSettingsPlayer8]));
+ PlayerIndexes.Add(new(PlayerIndex.Handheld, LocaleManager.Instance[LocaleKeys.ControllerSettingsHandheld]));
+ }
+
+ private void LoadConfiguration(InputConfig inputConfig = null)
+ {
+ Config = inputConfig ?? ConfigurationState.Instance.Hid.InputConfig.Value.Find(inputConfig => inputConfig.PlayerIndex == _playerId);
+
+ if (Config is StandardKeyboardInputConfig keyboardInputConfig)
+ {
+ Configuration = new InputConfiguration<Key, ConfigStickInputId>(keyboardInputConfig);
+ }
+
+ if (Config is StandardControllerInputConfig controllerInputConfig)
+ {
+ Configuration = new InputConfiguration<ConfigGamepadInputId, ConfigStickInputId>(controllerInputConfig);
+ }
+ }
+
+ public void LoadDevice()
+ {
+ if (Config == null || Config.Backend == InputBackendType.Invalid)
+ {
+ Device = 0;
+ }
+ else
+ {
+ var type = DeviceType.None;
+
+ if (Config is StandardKeyboardInputConfig)
+ {
+ type = DeviceType.Keyboard;
+ }
+
+ if (Config is StandardControllerInputConfig)
+ {
+ type = DeviceType.Controller;
+ }
+
+ var item = Devices.FirstOrDefault(x => x.Type == type && x.Id == Config.Id);
+ if (item != default)
+ {
+ Device = Devices.ToList().FindIndex(x => x.Id == item.Id);
+ }
+ else
+ {
+ Device = 0;
+ }
+ }
+ }
+
+ public async void ShowMotionConfig()
+ {
+ await MotionSettingsWindow.Show(this);
+ }
+
+ public async void ShowRumbleConfig()
+ {
+ await RumbleSettingsWindow.Show(this);
+ }
+
+ private void LoadInputDriver()
+ {
+ if (_device < 0)
+ {
+ return;
+ }
+
+ string id = GetCurrentGamepadId();
+ var type = Devices[Device].Type;
+
+ if (type == DeviceType.None)
+ {
+ return;
+ }
+ else if (type == DeviceType.Keyboard)
+ {
+ if (_mainWindow.InputManager.KeyboardDriver is AvaloniaKeyboardDriver)
+ {
+ // NOTE: To get input in this window, we need to bind a custom keyboard driver instead of using the InputManager one as the main window isn't focused...
+ SelectedGamepad = AvaloniaKeyboardDriver.GetGamepad(id);
+ }
+ else
+ {
+ SelectedGamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id);
+ }
+ }
+ else
+ {
+ SelectedGamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id);
+ }
+ }
+
+ private void HandleOnGamepadDisconnected(string id)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ LoadDevices();
+ });
+ }
+
+ private void HandleOnGamepadConnected(string id)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ LoadDevices();
+ });
+ }
+
+ private string GetCurrentGamepadId()
+ {
+ if (_device < 0)
+ {
+ return string.Empty;
+ }
+
+ var device = Devices[Device];
+
+ if (device.Type == DeviceType.None)
+ {
+ return null;
+ }
+
+ return device.Id.Split(" ")[0];
+ }
+
+ public void LoadControllers()
+ {
+ Controllers.Clear();
+
+ if (_playerId == PlayerIndex.Handheld)
+ {
+ Controllers.Add(new(ControllerType.Handheld, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeHandheld]));
+
+ Controller = 0;
+ }
+ else
+ {
+ Controllers.Add(new(ControllerType.ProController, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeProController]));
+ Controllers.Add(new(ControllerType.JoyconPair, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeJoyConPair]));
+ Controllers.Add(new(ControllerType.JoyconLeft, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeJoyConLeft]));
+ Controllers.Add(new(ControllerType.JoyconRight, LocaleManager.Instance[LocaleKeys.ControllerSettingsControllerTypeJoyConRight]));
+
+ if (Config != null && Controllers.ToList().FindIndex(x => x.Type == Config.ControllerType) != -1)
+ {
+ Controller = Controllers.ToList().FindIndex(x => x.Type == Config.ControllerType);
+ }
+ else
+ {
+ Controller = 0;
+ }
+ }
+ }
+
+ private static string GetShortGamepadName(string str)
+ {
+ const string Ellipsis = "...";
+ const int MaxSize = 50;
+
+ if (str.Length > MaxSize)
+ {
+ return $"{str.AsSpan(0, MaxSize - Ellipsis.Length)}{Ellipsis}";
+ }
+
+ return str;
+ }
+
+ private static string GetShortGamepadId(string str)
+ {
+ const string Hyphen = "-";
+ const int Offset = 1;
+
+ return str.Substring(str.IndexOf(Hyphen) + Offset);
+ }
+
+ public void LoadDevices()
+ {
+ lock (Devices)
+ {
+ Devices.Clear();
+ DeviceList.Clear();
+ Devices.Add((DeviceType.None, Disabled, LocaleManager.Instance[LocaleKeys.ControllerSettingsDeviceDisabled]));
+
+ foreach (string id in _mainWindow.InputManager.KeyboardDriver.GamepadsIds)
+ {
+ using IGamepad gamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id);
+
+ if (gamepad != null)
+ {
+ Devices.Add((DeviceType.Keyboard, id, $"{GetShortGamepadName(gamepad.Name)}"));
+ }
+ }
+
+ foreach (string id in _mainWindow.InputManager.GamepadDriver.GamepadsIds)
+ {
+ using IGamepad gamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id);
+
+ if (gamepad != null)
+ {
+ if (Devices.Any(controller => GetShortGamepadId(controller.Id) == GetShortGamepadId(gamepad.Id)))
+ {
+ _controllerNumber++;
+ }
+
+ Devices.Add((DeviceType.Controller, id, $"{GetShortGamepadName(gamepad.Name)} ({_controllerNumber})"));
+ }
+ }
+
+ _controllerNumber = 0;
+
+ DeviceList.AddRange(Devices.Select(x => x.Name));
+ Device = Math.Min(Device, DeviceList.Count);
+ }
+ }
+
+ private string GetProfileBasePath()
+ {
+ string path = AppDataManager.ProfilesDirPath;
+ var type = Devices[Device == -1 ? 0 : Device].Type;
+
+ if (type == DeviceType.Keyboard)
+ {
+ path = Path.Combine(path, KeyboardString);
+ }
+ else if (type == DeviceType.Controller)
+ {
+ path = Path.Combine(path, ControllerString);
+ }
+
+ return path;
+ }
+
+ private void LoadProfiles()
+ {
+ ProfilesList.Clear();
+
+ string basePath = GetProfileBasePath();
+
+ if (!Directory.Exists(basePath))
+ {
+ Directory.CreateDirectory(basePath);
+ }
+
+ ProfilesList.Add((LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault]));
+
+ foreach (string profile in Directory.GetFiles(basePath, "*.json", SearchOption.AllDirectories))
+ {
+ ProfilesList.Add(Path.GetFileNameWithoutExtension(profile));
+ }
+
+ if (string.IsNullOrWhiteSpace(ProfileName))
+ {
+ ProfileName = LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault];
+ }
+ }
+
+ public InputConfig LoadDefaultConfiguration()
+ {
+ var activeDevice = Devices.FirstOrDefault();
+
+ if (Devices.Count > 0 && Device < Devices.Count && Device >= 0)
+ {
+ activeDevice = Devices[Device];
+ }
+
+ InputConfig config;
+ if (activeDevice.Type == DeviceType.Keyboard)
+ {
+ string id = activeDevice.Id;
+
+ config = new StandardKeyboardInputConfig
+ {
+ Version = InputConfig.CurrentVersion,
+ Backend = InputBackendType.WindowKeyboard,
+ Id = id,
+ ControllerType = ControllerType.ProController,
+ LeftJoycon = new LeftJoyconCommonConfig<Key>
+ {
+ DpadUp = Key.Up,
+ DpadDown = Key.Down,
+ DpadLeft = Key.Left,
+ DpadRight = Key.Right,
+ ButtonMinus = Key.Minus,
+ ButtonL = Key.E,
+ ButtonZl = Key.Q,
+ ButtonSl = Key.Unbound,
+ ButtonSr = Key.Unbound
+ },
+ LeftJoyconStick =
+ new JoyconConfigKeyboardStick<Key>
+ {
+ StickUp = Key.W,
+ StickDown = Key.S,
+ StickLeft = Key.A,
+ StickRight = Key.D,
+ StickButton = Key.F
+ },
+ RightJoycon = new RightJoyconCommonConfig<Key>
+ {
+ ButtonA = Key.Z,
+ ButtonB = Key.X,
+ ButtonX = Key.C,
+ ButtonY = Key.V,
+ ButtonPlus = Key.Plus,
+ ButtonR = Key.U,
+ ButtonZr = Key.O,
+ ButtonSl = Key.Unbound,
+ ButtonSr = Key.Unbound
+ },
+ RightJoyconStick = new JoyconConfigKeyboardStick<Key>
+ {
+ StickUp = Key.I,
+ StickDown = Key.K,
+ StickLeft = Key.J,
+ StickRight = Key.L,
+ StickButton = Key.H
+ }
+ };
+ }
+ else if (activeDevice.Type == DeviceType.Controller)
+ {
+ bool isNintendoStyle = Devices.ToList().Find(x => x.Id == activeDevice.Id).Name.Contains("Nintendo");
+
+ string id = activeDevice.Id.Split(" ")[0];
+
+ config = new StandardControllerInputConfig
+ {
+ Version = InputConfig.CurrentVersion,
+ Backend = InputBackendType.GamepadSDL2,
+ Id = id,
+ ControllerType = ControllerType.ProController,
+ DeadzoneLeft = 0.1f,
+ DeadzoneRight = 0.1f,
+ RangeLeft = 1.0f,
+ RangeRight = 1.0f,
+ TriggerThreshold = 0.5f,
+ LeftJoycon = new LeftJoyconCommonConfig<ConfigGamepadInputId>
+ {
+ DpadUp = ConfigGamepadInputId.DpadUp,
+ DpadDown = ConfigGamepadInputId.DpadDown,
+ DpadLeft = ConfigGamepadInputId.DpadLeft,
+ DpadRight = ConfigGamepadInputId.DpadRight,
+ ButtonMinus = ConfigGamepadInputId.Minus,
+ ButtonL = ConfigGamepadInputId.LeftShoulder,
+ ButtonZl = ConfigGamepadInputId.LeftTrigger,
+ ButtonSl = ConfigGamepadInputId.Unbound,
+ ButtonSr = ConfigGamepadInputId.Unbound
+ },
+ LeftJoyconStick = new JoyconConfigControllerStick<ConfigGamepadInputId, ConfigStickInputId>
+ {
+ Joystick = ConfigStickInputId.Left,
+ StickButton = ConfigGamepadInputId.LeftStick,
+ InvertStickX = false,
+ InvertStickY = false
+ },
+ RightJoycon = new RightJoyconCommonConfig<ConfigGamepadInputId>
+ {
+ ButtonA = isNintendoStyle ? ConfigGamepadInputId.A : ConfigGamepadInputId.B,
+ ButtonB = isNintendoStyle ? ConfigGamepadInputId.B : ConfigGamepadInputId.A,
+ ButtonX = isNintendoStyle ? ConfigGamepadInputId.X : ConfigGamepadInputId.Y,
+ ButtonY = isNintendoStyle ? ConfigGamepadInputId.Y : ConfigGamepadInputId.X,
+ ButtonPlus = ConfigGamepadInputId.Plus,
+ ButtonR = ConfigGamepadInputId.RightShoulder,
+ ButtonZr = ConfigGamepadInputId.RightTrigger,
+ ButtonSl = ConfigGamepadInputId.Unbound,
+ ButtonSr = ConfigGamepadInputId.Unbound
+ },
+ RightJoyconStick = new JoyconConfigControllerStick<ConfigGamepadInputId, ConfigStickInputId>
+ {
+ Joystick = ConfigStickInputId.Right,
+ StickButton = ConfigGamepadInputId.RightStick,
+ InvertStickX = false,
+ InvertStickY = false
+ },
+ Motion = new StandardMotionConfigController
+ {
+ MotionBackend = MotionInputBackendType.GamepadDriver,
+ EnableMotion = true,
+ Sensitivity = 100,
+ GyroDeadzone = 1
+ },
+ Rumble = new RumbleConfigController
+ {
+ StrongRumble = 1f,
+ WeakRumble = 1f,
+ EnableRumble = false
+ }
+ };
+ }
+ else
+ {
+ config = new InputConfig();
+ }
+
+ config.PlayerIndex = _playerId;
+
+ return config;
+ }
+
+ public async void LoadProfile()
+ {
+ if (Device == 0)
+ {
+ return;
+ }
+
+ InputConfig config = null;
+
+ if (string.IsNullOrWhiteSpace(ProfileName))
+ {
+ return;
+ }
+
+ if (ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault])
+ {
+ config = LoadDefaultConfiguration();
+ }
+ else
+ {
+ string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json");
+
+ if (!File.Exists(path))
+ {
+ var index = ProfilesList.IndexOf(ProfileName);
+ if (index != -1)
+ {
+ ProfilesList.RemoveAt(index);
+ }
+ return;
+ }
+
+ try
+ {
+ config = JsonHelper.DeserializeFromFile(path, SerializerContext.InputConfig);
+ }
+ catch (JsonException) { }
+ catch (InvalidOperationException)
+ {
+ Logger.Error?.Print(LogClass.Configuration, $"Profile {ProfileName} is incompatible with the current input configuration system.");
+
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogProfileInvalidProfileErrorMessage, ProfileName));
+
+ return;
+ }
+ }
+
+ if (config != null)
+ {
+ _isLoaded = false;
+
+ LoadConfiguration(config);
+
+ LoadDevice();
+
+ _isLoaded = true;
+
+ NotifyChanges();
+ }
+ }
+
+ public async void SaveProfile()
+ {
+ if (Device == 0)
+ {
+ return;
+ }
+
+ if (Configuration == null)
+ {
+ return;
+ }
+
+ if (ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault])
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogProfileDefaultProfileOverwriteErrorMessage]);
+
+ return;
+ }
+ else
+ {
+ bool validFileName = ProfileName.IndexOfAny(Path.GetInvalidFileNameChars()) == -1;
+
+ if (validFileName)
+ {
+ string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json");
+
+ InputConfig config = null;
+
+ if (IsKeyboard)
+ {
+ config = (Configuration as InputConfiguration<Key, ConfigStickInputId>).GetConfig();
+ }
+ else if (IsController)
+ {
+ config = (Configuration as InputConfiguration<GamepadInputId, ConfigStickInputId>).GetConfig();
+ }
+
+ config.ControllerType = Controllers[_controller].Type;
+
+ string jsonString = JsonHelper.Serialize(config, SerializerContext.InputConfig);
+
+ await File.WriteAllTextAsync(path, jsonString);
+
+ LoadProfiles();
+ }
+ else
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogProfileInvalidProfileNameErrorMessage]);
+ }
+ }
+ }
+
+ public async void RemoveProfile()
+ {
+ if (Device == 0 || ProfileName == LocaleManager.Instance[LocaleKeys.ControllerSettingsProfileDefault] || ProfilesList.IndexOf(ProfileName) == -1)
+ {
+ return;
+ }
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ LocaleManager.Instance[LocaleKeys.DialogProfileDeleteProfileTitle],
+ LocaleManager.Instance[LocaleKeys.DialogProfileDeleteProfileMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ string path = Path.Combine(GetProfileBasePath(), ProfileName + ".json");
+
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+
+ LoadProfiles();
+ }
+ }
+
+ public void Save()
+ {
+ IsModified = false;
+
+ List<InputConfig> newConfig = new();
+
+ newConfig.AddRange(ConfigurationState.Instance.Hid.InputConfig.Value);
+
+ newConfig.Remove(newConfig.Find(x => x == null));
+
+ if (Device == 0)
+ {
+ newConfig.Remove(newConfig.Find(x => x.PlayerIndex == this.PlayerId));
+ }
+ else
+ {
+ var device = Devices[Device];
+
+ if (device.Type == DeviceType.Keyboard)
+ {
+ var inputConfig = Configuration as InputConfiguration<Key, ConfigStickInputId>;
+ inputConfig.Id = device.Id;
+ }
+ else
+ {
+ var inputConfig = Configuration as InputConfiguration<GamepadInputId, ConfigStickInputId>;
+ inputConfig.Id = device.Id.Split(" ")[0];
+ }
+
+ var config = !IsController
+ ? (Configuration as InputConfiguration<Key, ConfigStickInputId>).GetConfig()
+ : (Configuration as InputConfiguration<GamepadInputId, ConfigStickInputId>).GetConfig();
+ config.ControllerType = Controllers[_controller].Type;
+ config.PlayerIndex = _playerId;
+
+ int i = newConfig.FindIndex(x => x.PlayerIndex == PlayerId);
+ if (i == -1)
+ {
+ newConfig.Add(config);
+ }
+ else
+ {
+ newConfig[i] = config;
+ }
+ }
+
+ _mainWindow.ViewModel.AppHost?.NpadManager.ReloadConfiguration(newConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
+
+ // Atomically replace and signal input change.
+ // NOTE: Do not modify InputConfig.Value directly as other code depends on the on-change event.
+ ConfigurationState.Instance.Hid.InputConfig.Value = newConfig;
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+
+ public void NotifyChange(string property)
+ {
+ OnPropertyChanged(property);
+ }
+
+ public void NotifyChanges()
+ {
+ OnPropertyChanged(nameof(Configuration));
+ OnPropertyChanged(nameof(IsController));
+ OnPropertyChanged(nameof(ShowSettings));
+ OnPropertyChanged(nameof(IsKeyboard));
+ OnPropertyChanged(nameof(IsRight));
+ OnPropertyChanged(nameof(IsLeft));
+ }
+
+ public void Dispose()
+ {
+ _mainWindow.InputManager.GamepadDriver.OnGamepadConnected -= HandleOnGamepadConnected;
+ _mainWindow.InputManager.GamepadDriver.OnGamepadDisconnected -= HandleOnGamepadDisconnected;
+
+ _mainWindow.ViewModel.AppHost?.NpadManager.UnblockInputUpdates();
+
+ SelectedGamepad?.Dispose();
+
+ AvaloniaKeyboardDriver.Dispose();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs
new file mode 100644
index 00000000..1d7da9a4
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/DownloadableContentManagerViewModel.cs
@@ -0,0 +1,338 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Threading;
+using DynamicData;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.HLE.FileSystem;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Path = System.IO.Path;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class DownloadableContentManagerViewModel : BaseModel
+ {
+ private readonly List<DownloadableContentContainer> _downloadableContentContainerList;
+ private readonly string _downloadableContentJsonPath;
+
+ private VirtualFileSystem _virtualFileSystem;
+ private AvaloniaList<DownloadableContentModel> _downloadableContents = new();
+ private AvaloniaList<DownloadableContentModel> _views = new();
+ private AvaloniaList<DownloadableContentModel> _selectedDownloadableContents = new();
+
+ private string _search;
+ private ulong _titleId;
+ private string _titleName;
+
+ private static readonly DownloadableContentJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public AvaloniaList<DownloadableContentModel> DownloadableContents
+ {
+ get => _downloadableContents;
+ set
+ {
+ _downloadableContents = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+ }
+ }
+
+ public AvaloniaList<DownloadableContentModel> Views
+ {
+ get => _views;
+ set
+ {
+ _views = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public AvaloniaList<DownloadableContentModel> SelectedDownloadableContents
+ {
+ get => _selectedDownloadableContents;
+ set
+ {
+ _selectedDownloadableContents = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public string Search
+ {
+ get => _search;
+ set
+ {
+ _search = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public string UpdateCount
+ {
+ get => string.Format(LocaleManager.Instance[LocaleKeys.DlcWindowHeading], DownloadableContents.Count);
+ }
+
+ public DownloadableContentManagerViewModel(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ _virtualFileSystem = virtualFileSystem;
+
+ _titleId = titleId;
+ _titleName = titleName;
+
+ _downloadableContentJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "dlc.json");
+
+ try
+ {
+ _downloadableContentContainerList = JsonHelper.DeserializeFromFile(_downloadableContentJsonPath, SerializerContext.ListDownloadableContentContainer);
+ }
+ catch
+ {
+ Logger.Error?.Print(LogClass.Configuration, "Downloadable Content JSON failed to deserialize.");
+ _downloadableContentContainerList = new List<DownloadableContentContainer>();
+ }
+
+ LoadDownloadableContents();
+ }
+
+ private void LoadDownloadableContents()
+ {
+ foreach (DownloadableContentContainer downloadableContentContainer in _downloadableContentContainerList)
+ {
+ if (File.Exists(downloadableContentContainer.ContainerPath))
+ {
+ using FileStream containerFile = File.OpenRead(downloadableContentContainer.ContainerPath);
+
+ PartitionFileSystem partitionFileSystem = new(containerFile.AsStorage());
+
+ _virtualFileSystem.ImportTickets(partitionFileSystem);
+
+ foreach (DownloadableContentNca downloadableContentNca in downloadableContentContainer.DownloadableContentNcaList)
+ {
+ using UniqueRef<IFile> ncaFile = new();
+
+ partitionFileSystem.OpenFile(ref ncaFile.Ref, downloadableContentNca.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ Nca nca = TryOpenNca(ncaFile.Get.AsStorage(), downloadableContentContainer.ContainerPath);
+ if (nca != null)
+ {
+ var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"),
+ downloadableContentContainer.ContainerPath,
+ downloadableContentNca.FullPath,
+ downloadableContentNca.Enabled);
+
+ DownloadableContents.Add(content);
+
+ if (content.Enabled)
+ {
+ SelectedDownloadableContents.Add(content);
+ }
+
+ OnPropertyChanged(nameof(UpdateCount));
+ }
+ }
+ }
+ }
+
+ // NOTE: Save the list again to remove leftovers.
+ Save();
+ Sort();
+ }
+
+ public void Sort()
+ {
+ DownloadableContents.AsObservableChangeSet()
+ .Filter(Filter)
+ .Bind(out var view).AsObservableList();
+
+ _views.Clear();
+ _views.AddRange(view);
+ OnPropertyChanged(nameof(Views));
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is DownloadableContentModel content)
+ {
+ return string.IsNullOrWhiteSpace(_search) || content.FileName.ToLower().Contains(_search.ToLower()) || content.TitleId.ToLower().Contains(_search.ToLower());
+ }
+
+ return false;
+ }
+
+ private Nca TryOpenNca(IStorage ncaStorage, string containerPath)
+ {
+ try
+ {
+ return new Nca(_virtualFileSystem.KeySet, ncaStorage);
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance[LocaleKeys.DialogLoadNcaErrorMessage], ex.Message, containerPath));
+ });
+ }
+
+ return null;
+ }
+
+ public async void Add()
+ {
+ OpenFileDialog dialog = new OpenFileDialog()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.SelectDlcDialogTitle],
+ AllowMultiple = true
+ };
+
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = "NSP",
+ Extensions = { "nsp" }
+ });
+
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ string[] files = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (files != null)
+ {
+ foreach (string file in files)
+ {
+ await AddDownloadableContent(file);
+ }
+ }
+ }
+ }
+
+ private async Task AddDownloadableContent(string path)
+ {
+ if (!File.Exists(path) || DownloadableContents.FirstOrDefault(x => x.ContainerPath == path) != null)
+ {
+ return;
+ }
+
+ using FileStream containerFile = File.OpenRead(path);
+
+ PartitionFileSystem partitionFileSystem = new(containerFile.AsStorage());
+ bool containsDownloadableContent = false;
+
+ _virtualFileSystem.ImportTickets(partitionFileSystem);
+
+ foreach (DirectoryEntryEx fileEntry in partitionFileSystem.EnumerateEntries("/", "*.nca"))
+ {
+ using var ncaFile = new UniqueRef<IFile>();
+
+ partitionFileSystem.OpenFile(ref ncaFile.Ref, fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ Nca nca = TryOpenNca(ncaFile.Get.AsStorage(), path);
+ if (nca == null)
+ {
+ continue;
+ }
+
+ if (nca.Header.ContentType == NcaContentType.PublicData)
+ {
+ if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000) != _titleId)
+ {
+ break;
+ }
+
+ var content = new DownloadableContentModel(nca.Header.TitleId.ToString("X16"), path, fileEntry.FullPath, true);
+ DownloadableContents.Add(content);
+ SelectedDownloadableContents.Add(content);
+
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+
+ containsDownloadableContent = true;
+ }
+ }
+
+ if (!containsDownloadableContent)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogDlcNoDlcErrorMessage]);
+ }
+ }
+
+ public void Remove(DownloadableContentModel model)
+ {
+ DownloadableContents.Remove(model);
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+ }
+
+ public void RemoveAll()
+ {
+ DownloadableContents.Clear();
+ OnPropertyChanged(nameof(UpdateCount));
+ Sort();
+ }
+
+ public void EnableAll()
+ {
+ SelectedDownloadableContents = new(DownloadableContents);
+ }
+
+ public void DisableAll()
+ {
+ SelectedDownloadableContents.Clear();
+ }
+
+ public void Save()
+ {
+ _downloadableContentContainerList.Clear();
+
+ DownloadableContentContainer container = default;
+
+ foreach (DownloadableContentModel downloadableContent in DownloadableContents)
+ {
+ if (container.ContainerPath != downloadableContent.ContainerPath)
+ {
+ if (!string.IsNullOrWhiteSpace(container.ContainerPath))
+ {
+ _downloadableContentContainerList.Add(container);
+ }
+
+ container = new DownloadableContentContainer
+ {
+ ContainerPath = downloadableContent.ContainerPath,
+ DownloadableContentNcaList = new List<DownloadableContentNca>()
+ };
+ }
+
+ container.DownloadableContentNcaList.Add(new DownloadableContentNca
+ {
+ Enabled = downloadableContent.Enabled,
+ TitleId = Convert.ToUInt64(downloadableContent.TitleId, 16),
+ FullPath = downloadableContent.FullPath
+ });
+ }
+
+ if (!string.IsNullOrWhiteSpace(container.ContainerPath))
+ {
+ _downloadableContentContainerList.Add(container);
+ }
+
+ JsonHelper.SerializeToFile(_downloadableContentJsonPath, _downloadableContentContainerList, SerializerContext.ListDownloadableContentContainer);
+ }
+
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 00000000..14d7a0fe
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,1907 @@
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Threading;
+using DynamicData;
+using DynamicData.Binding;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.Input;
+using Ryujinx.Ava.UI.Controls;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Ava.UI.Renderer;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Cpu;
+using Ryujinx.HLE;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using Ryujinx.HLE.Ui;
+using Ryujinx.Ui.App.Common;
+using Ryujinx.Ui.Common;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Helper;
+using SixLabors.ImageSharp.PixelFormats;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using Path = System.IO.Path;
+using ShaderCacheLoadingState = Ryujinx.Graphics.Gpu.Shader.ShaderCacheState;
+using UserId = LibHac.Fs.UserId;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class MainWindowViewModel : BaseModel
+ {
+ private const int HotKeyPressDelayMs = 500;
+
+ private ObservableCollection<ApplicationData> _applications;
+ private string _aspectStatusText;
+
+ private string _loadHeading;
+ private string _cacheLoadStatus;
+ private string _searchText;
+ private Timer _searchTimer;
+ private string _dockedStatusText;
+ private string _fifoStatusText;
+ private string _gameStatusText;
+ private string _volumeStatusText;
+ private string _gpuStatusText;
+ private bool _isAmiiboRequested;
+ private bool _isGameRunning;
+ private bool _isFullScreen;
+ private int _progressMaximum;
+ private int _progressValue;
+ private long _lastFullscreenToggle = Environment.TickCount64;
+ private bool _showLoadProgress;
+ private bool _showMenuAndStatusBar = true;
+ private bool _showStatusSeparator;
+ private Brush _progressBarForegroundColor;
+ private Brush _progressBarBackgroundColor;
+ private Brush _vsyncColor;
+ private byte[] _selectedIcon;
+ private bool _isAppletMenuActive;
+ private int _statusBarProgressMaximum;
+ private int _statusBarProgressValue;
+ private bool _isPaused;
+ private bool _showContent = true;
+ private bool _isLoadingIndeterminate = true;
+ private bool _showAll;
+ private string _lastScannedAmiiboId;
+ private bool _statusBarVisible;
+ private ReadOnlyObservableCollection<ApplicationData> _appsObservableList;
+
+ private string _showUiKey = "F4";
+ private string _pauseKey = "F5";
+ private string _screenshotKey = "F8";
+ private float _volume;
+ private string _backendText;
+
+ private bool _canUpdate = true;
+ private Cursor _cursor;
+ private string _title;
+ private string _currentEmulatedGamePath;
+ private AutoResetEvent _rendererWaitEvent;
+ private WindowState _windowState;
+ private bool _isActive;
+
+ public ApplicationData ListSelectedApplication;
+ public ApplicationData GridSelectedApplication;
+
+ public event Action ReloadGameList;
+
+ private string TitleName { get; set; }
+ internal AppHost AppHost { get; set; }
+
+ public MainWindowViewModel()
+ {
+ Applications = new ObservableCollection<ApplicationData>();
+
+ Applications.ToObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out _appsObservableList).AsObservableList();
+
+ _rendererWaitEvent = new AutoResetEvent(false);
+
+ if (Program.PreviewerDetached)
+ {
+ LoadConfigurableHotKeys();
+
+ Volume = ConfigurationState.Instance.System.AudioVolume;
+ }
+ }
+
+ public void Initialize(
+ ContentManager contentManager,
+ ApplicationLibrary applicationLibrary,
+ VirtualFileSystem virtualFileSystem,
+ AccountManager accountManager,
+ Ryujinx.Input.HLE.InputManager inputManager,
+ UserChannelPersistence userChannelPersistence,
+ LibHacHorizonManager libHacHorizonManager,
+ IHostUiHandler uiHandler,
+ Action<bool> showLoading,
+ Action<bool> switchToGameControl,
+ Action<Control> setMainContent,
+ TopLevel topLevel)
+ {
+ ContentManager = contentManager;
+ ApplicationLibrary = applicationLibrary;
+ VirtualFileSystem = virtualFileSystem;
+ AccountManager = accountManager;
+ InputManager = inputManager;
+ UserChannelPersistence = userChannelPersistence;
+ LibHacHorizonManager = libHacHorizonManager;
+ UiHandler = uiHandler;
+
+ ShowLoading = showLoading;
+ SwitchToGameControl = switchToGameControl;
+ SetMainContent = setMainContent;
+ TopLevel = topLevel;
+ }
+
+#region Properties
+
+ public string SearchText
+ {
+ get => _searchText;
+ set
+ {
+ _searchText = value;
+
+ _searchTimer?.Dispose();
+
+ _searchTimer = new Timer(TimerCallback, null, 1000, 0);
+ }
+ }
+
+ private void TimerCallback(object obj)
+ {
+ RefreshView();
+
+ _searchTimer.Dispose();
+ _searchTimer = null;
+ }
+
+ public bool CanUpdate
+ {
+ get => _canUpdate && EnableNonGameRunningControls && Modules.Updater.CanUpdate(false);
+ set
+ {
+ _canUpdate = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public Cursor Cursor
+ {
+ get => _cursor;
+ set
+ {
+ _cursor = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public ReadOnlyObservableCollection<ApplicationData> AppsObservableList
+ {
+ get => _appsObservableList;
+ set
+ {
+ _appsObservableList = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsPaused
+ {
+ get => _isPaused;
+ set
+ {
+ _isPaused = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public long LastFullscreenToggle
+ {
+ get => _lastFullscreenToggle;
+ set
+ {
+ _lastFullscreenToggle = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool StatusBarVisible
+ {
+ get => _statusBarVisible && EnableNonGameRunningControls;
+ set
+ {
+ _statusBarVisible = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool EnableNonGameRunningControls => !IsGameRunning;
+
+ public bool ShowFirmwareStatus => !ShowLoadProgress;
+
+ public bool IsGameRunning
+ {
+ get => _isGameRunning;
+ set
+ {
+ _isGameRunning = value;
+
+ if (!value)
+ {
+ ShowMenuAndStatusBar = false;
+ }
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(EnableNonGameRunningControls));
+ OnPropertyChanged(nameof(StatusBarVisible));
+ OnPropertyChanged(nameof(ShowFirmwareStatus));
+ }
+ }
+
+ public bool IsAmiiboRequested
+ {
+ get => _isAmiiboRequested && _isGameRunning;
+ set
+ {
+ _isAmiiboRequested = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowLoadProgress
+ {
+ get => _showLoadProgress;
+ set
+ {
+ _showLoadProgress = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ShowFirmwareStatus));
+ }
+ }
+
+ public string GameStatusText
+ {
+ get => _gameStatusText;
+ set
+ {
+ _gameStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsFullScreen
+ {
+ get => _isFullScreen;
+ set
+ {
+ _isFullScreen = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowAll
+ {
+ get => _showAll;
+ set
+ {
+ _showAll = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string LastScannedAmiiboId
+ {
+ get => _lastScannedAmiiboId;
+ set
+ {
+ _lastScannedAmiiboId = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public ApplicationData SelectedApplication
+ {
+ get
+ {
+ return Glyph switch
+ {
+ Glyph.List => ListSelectedApplication,
+ Glyph.Grid => GridSelectedApplication,
+ _ => null,
+ };
+ }
+ }
+
+ public bool EnabledUserSaveDirectory => !Utilities.IsZeros(SelectedApplication.ControlHolder.ByteSpan) && SelectedApplication.ControlHolder.Value.UserAccountSaveDataSize > 0;
+
+ public bool EnabledDeviceSaveDirectory => !Utilities.IsZeros(SelectedApplication.ControlHolder.ByteSpan) && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0;
+
+ public bool EnabledBcatSaveDirectory => !Utilities.IsZeros(SelectedApplication.ControlHolder.ByteSpan) && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;
+
+ public string LoadHeading
+ {
+ get => _loadHeading;
+ set
+ {
+ _loadHeading = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string CacheLoadStatus
+ {
+ get => _cacheLoadStatus;
+ set
+ {
+ _cacheLoadStatus = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Brush ProgressBarBackgroundColor
+ {
+ get => _progressBarBackgroundColor;
+ set
+ {
+ _progressBarBackgroundColor = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Brush ProgressBarForegroundColor
+ {
+ get => _progressBarForegroundColor;
+ set
+ {
+ _progressBarForegroundColor = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Brush VsyncColor
+ {
+ get => _vsyncColor;
+ set
+ {
+ _vsyncColor = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public byte[] SelectedIcon
+ {
+ get => _selectedIcon;
+ set
+ {
+ _selectedIcon = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int ProgressMaximum
+ {
+ get => _progressMaximum;
+ set
+ {
+ _progressMaximum = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int ProgressValue
+ {
+ get => _progressValue;
+ set
+ {
+ _progressValue = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int StatusBarProgressMaximum
+ {
+ get => _statusBarProgressMaximum;
+ set
+ {
+ _statusBarProgressMaximum = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int StatusBarProgressValue
+ {
+ get => _statusBarProgressValue;
+ set
+ {
+ _statusBarProgressValue = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string FifoStatusText
+ {
+ get => _fifoStatusText;
+ set
+ {
+ _fifoStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string GpuNameText
+ {
+ get => _gpuStatusText;
+ set
+ {
+ _gpuStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string BackendText
+ {
+ get => _backendText;
+ set
+ {
+ _backendText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string DockedStatusText
+ {
+ get => _dockedStatusText;
+ set
+ {
+ _dockedStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string AspectRatioStatusText
+ {
+ get => _aspectStatusText;
+ set
+ {
+ _aspectStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string VolumeStatusText
+ {
+ get => _volumeStatusText;
+ set
+ {
+ _volumeStatusText = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool VolumeMuted => _volume == 0;
+
+ public float Volume
+ {
+ get => _volume;
+ set
+ {
+ _volume = value;
+
+ if (_isGameRunning)
+ {
+ AppHost.Device.SetVolume(_volume);
+ }
+
+ OnPropertyChanged(nameof(VolumeStatusText));
+ OnPropertyChanged(nameof(VolumeMuted));
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowStatusSeparator
+ {
+ get => _showStatusSeparator;
+ set
+ {
+ _showStatusSeparator = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowMenuAndStatusBar
+ {
+ get => _showMenuAndStatusBar;
+ set
+ {
+ _showMenuAndStatusBar = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsLoadingIndeterminate
+ {
+ get => _isLoadingIndeterminate;
+ set
+ {
+ _isLoadingIndeterminate = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsActive
+ {
+ get => _isActive;
+ set
+ {
+ _isActive = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+
+ public bool ShowContent
+ {
+ get => _showContent;
+ set
+ {
+ _showContent = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsAppletMenuActive
+ {
+ get => _isAppletMenuActive && EnableNonGameRunningControls;
+ set
+ {
+ _isAppletMenuActive = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public WindowState WindowState
+ {
+ get => _windowState;
+ internal set
+ {
+ _windowState = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsGrid => Glyph == Glyph.Grid;
+ public bool IsList => Glyph == Glyph.List;
+
+ internal void Sort(bool isAscending)
+ {
+ IsAscending = isAscending;
+
+ RefreshView();
+ }
+
+ internal void Sort(ApplicationSort sort)
+ {
+ SortMode = sort;
+
+ RefreshView();
+ }
+
+ public bool StartGamesInFullscreen
+ {
+ get => ConfigurationState.Instance.Ui.StartFullscreen;
+ set
+ {
+ ConfigurationState.Instance.Ui.StartFullscreen.Value = value;
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowConsole
+ {
+ get => ConfigurationState.Instance.Ui.ShowConsole;
+ set
+ {
+ ConfigurationState.Instance.Ui.ShowConsole.Value = value;
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public string Title
+ {
+ get => _title;
+ set
+ {
+ _title = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool ShowConsoleVisible
+ {
+ get => ConsoleHelper.SetConsoleWindowStateSupported;
+ }
+
+ public bool ManageFileTypesVisible
+ {
+ get => FileAssociationHelper.IsTypeAssociationSupported;
+ }
+
+ public ObservableCollection<ApplicationData> Applications
+ {
+ get => _applications;
+ set
+ {
+ _applications = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public Glyph Glyph
+ {
+ get => (Glyph)ConfigurationState.Instance.Ui.GameListViewMode.Value;
+ set
+ {
+ ConfigurationState.Instance.Ui.GameListViewMode.Value = (int)value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsGrid));
+ OnPropertyChanged(nameof(IsList));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public bool ShowNames
+ {
+ get => ConfigurationState.Instance.Ui.ShowNames && ConfigurationState.Instance.Ui.GridSize > 1; set
+ {
+ ConfigurationState.Instance.Ui.ShowNames.Value = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(GridSizeScale));
+ OnPropertyChanged(nameof(GridItemSelectorSize));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ internal ApplicationSort SortMode
+ {
+ get => (ApplicationSort)ConfigurationState.Instance.Ui.ApplicationSort.Value;
+ private set
+ {
+ ConfigurationState.Instance.Ui.ApplicationSort.Value = (int)value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SortName));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public int ListItemSelectorSize
+ {
+ get
+ {
+ return ConfigurationState.Instance.Ui.GridSize.Value switch
+ {
+ 1 => 78,
+ 2 => 100,
+ 3 => 120,
+ 4 => 140,
+ _ => 16,
+ };
+ }
+ }
+
+ public int GridItemSelectorSize
+ {
+ get
+ {
+ return ConfigurationState.Instance.Ui.GridSize.Value switch
+ {
+ 1 => 120,
+ 2 => ShowNames ? 210 : 150,
+ 3 => ShowNames ? 240 : 180,
+ 4 => ShowNames ? 280 : 220,
+ _ => 16,
+ };
+ }
+ }
+
+ public int GridSizeScale
+ {
+ get => ConfigurationState.Instance.Ui.GridSize;
+ set
+ {
+ ConfigurationState.Instance.Ui.GridSize.Value = value;
+
+ if (value < 2)
+ {
+ ShowNames = false;
+ }
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsGridSmall));
+ OnPropertyChanged(nameof(IsGridMedium));
+ OnPropertyChanged(nameof(IsGridLarge));
+ OnPropertyChanged(nameof(IsGridHuge));
+ OnPropertyChanged(nameof(ListItemSelectorSize));
+ OnPropertyChanged(nameof(GridItemSelectorSize));
+ OnPropertyChanged(nameof(ShowNames));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public string SortName
+ {
+ get
+ {
+ return SortMode switch
+ {
+ ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
+ ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListHeaderDeveloper],
+ ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderLastPlayed],
+ ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderTimePlayed],
+ ApplicationSort.FileType => LocaleManager.Instance[LocaleKeys.GameListHeaderFileExtension],
+ ApplicationSort.FileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize],
+ ApplicationSort.Path => LocaleManager.Instance[LocaleKeys.GameListHeaderPath],
+ ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite],
+ _ => string.Empty,
+ };
+ }
+ }
+
+ public bool IsAscending
+ {
+ get => ConfigurationState.Instance.Ui.IsAscendingOrder;
+ private set
+ {
+ ConfigurationState.Instance.Ui.IsAscendingOrder.Value = value;
+
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(SortMode));
+ OnPropertyChanged(nameof(SortName));
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public KeyGesture ShowUiKey
+ {
+ get => KeyGesture.Parse(_showUiKey);
+ set
+ {
+ _showUiKey = value.ToString();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public KeyGesture ScreenshotKey
+ {
+ get => KeyGesture.Parse(_screenshotKey);
+ set
+ {
+ _screenshotKey = value.ToString();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public KeyGesture PauseKey
+ {
+ get => KeyGesture.Parse(_pauseKey); set
+ {
+ _pauseKey = value.ToString();
+
+ OnPropertyChanged();
+ }
+ }
+
+ public ContentManager ContentManager { get; private set; }
+ public ApplicationLibrary ApplicationLibrary { get; private set; }
+ public VirtualFileSystem VirtualFileSystem { get; private set; }
+ public AccountManager AccountManager { get; private set; }
+ public Ryujinx.Input.HLE.InputManager InputManager { get; private set; }
+ public UserChannelPersistence UserChannelPersistence { get; private set; }
+ public Action<bool> ShowLoading { get; private set; }
+ public Action<bool> SwitchToGameControl { get; private set; }
+ public Action<Control> SetMainContent { get; private set; }
+ public TopLevel TopLevel { get; private set; }
+ public RendererHost RendererHostControl { get; private set; }
+ public bool IsClosing { get; set; }
+ public LibHacHorizonManager LibHacHorizonManager { get; internal set; }
+ public IHostUiHandler UiHandler { get; internal set; }
+ public bool IsSortedByFavorite => SortMode == ApplicationSort.Favorite;
+ public bool IsSortedByTitle => SortMode == ApplicationSort.Title;
+ public bool IsSortedByDeveloper => SortMode == ApplicationSort.Developer;
+ public bool IsSortedByLastPlayed => SortMode == ApplicationSort.LastPlayed;
+ public bool IsSortedByTimePlayed => SortMode == ApplicationSort.TotalTimePlayed;
+ public bool IsSortedByType => SortMode == ApplicationSort.FileType;
+ public bool IsSortedBySize => SortMode == ApplicationSort.FileSize;
+ public bool IsSortedByPath => SortMode == ApplicationSort.Path;
+ public bool IsGridSmall => ConfigurationState.Instance.Ui.GridSize == 1;
+ public bool IsGridMedium => ConfigurationState.Instance.Ui.GridSize == 2;
+ public bool IsGridLarge => ConfigurationState.Instance.Ui.GridSize == 3;
+ public bool IsGridHuge => ConfigurationState.Instance.Ui.GridSize == 4;
+
+#endregion
+
+#region PrivateMethods
+
+ private IComparer<ApplicationData> GetComparer()
+ {
+ return SortMode switch
+ {
+ ApplicationSort.LastPlayed => new Models.Generic.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)
+ : 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)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.Developer),
+ ApplicationSort.FileType => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.FileExtension)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.FileExtension),
+ ApplicationSort.Path => IsAscending ? SortExpressionComparer<ApplicationData>.Ascending(app => app.Path)
+ : SortExpressionComparer<ApplicationData>.Descending(app => app.Path),
+ _ => null,
+ };
+ }
+
+ private void RefreshView()
+ {
+ RefreshGrid();
+ }
+
+ private void RefreshGrid()
+ {
+ Applications.ToObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out _appsObservableList).AsObservableList();
+
+ OnPropertyChanged(nameof(AppsObservableList));
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is ApplicationData app)
+ {
+ return string.IsNullOrWhiteSpace(_searchText) || app.TitleName.ToLower().Contains(_searchText.ToLower());
+ }
+
+ return false;
+ }
+
+ private async Task HandleFirmwareInstallation(string filename)
+ {
+ try
+ {
+ SystemVersion firmwareVersion = ContentManager.VerifyFirmwarePackage(filename);
+
+ if (firmwareVersion == null)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareNotFoundErrorMessage, filename));
+
+ return;
+ }
+
+ string dialogTitle = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallTitle, firmwareVersion.VersionString);
+ string dialogMessage = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallMessage, firmwareVersion.VersionString);
+
+ SystemVersion currentVersion = ContentManager.GetCurrentFirmwareVersion();
+ if (currentVersion != null)
+ {
+ dialogMessage += LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallSubMessage, currentVersion.VersionString);
+ }
+
+ dialogMessage += LocaleManager.Instance[LocaleKeys.DialogFirmwareInstallerFirmwareInstallConfirmMessage];
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ dialogTitle,
+ dialogMessage,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ UpdateWaitWindow waitingDialog = new(dialogTitle, LocaleManager.Instance[LocaleKeys.DialogFirmwareInstallerFirmwareInstallWaitMessage]);
+
+ if (result == UserResult.Yes)
+ {
+ Logger.Info?.Print(LogClass.Application, $"Installing firmware {firmwareVersion.VersionString}");
+
+ Thread thread = new(() =>
+ {
+ Dispatcher.UIThread.InvokeAsync(delegate
+ {
+ waitingDialog.Show();
+ });
+
+ try
+ {
+ ContentManager.InstallFirmware(filename);
+
+ Dispatcher.UIThread.InvokeAsync(async delegate
+ {
+ waitingDialog.Close();
+
+ string message = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogFirmwareInstallerFirmwareInstallSuccessMessage, firmwareVersion.VersionString);
+
+ await ContentDialogHelper.CreateInfoDialog(dialogTitle, message, LocaleManager.Instance[LocaleKeys.InputDialogOk], "", LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+
+ Logger.Info?.Print(LogClass.Application, message);
+
+ // Purge Applet Cache.
+
+ DirectoryInfo miiEditorCacheFolder = new DirectoryInfo(Path.Combine(AppDataManager.GamesDirPath, "0100000000001009", "cache"));
+
+ if (miiEditorCacheFolder.Exists)
+ {
+ miiEditorCacheFolder.Delete(true);
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ waitingDialog.Close();
+
+ await ContentDialogHelper.CreateErrorDialog(ex.Message);
+ });
+ }
+ finally
+ {
+ RefreshFirmwareStatus();
+ }
+ }) { Name = "GUI.FirmwareInstallerThread" };
+
+ thread.Start();
+ }
+ }
+ catch (LibHac.Common.Keys.MissingKeyException ex)
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ Logger.Error?.Print(LogClass.Application, ex.ToString());
+
+ async void Action() => await UserErrorDialog.ShowUserErrorDialog(UserError.NoKeys, (desktop.MainWindow as MainWindow));
+
+ Dispatcher.UIThread.Post(Action);
+ }
+ }
+ catch (Exception ex)
+ {
+ await ContentDialogHelper.CreateErrorDialog(ex.Message);
+ }
+ }
+
+ private void ProgressHandler<T>(T state, int current, int total) where T : Enum
+ {
+ Dispatcher.UIThread.Post((() =>
+ {
+ ProgressMaximum = total;
+ ProgressValue = current;
+
+ switch (state)
+ {
+ case LoadState ptcState:
+ CacheLoadStatus = $"{current} / {total}";
+ switch (ptcState)
+ {
+ case LoadState.Unloaded:
+ case LoadState.Loading:
+ LoadHeading = LocaleManager.Instance[LocaleKeys.CompilingPPTC];
+ IsLoadingIndeterminate = false;
+ break;
+ case LoadState.Loaded:
+ LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, TitleName);
+ IsLoadingIndeterminate = true;
+ CacheLoadStatus = "";
+ break;
+ }
+ break;
+ case ShaderCacheLoadingState shaderCacheState:
+ CacheLoadStatus = $"{current} / {total}";
+ switch (shaderCacheState)
+ {
+ case ShaderCacheLoadingState.Start:
+ case ShaderCacheLoadingState.Loading:
+ LoadHeading = LocaleManager.Instance[LocaleKeys.CompilingShaders];
+ IsLoadingIndeterminate = false;
+ break;
+ case ShaderCacheLoadingState.Loaded:
+ LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, TitleName);
+ IsLoadingIndeterminate = true;
+ CacheLoadStatus = "";
+ break;
+ }
+ break;
+ default:
+ throw new ArgumentException($"Unknown Progress Handler type {typeof(T)}");
+ }
+ }));
+ }
+
+ private async void ExtractLogo()
+ {
+ if (SelectedApplication != null)
+ {
+ await ApplicationHelper.ExtractSection(NcaSectionType.Logo, SelectedApplication.Path, SelectedApplication.TitleName);
+ }
+ }
+
+ private async void ExtractRomFs()
+ {
+ if (SelectedApplication != null)
+ {
+ await ApplicationHelper.ExtractSection(NcaSectionType.Data, SelectedApplication.Path, SelectedApplication.TitleName);
+ }
+ }
+
+ private async void ExtractExeFs()
+ {
+ if (SelectedApplication != null)
+ {
+ await ApplicationHelper.ExtractSection(NcaSectionType.Code, SelectedApplication.Path, SelectedApplication.TitleName);
+ }
+ }
+
+ private void PrepareLoadScreen()
+ {
+ using MemoryStream stream = new(SelectedIcon);
+ using var gameIconBmp = SixLabors.ImageSharp.Image.Load<Bgra32>(stream);
+
+ var dominantColor = IconColorPicker.GetFilteredColor(gameIconBmp).ToPixel<Bgra32>();
+
+ const float colorMultiple = 0.5f;
+
+ Color progressFgColor = Color.FromRgb(dominantColor.R, dominantColor.G, dominantColor.B);
+ Color progressBgColor = Color.FromRgb(
+ (byte)(dominantColor.R * colorMultiple),
+ (byte)(dominantColor.G * colorMultiple),
+ (byte)(dominantColor.B * colorMultiple));
+
+ ProgressBarForegroundColor = new SolidColorBrush(progressFgColor);
+ ProgressBarBackgroundColor = new SolidColorBrush(progressBgColor);
+ }
+
+ private void InitializeGame()
+ {
+ RendererHostControl.WindowCreated += RendererHost_Created;
+
+ AppHost.StatusUpdatedEvent += Update_StatusBar;
+ AppHost.AppExit += AppHost_AppExit;
+
+ _rendererWaitEvent.WaitOne();
+
+ AppHost?.Start();
+
+ AppHost?.DisposeContext();
+ }
+
+ private void HandleRelaunch()
+ {
+ if (UserChannelPersistence.PreviousIndex != -1 && UserChannelPersistence.ShouldRestart)
+ {
+ UserChannelPersistence.ShouldRestart = false;
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ LoadApplication(_currentEmulatedGamePath);
+ });
+ }
+ else
+ {
+ // Otherwise, clear state.
+ UserChannelPersistence = new UserChannelPersistence();
+ _currentEmulatedGamePath = null;
+ }
+ }
+
+ private void Update_StatusBar(object sender, StatusUpdatedEventArgs args)
+ {
+ if (ShowMenuAndStatusBar && !ShowLoadProgress)
+ {
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Avalonia.Application.Current.Styles.TryGetResource(args.VSyncEnabled
+ ? "VsyncEnabled"
+ : "VsyncDisabled", out object color);
+
+ if (color is not null)
+ {
+ VsyncColor = new SolidColorBrush((Color)color);
+ }
+
+ DockedStatusText = args.DockedMode;
+ AspectRatioStatusText = args.AspectRatio;
+ GameStatusText = args.GameStatus;
+ VolumeStatusText = args.VolumeStatus;
+ FifoStatusText = args.FifoStatus;
+ GpuNameText = args.GpuName;
+ BackendText = args.GpuBackend;
+
+ ShowStatusSeparator = true;
+ });
+ }
+ }
+
+ private void RendererHost_Created(object sender, EventArgs e)
+ {
+ ShowLoading(false);
+
+ _rendererWaitEvent.Set();
+ }
+
+#endregion
+
+#region PublicMethods
+
+ public void SetUIProgressHandlers(Switch emulationContext)
+ {
+ if (emulationContext.Processes.ActiveApplication.DiskCacheLoadState != null)
+ {
+ emulationContext.Processes.ActiveApplication.DiskCacheLoadState.StateChanged -= ProgressHandler;
+ emulationContext.Processes.ActiveApplication.DiskCacheLoadState.StateChanged += ProgressHandler;
+ }
+
+ emulationContext.Gpu.ShaderCacheStateChanged -= ProgressHandler;
+ emulationContext.Gpu.ShaderCacheStateChanged += ProgressHandler;
+ }
+
+ public void LoadConfigurableHotKeys()
+ {
+ if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.ShowUi, out var showUiKey))
+ {
+ ShowUiKey = new KeyGesture(showUiKey);
+ }
+
+ if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.Screenshot, out var screenshotKey))
+ {
+ ScreenshotKey = new KeyGesture(screenshotKey);
+ }
+
+ if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.Pause, out var pauseKey))
+ {
+ PauseKey = new KeyGesture(pauseKey);
+ }
+ }
+
+ public void TakeScreenshot()
+ {
+ AppHost.ScreenshotRequested = true;
+ }
+
+ public void HideUi()
+ {
+ ShowMenuAndStatusBar = false;
+ }
+
+ public void SetListMode()
+ {
+ Glyph = Glyph.List;
+ }
+
+ public void SetGridMode()
+ {
+ Glyph = Glyph.Grid;
+ }
+
+ public async void InstallFirmwareFromFile()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFileDialog dialog = new() { AllowMultiple = false };
+ dialog.Filters.Add(new FileDialogFilter { Name = LocaleManager.Instance[LocaleKeys.FileDialogAllTypes], Extensions = { "xci", "zip" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "XCI", Extensions = { "xci" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "ZIP", Extensions = { "zip" } });
+
+ string[] file = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (file != null && file.Length > 0)
+ {
+ await HandleFirmwareInstallation(file[0]);
+ }
+ }
+ }
+
+ public async void InstallFirmwareFromFolder()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFolderDialog dialog = new();
+
+ string folder = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (!string.IsNullOrEmpty(folder))
+ {
+ await HandleFirmwareInstallation(folder);
+ }
+ }
+ }
+
+ public static void OpenRyujinxFolder()
+ {
+ OpenHelper.OpenFolder(AppDataManager.BaseDirPath);
+ }
+
+ public static void OpenLogsFolder()
+ {
+ string logPath = Path.Combine(ReleaseInformation.GetBaseApplicationDirectory(), "Logs");
+
+ new DirectoryInfo(logPath).Create();
+
+ OpenHelper.OpenFolder(logPath);
+ }
+
+ public void ToggleDockMode()
+ {
+ if (IsGameRunning)
+ {
+ ConfigurationState.Instance.System.EnableDockedMode.Value = !ConfigurationState.Instance.System.EnableDockedMode.Value;
+ }
+ }
+
+ public async void ExitCurrentState()
+ {
+ if (WindowState == WindowState.FullScreen)
+ {
+ ToggleFullscreen();
+ }
+ else if (IsGameRunning)
+ {
+ await Task.Delay(100);
+
+ AppHost?.ShowExitPrompt();
+ }
+ }
+
+ public void ChangeLanguage(object languageCode)
+ {
+ LocaleManager.Instance.LoadLanguage((string)languageCode);
+
+ if (Program.PreviewerDetached)
+ {
+ ConfigurationState.Instance.Ui.LanguageCode.Value = (string)languageCode;
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+ }
+
+ public void ToggleFileType(string fileType)
+ {
+ _ = fileType switch
+ {
+ "NSP" => ConfigurationState.Instance.Ui.ShownFileTypes.NSP.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NSP,
+ "PFS0" => ConfigurationState.Instance.Ui.ShownFileTypes.PFS0.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.PFS0,
+ "XCI" => ConfigurationState.Instance.Ui.ShownFileTypes.XCI.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.XCI,
+ "NCA" => ConfigurationState.Instance.Ui.ShownFileTypes.NCA.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NCA,
+ "NRO" => ConfigurationState.Instance.Ui.ShownFileTypes.NRO.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NRO,
+ "NSO" => ConfigurationState.Instance.Ui.ShownFileTypes.NSO.Value = !ConfigurationState.Instance.Ui.ShownFileTypes.NSO,
+ _ => throw new ArgumentOutOfRangeException(fileType),
+ };
+
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ LoadApplications();
+ }
+
+ public async void ManageProfiles()
+ {
+ await NavigationDialogHost.Show(AccountManager, ContentManager, VirtualFileSystem, LibHacHorizonManager.RyujinxClient);
+ }
+
+ public void OpenPtcDirectory()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ string ptcDir = Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "cpu");
+ string mainPath = Path.Combine(ptcDir, "0");
+ string backupPath = Path.Combine(ptcDir, "1");
+
+ if (!Directory.Exists(ptcDir))
+ {
+ Directory.CreateDirectory(ptcDir);
+ Directory.CreateDirectory(mainPath);
+ Directory.CreateDirectory(backupPath);
+ }
+
+ OpenHelper.OpenFolder(ptcDir);
+ }
+ }
+
+ public async void PurgePtcCache()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ DirectoryInfo mainDir = new(Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "cpu", "0"));
+ DirectoryInfo backupDir = new(Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "cpu", "1"));
+
+ // FIXME: Found a way to reproduce the bold effect on the title name (fork?).
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance[LocaleKeys.DialogWarning],
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionMessage, selection.TitleName),
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ List<FileInfo> cacheFiles = new();
+
+ if (mainDir.Exists)
+ {
+ cacheFiles.AddRange(mainDir.EnumerateFiles("*.cache"));
+ }
+
+ if (backupDir.Exists)
+ {
+ cacheFiles.AddRange(backupDir.EnumerateFiles("*.cache"));
+ }
+
+ if (cacheFiles.Count > 0 && result == UserResult.Yes)
+ {
+ foreach (FileInfo file in cacheFiles)
+ {
+ try
+ {
+ file.Delete();
+ }
+ catch (Exception e)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionErrorMessage, file.Name, e));
+ }
+ }
+ }
+ }
+ }
+
+ public void OpenShaderCacheDirectory()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ string shaderCacheDir = Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "shader");
+
+ if (!Directory.Exists(shaderCacheDir))
+ {
+ Directory.CreateDirectory(shaderCacheDir);
+ }
+
+ OpenHelper.OpenFolder(shaderCacheDir);
+ }
+ }
+
+ public void SimulateWakeUpMessage()
+ {
+ AppHost.Device.System.SimulateWakeUpMessage();
+ }
+
+ public async void PurgeShaderCache()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ DirectoryInfo shaderCacheDir = new(Path.Combine(AppDataManager.GamesDirPath, selection.TitleId, "cache", "shader"));
+
+ // FIXME: Found a way to reproduce the bold effect on the title name (fork?).
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance[LocaleKeys.DialogWarning],
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogShaderDeletionMessage, selection.TitleName),
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ List<DirectoryInfo> oldCacheDirectories = new();
+ List<FileInfo> newCacheFiles = new();
+
+ if (shaderCacheDir.Exists)
+ {
+ oldCacheDirectories.AddRange(shaderCacheDir.EnumerateDirectories("*"));
+ newCacheFiles.AddRange(shaderCacheDir.GetFiles("*.toc"));
+ newCacheFiles.AddRange(shaderCacheDir.GetFiles("*.data"));
+ }
+
+ if ((oldCacheDirectories.Count > 0 || newCacheFiles.Count > 0) && result == UserResult.Yes)
+ {
+ foreach (DirectoryInfo directory in oldCacheDirectories)
+ {
+ try
+ {
+ directory.Delete(true);
+ }
+ catch (Exception e)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogPPTCDeletionErrorMessage, directory.Name, e));
+ }
+ }
+ }
+
+ foreach (FileInfo file in newCacheFiles)
+ {
+ try
+ {
+ file.Delete();
+ }
+ catch (Exception e)
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.ShaderCachePurgeError, file.Name, e));
+ }
+ }
+ }
+ }
+
+ public void ToggleFavorite()
+ {
+ ApplicationData selection = SelectedApplication;
+ if (selection != null)
+ {
+ selection.Favorite = !selection.Favorite;
+
+ ApplicationLibrary.LoadAndSaveMetaData(selection.TitleId, appMetadata =>
+ {
+ appMetadata.Favorite = selection.Favorite;
+ });
+
+ RefreshView();
+ }
+ }
+
+ public void OpenUserSaveDirectory()
+ {
+ OpenSaveDirectory(SaveDataType.Account, userId: new UserId((ulong)AccountManager.LastOpenedUser.UserId.High, (ulong)AccountManager.LastOpenedUser.UserId.Low));
+ }
+
+ public void OpenDeviceSaveDirectory()
+ {
+ OpenSaveDirectory(SaveDataType.Device, userId: default);
+ }
+
+ public void OpenBcatSaveDirectory()
+ {
+ OpenSaveDirectory(SaveDataType.Bcat, userId: default);
+ }
+
+ private void OpenSaveDirectory(SaveDataType saveDataType, UserId userId)
+ {
+ if (SelectedApplication != null)
+ {
+ if (!ulong.TryParse(SelectedApplication.TitleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdNumber))
+ {
+ Dispatcher.UIThread.InvokeAsync(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogRyujinxErrorMessage], LocaleManager.Instance[LocaleKeys.DialogInvalidTitleIdErrorMessage]);
+ });
+
+ return;
+ }
+
+ var saveDataFilter = SaveDataFilter.Make(titleIdNumber, saveDataType, userId, saveDataId: default, index: default);
+
+ ApplicationHelper.OpenSaveDir(in saveDataFilter, titleIdNumber, SelectedApplication.ControlHolder, SelectedApplication.TitleName);
+ }
+ }
+
+ public void OpenModsDirectory()
+ {
+ if (SelectedApplication != null)
+ {
+ string modsBasePath = VirtualFileSystem.ModLoader.GetModsBasePath();
+ string titleModsPath = VirtualFileSystem.ModLoader.GetTitleDir(modsBasePath, SelectedApplication.TitleId);
+
+ OpenHelper.OpenFolder(titleModsPath);
+ }
+ }
+
+ public void OpenSdModsDirectory()
+ {
+ if (SelectedApplication != null)
+ {
+ string sdModsBasePath = VirtualFileSystem.ModLoader.GetSdModsBasePath();
+ string titleModsPath = VirtualFileSystem.ModLoader.GetTitleDir(sdModsBasePath, SelectedApplication.TitleId);
+
+ OpenHelper.OpenFolder(titleModsPath);
+ }
+ }
+
+ public async void OpenTitleUpdateManager()
+ {
+ if (SelectedApplication != null)
+ {
+ await TitleUpdateWindow.Show(VirtualFileSystem, ulong.Parse(SelectedApplication.TitleId, NumberStyles.HexNumber), SelectedApplication.TitleName);
+ }
+ }
+
+ public async void OpenDownloadableContentManager()
+ {
+ if (SelectedApplication != null)
+ {
+ await DownloadableContentManagerWindow.Show(VirtualFileSystem, ulong.Parse(SelectedApplication.TitleId, NumberStyles.HexNumber), SelectedApplication.TitleName);
+ }
+ }
+
+ public async void OpenCheatManager()
+ {
+ if (SelectedApplication != null)
+ {
+ await new CheatWindow(VirtualFileSystem, SelectedApplication.TitleId, SelectedApplication.TitleName).ShowDialog(TopLevel as Window);
+ }
+ }
+
+ public async void LoadApplications()
+ {
+ await Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Applications.Clear();
+
+ StatusBarVisible = true;
+ StatusBarProgressMaximum = 0;
+ StatusBarProgressValue = 0;
+
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarGamesLoaded, 0, 0);
+ });
+
+ ReloadGameList?.Invoke();
+ }
+
+ public async void OpenFile()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFileDialog dialog = new()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.OpenFileDialogTitle]
+ };
+
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = LocaleManager.Instance[LocaleKeys.AllSupportedFormats],
+ Extensions =
+ {
+ "nsp",
+ "pfs0",
+ "xci",
+ "nca",
+ "nro",
+ "nso"
+ }
+ });
+
+ dialog.Filters.Add(new FileDialogFilter { Name = "NSP", Extensions = { "nsp" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "PFS0", Extensions = { "pfs0" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "XCI", Extensions = { "xci" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "NCA", Extensions = { "nca" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "NRO", Extensions = { "nro" } });
+ dialog.Filters.Add(new FileDialogFilter { Name = "NSO", Extensions = { "nso" } });
+
+ string[] files = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (files != null && files.Length > 0)
+ {
+ LoadApplication(files[0]);
+ }
+ }
+ }
+
+ public async void OpenFolder()
+ {
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ OpenFolderDialog dialog = new()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.OpenFolderDialogTitle]
+ };
+
+ string folder = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder))
+ {
+ LoadApplication(folder);
+ }
+ }
+ }
+
+ public async void LoadApplication(string path, bool startFullscreen = false, string titleName = "")
+ {
+ if (AppHost != null)
+ {
+ await ContentDialogHelper.CreateInfoDialog(
+ LocaleManager.Instance[LocaleKeys.DialogLoadAppGameAlreadyLoadedMessage],
+ LocaleManager.Instance[LocaleKeys.DialogLoadAppGameAlreadyLoadedSubMessage],
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ "",
+ LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
+
+ return;
+ }
+
+#if RELEASE
+ await PerformanceCheck();
+#endif
+
+ Logger.RestartTime();
+
+ if (SelectedIcon == null)
+ {
+ SelectedIcon = ApplicationLibrary.GetApplicationIcon(path);
+ }
+
+ PrepareLoadScreen();
+
+ RendererHostControl = new RendererHost();
+
+ AppHost = new AppHost(
+ RendererHostControl,
+ InputManager,
+ path,
+ VirtualFileSystem,
+ ContentManager,
+ AccountManager,
+ UserChannelPersistence,
+ this,
+ TopLevel);
+
+ async void Action()
+ {
+ if (!await AppHost.LoadGuestApplication())
+ {
+ AppHost.DisposeContext();
+ AppHost = null;
+
+ return;
+ }
+
+ CanUpdate = false;
+
+ LoadHeading = TitleName = titleName;
+
+ if (string.IsNullOrWhiteSpace(titleName))
+ {
+ LoadHeading = LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.LoadingHeading, AppHost.Device.Processes.ActiveApplication.Name);
+ TitleName = AppHost.Device.Processes.ActiveApplication.Name;
+ }
+
+ SwitchToRenderer(startFullscreen);
+
+ _currentEmulatedGamePath = path;
+
+ Thread gameThread = new(InitializeGame) { Name = "GUI.WindowThread" };
+ gameThread.Start();
+ }
+
+ Dispatcher.UIThread.Post(Action);
+ }
+
+ public void SwitchToRenderer(bool startFullscreen)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ SwitchToGameControl(startFullscreen);
+
+ SetMainContent(RendererHostControl);
+
+ RendererHostControl.Focus();
+ });
+ }
+
+ public void UpdateGameMetadata(string titleId)
+ {
+ ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata =>
+ {
+ if (DateTime.TryParse(appMetadata.LastPlayed, out DateTime lastPlayedDateTime))
+ {
+ double sessionTimePlayed = DateTime.UtcNow.Subtract(lastPlayedDateTime).TotalSeconds;
+
+ appMetadata.TimePlayed += Math.Round(sessionTimePlayed, MidpointRounding.AwayFromZero);
+ }
+ });
+ }
+
+ public void RefreshFirmwareStatus()
+ {
+ SystemVersion version = null;
+ try
+ {
+ version = ContentManager.GetCurrentFirmwareVersion();
+ }
+ catch (Exception) { }
+
+ bool hasApplet = false;
+
+ if (version != null)
+ {
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarSystemVersion, version.VersionString);
+
+ hasApplet = version.Major > 3;
+ }
+ else
+ {
+ LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarSystemVersion, "0.0");
+ }
+
+ IsAppletMenuActive = hasApplet;
+ }
+
+ public void AppHost_AppExit(object sender, EventArgs e)
+ {
+ if (IsClosing)
+ {
+ return;
+ }
+
+ IsGameRunning = false;
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ ShowMenuAndStatusBar = true;
+ ShowContent = true;
+ ShowLoadProgress = false;
+ IsLoadingIndeterminate = false;
+ CanUpdate = true;
+ Cursor = Cursor.Default;
+
+ SetMainContent(null);
+
+ AppHost = null;
+
+ HandleRelaunch();
+ });
+
+ RendererHostControl.WindowCreated -= RendererHost_Created;
+ RendererHostControl = null;
+
+ SelectedIcon = null;
+
+ Dispatcher.UIThread.InvokeAsync(() =>
+ {
+ Title = $"Ryujinx {Program.Version}";
+ });
+ }
+
+ public void ToggleFullscreen()
+ {
+ if (Environment.TickCount64 - LastFullscreenToggle < HotKeyPressDelayMs)
+ {
+ return;
+ }
+
+ LastFullscreenToggle = Environment.TickCount64;
+
+ if (WindowState == WindowState.FullScreen)
+ {
+ WindowState = WindowState.Normal;
+
+ if (IsGameRunning)
+ {
+ ShowMenuAndStatusBar = true;
+ }
+ }
+ else
+ {
+ WindowState = WindowState.FullScreen;
+
+ if (IsGameRunning)
+ {
+ ShowMenuAndStatusBar = false;
+ }
+ }
+
+ IsFullScreen = WindowState == WindowState.FullScreen;
+ }
+
+ public static void SaveConfig()
+ {
+ ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+ }
+
+ public static async Task PerformanceCheck()
+ {
+ if (ConfigurationState.Instance.Logger.EnableTrace.Value)
+ {
+ string mainMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckLoggingEnabledMessage];
+ string secondaryMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckLoggingEnabledConfirmMessage];
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ mainMessage,
+ secondaryMessage,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ ConfigurationState.Instance.Logger.EnableTrace.Value = false;
+
+ SaveConfig();
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(ConfigurationState.Instance.Graphics.ShadersDumpPath.Value))
+ {
+ string mainMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckShaderDumpEnabledMessage];
+ string secondaryMessage = LocaleManager.Instance[LocaleKeys.DialogPerformanceCheckShaderDumpEnabledConfirmMessage];
+
+ UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
+ mainMessage,
+ secondaryMessage,
+ LocaleManager.Instance[LocaleKeys.InputDialogYes],
+ LocaleManager.Instance[LocaleKeys.InputDialogNo],
+ LocaleManager.Instance[LocaleKeys.RyujinxConfirm]);
+
+ if (result == UserResult.Yes)
+ {
+ ConfigurationState.Instance.Graphics.ShadersDumpPath.Value = "";
+
+ SaveConfig();
+ }
+ }
+ }
+
+#endregion
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs
new file mode 100644
index 00000000..232c9d43
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs
@@ -0,0 +1,585 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using DynamicData;
+using LibHac.Tools.FsSystem;
+using Ryujinx.Audio.Backends.OpenAL;
+using Ryujinx.Audio.Backends.SDL2;
+using Ryujinx.Audio.Backends.SoundIo;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Windows;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Configuration.Hid;
+using Ryujinx.Common.GraphicsDriver;
+using Ryujinx.Common.Logging;
+using Ryujinx.Graphics.Vulkan;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS.Services.Time.TimeZone;
+using Ryujinx.Ui.Common.Configuration;
+using Ryujinx.Ui.Common.Configuration.System;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Net.NetworkInformation;
+using TimeZone = Ryujinx.Ava.UI.Models.TimeZone;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class SettingsViewModel : BaseModel
+ {
+ private readonly VirtualFileSystem _virtualFileSystem;
+ private readonly ContentManager _contentManager;
+ private TimeZoneContentManager _timeZoneContentManager;
+
+ private readonly List<string> _validTzRegions;
+
+ private readonly Dictionary<string, string> _networkInterfaces;
+
+ private float _customResolutionScale;
+ private int _resolutionScale;
+ private int _graphicsBackendMultithreadingIndex;
+ private float _volume;
+ private bool _isVulkanAvailable = true;
+ private bool _directoryChanged;
+ private List<string> _gpuIds = new();
+ private KeyboardHotkeys _keyboardHotkeys;
+ private int _graphicsBackendIndex;
+ private string _customThemePath;
+ private int _scalingFilter;
+ private int _scalingFilterLevel;
+
+ public event Action CloseWindow;
+ public event Action SaveSettingsEvent;
+ private int _networkInterfaceIndex;
+
+ public int ResolutionScale
+ {
+ get => _resolutionScale;
+ set
+ {
+ _resolutionScale = value;
+
+ OnPropertyChanged(nameof(CustomResolutionScale));
+ OnPropertyChanged(nameof(IsCustomResolutionScaleActive));
+ }
+ }
+
+ public int GraphicsBackendMultithreadingIndex
+ {
+ get => _graphicsBackendMultithreadingIndex;
+ set
+ {
+ _graphicsBackendMultithreadingIndex = value;
+
+ if (_graphicsBackendMultithreadingIndex != (int)ConfigurationState.Instance.Graphics.BackendThreading.Value)
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogSettingsBackendThreadingWarningMessage],
+ "",
+ "",
+ LocaleManager.Instance[LocaleKeys.InputDialogOk],
+ LocaleManager.Instance[LocaleKeys.DialogSettingsBackendThreadingWarningTitle]);
+ });
+ }
+
+ OnPropertyChanged();
+ }
+ }
+
+ public float CustomResolutionScale
+ {
+ get => _customResolutionScale;
+ set
+ {
+ _customResolutionScale = MathF.Round(value, 1);
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsVulkanAvailable
+ {
+ get => _isVulkanAvailable;
+ set
+ {
+ _isVulkanAvailable = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsOpenGLAvailable => !OperatingSystem.IsMacOS();
+
+ public bool IsHypervisorAvailable => OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64;
+
+ public bool DirectoryChanged
+ {
+ get => _directoryChanged;
+ set
+ {
+ _directoryChanged = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public bool IsMacOS => OperatingSystem.IsMacOS();
+
+ public bool EnableDiscordIntegration { get; set; }
+ public bool CheckUpdatesOnStart { get; set; }
+ public bool ShowConfirmExit { get; set; }
+ public bool HideCursorOnIdle { get; set; }
+ public bool EnableDockedMode { get; set; }
+ public bool EnableKeyboard { get; set; }
+ public bool EnableMouse { get; set; }
+ public bool EnableVsync { get; set; }
+ public bool EnablePptc { get; set; }
+ public bool EnableInternetAccess { get; set; }
+ public bool EnableFsIntegrityChecks { get; set; }
+ public bool IgnoreMissingServices { get; set; }
+ public bool ExpandDramSize { get; set; }
+ public bool EnableShaderCache { get; set; }
+ public bool EnableTextureRecompression { get; set; }
+ public bool EnableMacroHLE { get; set; }
+ public bool EnableFileLog { get; set; }
+ public bool EnableStub { get; set; }
+ public bool EnableInfo { get; set; }
+ public bool EnableWarn { get; set; }
+ public bool EnableError { get; set; }
+ public bool EnableTrace { get; set; }
+ public bool EnableGuest { get; set; }
+ public bool EnableFsAccessLog { get; set; }
+ public bool EnableDebug { get; set; }
+ public bool IsOpenAlEnabled { get; set; }
+ public bool IsSoundIoEnabled { get; set; }
+ public bool IsSDL2Enabled { get; set; }
+ public bool EnableCustomTheme { get; set; }
+ public bool IsCustomResolutionScaleActive => _resolutionScale == 4;
+ public bool IsScalingFilterActive => _scalingFilter == (int)Ryujinx.Common.Configuration.ScalingFilter.Fsr;
+
+ public bool IsVulkanSelected => GraphicsBackendIndex == 0;
+ public bool UseHypervisor { get; set; }
+
+ public string TimeZone { get; set; }
+ public string ShaderDumpPath { get; set; }
+
+ public string CustomThemePath
+ {
+ get
+ {
+ return _customThemePath;
+ }
+ set
+ {
+ _customThemePath = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int Language { get; set; }
+ public int Region { get; set; }
+ public int FsGlobalAccessLogMode { get; set; }
+ public int AudioBackend { get; set; }
+ public int MaxAnisotropy { get; set; }
+ public int AspectRatio { get; set; }
+ public int AntiAliasingEffect { get; set; }
+ public string ScalingFilterLevelText => ScalingFilterLevel.ToString("0");
+ public int ScalingFilterLevel
+ {
+ get => _scalingFilterLevel;
+ set
+ {
+ _scalingFilterLevel = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(ScalingFilterLevelText));
+ }
+ }
+ public int OpenglDebugLevel { get; set; }
+ public int MemoryMode { get; set; }
+ public int BaseStyleIndex { get; set; }
+ public int GraphicsBackendIndex
+ {
+ get => _graphicsBackendIndex;
+ set
+ {
+ _graphicsBackendIndex = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsVulkanSelected));
+ }
+ }
+ public int ScalingFilter
+ {
+ get => _scalingFilter;
+ set
+ {
+ _scalingFilter = value;
+ OnPropertyChanged();
+ OnPropertyChanged(nameof(IsScalingFilterActive));
+ }
+ }
+
+ public int PreferredGpuIndex { get; set; }
+
+ public float Volume
+ {
+ get => _volume;
+ set
+ {
+ _volume = value;
+
+ ConfigurationState.Instance.System.AudioVolume.Value = _volume / 100;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public DateTimeOffset DateOffset { get; set; }
+ public TimeSpan TimeOffset { get; set; }
+ internal AvaloniaList<TimeZone> TimeZones { get; set; }
+ public AvaloniaList<string> GameDirectories { get; set; }
+ public ObservableCollection<ComboBoxItem> AvailableGpus { get; set; }
+
+ public AvaloniaList<string> NetworkInterfaceList
+ {
+ get => new AvaloniaList<string>(_networkInterfaces.Keys);
+ }
+
+ public KeyboardHotkeys KeyboardHotkeys
+ {
+ get => _keyboardHotkeys;
+ set
+ {
+ _keyboardHotkeys = value;
+
+ OnPropertyChanged();
+ }
+ }
+
+ public int NetworkInterfaceIndex
+ {
+ get => _networkInterfaceIndex;
+ set
+ {
+ _networkInterfaceIndex = value != -1 ? value : 0;
+ ConfigurationState.Instance.Multiplayer.LanInterfaceId.Value = _networkInterfaces[NetworkInterfaceList[_networkInterfaceIndex]];
+ }
+ }
+
+ public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager) : this()
+ {
+ _virtualFileSystem = virtualFileSystem;
+ _contentManager = contentManager;
+ if (Program.PreviewerDetached)
+ {
+ LoadTimeZones();
+ }
+ }
+
+ public SettingsViewModel()
+ {
+ GameDirectories = new AvaloniaList<string>();
+ TimeZones = new AvaloniaList<TimeZone>();
+ AvailableGpus = new ObservableCollection<ComboBoxItem>();
+ _validTzRegions = new List<string>();
+ _networkInterfaces = new Dictionary<string, string>();
+
+ CheckSoundBackends();
+ PopulateNetworkInterfaces();
+
+ if (Program.PreviewerDetached)
+ {
+ LoadAvailableGpus();
+ LoadCurrentConfiguration();
+ }
+ }
+
+ public void CheckSoundBackends()
+ {
+ IsOpenAlEnabled = OpenALHardwareDeviceDriver.IsSupported;
+ IsSoundIoEnabled = SoundIoHardwareDeviceDriver.IsSupported;
+ IsSDL2Enabled = SDL2HardwareDeviceDriver.IsSupported;
+ }
+
+ private void LoadAvailableGpus()
+ {
+ _gpuIds = new List<string>();
+ List<string> names = new();
+ var devices = VulkanRenderer.GetPhysicalDevices();
+
+ if (devices.Length == 0)
+ {
+ IsVulkanAvailable = false;
+ GraphicsBackendIndex = 1;
+ }
+ else
+ {
+ foreach (var device in devices)
+ {
+ _gpuIds.Add(device.Id);
+ names.Add($"{device.Name} {(device.IsDiscrete ? "(dGPU)" : "")}");
+ }
+ }
+
+ AvailableGpus.Clear();
+ AvailableGpus.AddRange(names.Select(x => new ComboBoxItem { Content = x }));
+ }
+
+ public void LoadTimeZones()
+ {
+ _timeZoneContentManager = new TimeZoneContentManager();
+
+ _timeZoneContentManager.InitializeInstance(_virtualFileSystem, _contentManager, IntegrityCheckLevel.None);
+
+ foreach ((int offset, string location, string abbr) in _timeZoneContentManager.ParseTzOffsets())
+ {
+ int hours = Math.DivRem(offset, 3600, out int seconds);
+ int minutes = Math.Abs(seconds) / 60;
+
+ string abbr2 = abbr.StartsWith('+') || abbr.StartsWith('-') ? string.Empty : abbr;
+
+ TimeZones.Add(new TimeZone($"UTC{hours:+0#;-0#;+00}:{minutes:D2}", location, abbr2));
+
+ _validTzRegions.Add(location);
+ }
+ }
+
+ private void PopulateNetworkInterfaces()
+ {
+ _networkInterfaces.Clear();
+ _networkInterfaces.Add(LocaleManager.Instance[LocaleKeys.NetworkInterfaceDefault], "0");
+
+ foreach (NetworkInterface networkInterface in NetworkInterface.GetAllNetworkInterfaces())
+ {
+ _networkInterfaces.Add(networkInterface.Name, networkInterface.Id);
+ }
+ }
+
+ public void ValidateAndSetTimeZone(string location)
+ {
+ if (_validTzRegions.Contains(location))
+ {
+ TimeZone = location;
+ }
+ }
+
+ public void LoadCurrentConfiguration()
+ {
+ ConfigurationState config = ConfigurationState.Instance;
+
+ // User Interface
+ EnableDiscordIntegration = config.EnableDiscordIntegration;
+ CheckUpdatesOnStart = config.CheckUpdatesOnStart;
+ ShowConfirmExit = config.ShowConfirmExit;
+ HideCursorOnIdle = config.HideCursorOnIdle;
+
+ GameDirectories.Clear();
+ GameDirectories.AddRange(config.Ui.GameDirs.Value);
+
+ EnableCustomTheme = config.Ui.EnableCustomTheme;
+ CustomThemePath = config.Ui.CustomThemePath;
+ BaseStyleIndex = config.Ui.BaseStyle == "Light" ? 0 : 1;
+
+ // Input
+ EnableDockedMode = config.System.EnableDockedMode;
+ EnableKeyboard = config.Hid.EnableKeyboard;
+ EnableMouse = config.Hid.EnableMouse;
+
+ // Keyboard Hotkeys
+ KeyboardHotkeys = config.Hid.Hotkeys.Value;
+
+ // System
+ Region = (int)config.System.Region.Value;
+ Language = (int)config.System.Language.Value;
+ TimeZone = config.System.TimeZone;
+
+ DateTime dateTimeOffset = DateTime.Now.AddSeconds(config.System.SystemTimeOffset);
+
+ DateOffset = dateTimeOffset.Date;
+ TimeOffset = dateTimeOffset.TimeOfDay;
+ EnableVsync = config.Graphics.EnableVsync;
+ EnableFsIntegrityChecks = config.System.EnableFsIntegrityChecks;
+ ExpandDramSize = config.System.ExpandRam;
+ IgnoreMissingServices = config.System.IgnoreMissingServices;
+
+ // CPU
+ EnablePptc = config.System.EnablePtc;
+ MemoryMode = (int)config.System.MemoryManagerMode.Value;
+ UseHypervisor = config.System.UseHypervisor;
+
+ // Graphics
+ GraphicsBackendIndex = (int)config.Graphics.GraphicsBackend.Value;
+ PreferredGpuIndex = _gpuIds.Contains(config.Graphics.PreferredGpu) ? _gpuIds.IndexOf(config.Graphics.PreferredGpu) : 0;
+ EnableShaderCache = config.Graphics.EnableShaderCache;
+ EnableTextureRecompression = config.Graphics.EnableTextureRecompression;
+ EnableMacroHLE = config.Graphics.EnableMacroHLE;
+ ResolutionScale = config.Graphics.ResScale == -1 ? 4 : config.Graphics.ResScale - 1;
+ CustomResolutionScale = config.Graphics.ResScaleCustom;
+ MaxAnisotropy = config.Graphics.MaxAnisotropy == -1 ? 0 : (int)(MathF.Log2(config.Graphics.MaxAnisotropy));
+ AspectRatio = (int)config.Graphics.AspectRatio.Value;
+ GraphicsBackendMultithreadingIndex = (int)config.Graphics.BackendThreading.Value;
+ ShaderDumpPath = config.Graphics.ShadersDumpPath;
+ AntiAliasingEffect = (int)config.Graphics.AntiAliasing.Value;
+ ScalingFilter = (int)config.Graphics.ScalingFilter.Value;
+ ScalingFilterLevel = config.Graphics.ScalingFilterLevel.Value;
+
+ // Audio
+ AudioBackend = (int)config.System.AudioBackend.Value;
+ Volume = config.System.AudioVolume * 100;
+
+ // Network
+ EnableInternetAccess = config.System.EnableInternetAccess;
+
+ // Logging
+ EnableFileLog = config.Logger.EnableFileLog;
+ EnableStub = config.Logger.EnableStub;
+ EnableInfo = config.Logger.EnableInfo;
+ EnableWarn = config.Logger.EnableWarn;
+ EnableError = config.Logger.EnableError;
+ EnableTrace = config.Logger.EnableTrace;
+ EnableGuest = config.Logger.EnableGuest;
+ EnableDebug = config.Logger.EnableDebug;
+ EnableFsAccessLog = config.Logger.EnableFsAccessLog;
+ FsGlobalAccessLogMode = config.System.FsGlobalAccessLogMode;
+ OpenglDebugLevel = (int)config.Logger.GraphicsDebugLevel.Value;
+
+ NetworkInterfaceIndex = _networkInterfaces.Values.ToList().IndexOf(config.Multiplayer.LanInterfaceId.Value);
+ }
+
+ public void SaveSettings()
+ {
+ ConfigurationState config = ConfigurationState.Instance;
+
+ // User Interface
+ config.EnableDiscordIntegration.Value = EnableDiscordIntegration;
+ config.CheckUpdatesOnStart.Value = CheckUpdatesOnStart;
+ config.ShowConfirmExit.Value = ShowConfirmExit;
+ config.HideCursorOnIdle.Value = HideCursorOnIdle;
+
+ if (_directoryChanged)
+ {
+ List<string> gameDirs = new(GameDirectories);
+ config.Ui.GameDirs.Value = gameDirs;
+ }
+
+ config.Ui.EnableCustomTheme.Value = EnableCustomTheme;
+ config.Ui.CustomThemePath.Value = CustomThemePath;
+ config.Ui.BaseStyle.Value = BaseStyleIndex == 0 ? "Light" : "Dark";
+
+ // Input
+ config.System.EnableDockedMode.Value = EnableDockedMode;
+ config.Hid.EnableKeyboard.Value = EnableKeyboard;
+ config.Hid.EnableMouse.Value = EnableMouse;
+
+ // Keyboard Hotkeys
+ config.Hid.Hotkeys.Value = KeyboardHotkeys;
+
+ // System
+ config.System.Region.Value = (Region)Region;
+ config.System.Language.Value = (Language)Language;
+
+ if (_validTzRegions.Contains(TimeZone))
+ {
+ config.System.TimeZone.Value = TimeZone;
+ }
+
+ TimeSpan systemTimeOffset = DateOffset - DateTime.Now;
+
+ config.System.SystemTimeOffset.Value = systemTimeOffset.Seconds;
+ config.Graphics.EnableVsync.Value = EnableVsync;
+ config.System.EnableFsIntegrityChecks.Value = EnableFsIntegrityChecks;
+ config.System.ExpandRam.Value = ExpandDramSize;
+ config.System.IgnoreMissingServices.Value = IgnoreMissingServices;
+
+ // CPU
+ config.System.EnablePtc.Value = EnablePptc;
+ config.System.MemoryManagerMode.Value = (MemoryManagerMode)MemoryMode;
+ config.System.UseHypervisor.Value = UseHypervisor;
+
+ // Graphics
+ config.Graphics.GraphicsBackend.Value = (GraphicsBackend)GraphicsBackendIndex;
+ config.Graphics.PreferredGpu.Value = _gpuIds.ElementAtOrDefault(PreferredGpuIndex);
+ config.Graphics.EnableShaderCache.Value = EnableShaderCache;
+ config.Graphics.EnableTextureRecompression.Value = EnableTextureRecompression;
+ config.Graphics.EnableMacroHLE.Value = EnableMacroHLE;
+ config.Graphics.ResScale.Value = ResolutionScale == 4 ? -1 : ResolutionScale + 1;
+ config.Graphics.ResScaleCustom.Value = CustomResolutionScale;
+ config.Graphics.MaxAnisotropy.Value = MaxAnisotropy == 0 ? -1 : MathF.Pow(2, MaxAnisotropy);
+ config.Graphics.AspectRatio.Value = (AspectRatio)AspectRatio;
+ config.Graphics.AntiAliasing.Value = (AntiAliasing)AntiAliasingEffect;
+ config.Graphics.ScalingFilter.Value = (ScalingFilter)ScalingFilter;
+ config.Graphics.ScalingFilterLevel.Value = ScalingFilterLevel;
+
+ if (ConfigurationState.Instance.Graphics.BackendThreading != (BackendThreading)GraphicsBackendMultithreadingIndex)
+ {
+ DriverUtilities.ToggleOGLThreading(GraphicsBackendMultithreadingIndex == (int)BackendThreading.Off);
+ }
+
+ config.Graphics.BackendThreading.Value = (BackendThreading)GraphicsBackendMultithreadingIndex;
+ config.Graphics.ShadersDumpPath.Value = ShaderDumpPath;
+
+ // Audio
+ AudioBackend audioBackend = (AudioBackend)AudioBackend;
+ if (audioBackend != config.System.AudioBackend.Value)
+ {
+ config.System.AudioBackend.Value = audioBackend;
+
+ Logger.Info?.Print(LogClass.Application, $"AudioBackend toggled to: {audioBackend}");
+ }
+
+ config.System.AudioVolume.Value = Volume / 100;
+
+ // Network
+ config.System.EnableInternetAccess.Value = EnableInternetAccess;
+
+ // Logging
+ config.Logger.EnableFileLog.Value = EnableFileLog;
+ config.Logger.EnableStub.Value = EnableStub;
+ config.Logger.EnableInfo.Value = EnableInfo;
+ config.Logger.EnableWarn.Value = EnableWarn;
+ config.Logger.EnableError.Value = EnableError;
+ config.Logger.EnableTrace.Value = EnableTrace;
+ config.Logger.EnableGuest.Value = EnableGuest;
+ config.Logger.EnableDebug.Value = EnableDebug;
+ config.Logger.EnableFsAccessLog.Value = EnableFsAccessLog;
+ config.System.FsGlobalAccessLogMode.Value = FsGlobalAccessLogMode;
+ config.Logger.GraphicsDebugLevel.Value = (GraphicsDebugLevel)OpenglDebugLevel;
+
+ config.Multiplayer.LanInterfaceId.Value = _networkInterfaces[NetworkInterfaceList[NetworkInterfaceIndex]];
+
+ config.ToFileFormat().SaveConfig(Program.ConfigurationPath);
+
+ MainWindow.UpdateGraphicsConfig();
+
+ SaveSettingsEvent?.Invoke();
+
+ _directoryChanged = false;
+ }
+
+ public void RevertIfNotSaved()
+ {
+ Program.ReloadConfig();
+ }
+
+ public void ApplyButton()
+ {
+ SaveSettings();
+ }
+
+ public void OkButton()
+ {
+ SaveSettings();
+ CloseWindow?.Invoke();
+ }
+
+ public void CancelButton()
+ {
+ RevertIfNotSaved();
+ CloseWindow?.Invoke();
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs
new file mode 100644
index 00000000..1f4e3c62
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/TitleUpdateViewModel.cs
@@ -0,0 +1,252 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Threading;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ns;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Helpers;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.Common.Configuration;
+using Ryujinx.Common.Logging;
+using Ryujinx.Common.Utilities;
+using Ryujinx.HLE.FileSystem;
+using Ryujinx.HLE.HOS;
+using Ryujinx.Ui.App.Common;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Path = System.IO.Path;
+using SpanHelpers = LibHac.Common.SpanHelpers;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class TitleUpdateViewModel : BaseModel
+ {
+ public TitleUpdateMetadata _titleUpdateWindowData;
+ public readonly string _titleUpdateJsonPath;
+ private VirtualFileSystem _virtualFileSystem { get; }
+ private ulong _titleId { get; }
+ private string _titleName { get; }
+
+ private AvaloniaList<TitleUpdateModel> _titleUpdates = new();
+ private AvaloniaList<object> _views = new();
+ private object _selectedUpdate;
+
+ private static readonly TitleUpdateMetadataJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
+
+ public AvaloniaList<TitleUpdateModel> TitleUpdates
+ {
+ get => _titleUpdates;
+ set
+ {
+ _titleUpdates = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public AvaloniaList<object> Views
+ {
+ get => _views;
+ set
+ {
+ _views = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public object SelectedUpdate
+ {
+ get => _selectedUpdate;
+ set
+ {
+ _selectedUpdate = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public TitleUpdateViewModel(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName)
+ {
+ _virtualFileSystem = virtualFileSystem;
+
+ _titleId = titleId;
+ _titleName = titleName;
+
+ _titleUpdateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "updates.json");
+
+ try
+ {
+ _titleUpdateWindowData = JsonHelper.DeserializeFromFile(_titleUpdateJsonPath, SerializerContext.TitleUpdateMetadata);
+ }
+ catch
+ {
+ Logger.Warning?.Print(LogClass.Application, $"Failed to deserialize title update data for {_titleId} at {_titleUpdateJsonPath}");
+
+ _titleUpdateWindowData = new TitleUpdateMetadata
+ {
+ Selected = "",
+ Paths = new List<string>()
+ };
+
+ Save();
+ }
+
+ LoadUpdates();
+ }
+
+ private void LoadUpdates()
+ {
+ foreach (string path in _titleUpdateWindowData.Paths)
+ {
+ AddUpdate(path);
+ }
+
+ TitleUpdateModel selected = TitleUpdates.FirstOrDefault(x => x.Path == _titleUpdateWindowData.Selected, null);
+
+ SelectedUpdate = selected;
+
+ // NOTE: Save the list again to remove leftovers.
+ Save();
+ SortUpdates();
+ }
+
+ public void SortUpdates()
+ {
+ var list = TitleUpdates.ToList();
+
+ list.Sort((first, second) =>
+ {
+ if (string.IsNullOrEmpty(first.Control.DisplayVersionString.ToString()))
+ {
+ return -1;
+ }
+ else if (string.IsNullOrEmpty(second.Control.DisplayVersionString.ToString()))
+ {
+ return 1;
+ }
+
+ return Version.Parse(first.Control.DisplayVersionString.ToString()).CompareTo(Version.Parse(second.Control.DisplayVersionString.ToString())) * -1;
+ });
+
+ Views.Clear();
+ Views.Add(new BaseModel());
+ Views.AddRange(list);
+
+ if (SelectedUpdate == null)
+ {
+ SelectedUpdate = Views[0];
+ }
+ else if (!TitleUpdates.Contains(SelectedUpdate))
+ {
+ if (Views.Count > 1)
+ {
+ SelectedUpdate = Views[1];
+ }
+ else
+ {
+ SelectedUpdate = Views[0];
+ }
+ }
+ }
+
+ private void AddUpdate(string path)
+ {
+ if (File.Exists(path) && TitleUpdates.All(x => x.Path != path))
+ {
+ using FileStream file = new(path, FileMode.Open, FileAccess.Read);
+
+ try
+ {
+ (Nca patchNca, Nca controlNca) = ApplicationLibrary.GetGameUpdateDataFromPartition(_virtualFileSystem, new PartitionFileSystem(file.AsStorage()), _titleId.ToString("x16"), 0);
+
+ if (controlNca != null && patchNca != null)
+ {
+ ApplicationControlProperty controlData = new();
+
+ using UniqueRef<IFile> nacpFile = new();
+
+ controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None).OpenFile(ref nacpFile.Ref, "/control.nacp".ToU8Span(), OpenMode.Read).ThrowIfFailure();
+ nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None).ThrowIfFailure();
+
+ TitleUpdates.Add(new TitleUpdateModel(controlData, path));
+ }
+ else
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance[LocaleKeys.DialogUpdateAddUpdateErrorMessage]);
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogLoadNcaErrorMessage, ex.Message, path));
+ });
+ }
+ }
+ }
+
+ public void RemoveUpdate(TitleUpdateModel update)
+ {
+ TitleUpdates.Remove(update);
+
+ SortUpdates();
+ }
+
+ public async void Add()
+ {
+ OpenFileDialog dialog = new()
+ {
+ Title = LocaleManager.Instance[LocaleKeys.SelectUpdateDialogTitle],
+ AllowMultiple = true
+ };
+
+ dialog.Filters.Add(new FileDialogFilter
+ {
+ Name = "NSP",
+ Extensions = { "nsp" }
+ });
+
+ if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ string[] files = await dialog.ShowAsync(desktop.MainWindow);
+
+ if (files != null)
+ {
+ foreach (string file in files)
+ {
+ AddUpdate(file);
+ }
+ }
+ }
+
+ SortUpdates();
+ }
+
+ public void Save()
+ {
+ _titleUpdateWindowData.Paths.Clear();
+ _titleUpdateWindowData.Selected = "";
+
+ foreach (TitleUpdateModel update in TitleUpdates)
+ {
+ _titleUpdateWindowData.Paths.Add(update.Path);
+
+ if (update == SelectedUpdate)
+ {
+ _titleUpdateWindowData.Selected = update.Path;
+ }
+ }
+
+ JsonHelper.SerializeToFile(_titleUpdateJsonPath, _titleUpdateWindowData, SerializerContext.TitleUpdateMetadata);
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs
new file mode 100644
index 00000000..558cad5a
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserFirmwareAvatarSelectorViewModel.cs
@@ -0,0 +1,230 @@
+using Avalonia.Media;
+using LibHac.Common;
+using LibHac.Fs;
+using LibHac.Fs.Fsa;
+using LibHac.FsSystem;
+using LibHac.Ncm;
+using LibHac.Tools.Fs;
+using LibHac.Tools.FsSystem;
+using LibHac.Tools.FsSystem.NcaUtils;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.FileSystem;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+using System;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using Color = Avalonia.Media.Color;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ internal class UserFirmwareAvatarSelectorViewModel : BaseModel
+ {
+ private static readonly Dictionary<string, byte[]> _avatarStore = new();
+
+ private ObservableCollection<ProfileImageModel> _images;
+ private Color _backgroundColor = Colors.White;
+
+ private int _selectedIndex;
+ private byte[] _selectedImage;
+
+ public UserFirmwareAvatarSelectorViewModel()
+ {
+ _images = new ObservableCollection<ProfileImageModel>();
+
+ LoadImagesFromStore();
+ }
+
+ public Color BackgroundColor
+ {
+ get => _backgroundColor;
+ set
+ {
+ _backgroundColor = value;
+ OnPropertyChanged();
+ ChangeImageBackground();
+ }
+ }
+
+ public ObservableCollection<ProfileImageModel> Images
+ {
+ get => _images;
+ set
+ {
+ _images = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public int SelectedIndex
+ {
+ get => _selectedIndex;
+ set
+ {
+ _selectedIndex = value;
+
+ if (_selectedIndex == -1)
+ {
+ SelectedImage = null;
+ }
+ else
+ {
+ SelectedImage = _images[_selectedIndex].Data;
+ }
+
+ OnPropertyChanged();
+ }
+ }
+
+ public byte[] SelectedImage
+ {
+ get => _selectedImage;
+ private set => _selectedImage = value;
+ }
+
+ private void LoadImagesFromStore()
+ {
+ Images.Clear();
+
+ foreach (var image in _avatarStore)
+ {
+ Images.Add(new ProfileImageModel(image.Key, image.Value));
+ }
+ }
+
+ private void ChangeImageBackground()
+ {
+ foreach (var image in Images)
+ {
+ image.BackgroundColor = new SolidColorBrush(BackgroundColor);
+ }
+ }
+
+ public static void PreloadAvatars(ContentManager contentManager, VirtualFileSystem virtualFileSystem)
+ {
+ if (_avatarStore.Count > 0)
+ {
+ return;
+ }
+
+ string contentPath = contentManager.GetInstalledContentPath(0x010000000000080A, StorageId.BuiltInSystem, NcaContentType.Data);
+ string avatarPath = virtualFileSystem.SwitchPathToSystemPath(contentPath);
+
+ if (!string.IsNullOrWhiteSpace(avatarPath))
+ {
+ using (IStorage ncaFileStream = new LocalStorage(avatarPath, FileAccess.Read, FileMode.Open))
+ {
+ Nca nca = new(virtualFileSystem.KeySet, ncaFileStream);
+ IFileSystem romfs = nca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.ErrorOnInvalid);
+
+ foreach (DirectoryEntryEx item in romfs.EnumerateEntries())
+ {
+ // TODO: Parse DatabaseInfo.bin and table.bin files for more accuracy.
+ if (item.Type == DirectoryEntryType.File && item.FullPath.Contains("chara") && item.FullPath.Contains("szs"))
+ {
+ using var file = new UniqueRef<IFile>();
+
+ romfs.OpenFile(ref file.Ref, ("/" + item.FullPath).ToU8Span(), OpenMode.Read).ThrowIfFailure();
+
+ using (MemoryStream stream = new())
+ using (MemoryStream streamPng = new())
+ {
+ file.Get.AsStream().CopyTo(stream);
+
+ stream.Position = 0;
+
+ Image avatarImage = Image.LoadPixelData<Rgba32>(DecompressYaz0(stream), 256, 256);
+
+ avatarImage.SaveAsPng(streamPng);
+
+ _avatarStore.Add(item.FullPath, streamPng.ToArray());
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private static byte[] DecompressYaz0(Stream stream)
+ {
+ using (BinaryReader reader = new(stream))
+ {
+ reader.ReadInt32(); // Magic
+
+ uint decodedLength = BinaryPrimitives.ReverseEndianness(reader.ReadUInt32());
+
+ reader.ReadInt64(); // Padding
+
+ byte[] input = new byte[stream.Length - stream.Position];
+ stream.Read(input, 0, input.Length);
+
+ uint inputOffset = 0;
+
+ byte[] output = new byte[decodedLength];
+ uint outputOffset = 0;
+
+ ushort mask = 0;
+ byte header = 0;
+
+ while (outputOffset < decodedLength)
+ {
+ if ((mask >>= 1) == 0)
+ {
+ header = input[inputOffset++];
+ mask = 0x80;
+ }
+
+ if ((header & mask) != 0)
+ {
+ if (outputOffset == output.Length)
+ {
+ break;
+ }
+
+ output[outputOffset++] = input[inputOffset++];
+ }
+ else
+ {
+ byte byte1 = input[inputOffset++];
+ byte byte2 = input[inputOffset++];
+
+ uint dist = (uint)((byte1 & 0xF) << 8) | byte2;
+ uint position = outputOffset - (dist + 1);
+
+ uint length = (uint)byte1 >> 4;
+ if (length == 0)
+ {
+ length = (uint)input[inputOffset++] + 0x12;
+ }
+ else
+ {
+ length += 2;
+ }
+
+ uint gap = outputOffset - position;
+ uint nonOverlappingLength = length;
+
+ if (nonOverlappingLength > gap)
+ {
+ nonOverlappingLength = gap;
+ }
+
+ Buffer.BlockCopy(output, (int)position, output, (int)outputOffset, (int)nonOverlappingLength);
+ outputOffset += nonOverlappingLength;
+ position += nonOverlappingLength;
+ length -= nonOverlappingLength;
+
+ while (length-- > 0)
+ {
+ output[outputOffset++] = output[position++];
+ }
+ }
+ }
+
+ return output;
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs
new file mode 100644
index 00000000..7261631c
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserProfileImageSelectorViewModel.cs
@@ -0,0 +1,18 @@
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ internal class UserProfileImageSelectorViewModel : BaseModel
+ {
+ private bool _firmwareFound;
+
+ public bool FirmwareFound
+ {
+ get => _firmwareFound;
+
+ set
+ {
+ _firmwareFound = value;
+ OnPropertyChanged();
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs
new file mode 100644
index 00000000..8f997efc
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs
@@ -0,0 +1,25 @@
+using Microsoft.IdentityModel.Tokens;
+using System;
+using System.Collections.ObjectModel;
+using UserProfile = Ryujinx.Ava.UI.Models.UserProfile;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class UserProfileViewModel : BaseModel, IDisposable
+ {
+ public UserProfileViewModel()
+ {
+ Profiles = new ObservableCollection<BaseModel>();
+ LostProfiles = new ObservableCollection<UserProfile>();
+ IsEmpty = LostProfiles.IsNullOrEmpty();
+ }
+
+ public ObservableCollection<BaseModel> Profiles { get; set; }
+
+ public ObservableCollection<UserProfile> LostProfiles { get; set; }
+
+ public bool IsEmpty { get; set; }
+
+ public void Dispose() { }
+ }
+} \ No newline at end of file
diff --git a/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs b/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs
new file mode 100644
index 00000000..097634a8
--- /dev/null
+++ b/src/Ryujinx.Ava/UI/ViewModels/UserSaveManagerViewModel.cs
@@ -0,0 +1,120 @@
+using DynamicData;
+using DynamicData.Binding;
+using Ryujinx.Ava.Common.Locale;
+using Ryujinx.Ava.UI.Models;
+using Ryujinx.HLE.HOS.Services.Account.Acc;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+
+namespace Ryujinx.Ava.UI.ViewModels
+{
+ public class UserSaveManagerViewModel : BaseModel
+ {
+ private int _sortIndex;
+ private int _orderIndex;
+ private string _search;
+ private ObservableCollection<SaveModel> _saves = new();
+ private ObservableCollection<SaveModel> _views = new();
+ private AccountManager _accountManager;
+
+ public string SaveManagerHeading => LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.SaveManagerHeading, _accountManager.LastOpenedUser.Name, _accountManager.LastOpenedUser.UserId);
+
+ public int SortIndex
+ {
+ get => _sortIndex;
+ set
+ {
+ _sortIndex = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public int OrderIndex
+ {
+ get => _orderIndex;
+ set
+ {
+ _orderIndex = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public string Search
+ {
+ get => _search;
+ set
+ {
+ _search = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public ObservableCollection<SaveModel> Saves
+ {
+ get => _saves;
+ set
+ {
+ _saves = value;
+ OnPropertyChanged();
+ Sort();
+ }
+ }
+
+ public ObservableCollection<SaveModel> Views
+ {
+ get => _views;
+ set
+ {
+ _views = value;
+ OnPropertyChanged();
+ }
+ }
+
+ public UserSaveManagerViewModel(AccountManager accountManager)
+ {
+ _accountManager = accountManager;
+ }
+
+ public void Sort()
+ {
+ Saves.AsObservableChangeSet()
+ .Filter(Filter)
+ .Sort(GetComparer())
+ .Bind(out var view).AsObservableList();
+
+ _views.Clear();
+ _views.AddRange(view);
+ OnPropertyChanged(nameof(Views));
+ }
+
+ private bool Filter(object arg)
+ {
+ if (arg is SaveModel save)
+ {
+ return string.IsNullOrWhiteSpace(_search) || save.Title.ToLower().Contains(_search.ToLower());
+ }
+
+ return false;
+ }
+
+ private IComparer<SaveModel> GetComparer()
+ {
+ switch (SortIndex)
+ {
+ case 0:
+ return OrderIndex == 0
+ ? SortExpressionComparer<SaveModel>.Ascending(save => save.Title)
+ : SortExpressionComparer<SaveModel>.Descending(save => save.Title);
+ case 1:
+ return OrderIndex == 0
+ ? SortExpressionComparer<SaveModel>.Ascending(save => save.Size)
+ : SortExpressionComparer<SaveModel>.Descending(save => save.Size);
+ default:
+ return null;
+ }
+ }
+ }
+} \ No newline at end of file