From 3af42d6c7e9e71c504b87a7b0f7f960fe83418fb Mon Sep 17 00:00:00 2001 From: Emmanuel Hansen <emmausssss@gmail.com> Date: Fri, 8 Jul 2022 18:47:11 +0000 Subject: [PATCH] UI - Avalonia Part 3 (#3441) * Add all other windows * addreesed review * Prevent "No Update" option from being deleted * Select no update is the current update is removed from the title update window * fix amiibo crash --- Ryujinx.Ava/Assets/Locales/en_US.json | 18 +- .../ProfileImageSelectionDialog.axaml | 35 ++ .../ProfileImageSelectionDialog.axaml.cs | 105 ++++ Ryujinx.Ava/Ui/Models/Amiibo.cs | 72 +++ Ryujinx.Ava/Ui/Models/CheatModel.cs | 37 ++ Ryujinx.Ava/Ui/Models/CheatsList.cs | 51 ++ Ryujinx.Ava/Ui/Models/DlcModel.cs | 18 + Ryujinx.Ava/Ui/Models/ProfileImageModel.cs | 2 +- Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs | 7 +- Ryujinx.Ava/Ui/Models/UserProfile.cs | 61 +++ .../Ui/ViewModels/AmiiboWindowViewModel.cs | 450 ++++++++++++++++++ .../Ui/ViewModels/AvatarProfileViewModel.cs | 363 ++++++++++++++ .../Ui/ViewModels/MainWindowViewModel.cs | 79 ++- .../Ui/ViewModels/UserProfileViewModel.cs | 166 +++++++ Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml | 68 +++ Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml.cs | 70 +++ Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml | 53 +++ Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml.cs | 71 +++ Ryujinx.Ava/Ui/Windows/CheatWindow.axaml | 90 ++++ Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs | 137 ++++++ Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml | 132 +++++ .../Ui/Windows/DlcManagerWindow.axaml.cs | 261 ++++++++++ .../Ui/Windows/TitleUpdateWindow.axaml | 104 ++++ .../Ui/Windows/TitleUpdateWindow.axaml.cs | 265 +++++++++++ .../Ui/Windows/UserProfileWindow.axaml | 107 +++++ .../Ui/Windows/UserProfileWindow.axaml.cs | 102 ++++ 26 files changed, 2901 insertions(+), 23 deletions(-) create mode 100644 Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml create mode 100644 Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs create mode 100644 Ryujinx.Ava/Ui/Models/Amiibo.cs create mode 100644 Ryujinx.Ava/Ui/Models/CheatModel.cs create mode 100644 Ryujinx.Ava/Ui/Models/CheatsList.cs create mode 100644 Ryujinx.Ava/Ui/Models/DlcModel.cs create mode 100644 Ryujinx.Ava/Ui/Models/UserProfile.cs create mode 100644 Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs create mode 100644 Ryujinx.Ava/Ui/ViewModels/AvatarProfileViewModel.cs create mode 100644 Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs create mode 100644 Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml create mode 100644 Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml.cs create mode 100644 Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml create mode 100644 Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml.cs create mode 100644 Ryujinx.Ava/Ui/Windows/CheatWindow.axaml create mode 100644 Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs create mode 100644 Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml create mode 100644 Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs create mode 100644 Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml create mode 100644 Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs create mode 100644 Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml create mode 100644 Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml.cs diff --git a/Ryujinx.Ava/Assets/Locales/en_US.json b/Ryujinx.Ava/Assets/Locales/en_US.json index ebf30df601..d9483db47a 100644 --- a/Ryujinx.Ava/Assets/Locales/en_US.json +++ b/Ryujinx.Ava/Assets/Locales/en_US.json @@ -553,6 +553,20 @@ "SettingsTabHotkeysToggleMuteHotkey": "Mute:", "ControllerMotionTitle": "Motion Control Settings", "ControllerRumbleTitle": "Rumble Settings", - "SettingsSelectThemeFileDialogTitle" : "Select Theme File", - "SettingsXamlThemeFile" : "Xaml Theme File" + "SettingsSelectThemeFileDialogTitle": "Select Theme File", + "SettingsXamlThemeFile": "Xaml Theme File", + "AvatarWindowTitle": "Manage Accounts - Avatar", + "Amiibo": "Amiibo", + "Unknown": "Unknown", + "Usage": "Usage", + "Writable": "Writable", + "SelectDlcDialogTitle": "Select DLC files", + "SelectUpdateDialogTitle": "Select update files", + "UserProfileWindowTitle": "Manage User Profiles", + "CheatWindowTitle": "Manage Game Cheats", + "DlcWindowTitle": "Manage Game DLC", + "UpdateWindowTitle": "Manage Game Updates", + "CheatWindowHeading": "Cheats Available for {0} [{1}]", + "DlcWindowHeading": "DLC Available for {0} [{1}]", + "GameUpdateWindowHeading": "DLC Available for {0} [{1}]" } diff --git a/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml new file mode 100644 index 0000000000..c6f43f43f8 --- /dev/null +++ b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml @@ -0,0 +1,35 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" + xmlns:Locale="clr-namespace:Ryujinx.Ava.Common.Locale" + x:Class="Ryujinx.Ava.Ui.Controls.ProfileImageSelectionDialog" + SizeToContent="WidthAndHeight" + WindowStartupLocation="CenterOwner" + Title="{Locale:Locale ProfileImageSelectionTitle}" + CanResize="false"> + <Grid HorizontalAlignment="Stretch" VerticalAlignment="Center" Margin="5,10,5, 5"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="70" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock FontWeight="Bold" FontSize="18" HorizontalAlignment="Center" Grid.Row="1" + Text="{Locale:Locale ProfileImageSelectionHeader}" /> + <TextBlock FontWeight="Bold" Grid.Row="2" Margin="10" MaxWidth="400" TextWrapping="Wrap" + HorizontalAlignment="Center" TextAlignment="Center" Text="{Locale:Locale ProfileImageSelectionNote}" /> + <StackPanel Margin="5,0" Spacing="10" Grid.Row="4" HorizontalAlignment="Center" + Orientation="Horizontal"> + <Button Name="Import" Click="Import_OnClick" Width="200"> + <TextBlock Text="{Locale:Locale ProfileImageSelectionImportImage}" /> + </Button> + <Button Name="SelectFirmwareImage" IsEnabled="{Binding FirmwareFound}" Click="SelectFirmwareImage_OnClick" + Width="200"> + <TextBlock Text="{Locale:Locale ProfileImageSelectionSelectAvatar}" /> + </Button> + </StackPanel> + </Grid> +</Window> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs new file mode 100644 index 0000000000..728b890695 --- /dev/null +++ b/Ryujinx.Ava/Ui/Controls/ProfileImageSelectionDialog.axaml.cs @@ -0,0 +1,105 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Windows; +using Ryujinx.HLE.FileSystem; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; +using System.IO; +using Image = SixLabors.ImageSharp.Image; + +namespace Ryujinx.Ava.Ui.Controls +{ + public class ProfileImageSelectionDialog : StyleableWindow + { + private readonly ContentManager _contentManager; + + public bool FirmwareFound => _contentManager.GetCurrentFirmwareVersion() != null; + + public byte[] BufferImageProfile { get; set; } + + public ProfileImageSelectionDialog(ContentManager contentManager) + { + _contentManager = contentManager; + DataContext = this; + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } + + public ProfileImageSelectionDialog() + { + DataContext = this; + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private async void Import_OnClick(object sender, RoutedEventArgs e) + { + OpenFileDialog dialog = new(); + dialog.Filters.Add(new FileDialogFilter + { + Name = LocaleManager.Instance["AllSupportedFormats"], + Extensions = { "jpg", "jpeg", "png", "bmp" } + }); + dialog.Filters.Add(new FileDialogFilter { Name = "JPEG", Extensions = { "jpg", "jpeg" } }); + dialog.Filters.Add(new FileDialogFilter { Name = "PNG", Extensions = { "png" } }); + dialog.Filters.Add(new FileDialogFilter { Name = "BMP", Extensions = { "bmp" } }); + + dialog.AllowMultiple = false; + + string[] image = await dialog.ShowAsync(this); + + if (image != null) + { + if (image.Length > 0) + { + string imageFile = image[0]; + + ProcessProfileImage(File.ReadAllBytes(imageFile)); + } + + Close(); + } + } + + private async void SelectFirmwareImage_OnClick(object sender, RoutedEventArgs e) + { + if (FirmwareFound) + { + AvatarWindow window = new(_contentManager); + + await window.ShowDialog(this); + + BufferImageProfile = window.SelectedImage; + + Close(); + } + } + + private void ProcessProfileImage(byte[] buffer) + { + using (Image image = Image.Load(buffer)) + { + image.Mutate(x => x.Resize(256, 256)); + + using (MemoryStream streamJpg = new()) + { + image.SaveAsJpeg(streamJpg); + + BufferImageProfile = streamJpg.ToArray(); + } + } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/Amiibo.cs b/Ryujinx.Ava/Ui/Models/Amiibo.cs new file mode 100644 index 0000000000..8644ab52c2 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/Amiibo.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Ryujinx.Ava.Ui.Models +{ + public class Amiibo + { + public struct AmiiboJson + { + [JsonPropertyName("amiibo")] public List<AmiiboApi> Amiibo { get; set; } + [JsonPropertyName("lastUpdated")] public DateTime LastUpdated { get; set; } + } + + public struct AmiiboApi + { + [JsonPropertyName("name")] public string Name { get; set; } + [JsonPropertyName("head")] public string Head { get; set; } + [JsonPropertyName("tail")] public string Tail { get; set; } + [JsonPropertyName("image")] public string Image { get; set; } + [JsonPropertyName("amiiboSeries")] public string AmiiboSeries { get; set; } + [JsonPropertyName("character")] public string Character { get; set; } + [JsonPropertyName("gameSeries")] public string GameSeries { get; set; } + [JsonPropertyName("type")] public string Type { get; set; } + + [JsonPropertyName("release")] public Dictionary<string, string> Release { get; set; } + + [JsonPropertyName("gamesSwitch")] public List<AmiiboApiGamesSwitch> GamesSwitch { get; set; } + + public override string ToString() + { + return Name; + } + + public string GetId() + { + return Head + Tail; + } + + public override bool Equals(object obj) + { + if (obj is AmiiboApi amiibo) + { + return amiibo.Head + amiibo.Tail == Head + Tail; + } + + return false; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + } + + public class AmiiboApiGamesSwitch + { + [JsonPropertyName("amiiboUsage")] public List<AmiiboApiUsage> AmiiboUsage { get; set; } + + [JsonPropertyName("gameID")] public List<string> GameId { get; set; } + + [JsonPropertyName("gameName")] public string GameName { get; set; } + } + + public class AmiiboApiUsage + { + [JsonPropertyName("Usage")] public string Usage { get; set; } + + [JsonPropertyName("write")] public bool Write { get; set; } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/CheatModel.cs b/Ryujinx.Ava/Ui/Models/CheatModel.cs new file mode 100644 index 0000000000..cdab27cd84 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/CheatModel.cs @@ -0,0 +1,37 @@ +using Ryujinx.Ava.Ui.ViewModels; +using System; + +namespace Ryujinx.Ava.Ui.Models +{ + public class CheatModel : BaseModel + { + private bool _isEnabled; + + public event EventHandler<bool> EnableToggled; + + public CheatModel(string name, string buildId, bool isEnabled) + { + Name = name; + BuildId = buildId; + IsEnabled = isEnabled; + } + + public bool IsEnabled + { + get => _isEnabled; + set + { + _isEnabled = value; + EnableToggled?.Invoke(this, _isEnabled); + OnPropertyChanged(); + } + } + + public string BuildId { get; } + + public string BuildIdKey => $"{BuildId}-{Name}"; + public string Name { get; } + + public string CleanName => Name.Substring(1, Name.Length - 8); + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/CheatsList.cs b/Ryujinx.Ava/Ui/Models/CheatsList.cs new file mode 100644 index 0000000000..f2b0592e97 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/CheatsList.cs @@ -0,0 +1,51 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; + +namespace Ryujinx.Ava.Ui.Models +{ + public class CheatsList : ObservableCollection<CheatModel> + { + public CheatsList(string buildId, string path) + { + BuildId = buildId; + Path = path; + CollectionChanged += CheatsList_CollectionChanged; + } + + private void CheatsList_CollectionChanged(object sender, + NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + (e.NewItems[0] as CheatModel).EnableToggled += Item_EnableToggled; + } + } + + private void Item_EnableToggled(object sender, bool e) + { + OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsEnabled))); + } + + public string BuildId { get; } + public string Path { get; } + + public bool IsEnabled + { + get + { + return this.ToList().TrueForAll(x => x.IsEnabled); + } + set + { + foreach (var cheat in this) + { + cheat.IsEnabled = value; + } + + OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsEnabled))); + } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/DlcModel.cs b/Ryujinx.Ava/Ui/Models/DlcModel.cs new file mode 100644 index 0000000000..7e5f4a62fc --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/DlcModel.cs @@ -0,0 +1,18 @@ +namespace Ryujinx.Ava.Ui.Models +{ + public class DlcModel + { + public bool IsEnabled { get; set; } + public string TitleId { get; } + public string ContainerPath { get; } + public string FullPath { get; } + + public DlcModel(string titleId, string containerPath, string fullPath, bool isEnabled) + { + TitleId = titleId; + ContainerPath = containerPath; + FullPath = fullPath; + IsEnabled = isEnabled; + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs b/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs index a23b55ccb4..1c9f3b0539 100644 --- a/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs +++ b/Ryujinx.Ava/Ui/Models/ProfileImageModel.cs @@ -1,6 +1,6 @@ namespace Ryujinx.Ava.Ui.Models { - internal class ProfileImageModel + public class ProfileImageModel { public ProfileImageModel(string name, byte[] data) { diff --git a/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs b/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs index f864e70a91..2bf6dbfa1f 100644 --- a/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs +++ b/Ryujinx.Ava/Ui/Models/TitleUpdateModel.cs @@ -9,8 +9,11 @@ namespace Ryujinx.Ava.Ui.Models public bool IsNoUpdate { get; } public ApplicationControlProperty Control { get; } public string Path { get; } - public string Label => IsNoUpdate ? LocaleManager.Instance["NoUpdate"] : - string.Format(LocaleManager.Instance["TitleUpdateVersionLabel"], Control.DisplayVersionString.ToString(), Path); + + public string Label => IsNoUpdate + ? LocaleManager.Instance["NoUpdate"] + : string.Format(LocaleManager.Instance["TitleUpdateVersionLabel"], Control.DisplayVersionString.ToString(), + Path); public TitleUpdateModel(ApplicationControlProperty control, string path, bool isNoUpdate = false) { diff --git a/Ryujinx.Ava/Ui/Models/UserProfile.cs b/Ryujinx.Ava/Ui/Models/UserProfile.cs new file mode 100644 index 0000000000..351ada76f4 --- /dev/null +++ b/Ryujinx.Ava/Ui/Models/UserProfile.cs @@ -0,0 +1,61 @@ +using Ryujinx.Ava.Ui.ViewModels; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using Profile = Ryujinx.HLE.HOS.Services.Account.Acc.UserProfile; + +namespace Ryujinx.Ava.Ui.Models +{ + public class UserProfile : BaseModel + { + private readonly Profile _profile; + private byte[] _image; + private string _name; + private UserId _userId; + + public byte[] Image + { + get => _image; + set + { + _image = value; + OnPropertyChanged(); + } + } + + public UserId UserId + { + get => _userId; + set + { + _userId = value; + OnPropertyChanged(); + } + } + + public string Name + { + get => _name; + set + { + _name = value; + OnPropertyChanged(); + } + } + + public UserProfile(Profile profile) + { + _profile = profile; + + Image = profile.Image; + Name = profile.Name; + UserId = profile.UserId; + } + + public bool IsOpened => _profile.AccountState == AccountState.Open; + + public void UpdateState() + { + OnPropertyChanged(nameof(IsOpened)); + OnPropertyChanged(nameof(Name)); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs new file mode 100644 index 0000000000..9f411ba2aa --- /dev/null +++ b/Ryujinx.Ava/Ui/ViewModels/AmiiboWindowViewModel.cs @@ -0,0 +1,450 @@ +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.Models; +using Ryujinx.Ava.Ui.Windows; +using Ryujinx.Common; +using Ryujinx.Common.Configuration; +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(JsonSerializer.Deserialize<Amiibo.AmiiboJson>(amiiboJsonString).LastUpdated)) + { + amiiboJsonString = await DownloadAmiiboJson(); + } + } + else + { + try + { + amiiboJsonString = await DownloadAmiiboJson(); + } + catch + { + ShowInfoDialog(); + } + } + + _amiiboList = JsonSerializer.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 dlcJsonStream = File.Create(_amiiboJsonPath, 4096, FileOptions.WriteThrough)) + { + dlcJsonStream.Write(Encoding.UTF8.GetBytes(amiiboJsonString)); + } + + return amiiboJsonString; + } + + await ContentDialogHelper.CreateInfoDialog(_owner, 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(_owner, 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 0000000000..c29837414f --- /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/MainWindowViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs index dbc19f75f4..bc8e645081 100644 --- a/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs +++ b/Ryujinx.Ava/Ui/ViewModels/MainWindowViewModel.cs @@ -67,6 +67,8 @@ namespace Ryujinx.Ava.Ui.ViewModels private bool _isPaused; private bool _showContent = true; private bool _isLoadingIndeterminate = true; + private bool _showAll; + private string _lastScannedAmiiboId; private ReadOnlyObservableCollection<ApplicationData> _appsObservableList; public string TitleName { get; internal set; } @@ -695,15 +697,28 @@ namespace Ryujinx.Ava.Ui.ViewModels } } - public void OpenAmiiboWindow() + 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); - // TODO : Implement Amiibo window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + 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) @@ -953,10 +968,11 @@ namespace Ryujinx.Ava.Ui.ViewModels LoadConfigurableHotKeys(); } - public void ManageProfiles() + public async void ManageProfiles() { - // TODO : Implement Profiles window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + UserProfileWindow window = new(_owner.AccountManager, _owner.ContentManager, _owner.VirtualFileSystem); + + await window.ShowDialog(_owner); } public async void OpenAboutWindow() @@ -1227,33 +1243,60 @@ namespace Ryujinx.Ava.Ui.ViewModels } } - public void OpenTitleUpdateManager() + public async void OpenTitleUpdateManager() { - // TODO : Implement Update window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var selection = SelectedApplication; + + if (selection != null) + { + TitleUpdateWindow titleUpdateManager = + new(_owner.VirtualFileSystem, selection.TitleId, selection.TitleName); + + await titleUpdateManager.ShowDialog(_owner); + } } - public void OpenDlcManager() + public async void OpenDlcManager() { - // TODO : Implement Dlc window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var selection = SelectedApplication; + + if (selection != null) + { + DlcManagerWindow dlcManager = new(_owner.VirtualFileSystem, ulong.Parse(selection.TitleId, NumberStyles.HexNumber), selection.TitleName); + + await dlcManager.ShowDialog(_owner); + } } - public void OpenCheatManager() + public async void OpenCheatManager() { - // TODO : Implement cheat window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var selection = SelectedApplication; + + if (selection != null) + { + CheatWindow cheatManager = new(_owner.VirtualFileSystem, selection.TitleId, selection.TitleName); + + await cheatManager.ShowDialog(_owner); + } } - public void OpenCheatManagerForCurrentApp() + public async void OpenCheatManagerForCurrentApp() { if (!IsGameRunning) { return; } - // TODO : Implement cheat window - ContentDialogHelper.ShowNotAvailableMessage(_owner); + var application = _owner.AppHost.Device.Application; + + if (application != null) + { + CheatWindow cheatManager = new(_owner.VirtualFileSystem, application.TitleIdText, application.TitleName); + + await cheatManager.ShowDialog(_owner); + + _owner.AppHost.Device.EnableCheats(); + } } public void OpenDeviceSaveDirectory() diff --git a/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs b/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs new file mode 100644 index 0000000000..d75f65b1fb --- /dev/null +++ b/Ryujinx.Ava/Ui/ViewModels/UserProfileViewModel.cs @@ -0,0 +1,166 @@ +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Windows; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile; + +namespace Ryujinx.Ava.Ui.ViewModels +{ + public class UserProfileViewModel : BaseModel, IDisposable + { + private const uint MaxProfileNameLength = 0x20; + + private readonly UserProfileWindow _owner; + + private UserProfile _selectedProfile; + private string _tempUserName; + + public UserProfileViewModel() + { + Profiles = new ObservableCollection<UserProfile>(); + } + + public UserProfileViewModel(UserProfileWindow owner) : this() + { + _owner = owner; + + LoadProfiles(); + } + + public ObservableCollection<UserProfile> Profiles { get; set; } + + public UserProfile SelectedProfile + { + get => _selectedProfile; + set + { + _selectedProfile = value; + + OnPropertyChanged(nameof(SelectedProfile)); + OnPropertyChanged(nameof(IsSelectedProfileDeletable)); + } + } + + public bool IsSelectedProfileDeletable => + _selectedProfile != null && _selectedProfile.UserId != AccountManager.DefaultUserId; + + public void Dispose() + { + } + + public void LoadProfiles() + { + Profiles.Clear(); + + var profiles = _owner.AccountManager.GetAllUsers() + .OrderByDescending(x => x.AccountState == AccountState.Open); + + foreach (var profile in profiles) + { + Profiles.Add(new UserProfile(profile)); + } + + SelectedProfile = Profiles.FirstOrDefault(x => x.UserId == _owner.AccountManager.LastOpenedUser.UserId); + + if (SelectedProfile == null) + { + SelectedProfile = Profiles.First(); + + if (SelectedProfile != null) + { + _owner.AccountManager.OpenUser(_selectedProfile.UserId); + } + } + } + + public async void ChooseProfileImage() + { + await SelectProfileImage(); + } + + public async Task SelectProfileImage(bool isNewUser = false) + { + ProfileImageSelectionDialog selectionDialog = new(_owner.ContentManager); + + await selectionDialog.ShowDialog(_owner); + + if (selectionDialog.BufferImageProfile != null) + { + if (isNewUser) + { + if (!string.IsNullOrWhiteSpace(_tempUserName)) + { + _owner.AccountManager.AddUser(_tempUserName, selectionDialog.BufferImageProfile); + } + } + else if (SelectedProfile != null) + { + _owner.AccountManager.SetUserImage(SelectedProfile.UserId, selectionDialog.BufferImageProfile); + SelectedProfile.Image = selectionDialog.BufferImageProfile; + + SelectedProfile = null; + } + + LoadProfiles(); + } + } + + public async void AddUser() + { + var dlgTitle = LocaleManager.Instance["InputDialogAddNewProfileTitle"]; + var dlgMainText = LocaleManager.Instance["InputDialogAddNewProfileHeader"]; + var dlgSubText = string.Format(LocaleManager.Instance["InputDialogAddNewProfileSubtext"], + MaxProfileNameLength); + + _tempUserName = + await ContentDialogHelper.CreateInputDialog(dlgTitle, dlgMainText, dlgSubText, _owner, + MaxProfileNameLength); + + if (!string.IsNullOrWhiteSpace(_tempUserName)) + { + await SelectProfileImage(true); + } + + _tempUserName = String.Empty; + } + + public async void DeleteUser() + { + if (_selectedProfile != null) + { + var lastUserId = _owner.AccountManager.LastOpenedUser.UserId; + + if (_selectedProfile.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) + { + ContentDialogHelper.CreateErrorDialog(_owner, + LocaleManager.Instance["DialogUserProfileDeletionWarningMessage"]); + return; + } + + _owner.AccountManager.OpenUser(profile.UserId); + } + + var result = + await ContentDialogHelper.CreateConfirmationDialog(_owner, + LocaleManager.Instance["DialogUserProfileDeletionConfirmMessage"], "", + LocaleManager.Instance["InputDialogYes"], LocaleManager.Instance["InputDialogNo"], ""); + + if (result == UserResult.Yes) + { + _owner.AccountManager.DeleteUser(_selectedProfile.UserId); + } + } + + LoadProfiles(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml b/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml new file mode 100644 index 0000000000..f91bb3131e --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml @@ -0,0 +1,68 @@ +<window:StyleableWindow xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350" + x:Class="Ryujinx.Ava.Ui.Windows.AmiiboWindow" + xmlns:window="clr-namespace:Ryujinx.Ava.Ui.Windows" + xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels" + xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale" + CanResize="False" + WindowStartupLocation="CenterOwner" + Width="800" MinHeight="650" Height="650" + SizeToContent="Manual" + MinWidth="600"> + <Design.DataContext> + <viewModels:AmiiboWindowViewModel /> + </Design.DataContext> + <Grid Margin="15" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid Grid.Row="1" HorizontalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition /> + <ColumnDefinition /> + </Grid.ColumnDefinitions> + <StackPanel Spacing="10" Orientation="Horizontal" HorizontalAlignment="Left"> + <TextBlock VerticalAlignment="Center" Text="{locale:Locale AmiiboSeriesLabel}" /> + <ComboBox SelectedIndex="{Binding SeriesSelectedIndex}" Items="{Binding AmiiboSeries}" MinWidth="100" /> + </StackPanel> + <StackPanel Spacing="10" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right"> + <TextBlock VerticalAlignment="Center" Text="{locale:Locale AmiiboCharacterLabel}" /> + <ComboBox SelectedIndex="{Binding AmiiboSelectedIndex}" MinWidth="100" Items="{Binding AmiiboList}" /> + </StackPanel> + </Grid> + <StackPanel Margin="20" Grid.Row="2"> + <Image Source="{Binding AmiiboImage}" Height="350" Width="350" HorizontalAlignment="Center" /> + <ScrollViewer MaxHeight="120" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto" + Margin="20" VerticalAlignment="Top" HorizontalAlignment="Stretch"> + <TextBlock TextWrapping="Wrap" Text="{Binding Usage}" HorizontalAlignment="Center" + TextAlignment="Center" /> + </ScrollViewer> + </StackPanel> + <Grid Grid.Row="3"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <CheckBox Margin="10" Grid.Column="0" VerticalContentAlignment="Center" IsChecked="{Binding ShowAllAmiibo}" + Content="{locale:Locale AmiiboOptionsShowAllLabel}" /> + <CheckBox Margin="10" VerticalContentAlignment="Center" Grid.Column="1" IsChecked="{Binding UseRandomUuid}" + Content="{locale:Locale AmiiboOptionsUsRandomTagLabel}" /> + + <Button Grid.Column="3" IsEnabled="{Binding EnableScanning}" Width="80" + Content="{locale:Locale AmiiboScanButtonLabel}" Name="ScanButton" + Click="ScanButton_Click" /> + <Button Grid.Column="4" Margin="10,0" Width="80" Content="{locale:Locale InputDialogCancel}" + Name="CancelButton" + Click="CancelButton_Click" /> + </Grid> + </Grid> +</window:StyleableWindow> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml.cs new file mode 100644 index 0000000000..bd0935a9c4 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/AmiiboWindow.axaml.cs @@ -0,0 +1,70 @@ +using Avalonia; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.Ava.Ui.ViewModels; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class AmiiboWindow : StyleableWindow + { + public AmiiboWindow(bool showAll, string lastScannedAmiiboId, string titleId) + { + ViewModel = new AmiiboWindowViewModel(this, lastScannedAmiiboId, titleId); + + ViewModel.ShowAllAmiibo = showAll; + + DataContext = ViewModel; + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["Amiibo"]; + } + + public AmiiboWindow() + { + ViewModel = new AmiiboWindowViewModel(this, string.Empty, string.Empty); + + DataContext = ViewModel; + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + if (Program.PreviewerDetached) + { + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["Amiibo"]; + } + } + + public bool IsScanned { get; set; } + public Amiibo.AmiiboApi ScannedAmiibo { get; set; } + public AmiiboWindowViewModel ViewModel { get; set; } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + private void ScanButton_Click(object sender, RoutedEventArgs e) + { + if (ViewModel.AmiiboSelectedIndex > -1) + { + Amiibo.AmiiboApi amiibo = ViewModel.AmiiboList[ViewModel.AmiiboSelectedIndex]; + ScannedAmiibo = amiibo; + IsScanned = true; + Close(); + } + } + + private void CancelButton_Click(object sender, RoutedEventArgs e) + { + IsScanned = false; + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml b/Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml new file mode 100644 index 0000000000..6c7576bc0a --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml @@ -0,0 +1,53 @@ +<Window xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350" + x:Class="Ryujinx.Ava.Ui.Windows.AvatarWindow" + CanResize="False" + xmlns:Locale="clr-namespace:Ryujinx.Ava.Common.Locale" + xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels" + xmlns:controls="clr-namespace:Ryujinx.Ava.Ui.Controls" + WindowStartupLocation="CenterOwner" + x:CompileBindings="True" + x:DataType="viewModels:AvatarProfileViewModel" + SizeToContent="WidthAndHeight"> + <Design.DataContext> + <viewModels:AvatarProfileViewModel /> + </Design.DataContext> + <Window.Resources> + <controls:BitmapArrayValueConverter x:Key="ByteImage" /> + </Window.Resources> + <Grid Margin="5" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <ListBox Grid.Row="1" BorderThickness="0" SelectedIndex="{Binding SelectedIndex}" Width="600" Height="500" + Items="{Binding Images}" HorizontalAlignment="Stretch" VerticalAlignment="Center"> + <ListBox.ItemsPanel> + <ItemsPanelTemplate> + <WrapPanel Orientation="Horizontal" MaxWidth="600" Margin="0" HorizontalAlignment="Center" /> + </ItemsPanelTemplate> + </ListBox.ItemsPanel> + <ListBox.ItemTemplate> + <DataTemplate> + <Image Margin="5" Height="96" Width="96" + Source="{Binding Data, Converter={StaticResource ByteImage}}" /> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + <ProgressBar Grid.Row="2" IsIndeterminate="{Binding IsIndeterminate}" Value="{Binding ImagesLoaded}" HorizontalAlignment="Stretch" Margin="5" + Maximum="{Binding ImageCount}" Minimum="0" /> + <StackPanel Grid.Row="3" Orientation="Horizontal" Spacing="10" Margin="10" HorizontalAlignment="Center"> + <Button Content="{Locale:Locale AvatarChoose}" Width="200" Name="ChooseButton" Click="ChooseButton_OnClick" /> + <ui:ColorPickerButton Color="{Binding BackgroundColor, Mode=TwoWay}" Name="ColorButton" /> + <Button HorizontalAlignment="Right" Content="{Locale:Locale AvatarClose}" Click="CloseButton_OnClick" + Name="CloseButton" + Width="200" /> + </StackPanel> + </Grid> +</Window> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml.cs new file mode 100644 index 0000000000..25e89846c8 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/AvatarWindow.axaml.cs @@ -0,0 +1,71 @@ +using Avalonia; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.ViewModels; +using Ryujinx.HLE.FileSystem; +using System; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class AvatarWindow : StyleableWindow + { + public AvatarWindow(ContentManager contentManager) + { + ContentManager = contentManager; + ViewModel = new AvatarProfileViewModel(() => ViewModel.ReloadImages()); + + DataContext = ViewModel; + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["AvatarWindowTitle"]; + } + + public AvatarWindow() + { + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + if (Program.PreviewerDetached) + { + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["AvatarWindowTitle"]; + } + } + + public ContentManager ContentManager { get; } + + public byte[] SelectedImage { get; set; } + + internal AvatarProfileViewModel ViewModel { get; set; } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void OnClosed(EventArgs e) + { + ViewModel.Dispose(); + base.OnClosed(e); + } + + private void CloseButton_OnClick(object sender, RoutedEventArgs e) + { + Close(); + } + + private void ChooseButton_OnClick(object sender, RoutedEventArgs e) + { + if (ViewModel.SelectedIndex > -1) + { + SelectedImage = ViewModel.SelectedImage; + + Close(); + } + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml b/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml new file mode 100644 index 0000000000..1685ee807a --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml @@ -0,0 +1,90 @@ +<window:StyleableWindow x:Class="Ryujinx.Ava.Ui.Windows.CheatWindow" + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale" + xmlns:model="clr-namespace:Ryujinx.Ava.Ui.Models" + xmlns:window="clr-namespace:Ryujinx.Ava.Ui.Windows" + mc:Ignorable="d" + SizeToContent="Height" + Width="500" MinHeight="500" Height="500" + WindowStartupLocation="CenterOwner" + MinWidth="500"> + <Window.Styles> + <Style Selector="TreeViewItem"> + <Setter Property="IsExpanded" Value="True" /> + </Style> + </Window.Styles> + <Grid Name="DlcGrid" Margin="15"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock + Grid.Row="1" + Margin="20,15,20,20" + HorizontalAlignment="Center" + VerticalAlignment="Center" + MaxWidth="500" + LineHeight="18" + TextWrapping="Wrap" + Text="{Binding Heading}" + TextAlignment="Center" /> + <Border + Grid.Row="2" + Margin="5" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BorderBrush="Gray" + BorderThickness="1"> + <TreeView Items="{Binding LoadedCheats}" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + Name="CheatsView" + MinHeight="300"> + <TreeView.DataTemplates> + <TreeDataTemplate DataType="model:CheatsList" ItemsSource="{Binding}"> + <StackPanel HorizontalAlignment="Left" Orientation="Horizontal"> + <CheckBox IsChecked="{Binding IsEnabled}" MinWidth="20" /> + <TextBlock Width="150" + Text="{Binding BuildId}" /> + <TextBlock + Text="{Binding Path}" /> + </StackPanel> + </TreeDataTemplate> + <DataTemplate x:DataType="model:CheatModel"> + <StackPanel Orientation="Horizontal" HorizontalAlignment="Left"> + <CheckBox IsChecked="{Binding IsEnabled}" MinWidth="20" /> + <TextBlock Text="{Binding CleanName}" /> + </StackPanel> + </DataTemplate> + </TreeView.DataTemplates> + </TreeView> + </Border> + <DockPanel + Grid.Row="3" + Margin="0" + HorizontalAlignment="Stretch"> + <DockPanel Margin="0" HorizontalAlignment="Right"> + <Button + Name="SaveButton" + MinWidth="90" + Margin="5" + IsVisible="{Binding !NoCheatsFound}" + Command="{Binding Save}"> + <TextBlock Text="{locale:Locale SettingsButtonSave}" /> + </Button> + <Button + Name="CancelButton" + MinWidth="90" + Margin="5" + Command="{Binding Close}"> + <TextBlock Text="{locale:Locale InputDialogCancel}" /> + </Button> + </DockPanel> + </DockPanel> + </Grid> +</window:StyleableWindow> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs new file mode 100644 index 0000000000..33abeb8d7a --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/CheatWindow.axaml.cs @@ -0,0 +1,137 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class CheatWindow : StyleableWindow + { + private readonly string _enabledCheatsPath; + public bool NoCheatsFound { get; } + + private AvaloniaList<CheatsList> LoadedCheats { get; } + + public string Heading { get; } + + public CheatWindow() + { + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["CheatWindowTitle"]; + } + + public CheatWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName) + { + LoadedCheats = new AvaloniaList<CheatsList>(); + + Heading = string.Format(LocaleManager.Instance["CheatWindowHeading"], titleName, titleId.ToUpper()); + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + + string modsBasePath = virtualFileSystem.ModLoader.GetModsBasePath(); + string titleModsPath = virtualFileSystem.ModLoader.GetTitleDir(modsBasePath, titleId); + ulong titleIdValue = ulong.Parse(titleId, System.Globalization.NumberStyles.HexNumber); + + _enabledCheatsPath = Path.Combine(titleModsPath, "cheats", "enabled.txt"); + + string[] enabled = { }; + + if (File.Exists(_enabledCheatsPath)) + { + enabled = File.ReadAllLines(_enabledCheatsPath); + } + + int cheatAdded = 0; + + var mods = new ModLoader.ModCache(); + + ModLoader.QueryContentsDir(mods, new DirectoryInfo(Path.Combine(modsBasePath, "contents")), titleIdValue); + + string currentCheatFile = string.Empty; + string buildId = string.Empty; + string parentPath = string.Empty; + + CheatsList currentGroup = null; + + foreach (var cheat in mods.Cheats) + { + if (cheat.Path.FullName != currentCheatFile) + { + currentCheatFile = cheat.Path.FullName; + parentPath = currentCheatFile.Replace(titleModsPath, ""); + + buildId = Path.GetFileNameWithoutExtension(currentCheatFile).ToUpper(); + currentGroup = new CheatsList(buildId, parentPath); + + LoadedCheats.Add(currentGroup); + } + + var model = new CheatModel(cheat.Name, buildId, enabled.Contains($"{buildId}-{cheat.Name}")); + currentGroup?.Add(model); + + cheatAdded++; + } + + if (cheatAdded == 0) + { + NoCheatsFound = true; + } + + DataContext = this; + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["CheatWindowTitle"]; + } + + [Conditional("DEBUG")] + private void AttachDebugDevTools() + { + this.AttachDevTools(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + public void Save() + { + if (NoCheatsFound) + { + return; + } + + List<string> enabledCheats = new List<string>(); + + foreach (var cheats in LoadedCheats) + { + foreach (var cheat in cheats) + { + if (cheat.IsEnabled) + { + enabledCheats.Add(cheat.BuildIdKey); + } + } + } + + Directory.CreateDirectory(Path.GetDirectoryName(_enabledCheatsPath)); + + File.WriteAllLines(_enabledCheatsPath, enabledCheats); + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml new file mode 100644 index 0000000000..94b3895ef4 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml @@ -0,0 +1,132 @@ +<window:StyleableWindow + x:Class="Ryujinx.Ava.Ui.Windows.DlcManagerWindow" + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale" + xmlns:window="clr-namespace:Ryujinx.Ava.Ui.Windows" + SizeToContent="Height" + Width="600" MinHeight="500" Height="500" + WindowStartupLocation="CenterOwner" + MinWidth="600" + mc:Ignorable="d"> + <Grid Name="DlcGrid" Margin="15"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock + Grid.Row="1" + Margin="20,15,20,20" + HorizontalAlignment="Center" + VerticalAlignment="Center" + MaxWidth="500" + LineHeight="18" + TextWrapping="Wrap" + Text="{Binding Heading}" + TextAlignment="Center" /> + <Border + Grid.Row="2" + Margin="5" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BorderBrush="Gray" + BorderThickness="1"> + <DataGrid + MinHeight="200" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + HorizontalScrollBarVisibility="Auto" + Items="{Binding Dlcs}" + VerticalScrollBarVisibility="Auto"> + <DataGrid.Columns> + <DataGridTemplateColumn Width="90"> + <DataGridTemplateColumn.CellTemplate> + <DataTemplate> + <CheckBox + Width="50" + MinWidth="40" + HorizontalAlignment="Right" + IsChecked="{Binding IsEnabled}" /> + </DataTemplate> + </DataGridTemplateColumn.CellTemplate> + <DataGridTemplateColumn.Header> + <TextBlock Text="{locale:Locale DlcManagerTableHeadingEnabledLabel}" /> + </DataGridTemplateColumn.Header> + </DataGridTemplateColumn> + <DataGridTextColumn + Width="190" + Binding="{Binding TitleId}" + CanUserResize="True"> + <DataGridTextColumn.Header> + <TextBlock Text="{locale:Locale DlcManagerTableHeadingTitleIdLabel}" /> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn + Width="*" + Binding="{Binding ContainerPath}" + CanUserResize="True"> + <DataGridTextColumn.Header> + <TextBlock Text="{locale:Locale DlcManagerTableHeadingContainerPathLabel}" /> + </DataGridTextColumn.Header> + </DataGridTextColumn> + <DataGridTextColumn + Width="*" + Binding="{Binding FullPath}" + CanUserResize="True"> + <DataGridTextColumn.Header> + <TextBlock Text="{locale:Locale DlcManagerTableHeadingFullPathLabel}" /> + </DataGridTextColumn.Header> + </DataGridTextColumn> + </DataGrid.Columns> + </DataGrid> + </Border> + <DockPanel + Grid.Row="3" + Margin="0" + HorizontalAlignment="Stretch"> + <DockPanel Margin="0" HorizontalAlignment="Left"> + <Button + Name="AddButton" + MinWidth="90" + Margin="5" + Command="{Binding Add}"> + <TextBlock Text="{locale:Locale SettingsTabGeneralAdd}" /> + </Button> + <Button + Name="RemoveButton" + MinWidth="90" + Margin="5" + Command="{Binding RemoveSelected}"> + <TextBlock Text="{locale:Locale SettingsTabGeneralRemove}" /> + </Button> + <Button + Name="RemoveAllButton" + MinWidth="90" + Margin="5" + Command="{Binding RemoveAll}"> + <TextBlock Text="{locale:Locale DlcManagerRemoveAllButton}" /> + </Button> + </DockPanel> + <DockPanel Margin="0" HorizontalAlignment="Right"> + <Button + Name="SaveButton" + MinWidth="90" + Margin="5" + Command="{Binding Save}"> + <TextBlock Text="{locale:Locale SettingsButtonSave}" /> + </Button> + <Button + Name="CancelButton" + MinWidth="90" + Margin="5" + Command="{Binding Close}"> + <TextBlock Text="{locale:Locale InputDialogCancel}" /> + </Button> + </DockPanel> + </DockPanel> + </Grid> +</window:StyleableWindow> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs new file mode 100644 index 0000000000..cb2ed32404 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/DlcManagerWindow.axaml.cs @@ -0,0 +1,261 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Tools.Fs; +using LibHac.Tools.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using Path = System.IO.Path; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class DlcManagerWindow : StyleableWindow + { + private readonly List<DlcContainer> _dlcContainerList; + private readonly string _dlcJsonPath; + + public VirtualFileSystem VirtualFileSystem { get; } + + public AvaloniaList<DlcModel> Dlcs { get; set; } + public Grid DlcGrid { get; private set; } + public ulong TitleId { get; } + public string TitleName { get; } + + public string Heading => string.Format(LocaleManager.Instance["DlcWindowHeading"], TitleName, TitleId.ToString("X16")); + + public DlcManagerWindow() + { + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["DlcWindowTitle"]; + } + + public DlcManagerWindow(VirtualFileSystem virtualFileSystem, ulong titleId, string titleName) + { + VirtualFileSystem = virtualFileSystem; + TitleId = titleId; + TitleName = titleName; + + _dlcJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId.ToString("x16"), "dlc.json"); + + try + { + _dlcContainerList = JsonHelper.DeserializeFromFile<List<DlcContainer>>(_dlcJsonPath); + } + catch + { + _dlcContainerList = new List<DlcContainer>(); + } + + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["DlcWindowTitle"]; + + LoadDlcs(); + } + + [Conditional("DEBUG")] + private void AttachDebugDevTools() + { + this.AttachDevTools(); + } + + private void InitializeComponent() + { + Dlcs = new AvaloniaList<DlcModel>(); + + AvaloniaXamlLoader.Load(this); + + DlcGrid = this.FindControl<Grid>("DlcGrid"); + } + + private void LoadDlcs() + { + foreach (DlcContainer dlcContainer in _dlcContainerList) + { + using FileStream containerFile = File.OpenRead(dlcContainer.Path); + + PartitionFileSystem pfs = new PartitionFileSystem(containerFile.AsStorage()); + + VirtualFileSystem.ImportTickets(pfs); + + foreach (DlcNca dlcNca in dlcContainer.DlcNcaList) + { + using var ncaFile = new UniqueRef<IFile>(); + pfs.OpenFile(ref ncaFile.Ref(), dlcNca.Path.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), dlcContainer.Path); + + if (nca != null) + { + Dlcs.Add(new DlcModel(nca.Header.TitleId.ToString("X16"), dlcContainer.Path, dlcNca.Path, + dlcNca.Enabled)); + } + } + } + } + + private Nca TryCreateNca(IStorage ncaStorage, string containerPath) + { + try + { + return new Nca(VirtualFileSystem.KeySet, ncaStorage); + } + catch (Exception ex) + { + ContentDialogHelper.CreateErrorDialog(this, + string.Format(LocaleManager.Instance[ + "DialogDlcLoadNcaErrorMessage"], ex.Message, containerPath)); + } + + return null; + } + + private void AddDlc(string path) + { + if (!File.Exists(path) || Dlcs.FirstOrDefault(x => x.ContainerPath == path) != null) + { + return; + } + + using (FileStream containerFile = File.OpenRead(path)) + { + PartitionFileSystem pfs = new PartitionFileSystem(containerFile.AsStorage()); + bool containsDlc = false; + + VirtualFileSystem.ImportTickets(pfs); + + foreach (DirectoryEntryEx fileEntry in pfs.EnumerateEntries("/", "*.nca")) + { + using var ncaFile = new UniqueRef<IFile>(); + + pfs.OpenFile(ref ncaFile.Ref(), fileEntry.FullPath.ToU8Span(), OpenMode.Read).ThrowIfFailure(); + + Nca nca = TryCreateNca(ncaFile.Get.AsStorage(), path); + + if (nca == null) + { + continue; + } + + if (nca.Header.ContentType == NcaContentType.PublicData) + { + if ((nca.Header.TitleId & 0xFFFFFFFFFFFFE000) != TitleId) + { + break; + } + + Dlcs.Add(new DlcModel(nca.Header.TitleId.ToString("X16"), path, fileEntry.FullPath, true)); + + containsDlc = true; + } + } + + if (!containsDlc) + { + ContentDialogHelper.CreateErrorDialog(this, LocaleManager.Instance["DialogDlcNoDlcErrorMessage"]); + } + } + } + + private void RemoveDlcs(bool removeSelectedOnly = false) + { + if (removeSelectedOnly) + { + Dlcs.RemoveAll(Dlcs.Where(x => x.IsEnabled).ToList()); + } + else + { + Dlcs.Clear(); + } + } + + public void RemoveSelected() + { + RemoveDlcs(true); + } + + public void RemoveAll() + { + RemoveDlcs(); + } + + public async void Add() + { + OpenFileDialog dialog = new OpenFileDialog() { Title = LocaleManager.Instance["SelectDlcDialogTitle"], AllowMultiple = true }; + + dialog.Filters.Add(new FileDialogFilter { Name = "NSP", Extensions = { "nsp" } }); + + string[] files = await dialog.ShowAsync(this); + + if (files != null) + { + foreach (string file in files) + { + AddDlc(file); + } + } + } + + public void Save() + { + _dlcContainerList.Clear(); + + DlcContainer container = default; + + foreach (DlcModel dlc in Dlcs) + { + if (container.Path != dlc.ContainerPath) + { + if (!string.IsNullOrWhiteSpace(container.Path)) + { + _dlcContainerList.Add(container); + } + + container = new DlcContainer { Path = dlc.ContainerPath, DlcNcaList = new List<DlcNca>() }; + } + + container.DlcNcaList.Add(new DlcNca + { + Enabled = dlc.IsEnabled, + TitleId = Convert.ToUInt64(dlc.TitleId, 16), + Path = dlc.FullPath + }); + } + + if (!string.IsNullOrWhiteSpace(container.Path)) + { + _dlcContainerList.Add(container); + } + + using (FileStream dlcJsonStream = File.Create(_dlcJsonPath, 4096, FileOptions.WriteThrough)) + { + dlcJsonStream.Write(Encoding.UTF8.GetBytes(JsonHelper.Serialize(_dlcContainerList, true))); + } + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml new file mode 100644 index 0000000000..347c2cf527 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml @@ -0,0 +1,104 @@ +<window:StyleableWindow + x:Class="Ryujinx.Ava.Ui.Windows.TitleUpdateWindow" + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale" + xmlns:window="clr-namespace:Ryujinx.Ava.Ui.Windows" + SizeToContent="Height" + Width="600" MinHeight="500" Height="500" + WindowStartupLocation="CenterOwner" + MinWidth="600" + mc:Ignorable="d"> + <Grid Margin="15"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <TextBlock + Grid.Row="1" + Margin="20,15,20,20" + HorizontalAlignment="Center" + VerticalAlignment="Center" + MaxWidth="500" + LineHeight="18" + TextWrapping="Wrap" + Text="{Binding Heading}" + TextAlignment="Center" /> + <Border + Grid.Row="2" + Margin="5" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + BorderBrush="Gray" + BorderThickness="1"> + <ScrollViewer + Width="550" + MinHeight="200" + VerticalAlignment="Stretch" + HorizontalScrollBarVisibility="Auto" + VerticalScrollBarVisibility="Auto"> + <ItemsControl + Margin="10" + HorizontalAlignment="Stretch" + VerticalAlignment="Stretch" + Items="{Binding TitleUpdates}"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <RadioButton Padding="8, 0" VerticalContentAlignment="Center" GroupName="Update" IsChecked="{Binding IsEnabled, Mode=TwoWay}"> + <Label Margin="0" VerticalAlignment="Center" Content="{Binding Label}" /> + </RadioButton> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </ScrollViewer> + </Border> + <DockPanel + Grid.Row="3" + Margin="0" + HorizontalAlignment="Stretch"> + <DockPanel Margin="0" HorizontalAlignment="Left"> + <Button + Name="AddButton" + MinWidth="90" + Margin="5" + Command="{Binding Add}"> + <TextBlock Text="{locale:Locale SettingsTabGeneralAdd}" /> + </Button> + <Button + Name="RemoveButton" + MinWidth="90" + Margin="5" + Command="{Binding RemoveSelected}"> + <TextBlock Text="{locale:Locale SettingsTabGeneralRemove}" /> + </Button> + <Button + Name="RemoveAllButton" + MinWidth="90" + Margin="5" + Command="{Binding RemoveAll}"> + <TextBlock Text="{locale:Locale DlcManagerRemoveAllButton}" /> + </Button> + </DockPanel> + <DockPanel Margin="0" HorizontalAlignment="Right"> + <Button + Name="SaveButton" + MinWidth="90" + Margin="5" + Command="{Binding Save}"> + <TextBlock Text="{locale:Locale SettingsButtonSave}" /> + </Button> + <Button + Name="CancelButton" + MinWidth="90" + Margin="5" + Command="{Binding Close}"> + <TextBlock Text="{locale:Locale InputDialogCancel}" /> + </Button> + </DockPanel> + </DockPanel> + </Grid> +</window:StyleableWindow> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs new file mode 100644 index 0000000000..edc1abcd98 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/TitleUpdateWindow.axaml.cs @@ -0,0 +1,265 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Fsa; +using LibHac.FsSystem; +using LibHac.Tools.FsSystem.NcaUtils; +using LibHac.Ns; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.Controls; +using Ryujinx.Ava.Ui.Models; +using Ryujinx.Common.Configuration; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using Path = System.IO.Path; +using SpanHelpers = LibHac.Common.SpanHelpers; +using LibHac.Tools.FsSystem; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class TitleUpdateWindow : StyleableWindow + { + private readonly string _updateJsonPath; + private TitleUpdateMetadata _titleUpdateWindowData; + + public VirtualFileSystem VirtualFileSystem { get; } + + internal AvaloniaList<TitleUpdateModel> TitleUpdates { get; set; } + public string TitleId { get; } + public string TitleName { get; } + + public string Heading => string.Format(LocaleManager.Instance["GameUpdateWindowHeading"], TitleName, TitleId.ToUpper()); + + public TitleUpdateWindow() + { + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["UpdateWindowTitle"]; + } + + public TitleUpdateWindow(VirtualFileSystem virtualFileSystem, string titleId, string titleName) + { + VirtualFileSystem = virtualFileSystem; + TitleId = titleId; + TitleName = titleName; + + _updateJsonPath = Path.Combine(AppDataManager.GamesDirPath, titleId, "updates.json"); + + try + { + _titleUpdateWindowData = JsonHelper.DeserializeFromFile<TitleUpdateMetadata>(_updateJsonPath); + } + catch + { + _titleUpdateWindowData = new TitleUpdateMetadata {Selected = "", Paths = new List<string>()}; + } + + DataContext = this; + + InitializeComponent(); + AttachDebugDevTools(); + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["UpdateWindowTitle"]; + + LoadUpdates(); + } + + [Conditional("DEBUG")] + private void AttachDebugDevTools() + { + this.AttachDevTools(); + } + + private void InitializeComponent() + { + TitleUpdates = new AvaloniaList<TitleUpdateModel>(); + + AvaloniaXamlLoader.Load(this); + } + + private void LoadUpdates() + { + TitleUpdates.Add(new TitleUpdateModel(default, string.Empty, true)); + + foreach (string path in _titleUpdateWindowData.Paths) + { + AddUpdate(path); + } + + if (_titleUpdateWindowData.Selected == "") + { + TitleUpdates[0].IsEnabled = true; + } + else + { + TitleUpdateModel selected = TitleUpdates.FirstOrDefault(x => x.Path == _titleUpdateWindowData.Selected); + List<TitleUpdateModel> enabled = TitleUpdates.Where(x => x.IsEnabled).ToList(); + + foreach (TitleUpdateModel update in enabled) + { + update.IsEnabled = false; + } + + if (selected != null) + { + selected.IsEnabled = true; + } + } + + SortUpdates(); + } + + private void AddUpdate(string path) + { + if (File.Exists(path) && !TitleUpdates.Any(x => x.Path == path)) + { + using (FileStream file = new(path, FileMode.Open, FileAccess.Read)) + { + PartitionFileSystem nsp = new PartitionFileSystem(file.AsStorage()); + + try + { + (Nca patchNca, Nca controlNca) = + ApplicationLoader.GetGameUpdateDataFromPartition(VirtualFileSystem, nsp, TitleId, 0); + + if (controlNca != null && patchNca != null) + { + ApplicationControlProperty controlData = new ApplicationControlProperty(); + + using var nacpFile = new UniqueRef<IFile>(); + + controlNca.OpenFileSystem(NcaSectionType.Data, IntegrityCheckLevel.None) + .OpenFile(ref nacpFile.Ref(), "/control.nacp".ToU8Span(), OpenMode.Read) + .ThrowIfFailure(); + nacpFile.Get.Read(out _, 0, SpanHelpers.AsByteSpan(ref controlData), ReadOption.None) + .ThrowIfFailure(); + + TitleUpdates.Add(new TitleUpdateModel(controlData, path)); + } + else + { + ContentDialogHelper.CreateErrorDialog(this, + LocaleManager.Instance["DialogUpdateAddUpdateErrorMessage"]); + } + } + catch (Exception ex) + { + ContentDialogHelper.CreateErrorDialog(this, + string.Format(LocaleManager.Instance["DialogDlcLoadNcaErrorMessage"], ex.Message, path)); + } + } + } + } + + private void RemoveUpdates(bool removeSelectedOnly = false) + { + if (removeSelectedOnly) + { + TitleUpdates.RemoveAll(TitleUpdates.Where(x => x.IsEnabled && !x.IsNoUpdate).ToList()); + } + else + { + TitleUpdates.RemoveAll(TitleUpdates.Where(x => !x.IsNoUpdate).ToList()); + } + + TitleUpdates.FirstOrDefault(x => x.IsNoUpdate).IsEnabled = true; + + SortUpdates(); + } + + public void RemoveSelected() + { + RemoveUpdates(true); + } + + public void RemoveAll() + { + RemoveUpdates(); + } + + public async void Add() + { + OpenFileDialog dialog = new OpenFileDialog() { Title = LocaleManager.Instance["SelectUpdateDialogTitle"], AllowMultiple = true }; + + dialog.Filters.Add(new FileDialogFilter { Name = "NSP", Extensions = { "nsp" } }); + + string[] files = await dialog.ShowAsync(this); + + if (files != null) + { + foreach (string file in files) + { + AddUpdate(file); + } + } + + SortUpdates(); + } + + private void SortUpdates() + { + var list = TitleUpdates.ToList(); + + list.Sort((first, second) => + { + if (string.IsNullOrEmpty(first.Control.DisplayVersionString.ToString())) + { + return -1; + } + else if (string.IsNullOrEmpty(second.Control.DisplayVersionString.ToString())) + { + return 1; + } + + return Version.Parse(first.Control.DisplayVersionString.ToString()) + .CompareTo(Version.Parse(second.Control.DisplayVersionString.ToString())) * -1; + }); + + TitleUpdates.Clear(); + + TitleUpdates.AddRange(list); + } + + public void Save() + { + _titleUpdateWindowData.Paths.Clear(); + + _titleUpdateWindowData.Selected = ""; + + foreach (TitleUpdateModel update in TitleUpdates) + { + _titleUpdateWindowData.Paths.Add(update.Path); + + if (update.IsEnabled) + { + _titleUpdateWindowData.Selected = update.Path; + } + } + + using (FileStream dlcJsonStream = File.Create(_updateJsonPath, 4096, FileOptions.WriteThrough)) + { + dlcJsonStream.Write(Encoding.UTF8.GetBytes(JsonHelper.Serialize(_titleUpdateWindowData, true))); + } + + if (Owner is MainWindow window) + { + window.ViewModel.LoadApplications(); + } + + Close(); + } + } +} \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml b/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml new file mode 100644 index 0000000000..4b004206c8 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml @@ -0,0 +1,107 @@ +<window:StyleableWindow xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:d="http://schemas.microsoft.com/expression/blend/2008" + xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="350" + x:Class="Ryujinx.Ava.Ui.Windows.UserProfileWindow" + xmlns:Locale="clr-namespace:Ryujinx.Ava.Common.Locale" + xmlns:viewModels="clr-namespace:Ryujinx.Ava.Ui.ViewModels" + xmlns:controls="clr-namespace:Ryujinx.Ava.Ui.Controls" + xmlns:window="clr-namespace:Ryujinx.Ava.Ui.Windows" + CanResize="False" + Width="850" MinHeight="550" Height="550" + WindowStartupLocation="CenterOwner" + SizeToContent="Manual" + MinWidth="600"> + <Design.DataContext> + <viewModels:UserProfileViewModel /> + </Design.DataContext> + <Window.Resources> + <controls:BitmapArrayValueConverter x:Key="ByteImage" /> + </Window.Resources> + <Grid Margin="15" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + <RowDefinition /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + <Grid Grid.Row="1"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition /> + </Grid.RowDefinitions> + <ContentControl + Focusable="False" + IsVisible="False" + KeyboardNavigation.IsTabStop="False"> + <ui:ContentDialog Name="ContentDialog" + IsPrimaryButtonEnabled="True" + IsSecondaryButtonEnabled="True" + IsVisible="False" /> + </ContentControl> + <TextBlock Text="{Locale:Locale UserProfilesSelectedUserProfile}" /> + <Grid Grid.Row="1" Margin="10"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <Image Height="96" Width="96" + Source="{Binding SelectedProfile.Image, Converter={StaticResource ByteImage}}" /> + <StackPanel Orientation="Vertical" HorizontalAlignment="Stretch" Grid.Column="1" Spacing="10" + Margin="5, 10"> + <TextBox Name="NameBox" Text="{Binding SelectedProfile.Name, Mode=OneWay}" + HorizontalAlignment="Stretch" /> + <TextBlock Text="{Binding SelectedProfile.UserId}" /> + </StackPanel> + <StackPanel Orientation="Vertical" HorizontalAlignment="Stretch" Grid.Column="2" Spacing="10" + Margin="5"> + <Button Content="{Locale:Locale UserProfilesSaveProfileName}" Name="SetNameButton" + Click="SetNameButton_OnClick" /> + <Button Name="SelectProfileImage" Command="{Binding ChooseProfileImage}" + Content="{Locale:Locale UserProfilesChangeProfileImage}" /> + </StackPanel> + </Grid> + </Grid> + <Grid Grid.Row="2"> + <Grid.RowDefinitions> + <RowDefinition Height="Auto" /> + <RowDefinition /> + </Grid.RowDefinitions> + <TextBlock Text="{Locale:Locale UserProfilesAvailableUserProfiles}" /> + <ListBox Grid.Row="1" Margin="10" Name="ProfilesList" DoubleTapped="ProfilesList_DoubleTapped" + Items="{Binding Profiles}"> + <ListBox.ItemTemplate> + <DataTemplate> + <Grid HorizontalAlignment="Stretch"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition /> + </Grid.ColumnDefinitions> + <Grid Grid.Column="0" Background="{DynamicResource ThemeAccentColorBrush}" + Grid.ColumnSpan="2" + HorizontalAlignment="Stretch" VerticalAlignment="Stretch" MinHeight="5" MinWidth="5" + IsVisible="{Binding IsOpened}" /> + <Image Grid.Column="0" Height="96" Width="96" + Source="{Binding Image, Converter={StaticResource ByteImage}}" /> + <StackPanel Margin="10" Orientation="Vertical" HorizontalAlignment="Stretch" + VerticalAlignment="Center" Grid.Column="1"> + <TextBlock Text="{Binding Name}" /> + <TextBlock Text="{Binding UserId}" /> + </StackPanel> + </Grid> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> + </Grid> + <StackPanel Grid.Row="3" Orientation="Horizontal" Margin="10,0" Spacing="10" HorizontalAlignment="Stretch"> + <Button Content="{Locale:Locale UserProfilesAddNewProfile}" Command="{Binding AddUser}" /> + <Button IsEnabled="{Binding IsSelectedProfileDeletable}" + Content="{Locale:Locale UserProfilesDeleteSelectedProfile}" Command="{Binding DeleteUser}" /> + <Button HorizontalAlignment="Right" Content="{Locale:Locale UserProfilesClose}" Click="CloseButton_OnClick" + Name="CloseButton" /> + </StackPanel> + </Grid> +</window:StyleableWindow> \ No newline at end of file diff --git a/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml.cs b/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml.cs new file mode 100644 index 0000000000..e78e0384f3 --- /dev/null +++ b/Ryujinx.Ava/Ui/Windows/UserProfileWindow.axaml.cs @@ -0,0 +1,102 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.Ava.Ui.ViewModels; +using Ryujinx.HLE.FileSystem; +using Ryujinx.HLE.HOS.Services.Account.Acc; +using System.Threading.Tasks; +using UserProfile = Ryujinx.Ava.Ui.Models.UserProfile; + +namespace Ryujinx.Ava.Ui.Windows +{ + public class UserProfileWindow : StyleableWindow + { + private TextBox _nameBox; + + public UserProfileWindow(AccountManager accountManager, ContentManager contentManager, + VirtualFileSystem virtualFileSystem) + { + AccountManager = accountManager; + ContentManager = contentManager; + ViewModel = new UserProfileViewModel(this); + + DataContext = ViewModel; + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + if (contentManager.GetCurrentFirmwareVersion() != null) + { + Task.Run(() => + { + AvatarProfileViewModel.PreloadAvatars(contentManager, virtualFileSystem); + }); + } + + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["UserProfileWindowTitle"]; + } + + public UserProfileWindow() + { + ViewModel = new UserProfileViewModel(); + + DataContext = ViewModel; + + InitializeComponent(); +#if DEBUG + this.AttachDevTools(); +#endif + Title = $"Ryujinx {Program.Version} - " + LocaleManager.Instance["UserProfileWindowTitle"]; + } + + public AccountManager AccountManager { get; } + public ContentManager ContentManager { get; } + + public UserProfileViewModel ViewModel { get; set; } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + _nameBox = this.FindControl<TextBox>("NameBox"); + } + + private void ProfilesList_DoubleTapped(object sender, RoutedEventArgs e) + { + if (sender is ListBox listBox) + { + int selectedIndex = listBox.SelectedIndex; + + if (selectedIndex >= 0 && selectedIndex < ViewModel.Profiles.Count) + { + ViewModel.SelectedProfile = ViewModel.Profiles[selectedIndex]; + + AccountManager.OpenUser(ViewModel.SelectedProfile.UserId); + + ViewModel.LoadProfiles(); + + foreach (UserProfile profile in ViewModel.Profiles) + { + profile.UpdateState(); + } + } + } + } + + private void CloseButton_OnClick(object sender, RoutedEventArgs e) + { + Close(); + } + + private void SetNameButton_OnClick(object sender, RoutedEventArgs e) + { + if (!string.IsNullOrWhiteSpace(_nameBox.Text)) + { + ViewModel.SelectedProfile.Name = _nameBox.Text; + AccountManager.SetUserName(ViewModel.SelectedProfile.UserId, _nameBox.Text); + } + } + } +} \ No newline at end of file