diff options
| author | Isaac Marovitz <42140194+IsaacMarovitz@users.noreply.github.com> | 2022-12-29 14:24:05 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-12-29 15:24:05 +0100 |
| commit | 76671d63d4f3ea18f8ad99e9ce9f0b2ec9a2599d (patch) | |
| tree | 05013214e4696a9254369d0706173f58877f6a83 /Ryujinx.Ava/UI/ViewModels | |
| parent | 3d1a0bf3749afa14da5b5ba1e0666fdb78c99beb (diff) | |
Ava GUI: Restructure `Ryujinx.Ava` (#4165)
* Restructure `Ryujinx.Ava`
* Stylistic consistency
* Update Ryujinx.Ava/UI/Controls/UserEditor.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Controls/UserEditor.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Controls/UserSelector.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Controls/SaveManager.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Controls/SaveManager.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Windows/SettingsWindow.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Helpers/EmbeddedWindow.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Helpers/EmbeddedWindow.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Helpers/EmbeddedWindow.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Helpers/EmbeddedWindow.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Windows/SettingsWindow.axaml.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Update Ryujinx.Ava/UI/Helpers/EmbeddedWindow.cs
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
* Fix redundancies
* Remove redunancies
* Add back elses
Co-authored-by: TSRBerry <20988865+TSRBerry@users.noreply.github.com>
Diffstat (limited to 'Ryujinx.Ava/UI/ViewModels')
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs | 452 | ||||
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs | 363 | ||||
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/BaseModel.cs | 15 | ||||
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs | 901 | ||||
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs | 1540 | ||||
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs | 516 | ||||
| -rw-r--r-- | Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs | 215 |
7 files changed, 4002 insertions, 0 deletions
diff --git a/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs b/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs new file mode 100644 index 00000000..cf94a9aa --- /dev/null +++ b/Ryujinx.Ava/UI/ViewModels/AmiiboWindowViewModel.cs @@ -0,0 +1,452 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Controls; +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.Utilities; +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.Text.Json; +using System.Threading.Tasks; + +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<Amiibo.AmiiboApi> _amiiboList; + private AvaloniaList<Amiibo.AmiiboApi> _amiibos; + private ObservableCollection<string> _amiiboSeries; + + private int _amiiboSelectedIndex; + private int _seriesSelectedIndex; + private bool _enableScanning; + private bool _showAllAmiibo; + private bool _useRandomUuid; + private string _usage; + + public AmiiboWindowViewModel(StyleableWindow owner, string lastScannedAmiiboId, string titleId) + { + _owner = owner; + _httpClient = new HttpClient { Timeout = TimeSpan.FromMilliseconds(5000) }; + LastScannedAmiiboId = lastScannedAmiiboId; + TitleId = titleId; + + Directory.CreateDirectory(Path.Join(AppDataManager.BaseDirPath, "system", "amiibo")); + + _amiiboJsonPath = Path.Join(AppDataManager.BaseDirPath, "system", "amiibo", "Amiibo.json"); + _amiiboList = new List<Amiibo.AmiiboApi>(); + _amiiboSeries = new ObservableCollection<string>(); + _amiibos = new AvaloniaList<Amiibo.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; + +#pragma warning disable 4014 + ParseAmiiboData(); +#pragma warning restore 4014 + + OnPropertyChanged(); + } + } + + public AvaloniaList<Amiibo.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 = File.ReadAllText(_amiiboJsonPath); + + if (await NeedsUpdate(JsonHelper.Deserialize<Amiibo.AmiiboJson>(amiiboJsonString).LastUpdated)) + { + amiiboJsonString = await DownloadAmiiboJson(); + } + } + else + { + try + { + amiiboJsonString = await DownloadAmiiboJson(); + } + catch + { + ShowInfoDialog(); + } + } + + _amiiboList = JsonHelper.Deserialize<Amiibo.AmiiboJson>(amiiboJsonString).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 (Amiibo.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() + { + Amiibo.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<Amiibo.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 (Amiibo.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; + } + + Amiibo.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 (Amiibo.AmiiboApiGamesSwitch item in _amiiboList[i].GamesSwitch) + { + if (item.GameId.Contains(TitleId)) + { + foreach (Amiibo.AmiiboApiUsage usageItem in item.AmiiboUsage) + { + usageString += Environment.NewLine + + $"- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}"; + + writable = usageItem.Write; + } + } + } + + if (usageString.Length == 0) + { + usageString = LocaleManager.Instance["Unknown"] + "."; + } + + Usage = $"{LocaleManager.Instance["Usage"]} {(writable ? $" ({LocaleManager.Instance["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 + { + 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; + } + + await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance["DialogAmiiboApiTitle"], + LocaleManager.Instance["DialogAmiiboApiFailFetchMessage"], + LocaleManager.Instance["InputDialogOk"], + "", + LocaleManager.Instance["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)); + } + } + } + + private void ResetAmiiboPreview() + { + using (MemoryStream memoryStream = new(_amiiboLogoBytes)) + { + Bitmap bitmap = new(memoryStream); + + AmiiboImage = bitmap; + } + } + + private async void ShowInfoDialog() + { + await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance["DialogAmiiboApiTitle"], + LocaleManager.Instance["DialogAmiiboApiConnectErrorMessage"], + LocaleManager.Instance["InputDialogOk"], + "", + LocaleManager.Instance["RyujinxInfo"]); + } + } +}
\ No newline at end of file diff --git a/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs b/Ryujinx.Ava/UI/ViewModels/AvatarProfileViewModel.cs new file mode 100644 index 00000000..1d090623 --- /dev/null +++ b/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/Ryujinx.Ava/UI/ViewModels/BaseModel.cs b/Ryujinx.Ava/UI/ViewModels/BaseModel.cs new file mode 100644 index 00000000..5a3717fd --- /dev/null +++ b/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/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs b/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs new file mode 100644 index 00000000..692d2a8c --- /dev/null +++ b/Ryujinx.Ava/UI/ViewModels/ControllerSettingsViewModel.cs @@ -0,0 +1,901 @@ +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.Controls; +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; + + 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.AppHost != null) + { + _mainWindow.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["ControllerSettingsPlayer1"])); + PlayerIndexes.Add(new(PlayerIndex.Player2, LocaleManager.Instance["ControllerSettingsPlayer2"])); + PlayerIndexes.Add(new(PlayerIndex.Player3, LocaleManager.Instance["ControllerSettingsPlayer3"])); + PlayerIndexes.Add(new(PlayerIndex.Player4, LocaleManager.Instance["ControllerSettingsPlayer4"])); + PlayerIndexes.Add(new(PlayerIndex.Player5, LocaleManager.Instance["ControllerSettingsPlayer5"])); + PlayerIndexes.Add(new(PlayerIndex.Player6, LocaleManager.Instance["ControllerSettingsPlayer6"])); + PlayerIndexes.Add(new(PlayerIndex.Player7, LocaleManager.Instance["ControllerSettingsPlayer7"])); + PlayerIndexes.Add(new(PlayerIndex.Player8, LocaleManager.Instance["ControllerSettingsPlayer8"])); + PlayerIndexes.Add(new(PlayerIndex.Handheld, LocaleManager.Instance["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["ControllerSettingsControllerTypeHandheld"])); + + Controller = 0; + } + else + { + Controllers.Add(new(ControllerType.ProController, LocaleManager.Instance["ControllerSettingsControllerTypeProController"])); + Controllers.Add(new(ControllerType.JoyconPair, LocaleManager.Instance["ControllerSettingsControllerTypeJoyConPair"])); + Controllers.Add(new(ControllerType.JoyconLeft, LocaleManager.Instance["ControllerSettingsControllerTypeJoyConLeft"])); + Controllers.Add(new(ControllerType.JoyconRight, LocaleManager.Instance["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.Substring(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["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["ControllerSettingsProfileDefault"])); + + foreach (string profile in Directory.GetFiles(basePath, "*.json", SearchOption.AllDirectories)) + { + ProfilesList.Add(Path.GetFileNameWithoutExtension(profile)); + } + + if (string.IsNullOrWhiteSpace(ProfileName)) + { + ProfileName = LocaleManager.Instance["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["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 + { + using (Stream stream = File.OpenRead(path)) + { + config = JsonHelper.Deserialize<InputConfig>(stream); + } + } + catch (JsonException) { } + catch (InvalidOperationException) + { + Logger.Error?.Print(LogClass.Configuration, $"Profile {ProfileName} is incompatible with the current input configuration system."); + + await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance["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["ControllerSettingsProfileDefault"]) + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance["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, true); + + await File.WriteAllTextAsync(path, jsonString); + + LoadProfiles(); + } + else + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance["DialogProfileInvalidProfileNameErrorMessage"]); + } + } + } + + public async void RemoveProfile() + { + if (Device == 0 || ProfileName == LocaleManager.Instance["ControllerSettingsProfileDefault"] || ProfilesList.IndexOf(ProfileName) == -1) + { + return; + } + + UserResult result = await ContentDialogHelper.CreateConfirmationDialog( + LocaleManager.Instance["DialogProfileDeleteProfileTitle"], + LocaleManager.Instance["DialogProfileDeleteProfileMessage"], + LocaleManager.Instance["InputDialogYes"], + LocaleManager.Instance["InputDialogNo"], + LocaleManager.Instance["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.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.AppHost?.NpadManager.UnblockInputUpdates(); + + SelectedGamepad?.Dispose(); + + AvaloniaKeyboardDriver.Dispose(); + } + } +}
\ No newline at end of file diff --git a/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs b/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..e6d97193 --- /dev/null +++ b/Ryujinx.Ava/UI/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,1540 @@ +using ARMeilleure.Translation.PTC; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using DynamicData; +using DynamicData.Binding; +using LibHac.Fs; +using LibHac.FsSystem; +using LibHac.Ncm; +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.Windows; +using Ryujinx.Common; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Logging; +using Ryujinx.HLE; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using Ryujinx.Modules; +using Ryujinx.Ui.App.Common; +using Ryujinx.Ui.Common; +using Ryujinx.Ui.Common.Configuration; +using Ryujinx.Ui.Common.Helper; +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; + +namespace Ryujinx.Ava.UI.ViewModels +{ + internal class MainWindowViewModel : BaseModel + { + private const int HotKeyPressDelayMs = 500; + + private readonly MainWindow _owner; + 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 _isLoading; + 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 ReadOnlyObservableCollection<ApplicationData> _appsObservableList; + public ApplicationLibrary ApplicationLibrary => _owner.ApplicationLibrary; + + public string TitleName { get; internal set; } + + public MainWindowViewModel(MainWindow owner) : this() + { + _owner = owner; + } + + public MainWindowViewModel() + { + Applications = new ObservableCollection<ApplicationData>(); + + Applications.ToObservableChangeSet() + .Filter(Filter) + .Sort(GetComparer()) + .Bind(out _appsObservableList).AsObservableList(); + + if (Program.PreviewerDetached) + { + LoadConfigurableHotKeys(); + + Volume = ConfigurationState.Instance.System.AudioVolume; + } + } + + public void Initialize() + { + ApplicationLibrary.ApplicationCountUpdated += ApplicationLibrary_ApplicationCountUpdated; + ApplicationLibrary.ApplicationAdded += ApplicationLibrary_ApplicationAdded; + + Ptc.PtcStateChanged -= ProgressHandler; + Ptc.PtcStateChanged += ProgressHandler; + } + + 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 ReadOnlyObservableCollection<ApplicationData> AppsObservableList + { + get => _appsObservableList; + set + { + _appsObservableList = value; + + OnPropertyChanged(); + } + } + + public bool IsPaused + { + get => _isPaused; + set + { + _isPaused = 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(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(); + } + } + + private string _showUikey = "F4"; + private string _pauseKey = "F5"; + private string _screenshotkey = "F8"; + private float _volume; + private string _backendText; + + public ApplicationData SelectedApplication + { + get + { + return Glyph switch + { + Glyph.List => _owner.GameList.SelectedApplication, + Glyph.Grid => _owner.GameGrid.SelectedApplication, + _ => null, + }; + } + } + + 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) + { + _owner.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 ShowContent + { + get => _showContent; + set + { + _showContent = value; + + OnPropertyChanged(); + } + } + + public bool IsAppletMenuActive + { + get => _isAppletMenuActive && EnableNonGameRunningControls; + set + { + _isAppletMenuActive = 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(); + } + + 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)); + } + + 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 bool ShowConsoleVisible + { + get => ConsoleHelper.SetConsoleWindowStateSupported; + } + + 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)); + + 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 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 string SortName + { + get + { + return SortMode switch + { + ApplicationSort.Title => LocaleManager.Instance["GameListHeaderApplication"], + ApplicationSort.Developer => LocaleManager.Instance["GameListHeaderDeveloper"], + ApplicationSort.LastPlayed => LocaleManager.Instance["GameListHeaderLastPlayed"], + ApplicationSort.TotalTimePlayed => LocaleManager.Instance["GameListHeaderTimePlayed"], + ApplicationSort.FileType => LocaleManager.Instance["GameListHeaderFileExtension"], + ApplicationSort.FileSize => LocaleManager.Instance["GameListHeaderFileSize"], + ApplicationSort.Path => LocaleManager.Instance["GameListHeaderPath"], + ApplicationSort.Favorite => LocaleManager.Instance["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 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; + + 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(ShowNames)); + + ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath); + } + } + + public async void OpenAmiiboWindow() + { + if (!_isAmiiboRequested) + { + return; + } + + if (_owner.AppHost.Device.System.SearchingForAmiibo(out int deviceId)) + { + string titleId = _owner.AppHost.Device.Application.TitleIdText.ToUpper(); + AmiiboWindow window = new(_showAll, _lastScannedAmiiboId, titleId); + + await window.ShowDialog(_owner); + + if (window.IsScanned) + { + _showAll = window.ViewModel.ShowAllAmiibo; + _lastScannedAmiiboId = window.ScannedAmiibo.GetId(); + + _owner.AppHost.Device.System.ScanAmiibo(deviceId, _lastScannedAmiiboId, window.ViewModel.UseRandomUuid); + } + } + } + + public void HandleShaderProgress(Switch emulationContext) + { + emulationContext.Gpu.ShaderCacheStateChanged -= ProgressHandler; + emulationContext.Gpu.ShaderCacheStateChanged += ProgressHandler; + } + + private bool Filter(object arg) + { + if (arg is ApplicationData app) + { + return string.IsNullOrWhiteSpace(_searchText) || app.TitleName.ToLower().Contains(_searchText.ToLower()); + } + + return false; + } + + private void ApplicationLibrary_ApplicationAdded(object sender, ApplicationAddedEventArgs e) + { + AddApplication(e.AppData); + } + + private void ApplicationLibrary_ApplicationCountUpdated(object sender, ApplicationCountUpdatedEventArgs e) + { + StatusBarProgressValue = e.NumAppsLoaded; + StatusBarProgressMaximum = e.NumAppsFound; + + LocaleManager.Instance.UpdateDynamicValue("StatusBarGamesLoaded", StatusBarProgressValue, StatusBarProgressMaximum); + + Dispatcher.UIThread.Post(() => + { + if (e.NumAppsFound == 0) + { + _owner.LoadProgressBar.IsVisible = false; + } + + if (e.NumAppsLoaded == e.NumAppsFound) + { + _owner.LoadProgressBar.IsVisible = false; + } + }); + } + + public void AddApplication(ApplicationData applicationData) + { + Dispatcher.UIThread.InvokeAsync(() => + { + Applications.Add(applicationData); + }); + } + + public async void LoadApplications() + { + await Dispatcher.UIThread.InvokeAsync(() => + { + Applications.Clear(); + + _owner.LoadProgressBar.IsVisible = true; + StatusBarProgressMaximum = 0; + StatusBarProgressValue = 0; + + LocaleManager.Instance.UpdateDynamicValue("StatusBarGamesLoaded", 0, 0); + }); + + ReloadGameList(); + } + + private void ReloadGameList() + { + if (_isLoading) + { + return; + } + + _isLoading = true; + + Thread thread = new(() => + { + ApplicationLibrary.LoadApplications(ConfigurationState.Instance.Ui.GameDirs.Value, ConfigurationState.Instance.System.Language); + + _isLoading = false; + }) + { Name = "GUI.AppListLoadThread", Priority = ThreadPriority.AboveNormal }; + + thread.Start(); + } + + public async void OpenFile() + { + OpenFileDialog dialog = new() + { + Title = LocaleManager.Instance["OpenFileDialogTitle"] + }; + + dialog.Filters.Add(new FileDialogFilter + { + Name = LocaleManager.Instance["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(_owner); + + if (files != null && files.Length > 0) + { + _owner.LoadApplication(files[0]); + } + } + + public async void OpenFolder() + { + OpenFolderDialog dialog = new() + { + Title = LocaleManager.Instance["OpenFolderDialogTitle"] + }; + + string folder = await dialog.ShowAsync(_owner); + + if (!string.IsNullOrWhiteSpace(folder) && Directory.Exists(folder)) + { + _owner.LoadApplication(folder); + } + } + + public void LoadConfigurableHotKeys() + { + if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.ShowUi, out var showUiKey)) + { + ShowUiKey = new KeyGesture(showUiKey, KeyModifiers.None); + } + + if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.Screenshot, out var screenshotKey)) + { + ScreenshotKey = new KeyGesture(screenshotKey, KeyModifiers.None); + } + + if (AvaloniaKeyboardMappingHelper.TryGetAvaKey((Ryujinx.Input.Key)ConfigurationState.Instance.Hid.Hotkeys.Value.Pause, out var pauseKey)) + { + PauseKey = new KeyGesture(pauseKey, KeyModifiers.None); + } + } + + public void TakeScreenshot() + { + _owner.AppHost.ScreenshotRequested = true; + } + + public void HideUi() + { + ShowMenuAndStatusBar = false; + } + + public void SetListMode() + { + Glyph = Glyph.List; + } + + public void SetGridMode() + { + Glyph = Glyph.Grid; + } + + public void OpenMiiApplet() + { + string contentPath = _owner.ContentManager.GetInstalledContentPath(0x0100000000001009, StorageId.BuiltInSystem, NcaContentType.Program); + + if (!string.IsNullOrWhiteSpace(contentPath)) + { + _owner.LoadApplication(contentPath, false, "Mii Applet"); + } + } + + public static void OpenRyujinxFolder() + { + OpenHelper.OpenFolder(AppDataManager.BaseDirPath); + } + + public static void OpenLogsFolder() + { + string logPath = Path.Combine(ReleaseInformations.GetBaseApplicationDirectory(), "Logs"); + + new DirectoryInfo(logPath).Create(); + + OpenHelper.OpenFolder(logPath); + } + + public void ToggleFullscreen() + { + if (Environment.TickCount64 - _lastFullscreenToggle < HotKeyPressDelayMs) + { + return; + } + + _lastFullscreenToggle = Environment.TickCount64; + + if (_owner.WindowState == WindowState.FullScreen) + { + _owner.WindowState = WindowState.Normal; + + if (IsGameRunning) + { + ShowMenuAndStatusBar = true; + } + } + else + { + _owner.WindowState = WindowState.FullScreen; + + if (IsGameRunning) + { + ShowMenuAndStatusBar = false; + } + } + + OnPropertyChanged(nameof(IsFullScreen)); + } + + public bool IsFullScreen => _owner.WindowState == WindowState.FullScreen; + + public void ToggleDockMode() + { + if (IsGameRunning) + { + ConfigurationState.Instance.System.EnableDockedMode.Value = !ConfigurationState.Instance.System.EnableDockedMode.Value; + } + } + + public async void ExitCurrentState() + { + if (_owner.WindowState == WindowState.FullScreen) + { + ToggleFullscreen(); + } + else if (IsGameRunning) + { + await Task.Delay(100); + + _owner.AppHost?.ShowExitPrompt(); + } + } + + public async void OpenSettings() + { + _owner.SettingsWindow = new(_owner.VirtualFileSystem, _owner.ContentManager); + + await _owner.SettingsWindow.ShowDialog(_owner); + + LoadConfigurableHotKeys(); + } + + public async void ManageProfiles() + { + await NavigationDialogHost.Show(_owner.AccountManager, _owner.ContentManager, _owner.VirtualFileSystem, _owner.LibHacHorizonManager.RyujinxClient); + } + + public async void OpenAboutWindow() + { + await new AboutWindow().ShowDialog(_owner); + } + + public void ChangeLanguage(object obj) + { + LocaleManager.Instance.LoadDefaultLanguage(); + LocaleManager.Instance.LoadLanguage((string)obj); + } + + private void ProgressHandler<T>(T state, int current, int total) where T : Enum + { + try + { + ProgressMaximum = total; + ProgressValue = current; + + switch (state) + { + case PtcLoadingState ptcState: + CacheLoadStatus = $"{current} / {total}"; + switch (ptcState) + { + case PtcLoadingState.Start: + case PtcLoadingState.Loading: + LoadHeading = LocaleManager.Instance["CompilingPPTC"]; + IsLoadingIndeterminate = false; + break; + case PtcLoadingState.Loaded: + LoadHeading = string.Format(LocaleManager.Instance["LoadingHeading"], TitleName); + IsLoadingIndeterminate = true; + CacheLoadStatus = ""; + break; + } + break; + case ShaderCacheLoadingState shaderCacheState: + CacheLoadStatus = $"{current} / {total}"; + switch (shaderCacheState) + { + case ShaderCacheLoadingState.Start: + case ShaderCacheLoadingState.Loading: + LoadHeading = LocaleManager.Instance["CompilingShaders"]; + IsLoadingIndeterminate = false; + break; + case ShaderCacheLoadingState.Loaded: + LoadHeading = string.Format(LocaleManager.Instance["LoadingHeading"], TitleName); + IsLoadingIndeterminate = true; + CacheLoadStatus = ""; + break; + } + break; + default: + throw new ArgumentException($"Unknown Progress Handler type {typeof(T)}"); + } + } + catch (Exception) { } + } + + public void OpenUserSaveDirectory() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + Task.Run(() => + { + if (!ulong.TryParse(selection.TitleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdNumber)) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance["DialogRyujinxErrorMessage"], LocaleManager.Instance["DialogInvalidTitleIdErrorMessage"]); + }); + + return; + } + + UserId userId = new((ulong)_owner.AccountManager.LastOpenedUser.UserId.High, (ulong)_owner.AccountManager.LastOpenedUser.UserId.Low); + SaveDataFilter saveDataFilter = SaveDataFilter.Make(titleIdNumber, saveType: default, userId, saveDataId: default, index: default); + OpenSaveDirectory(in saveDataFilter, selection, titleIdNumber); + }); + } + } + + 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 OpenModsDirectory() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + string modsBasePath = _owner.VirtualFileSystem.ModLoader.GetModsBasePath(); + string titleModsPath = _owner.VirtualFileSystem.ModLoader.GetTitleDir(modsBasePath, selection.TitleId); + + OpenHelper.OpenFolder(titleModsPath); + } + } + + public void OpenSdModsDirectory() + { + ApplicationData selection = SelectedApplication; + + if (selection != null) + { + string sdModsBasePath = _owner.VirtualFileSystem.ModLoader.GetSdModsBasePath(); + string titleModsPath = _owner.VirtualFileSystem.ModLoader.GetTitleDir(sdModsBasePath, selection.TitleId); + + OpenHelper.OpenFolder(titleModsPath); + } + } + + 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["DialogWarning"], + string.Format(LocaleManager.Instance["DialogPPTCDeletionMessage"], selection.TitleName), + LocaleManager.Instance["InputDialogYes"], + LocaleManager.Instance["InputDialogNo"], + LocaleManager.Instance["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(string.Format(LocaleManager.Instance["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() + { + _owner.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["DialogWarning"], + string.Format(LocaleManager.Instance["DialogShaderDeletionMessage"], selection.TitleName), + LocaleManager.Instance["InputDialogYes"], + LocaleManager.Instance["InputDialogNo"], + LocaleManager.Instance["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(string.Format(LocaleManager.Instance["DialogPPTCDeletionErrorMessage"], directory.Name, e)); + } + } + } + + foreach (FileInfo file in newCacheFiles) + { + try + { + file.Delete(); + } + catch (Exception e) + { + await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance["ShaderCachePurgeError"], file.Name, e)); + } + } + } + } + + public async void CheckForUpdates() + { + if (Updater.CanUpdate(true, _owner)) + { + await Updater.BeginParse(_owner, true); + } + } + + public async void OpenTitleUpdateManager() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + await new TitleUpdateWindow(_owner.VirtualFileSystem, ulong.Parse(selection.TitleId, NumberStyles.HexNumber), selection.TitleName).ShowDialog(_owner); + } + } + + public async void OpenDownloadableContentManager() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + await new DownloadableContentManagerWindow(_owner.VirtualFileSystem, ulong.Parse(selection.TitleId, NumberStyles.HexNumber), selection.TitleName).ShowDialog(_owner); + } + } + + public async void OpenCheatManager() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + await new CheatWindow(_owner.VirtualFileSystem, selection.TitleId, selection.TitleName).ShowDialog(_owner); + } + } + + public async void OpenCheatManagerForCurrentApp() + { + if (!IsGameRunning) + { + return; + } + + ApplicationLoader application = _owner.AppHost.Device.Application; + if (application != null) + { + await new CheatWindow(_owner.VirtualFileSystem, application.TitleIdText, application.TitleName).ShowDialog(_owner); + + _owner.AppHost.Device.EnableCheats(); + } + } + + public void OpenDeviceSaveDirectory() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + Task.Run(() => + { + if (!ulong.TryParse(selection.TitleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdNumber)) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance["DialogRyujinxErrorMessage"], LocaleManager.Instance["DialogInvalidTitleIdErrorMessage"]); + }); + + return; + } + + var saveDataFilter = SaveDataFilter.Make(titleIdNumber, SaveDataType.Device, userId: default, saveDataId: default, index: default); + OpenSaveDirectory(in saveDataFilter, selection, titleIdNumber); + }); + } + } + + public void OpenBcatSaveDirectory() + { + ApplicationData selection = SelectedApplication; + if (selection != null) + { + Task.Run(() => + { + if (!ulong.TryParse(selection.TitleId, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out ulong titleIdNumber)) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance["DialogRyujinxErrorMessage"], LocaleManager.Instance["DialogInvalidTitleIdErrorMessage"]); + }); + + return; + } + + var saveDataFilter = SaveDataFilter.Make(titleIdNumber, SaveDataType.Bcat, userId: default, saveDataId: default, index: default); + OpenSaveDirectory(in saveDataFilter, selection, titleIdNumber); + }); + } + } + + private void OpenSaveDirectory(in SaveDataFilter filter, ApplicationData data, ulong titleId) + { + ApplicationHelper.OpenSaveDir(in filter, titleId, data.ControlHolder, data.TitleName); + } + + private async void ExtractLogo() + { + var selection = SelectedApplication; + if (selection != null) + { + await ApplicationHelper.ExtractSection(NcaSectionType.Logo, selection.Path); + } + } + + private async void ExtractRomFs() + { + var selection = SelectedApplication; + if (selection != null) + { + await ApplicationHelper.ExtractSection(NcaSectionType.Data, selection.Path); + } + } + + private async void ExtractExeFs() + { + var selection = SelectedApplication; + if (selection != null) + { + await ApplicationHelper.ExtractSection(NcaSectionType.Code, selection.Path); + } + } + + public void CloseWindow() + { + _owner.Close(); + } + + private async Task HandleFirmwareInstallation(string filename) + { + try + { + SystemVersion firmwareVersion = _owner.ContentManager.VerifyFirmwarePackage(filename); + + if (firmwareVersion == null) + { + await ContentDialogHelper.CreateErrorDialog(string.Format(LocaleManager.Instance["DialogFirmwareInstallerFirmwareNotFoundErrorMessage"], filename)); + + return; + } + + string dialogTitle = string.Format(LocaleManager.Instance["DialogFirmwareInstallerFirmwareInstallTitle"], firmwareVersion.VersionString); + + SystemVersion currentVersion = _owner.ContentManager.GetCurrentFirmwareVersion(); + + string dialogMessage = string.Format(LocaleManager.Instance["DialogFirmwareInstallerFirmwareInstallMessage"], firmwareVersion.VersionString); + + if (currentVersion != null) + { + dialogMessage += string.Format(LocaleManager.Instance["DialogFirmwareInstallerFirmwareInstallSubMessage"], currentVersion.VersionString); + } + + dialogMessage += LocaleManager.Instance["DialogFirmwareInstallerFirmwareInstallConfirmMessage"]; + + UserResult result = await ContentDialogHelper.CreateConfirmationDialog( + dialogTitle, + dialogMessage, + LocaleManager.Instance["InputDialogYes"], + LocaleManager.Instance["InputDialogNo"], + LocaleManager.Instance["RyujinxConfirm"]); + + UpdateWaitWindow waitingDialog = ContentDialogHelper.CreateWaitingDialog(dialogTitle, LocaleManager.Instance["DialogFirmwareInstallerFirmwareInstallWaitMessage"]); + + if (result == UserResult.Yes) + { + Logger.Info?.Print(LogClass.Application, $"Installing firmware {firmwareVersion.VersionString}"); + + Thread thread = new(() => + { + Dispatcher.UIThread.InvokeAsync(delegate + { + waitingDialog.Show(); + }); + + try + { + _owner.ContentManager.InstallFirmware(filename); + + Dispatcher.UIThread.InvokeAsync(async delegate + { + waitingDialog.Close(); + + string message = string.Format(LocaleManager.Instance["DialogFirmwareInstallerFirmwareInstallSuccessMessage"], firmwareVersion.VersionString); + + await ContentDialogHelper.CreateInfoDialog(dialogTitle, message, LocaleManager.Instance["InputDialogOk"], "", LocaleManager.Instance["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 + { + _owner.RefreshFirmwareStatus(); + } + }); + + thread.Name = "GUI.FirmwareInstallerThread"; + thread.Start(); + } + } + catch (LibHac.Common.Keys.MissingKeyException ex) + { + Logger.Error?.Print(LogClass.Application, ex.ToString()); + + Dispatcher.UIThread.Post(async () => await UserErrorDialog.ShowUserErrorDialog(UserError.NoKeys, _owner)); + } + catch (Exception ex) + { + await ContentDialogHelper.CreateErrorDialog(ex.Message); + } + } + + public async void InstallFirmwareFromFile() + { + OpenFileDialog dialog = new() { AllowMultiple = false }; + dialog.Filters.Add(new FileDialogFilter { Name = LocaleManager.Instance["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(_owner); + + if (file != null && file.Length > 0) + { + await HandleFirmwareInstallation(file[0]); + } + } + + public async void InstallFirmwareFromFolder() + { + OpenFolderDialog dialog = new(); + + string folder = await dialog.ShowAsync(_owner); + + if (!string.IsNullOrWhiteSpace(folder)) + { + await HandleFirmwareInstallation(folder); + } + } + } +}
\ No newline at end of file diff --git a/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs b/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs new file mode 100644 index 00000000..de1bde46 --- /dev/null +++ b/Ryujinx.Ava/UI/ViewModels/SettingsViewModel.cs @@ -0,0 +1,516 @@ +using Avalonia; +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.Input; +using Ryujinx.Ava.UI.Controls; +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.Input; +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 TimeZone = Ryujinx.Ava.UI.Models.TimeZone; + +namespace Ryujinx.Ava.UI.ViewModels +{ + internal class SettingsViewModel : BaseModel + { + private readonly VirtualFileSystem _virtualFileSystem; + private readonly ContentManager _contentManager; + private readonly StyleableWindow _owner; + private TimeZoneContentManager _timeZoneContentManager; + + private readonly List<string> _validTzRegions; + + private float _customResolutionScale; + private int _resolutionScale; + private int _graphicsBackendMultithreadingIndex; + private float _volume; + private bool _isVulkanAvailable = true; + private bool _directoryChanged = false; + private List<string> _gpuIds = new List<string>(); + private KeyboardHotkeys _keyboardHotkeys; + private int _graphicsBackendIndex; + + public int ResolutionScale + { + get => _resolutionScale; + set + { + _resolutionScale = value; + + OnPropertyChanged(nameof(CustomResolutionScale)); + OnPropertyChanged(nameof(IsCustomResolutionScaleActive)); + } + } + public int GraphicsBackendMultithreadingIndex + { + get => _graphicsBackendMultithreadingIndex; + set + { + _graphicsBackendMultithreadingIndex = value; + + if (_owner != null) + { + if (_graphicsBackendMultithreadingIndex != (int)ConfigurationState.Instance.Graphics.BackendThreading.Value) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance["DialogSettingsBackendThreadingWarningMessage"], + "", + "", + LocaleManager.Instance["InputDialogOk"], + LocaleManager.Instance["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 DirectoryChanged + { + get => _directoryChanged; + set + { + _directoryChanged = value; + + OnPropertyChanged(); + } + } + + public bool IsMacOS + { + get => 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 == 0; + public bool IsVulkanSelected => GraphicsBackendIndex == 0; + + public string TimeZone { get; set; } + public string ShaderDumpPath { get; set; } + public string CustomThemePath { get; set; } + + 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 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 PreferredGpuIndex { get; set; } + + public float Volume + { + get => _volume; + set + { + _volume = value; + + ConfigurationState.Instance.System.AudioVolume.Value = (float)(_volume / 100); + + OnPropertyChanged(); + } + } + + public DateTimeOffset DateOffset { get; set; } + public TimeSpan TimeOffset { get; set; } + public AvaloniaList<TimeZone> TimeZones { get; set; } + public AvaloniaList<string> GameDirectories { get; set; } + public ObservableCollection<ComboBoxItem> AvailableGpus { get; set; } + + public KeyboardHotkeys KeyboardHotkeys + { + get => _keyboardHotkeys; + set + { + _keyboardHotkeys = value; + + OnPropertyChanged(); + } + } + + public IGamepadDriver AvaloniaKeyboardDriver { get; } + + public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager, StyleableWindow owner) : this() + { + _virtualFileSystem = virtualFileSystem; + _contentManager = contentManager; + _owner = owner; + if (Program.PreviewerDetached) + { + LoadTimeZones(); + AvaloniaKeyboardDriver = new AvaloniaKeyboardDriver(owner); + } + } + + public SettingsViewModel() + { + GameDirectories = new AvaloniaList<string>(); + TimeZones = new AvaloniaList<TimeZone>(); + AvailableGpus = new ObservableCollection<ComboBoxItem>(); + _validTzRegions = new List<string>(); + + CheckSoundBackends(); + + if (Program.PreviewerDetached) + { + LoadAvailableGpus(); + LoadCurrentConfiguration(); + } + } + + public void CheckSoundBackends() + { + IsOpenAlEnabled = OpenALHardwareDeviceDriver.IsSupported; + IsSoundIoEnabled = SoundIoHardwareDeviceDriver.IsSupported; + IsSDL2Enabled = SDL2HardwareDeviceDriver.IsSupported; + } + + private unsafe void LoadAvailableGpus() + { + _gpuIds = new List<string>(); + List<string> names = new List<string>(); + 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); + } + } + + public void ValidateAndSetTimeZone(string location) + { + if (_validTzRegions.Contains(location)) + { + TimeZone = location; + } + } + + public async void BrowseTheme() + { + var dialog = new OpenFileDialog() + { + Title = LocaleManager.Instance["SettingsSelectThemeFileDialogTitle"], + AllowMultiple = false + }; + + dialog.Filters.Add(new FileDialogFilter() { Extensions = { "xaml" }, Name = LocaleManager.Instance["SettingsXamlThemeFile"] }); + + var file = await dialog.ShowAsync(_owner); + + if (file != null && file.Length > 0) + { + CustomThemePath = file[0]; + OnPropertyChanged(nameof(CustomThemePath)); + } + } + + public void LoadCurrentConfiguration() + { + ConfigurationState config = ConfigurationState.Instance; + + GameDirectories.Clear(); + GameDirectories.AddRange(config.Ui.GameDirs.Value); + + EnableDiscordIntegration = config.EnableDiscordIntegration; + CheckUpdatesOnStart = config.CheckUpdatesOnStart; + ShowConfirmExit = config.ShowConfirmExit; + HideCursorOnIdle = config.HideCursorOnIdle; + EnableDockedMode = config.System.EnableDockedMode; + EnableKeyboard = config.Hid.EnableKeyboard; + EnableMouse = config.Hid.EnableMouse; + EnableVsync = config.Graphics.EnableVsync; + EnablePptc = config.System.EnablePtc; + EnableInternetAccess = config.System.EnableInternetAccess; + EnableFsIntegrityChecks = config.System.EnableFsIntegrityChecks; + IgnoreMissingServices = config.System.IgnoreMissingServices; + ExpandDramSize = config.System.ExpandRam; + EnableShaderCache = config.Graphics.EnableShaderCache; + EnableTextureRecompression = config.Graphics.EnableTextureRecompression; + EnableMacroHLE = config.Graphics.EnableMacroHLE; + 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; + EnableCustomTheme = config.Ui.EnableCustomTheme; + Volume = config.System.AudioVolume * 100; + + GraphicsBackendMultithreadingIndex = (int)config.Graphics.BackendThreading.Value; + + OpenglDebugLevel = (int)config.Logger.GraphicsDebugLevel.Value; + + TimeZone = config.System.TimeZone; + ShaderDumpPath = config.Graphics.ShadersDumpPath; + CustomThemePath = config.Ui.CustomThemePath; + BaseStyleIndex = config.Ui.BaseStyle == "Light" ? 0 : 1; + GraphicsBackendIndex = (int)config.Graphics.GraphicsBackend.Value; + + PreferredGpuIndex = _gpuIds.Contains(config.Graphics.PreferredGpu) ? _gpuIds.IndexOf(config.Graphics.PreferredGpu) : 0; + + Language = (int)config.System.Language.Value; + Region = (int)config.System.Region.Value; + FsGlobalAccessLogMode = config.System.FsGlobalAccessLogMode; + AudioBackend = (int)config.System.AudioBackend.Value; + MemoryMode = (int)config.System.MemoryManagerMode.Value; + + float anisotropy = config.Graphics.MaxAnisotropy; + + MaxAnisotropy = anisotropy == -1 ? 0 : (int)(MathF.Log2(anisotropy)); + AspectRatio = (int)config.Graphics.AspectRatio.Value; + + int resolution = config.Graphics.ResScale; + + ResolutionScale = resolution == -1 ? 0 : resolution; + CustomResolutionScale = config.Graphics.ResScaleCustom; + + DateTime dateTimeOffset = DateTime.Now.AddSeconds(config.System.SystemTimeOffset); + + DateOffset = dateTimeOffset.Date; + TimeOffset = dateTimeOffset.TimeOfDay; + + KeyboardHotkeys = config.Hid.Hotkeys.Value; + } + + public void SaveSettings() + { + ConfigurationState config = ConfigurationState.Instance; + + if (_directoryChanged) + { + List<string> gameDirs = new List<string>(GameDirectories); + config.Ui.GameDirs.Value = gameDirs; + } + + if (_validTzRegions.Contains(TimeZone)) + { + config.System.TimeZone.Value = TimeZone; + } + + config.Logger.EnableError.Value = EnableError; + config.Logger.EnableTrace.Value = EnableTrace; + config.Logger.EnableWarn.Value = EnableWarn; + config.Logger.EnableInfo.Value = EnableInfo; + config.Logger.EnableStub.Value = EnableStub; + config.Logger.EnableDebug.Value = EnableDebug; + config.Logger.EnableGuest.Value = EnableGuest; + config.Logger.EnableFsAccessLog.Value = EnableFsAccessLog; + config.Logger.EnableFileLog.Value = EnableFileLog; + config.Logger.GraphicsDebugLevel.Value = (GraphicsDebugLevel)OpenglDebugLevel; + config.System.EnableDockedMode.Value = EnableDockedMode; + config.EnableDiscordIntegration.Value = EnableDiscordIntegration; + config.CheckUpdatesOnStart.Value = CheckUpdatesOnStart; + config.ShowConfirmExit.Value = ShowConfirmExit; + config.HideCursorOnIdle.Value = HideCursorOnIdle; + config.Graphics.EnableVsync.Value = EnableVsync; + config.Graphics.EnableShaderCache.Value = EnableShaderCache; + config.Graphics.EnableTextureRecompression.Value = EnableTextureRecompression; + config.Graphics.EnableMacroHLE.Value = EnableMacroHLE; + config.Graphics.GraphicsBackend.Value = (GraphicsBackend)GraphicsBackendIndex; + config.System.EnablePtc.Value = EnablePptc; + config.System.EnableInternetAccess.Value = EnableInternetAccess; + config.System.EnableFsIntegrityChecks.Value = EnableFsIntegrityChecks; + config.System.IgnoreMissingServices.Value = IgnoreMissingServices; + config.System.ExpandRam.Value = ExpandDramSize; + config.Hid.EnableKeyboard.Value = EnableKeyboard; + config.Hid.EnableMouse.Value = EnableMouse; + config.Ui.CustomThemePath.Value = CustomThemePath; + config.Ui.EnableCustomTheme.Value = EnableCustomTheme; + config.Ui.BaseStyle.Value = BaseStyleIndex == 0 ? "Light" : "Dark"; + config.System.Language.Value = (Language)Language; + config.System.Region.Value = (Region)Region; + + config.Graphics.PreferredGpu.Value = _gpuIds.ElementAtOrDefault(PreferredGpuIndex); + + if (ConfigurationState.Instance.Graphics.BackendThreading != (BackendThreading)GraphicsBackendMultithreadingIndex) + { + DriverUtilities.ToggleOGLThreading(GraphicsBackendMultithreadingIndex == (int)BackendThreading.Off); + } + + config.Graphics.BackendThreading.Value = (BackendThreading)GraphicsBackendMultithreadingIndex; + + TimeSpan systemTimeOffset = DateOffset - DateTime.Now; + + config.System.SystemTimeOffset.Value = systemTimeOffset.Seconds; + config.Graphics.ShadersDumpPath.Value = ShaderDumpPath; + config.System.FsGlobalAccessLogMode.Value = FsGlobalAccessLogMode; + config.System.MemoryManagerMode.Value = (MemoryManagerMode)MemoryMode; + + float anisotropy = MaxAnisotropy == 0 ? -1 : MathF.Pow(2, MaxAnisotropy); + + config.Graphics.MaxAnisotropy.Value = anisotropy; + config.Graphics.AspectRatio.Value = (AspectRatio)AspectRatio; + config.Graphics.ResScale.Value = ResolutionScale == 0 ? -1 : ResolutionScale; + config.Graphics.ResScaleCustom.Value = CustomResolutionScale; + config.System.AudioVolume.Value = Volume / 100; + + 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.Hid.Hotkeys.Value = KeyboardHotkeys; + + config.ToFileFormat().SaveConfig(Program.ConfigurationPath); + + MainWindow.UpdateGraphicsConfig(); + + if (_owner is SettingsWindow owner) + { + owner.ControllerSettings?.SaveCurrentProfile(); + } + + if (_owner.Owner is MainWindow window && _directoryChanged) + { + window.ViewModel.LoadApplications(); + } + + _directoryChanged = false; + } + + public void RevertIfNotSaved() + { + Program.ReloadConfig(); + } + + public void ApplyButton() + { + SaveSettings(); + } + + public void OkButton() + { + SaveSettings(); + _owner.Close(); + } + + public void CancelButton() + { + RevertIfNotSaved(); + _owner.Close(); + } + } +}
\ No newline at end of file diff --git a/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs b/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs new file mode 100644 index 00000000..7b2e1d39 --- /dev/null +++ b/Ryujinx.Ava/UI/ViewModels/UserProfileViewModel.cs @@ -0,0 +1,215 @@ +using Avalonia; +using Avalonia.Threading; +using FluentAvalonia.UI.Controls; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Shim; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.UI.Controls; +using Ryujinx.Ava.UI.Helpers; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using UserId = Ryujinx.HLE.HOS.Services.Account.Acc.UserId; +using UserProfile = Ryujinx.Ava.UI.Models.UserProfile; + +namespace Ryujinx.Ava.UI.ViewModels +{ + public class UserProfileViewModel : BaseModel, IDisposable + { + private readonly NavigationDialogHost _owner; + + private UserProfile _selectedProfile; + private UserProfile _highlightedProfile; + + public UserProfileViewModel() + { + Profiles = new ObservableCollection<UserProfile>(); + LostProfiles = new ObservableCollection<UserProfile>(); + } + + public UserProfileViewModel(NavigationDialogHost owner) : this() + { + _owner = owner; + + LoadProfiles(); + } + + public ObservableCollection<UserProfile> Profiles { get; set; } + + public ObservableCollection<UserProfile> LostProfiles { get; set; } + + public UserProfile SelectedProfile + { + get => _selectedProfile; + set + { + _selectedProfile = value; + + OnPropertyChanged(); + OnPropertyChanged(nameof(IsHighlightedProfileDeletable)); + OnPropertyChanged(nameof(IsHighlightedProfileEditable)); + } + } + + public bool IsHighlightedProfileEditable => _highlightedProfile != null; + + public bool IsHighlightedProfileDeletable => _highlightedProfile != null && _highlightedProfile.UserId != AccountManager.DefaultUserId; + + public UserProfile HighlightedProfile + { + get => _highlightedProfile; + set + { + _highlightedProfile = value; + + OnPropertyChanged(); + OnPropertyChanged(nameof(IsHighlightedProfileDeletable)); + OnPropertyChanged(nameof(IsHighlightedProfileEditable)); + } + } + + public void Dispose() { } + + public void LoadProfiles() + { + Profiles.Clear(); + LostProfiles.Clear(); + + var profiles = _owner.AccountManager.GetAllUsers().OrderByDescending(x => x.AccountState == AccountState.Open); + + foreach (var profile in profiles) + { + Profiles.Add(new UserProfile(profile, _owner)); + } + + SelectedProfile = Profiles.FirstOrDefault(x => x.UserId == _owner.AccountManager.LastOpenedUser.UserId); + + if (SelectedProfile == null) + { + SelectedProfile = Profiles.First(); + + if (SelectedProfile != null) + { + _owner.AccountManager.OpenUser(_selectedProfile.UserId); + } + } + + var saveDataFilter = SaveDataFilter.Make(programId: default, saveType: SaveDataType.Account, + default, saveDataId: default, index: default); + + using var saveDataIterator = new UniqueRef<SaveDataIterator>(); + + _owner.HorizonClient.Fs.OpenSaveDataIterator(ref saveDataIterator.Ref(), SaveDataSpaceId.User, in saveDataFilter).ThrowIfFailure(); + + Span<SaveDataInfo> saveDataInfo = stackalloc SaveDataInfo[10]; + + HashSet<UserId> lostAccounts = new HashSet<UserId>(); + + while (true) + { + saveDataIterator.Get.ReadSaveDataInfo(out long readCount, saveDataInfo).ThrowIfFailure(); + + if (readCount == 0) + { + break; + } + + for (int i = 0; i < readCount; i++) + { + var save = saveDataInfo[i]; + var id = new UserId((long)save.UserId.Id.Low, (long)save.UserId.Id.High); + if (Profiles.FirstOrDefault( x=> x.UserId == id) == null) + { + lostAccounts.Add(id); + } + } + } + + foreach(var account in lostAccounts) + { + LostProfiles.Add(new UserProfile(new HLE.HOS.Services.Account.Acc.UserProfile(account, "", null), _owner)); + } + } + + public void AddUser() + { + UserProfile userProfile = null; + + _owner.Navigate(typeof(UserEditor), (this._owner, userProfile, true)); + } + + public async void ManageSaves() + { + UserProfile userProfile = _highlightedProfile ?? SelectedProfile; + + SaveManager manager = new SaveManager(userProfile, _owner.HorizonClient, _owner.VirtualFileSystem); + + ContentDialog contentDialog = new ContentDialog + { + Title = string.Format(LocaleManager.Instance["SaveManagerHeading"], userProfile.Name), + PrimaryButtonText = "", + SecondaryButtonText = "", + CloseButtonText = LocaleManager.Instance["UserProfilesClose"], + Content = manager, + Padding = new Thickness(0) + }; + + await contentDialog.ShowAsync(); + } + + public void EditUser() + { + _owner.Navigate(typeof(UserEditor), (this._owner, _highlightedProfile ?? SelectedProfile, false)); + } + + public async void DeleteUser() + { + if (_highlightedProfile != null) + { + var lastUserId = _owner.AccountManager.LastOpenedUser.UserId; + + if (_highlightedProfile.UserId == lastUserId) + { + // If we are deleting the currently open profile, then we must open something else before deleting. + var profile = Profiles.FirstOrDefault(x => x.UserId != lastUserId); + + if (profile == null) + { + Dispatcher.UIThread.Post(async () => + { + await ContentDialogHelper.CreateErrorDialog(LocaleManager.Instance["DialogUserProfileDeletionWarningMessage"]); + }); + + return; + } + + _owner.AccountManager.OpenUser(profile.UserId); + } + + var result = + await ContentDialogHelper.CreateConfirmationDialog(LocaleManager.Instance["DialogUserProfileDeletionConfirmMessage"], "", + LocaleManager.Instance["InputDialogYes"], LocaleManager.Instance["InputDialogNo"], ""); + + if (result == UserResult.Yes) + { + _owner.AccountManager.DeleteUser(_highlightedProfile.UserId); + } + } + + LoadProfiles(); + } + + public void GoBack() + { + _owner.GoBack(); + } + + public void RecoverLostAccounts() + { + _owner.Navigate(typeof(UserRecoverer), (this._owner, this)); + } + } +}
\ No newline at end of file |
