diff --git a/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs b/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs index 2ec01077c..d1d83fc86 100644 --- a/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs +++ b/CollapseLauncher/Classes/EventsManagement/BackgroundActivityManager.cs @@ -259,6 +259,12 @@ private static void AttachEventToNotification(PresetConfig presetConfig, IBackgr activity!.ProgressChanged += progressChangedEventHandler; activity!.StatusChanged += statusChangedEventHandler; + // The notification can be attached after the operation has already + // started. Hydrate it immediately instead of waiting for another + // progress/status event and leaving the placeholder text visible. + progressChangedEventHandler(activity, activity.Progress); + statusChangedEventHandler(activity, activity.Status); + activity.FlushingTrigger += (_, _) => { activity.ProgressChanged -= progressChangedEventHandler; diff --git a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs index f344db39f..17134e599 100644 --- a/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs +++ b/CollapseLauncher/Classes/Interfaces/Class/ProgressBase.cs @@ -467,6 +467,13 @@ protected virtual void UpdateProgressCopyStream(long currentPosition, int read, protected double CalculateSpeed(long receivedBytes) => CalculateSpeed(receivedBytes, ref _scLastSpeed, ref _scLastReceivedBytes, ref _scLastTick); + protected void ResetSpeedCalculator() + { + Interlocked.Exchange(ref _scLastReceivedBytes, 0); + Interlocked.Exchange(ref _scLastTick, Environment.TickCount64); + _scLastSpeed = 0; + } + protected static double CalculateSpeed(long receivedBytes, ref double lastSpeedToUse, ref long lastReceivedBytesToUse, ref long lastTickToUse) { long currentTick = Environment.TickCount64 - lastTickToUse + 1; diff --git a/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs b/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs index 3eeda615b..ba378bf23 100644 --- a/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs +++ b/CollapseLauncher/Classes/Interfaces/IBackgroundActivity.cs @@ -11,6 +11,8 @@ internal interface IBackgroundActivity event EventHandler StatusChanged; event EventHandler FlushingTrigger; + TotalPerFileProgress Progress { get; } + TotalPerFileStatus Status { get; } bool IsRunning { get; } UIElement ParentUI { get; } void CancelRoutine(); diff --git a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs index 4d59a69b6..0d043a837 100644 --- a/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs +++ b/CollapseLauncher/Classes/Plugins/PluginGameInstallWrapper.cs @@ -73,6 +73,7 @@ public override string GamePath private InstallProgressState _currentInstallState = InstallProgressState.Idle; private string _lastActivityStatus = string.Empty; private float _lastLoggedPercentage = -1f; + private bool _resetSpeedBaseline = true; private PerFileProgressCallbackNative? _perFileProgressDelegate; private GCHandle _perFileProgressGcHandle; @@ -558,7 +559,18 @@ private void UpdateProgressCallback(in InstallProgress delegateProgress) long downloadedBytes = delegateProgress.DownloadedBytes; long downloadedBytesTotal = delegateProgress.TotalBytesToDownload; - long readDownload = delegateProgress.DownloadedBytes - _updateProgressProperty.LastDownloaded; + long readDownload = 0; + if (_resetSpeedBaseline) + { + _resetSpeedBaseline = false; + _updateProgressProperty.LastDownloaded = downloadedBytes; + } + else if (downloadedBytes >= _updateProgressProperty.LastDownloaded) + { + readDownload = downloadedBytes - _updateProgressProperty.LastDownloaded; + _updateProgressProperty.LastDownloaded = downloadedBytes; + } + double currentSpeed = CalculateSpeed(readDownload); Progress.ProgressAllSizeCurrent = downloadedBytes; @@ -605,8 +617,6 @@ or InstallProgressState.Verify : 0; } - _updateProgressProperty.LastDownloaded = downloadedBytes; - PublishProgressUi(updateProgressBar: true); } } @@ -623,7 +633,26 @@ private void UpdateStatusCallback(InstallProgressState delegateState) { using (_updateStatusLock.EnterScope()) { - _currentInstallState = delegateState; + if (_currentInstallState != delegateState) + { + _currentInstallState = delegateState; + _resetSpeedBaseline = true; + ResetSpeedCalculator(); + Progress.ProgressAllSpeed = 0; + } + + if (delegateState == InstallProgressState.Completed) + { + Progress.ProgressAllTimeLeft = TimeSpan.Zero; + if (Progress.ProgressPerFileSizeTotal > 0) + { + Progress.ProgressPerFilePercentage = Math.Min(100d, + ConverterTool.ToPercentage( + Progress.ProgressPerFileSizeTotal, + Progress.ProgressPerFileSizeCurrent)); + } + } + ApplyActivityStatusFromProperty(); PublishProgressUi(updateProgressBar: true); } diff --git a/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs b/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs index bd0578c38..6c8a77d52 100644 --- a/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs +++ b/CollapseLauncher/Classes/Plugins/PluginPresetConfigWrapper.cs @@ -25,6 +25,7 @@ public partial class PluginPresetConfigWrapper : PresetConfig, IDisposable { public DiscordPresenceExtension.DiscordPresenceContext DiscordPresenceContext { get; } public GameManagerExtension.RunGameFromGameManagerContext RunGameContext { get; } + public GameSettingsExtension.GameSettingsContext GameSettingsContext { get; } public readonly PluginInfo PluginInfo; public readonly IPlugin Plugin; private readonly IPluginPresetConfig _config; @@ -50,6 +51,7 @@ private unsafe PluginPresetConfigWrapper(PluginInfo pluginInfo, IPluginPresetCon }; DiscordPresenceContext = new DiscordPresenceExtension.DiscordPresenceContext(pluginInfo.Handle, config); + GameSettingsContext = new GameSettingsExtension.GameSettingsContext(pluginInfo.Handle, config); } public unsafe GameManagerExtension.RunGameFromGameManagerContext UseToggledGameLaunchContext() diff --git a/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs b/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs index fb8913904..ad9a7a1cf 100644 --- a/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs +++ b/CollapseLauncher/XAMLs/MainApp/MainPage.Navigation.cs @@ -203,11 +203,13 @@ void Impl() GameNameType.StarRail => typeof(StarRailGameSettingsPage), GameNameType.Genshin => typeof(GenshinGameSettingsPage), GameNameType.Zenless => typeof(ZenlessGameSettingsPage), + GameNameType.Plugin when presetConfig is PluginPresetConfigWrapper + { GameSettingsContext.HasPage: true } => typeof(PluginGameSettingsPage), _ => null }; NavigationViewItemsContext.GameSettingsPage.Item.Tag = gspPageType; - NavigationViewItemsContext.GameSettingsPage.Item.Visibility = isPluginGame ? Visibility.Collapsed : Visibility.Visible; + NavigationViewItemsContext.GameSettingsPage.Item.Visibility = gspPageType == null ? Visibility.Collapsed : Visibility.Visible; NavigationViewItemsContext.FileCleanupPage.Item.Visibility = isPluginGame ? Visibility.Collapsed : Visibility.Visible; } } diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.cs b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.cs new file mode 100644 index 000000000..8778c0e9c --- /dev/null +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.cs @@ -0,0 +1,290 @@ +using CollapseLauncher.Plugins; +using CollapseLauncher.Helper; +using CollapseLauncher.GameManagement.ImageBackground; +using Hi3Helper.Plugin.Core.UI.Settings; +using Hi3Helper.Plugin.Core.Utility; +using Microsoft.UI; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using System; +using System.Globalization; +using static CollapseLauncher.Statics.GamePropertyVault; + +#nullable enable +namespace CollapseLauncher.Pages; + +/// +/// Renders the declarative game settings page exposed by a v0.1.6 plugin. +/// +public sealed partial class PluginGameSettingsPage : Page +{ + private readonly GameSettingsExtension.GameSettingsContext _context; + private readonly TextBlock _statusText = new() + { + Margin = new Thickness(16, 0, 0, 0), + VerticalAlignment = VerticalAlignment.Center, + TextWrapping = TextWrapping.Wrap + }; + + public PluginGameSettingsPage() + { + InitializeComponent(); + + ImageBackgroundManager.Shared.IsBackgroundElevated = true; + ImageBackgroundManager.Shared.ForegroundOpacity = 0d; + ImageBackgroundManager.Shared.SmokeOpacity = 1d; + + NavigationCacheMode = Microsoft.UI.Xaml.Navigation.NavigationCacheMode.Disabled; + + if (GetCurrentGameProperty().GameVersion.GamePreset is not PluginPresetConfigWrapper preset) + { + throw new InvalidOperationException("The current game preset is not provided by a plugin"); + } + + _context = preset.GameSettingsContext; + Content = CreateContent(); + } + + private UIElement CreateContent() + { + if (!_context.TryGetPage(out GameSettingsPage? page, out Exception? error) || page == null) + { + return new TextBlock + { + Margin = new Thickness(32, 40, 32, 32), + Text = error?.Message ?? "This plugin did not provide a game settings page.", + TextWrapping = TextWrapping.Wrap + }; + } + + Grid root = new(); + root.RowDefinitions.Add(new RowDefinition()); + root.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + + StackPanel sectionsPanel = new() { Margin = new Thickness(32, 40, 32, 32), Spacing = 24 }; + if (!string.IsNullOrWhiteSpace(page.Title)) + { + sectionsPanel.Children.Add(new TextBlock + { + Text = page.Title, + Style = Application.Current.Resources["TitleLargeTextBlockStyle"] as Style, + TextWrapping = TextWrapping.Wrap + }); + } + + foreach (GameSettingsSection section in page.Sections) + { + sectionsPanel.Children.Add(CreateSection(section)); + } + + ScrollViewer scrollViewer = new() + { + Content = sectionsPanel, + VerticalScrollBarVisibility = ScrollBarVisibility.Auto + }; + root.Children.Add(scrollViewer); + + Grid applyPanel = new() + { + Padding = new Thickness(32, 16, 32, 16), + Background = Application.Current.Resources["GameSettingsApplyGridBrush"] as Brush + }; + applyPanel.ColumnDefinitions.Add(new ColumnDefinition()); + applyPanel.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + applyPanel.Children.Add(_statusText); + + Button applyButton = new() + { + Content = Locale.Current.Lang?._GameSettingsPage?.ApplyBtn ?? "Apply settings", + MinWidth = 144, + CornerRadius = new CornerRadius(16), + Style = Application.Current.Resources["AccentButtonStyle"] as Style + }; + applyButton.Click += OnApply; + Grid.SetColumn(applyButton, 1); + applyPanel.Children.Add(applyButton); + Grid.SetRow(applyPanel, 1); + root.Children.Add(applyPanel); + + return root; + } + + private FrameworkElement CreateSection(GameSettingsSection section) + { + StackPanel panel = new() { Spacing = 8 }; + panel.Children.Add(new TextBlock + { + Text = section.Title, + Style = Application.Current.Resources["SubtitleTextBlockStyle"] as Style, + TextWrapping = TextWrapping.Wrap + }); + + if (!string.IsNullOrWhiteSpace(section.Description)) + { + panel.Children.Add(new TextBlock + { + Text = section.Description, + Opacity = 0.72, + TextWrapping = TextWrapping.Wrap + }); + } + + foreach (GameSettingEntry entry in section.Entries) + { + panel.Children.Add(CreateEntry(entry)); + } + + return panel; + } + + private FrameworkElement CreateEntry(GameSettingEntry entry) + { + Grid card = new() + { + Padding = new Thickness(16, 12, 16, 12), + ColumnSpacing = 24, + Background = Application.Current.Resources["CardBackgroundFillColorDefaultBrush"] as Brush, + CornerRadius = new CornerRadius(8) + }; + card.ColumnDefinitions.Add(new ColumnDefinition()); + card.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + StackPanel text = new() { VerticalAlignment = VerticalAlignment.Center, Spacing = 2 }; + text.Children.Add(new TextBlock { Text = entry.Title, TextWrapping = TextWrapping.Wrap }); + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + text.Children.Add(new TextBlock + { + Text = entry.Description, + Opacity = 0.72, + TextWrapping = TextWrapping.Wrap + }); + } + card.Children.Add(text); + + FrameworkElement editor = CreateEditor(entry); + editor.MinWidth = entry.Kind is GameSettingKind.Toggle ? 0 : 180; + editor.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(editor, 1); + card.Children.Add(editor); + return card; + } + + private FrameworkElement CreateEditor(GameSettingEntry entry) => entry.Kind switch + { + GameSettingKind.Toggle => CreateToggle(entry), + GameSettingKind.Text => CreateText(entry), + GameSettingKind.Number => CreateNumber(entry), + GameSettingKind.Slider => CreateSlider(entry), + GameSettingKind.Choice => CreateChoice(entry), + _ => throw new ArgumentOutOfRangeException(nameof(entry.Kind)) + }; + + private ToggleSwitch CreateToggle(GameSettingEntry entry) + { + ToggleSwitch control = new() { IsOn = bool.TryParse(entry.Value, out bool value) && value }; + control.Toggled += (_, _) => SetValue(entry.Key, control.IsOn ? bool.TrueString : bool.FalseString); + return control; + } + + private TextBox CreateText(GameSettingEntry entry) + { + TextBox control = new() { Text = entry.Value, PlaceholderText = entry.Placeholder }; + control.TextChanged += (_, _) => SetValue(entry.Key, control.Text); + return control; + } + + private NumberBox CreateNumber(GameSettingEntry entry) + { + NumberBox control = new() + { + Minimum = entry.Minimum, + Maximum = entry.Maximum, + SmallChange = entry.Step, + SpinButtonPlacementMode = NumberBoxSpinButtonPlacementMode.Compact, + Value = ParseNumber(entry.Value, entry.Minimum) + }; + control.ValueChanged += (_, args) => + { + if (!double.IsNaN(args.NewValue)) + { + SetValue(entry.Key, args.NewValue.ToString(CultureInfo.InvariantCulture)); + } + }; + return control; + } + + private Slider CreateSlider(GameSettingEntry entry) + { + Slider control = new() + { + Minimum = entry.Minimum, + Maximum = entry.Maximum, + StepFrequency = entry.Step, + Value = ParseNumber(entry.Value, entry.Minimum), + Width = 220 + }; + control.ValueChanged += (_, args) => + SetValue(entry.Key, args.NewValue.ToString(CultureInfo.InvariantCulture)); + return control; + } + + private ComboBox CreateChoice(GameSettingEntry entry) + { + ComboBox control = new(); + foreach (GameSettingChoice choice in entry.Choices ?? []) + { + ComboBoxItem item = new() { Content = choice.Title, Tag = choice.Value }; + control.Items.Add(item); + if (string.Equals(choice.Value, entry.Value, StringComparison.Ordinal)) + { + control.SelectedItem = item; + } + } + + control.SelectionChanged += (_, _) => + { + if (control.SelectedItem is ComboBoxItem { Tag: string value }) + { + SetValue(entry.Key, value); + } + }; + return control; + } + + private static double ParseNumber(string value, double fallback) => + double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double result) ? result : fallback; + + private void SetValue(string key, string value) + { + try + { + _context.SetValue(key, value); + SetStatus(null); + } + catch (Exception ex) + { + SetStatus(ex.Message, true); + } + } + + private void OnApply(object sender, RoutedEventArgs args) + { + try + { + _context.Apply(); + SetStatus(Locale.Current.Lang?._GameSettingsPage?.SettingsApplied ?? "Settings applied."); + } + catch (Exception ex) + { + SetStatus(ex.Message, true); + } + } + + private void SetStatus(string? text, bool isError = false) + { + _statusText.Text = text ?? string.Empty; + _statusText.Foreground = isError ? new SolidColorBrush(Colors.IndianRed) : null; + } +} diff --git a/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.xaml b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.xaml new file mode 100644 index 000000000..9b59cc711 --- /dev/null +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.xaml @@ -0,0 +1,5 @@ + + diff --git a/CollapseLauncher/packages.lock.json b/CollapseLauncher/packages.lock.json index 3c108b17a..0f957bdef 100644 --- a/CollapseLauncher/packages.lock.json +++ b/CollapseLauncher/packages.lock.json @@ -456,8 +456,8 @@ }, "Sentry": { "type": "Transitive", - "resolved": "6.8.0", - "contentHash": "amUjwslDtdx1p8r6z4APCxM8vHCKG4CBamPpSTwxmTMoBQ2CLwx2m3CaIttJXRT/hw9S5rNfXgjol2OqADytLQ==" + "resolved": "6.9.0", + "contentHash": "qQIvEwuvjAB6fDLVLLcDj/5f8n5jOyPyHjj3a/GQ1ogTLLQqsSxgQj1fEEquNT9HQuj4ZTyCg3c1DCBMUIvJGQ==" }, "SharpHDiffPatch.Core": { "type": "Transitive", @@ -494,7 +494,7 @@ "colorthief": { "type": "Project", "dependencies": { - "System.Drawing.Common": "[10.0.10, )" + "System.Drawing.Common": "[10.0.11, )" } }, "discordrpc": { @@ -529,7 +529,7 @@ "Hi3Helper.EncTool": "[1.0.0, )", "Hi3Helper.Win32": "[1.0.0, )", "Microsoft.Windows.CsWinRT": "[2.3.1, )", - "Sentry": "[6.8.0, )" + "Sentry": "[6.9.0, )" } }, "hi3helper.enctool": { @@ -554,7 +554,7 @@ "hi3helper.plugin.core": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )" } }, "hi3helper.simpleziparchivereader": { diff --git a/ColorThief b/ColorThief index cfd412a69..b568f2973 160000 --- a/ColorThief +++ b/ColorThief @@ -1 +1 @@ -Subproject commit cfd412a6958cfaf37bf3c4882ef1524d91c3ab31 +Subproject commit b568f2973aa050c4a5665362da665c71945da2bb diff --git a/H.NotifyIcon b/H.NotifyIcon index 414b1f9e3..fe2c3647a 160000 --- a/H.NotifyIcon +++ b/H.NotifyIcon @@ -1 +1 @@ -Subproject commit 414b1f9e3b43905c5436ecc9efcbc97418f8fe18 +Subproject commit fe2c3647a3de1d2534ff3cf4c9ccfd6401543f10 diff --git a/Hi3Helper.Core/Hi3Helper.Core.csproj b/Hi3Helper.Core/Hi3Helper.Core.csproj index ce07c81b0..f2285a8bf 100644 --- a/Hi3Helper.Core/Hi3Helper.Core.csproj +++ b/Hi3Helper.Core/Hi3Helper.Core.csproj @@ -45,7 +45,7 @@ - + diff --git a/Hi3Helper.Core/packages.lock.json b/Hi3Helper.Core/packages.lock.json index a618be123..1e60b5fad 100644 --- a/Hi3Helper.Core/packages.lock.json +++ b/Hi3Helper.Core/packages.lock.json @@ -16,9 +16,9 @@ }, "Sentry": { "type": "Direct", - "requested": "[6.8.0, )", - "resolved": "6.8.0", - "contentHash": "amUjwslDtdx1p8r6z4APCxM8vHCKG4CBamPpSTwxmTMoBQ2CLwx2m3CaIttJXRT/hw9S5rNfXgjol2OqADytLQ==" + "requested": "[6.9.0, )", + "resolved": "6.9.0", + "contentHash": "qQIvEwuvjAB6fDLVLLcDj/5f8n5jOyPyHjj3a/GQ1ogTLLQqsSxgQj1fEEquNT9HQuj4ZTyCg3c1DCBMUIvJGQ==" }, "Google.Protobuf": { "type": "Transitive", diff --git a/Hi3Helper.Plugin.Core b/Hi3Helper.Plugin.Core index 5811f4e7a..cff6f3a4d 160000 --- a/Hi3Helper.Plugin.Core +++ b/Hi3Helper.Plugin.Core @@ -1 +1 @@ -Subproject commit 5811f4e7a81c79374d32becce6d90e3a63b5a259 +Subproject commit cff6f3a4d303cc11907295ad51053bbc1c659a74 diff --git a/Hi3Helper.TaskScheduler/packages.lock.json b/Hi3Helper.TaskScheduler/packages.lock.json index dd14c88fc..24f3fdee7 100644 --- a/Hi3Helper.TaskScheduler/packages.lock.json +++ b/Hi3Helper.TaskScheduler/packages.lock.json @@ -17,6 +17,15 @@ "resolved": "6.9.3", "contentHash": "1CUGgFdyECDKgi5HaUBhdv6k+VG9Iy4OCforGfHyar3xQXAJypZkzymgKtWj/4SPd6nSG0Qi7NH71qHrDSZLaA==" }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" + } + }, "System.Net.Http": { "type": "Direct", "requested": "[4.3.4, )", @@ -38,6 +47,11 @@ "resolved": "2.12.2", "contentHash": "glpAb3VrwfdAofp6PIyAzL0ZeTV7XUJ8muu0oZoTeyU5jtk2sMJ6QAMRRuFbovcaj+SBJiEUGklxIWOqQoxshA==" }, + "Microsoft.NETFramework.ReferenceAssemblies.net462": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" + }, "System.Security.Cryptography.Algorithms": { "type": "Transitive", "resolved": "4.3.0",