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 f6fc94a65..c060d5371 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..0c8dc08fc --- /dev/null +++ b/CollapseLauncher/XAMLs/MainApp/Pages/GameSettingsPages/PluginGameSettingsPage.cs @@ -0,0 +1,334 @@ +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.Automation; +using Microsoft.UI.Xaml.Media; +using System; +using System.Globalization; +using Microsoft.UI.Text; +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 + }); + } + + Grid sectionGrid = new() { ColumnSpacing = 32, RowSpacing = 28 }; + foreach (GameSettingsSection section in page.Sections) + { + // Informational sections, including plugin warnings, remain full width. + if (section.Entries.Count == 0) + sectionsPanel.Children.Add(CreateSection(section)); + else + sectionGrid.Children.Add(CreateSection(section)); + } + + sectionsPanel.Children.Add(sectionGrid); + sectionGrid.SizeChanged += (_, args) => ArrangeGrid(sectionGrid, args.NewSize.Width >= 900 ? 2 : 1); + ArrangeGrid(sectionGrid, 1); + + 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 { Width = GridLength.Auto }); + applyPanel.ColumnDefinitions.Add(new ColumnDefinition()); + Grid.SetColumn(_statusText, 1); + 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, 0); + applyPanel.Children.Add(applyButton); + Grid.SetRow(applyPanel, 1); + root.Children.Add(applyPanel); + + return root; + } + + private static void ArrangeGrid(Grid grid, int columns) + { + int rows = (grid.Children.Count + columns - 1) / columns; + if (grid.ColumnDefinitions.Count == columns && grid.RowDefinitions.Count == rows) + return; + + grid.ColumnDefinitions.Clear(); + grid.RowDefinitions.Clear(); + for (int column = 0; column < columns; column++) + grid.ColumnDefinitions.Add(new ColumnDefinition()); + for (int row = 0; row < rows; row++) + grid.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); + for (int index = 0; index < grid.Children.Count; index++) + { + FrameworkElement child = (FrameworkElement)grid.Children[index]; + Grid.SetColumn(child, index % columns); + Grid.SetRow(child, index / columns); + } + } + + private FrameworkElement CreateSection(GameSettingsSection section) + { + StackPanel panel = new() { Spacing = 12, VerticalAlignment = VerticalAlignment.Top }; + 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 + }); + } + + Grid entries = new() { ColumnSpacing = 16, RowSpacing = 16 }; + foreach (GameSettingEntry entry in section.Entries) + entries.Children.Add(CreateEntry(entry)); + + if (entries.Children.Count > 0) + { + entries.SizeChanged += (_, args) => + ArrangeGrid(entries, args.NewSize.Width >= 520 ? 3 : args.NewSize.Width >= 340 ? 2 : 1); + ArrangeGrid(entries, 1); + panel.Children.Add(entries); + } + return panel; + } + + private FrameworkElement CreateEntry(GameSettingEntry entry) + { + FrameworkElement editor = CreateEditor(entry); + editor.HorizontalAlignment = HorizontalAlignment.Stretch; + editor.VerticalAlignment = VerticalAlignment.Top; + AutomationProperties.SetName(editor, entry.Title); + + StackPanel panel = new() { Spacing = 6 }; + if (entry.Kind != GameSettingKind.Toggle) + { + panel.Children.Add(new TextBlock + { + Text = entry.Title, + FontWeight = FontWeights.SemiBold, + TextWrapping = TextWrapping.Wrap + }); + } + panel.Children.Add(editor); + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + panel.Children.Add(new TextBlock + { + Text = entry.Description, + FontSize = 12, + Opacity = 0.72, + TextWrapping = TextWrapping.Wrap + }); + AutomationProperties.SetHelpText(editor, entry.Description); + } + return panel; + } + + 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 CheckBox CreateToggle(GameSettingEntry entry) + { + CheckBox control = new() + { + Content = new TextBlock { Text = entry.Title, TextWrapping = TextWrapping.Wrap }, + IsChecked = bool.TryParse(entry.Value, out bool value) && value + }; + control.Checked += (_, _) => SetValue(entry.Key, bool.TrueString); + control.Unchecked += (_, _) => SetValue(entry.Key, 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, + Style = Application.Current.Resources["FatSliderStyle"] as Style, + TickFrequency = Math.Max(entry.Step, (entry.Maximum - entry.Minimum) / 10), + TickPlacement = Microsoft.UI.Xaml.Controls.Primitives.TickPlacement.Outside, + Value = ParseNumber(entry.Value, entry.Minimum) + }; + control.ValueChanged += (_, args) => + SetValue(entry.Key, args.NewValue.ToString(CultureInfo.InvariantCulture)); + return control; + } + + private ComboBox CreateChoice(GameSettingEntry entry) + { + ComboBox control = new() { CornerRadius = new CornerRadius(14) }; + 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/Hi3Helper.Plugin.Core b/Hi3Helper.Plugin.Core index 3613d20b4..14293ba31 160000 --- a/Hi3Helper.Plugin.Core +++ b/Hi3Helper.Plugin.Core @@ -1 +1 @@ -Subproject commit 3613d20b405626e694f78793ac0d5f1626a37ff1 +Subproject commit 14293ba31be02b513d3088d6866727504fa78da0 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",