diff options
| author | TSR Berry <20988865+TSRBerry@users.noreply.github.com> | 2023-04-08 01:22:00 +0200 |
|---|---|---|
| committer | Mary <thog@protonmail.com> | 2023-04-27 23:51:14 +0200 |
| commit | cee712105850ac3385cd0091a923438167433f9f (patch) | |
| tree | 4a5274b21d8b7f938c0d0ce18736d3f2993b11b1 /src/Ryujinx.Ava/UI/ViewModels | |
| parent | cd124bda587ef09668a971fa1cac1c3f0cfc9f21 (diff) | |
Move solution and projects to src
Diffstat (limited to 'src/Ryujinx.Ava/UI/ViewModels')
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 |
